refactor(core): consolidate filesystem services (#30447)

This commit is contained in:
Dax
2026-06-02 16:09:26 -04:00
committed by GitHub
parent b93963e462
commit 604a5f781f
153 changed files with 2553 additions and 4312 deletions

View File

@@ -2,7 +2,7 @@ import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
@@ -51,7 +51,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Au
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const fsys = yield* FSUtil.Service
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(function* () {
@@ -91,6 +91,6 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer))
export * as Auth from "."

View File

@@ -1,10 +1,18 @@
import { EOL } from "os"
import { Effect } from "effect"
import { File } from "../../../file"
import { Ripgrep } from "@/file/ripgrep"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { effectCmd } from "../../effect-cmd"
import { cmd } from "../cmd"
const filesystem = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(process.cwd()) })),
Effect.provide(LocationServiceMap.layer),
)
const FileSearchCommand = effectCmd({
command: "search <query>",
describe: "search files by query",
@@ -15,8 +23,8 @@ const FileSearchCommand = effectCmd({
description: "Search query",
}),
handler: Effect.fn("Cli.debug.file.search")(function* (args) {
const results = yield* File.Service.use((svc) => svc.search({ query: args.query }))
process.stdout.write(results.join(EOL) + EOL)
const results = yield* filesystem(FileSystem.Service.use((svc) => svc.find({ query: args.query })))
process.stdout.write(results.map((item) => item.path).join(EOL) + EOL)
}),
})
@@ -30,21 +38,11 @@ const FileReadCommand = effectCmd({
description: "File path to read",
}),
handler: Effect.fn("Cli.debug.file.read")(function* (args) {
const content = yield* File.Service.use((svc) => svc.read(args.path))
const content = yield* filesystem(FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })))
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
}),
})
const FileStatusCommand = effectCmd({
command: "status",
describe: "show file status information",
builder: (yargs) => yargs,
handler: Effect.fn("Cli.debug.file.status")(function* () {
const status = yield* File.Service.use((svc) => svc.status())
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
}),
})
const FileListCommand = effectCmd({
command: "list <path>",
describe: "list files in a directory",
@@ -55,7 +53,7 @@ const FileListCommand = effectCmd({
description: "File path to list",
}),
handler: Effect.fn("Cli.debug.file.list")(function* (args) {
const files = yield* File.Service.use((svc) => svc.list(args.path))
const files = yield* filesystem(FileSystem.Service.use((svc) => svc.list({ path: RelativePath.make(args.path) })))
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
}),
})
@@ -81,7 +79,6 @@ export const FileCommand = cmd({
builder: (yargs) =>
yargs
.command(FileReadCommand)
.command(FileStatusCommand)
.command(FileListCommand)
.command(FileSearchCommand)
.command(FileTreeCommand)

View File

@@ -1,6 +1,6 @@
import { EOL } from "os"
import { Effect, Stream } from "effect"
import { Ripgrep } from "../../../file/ripgrep"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { effectCmd } from "../../effect-cmd"
import { cmd } from "../cmd"
import { InstanceRef } from "@/effect/instance-ref"

View File

@@ -9,7 +9,7 @@ import { InstanceRef } from "@/effect/instance-ref"
import { ShareNext } from "@/share/share-next"
import { EOL } from "os"
import path from "path"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect, Schema } from "effect"
import type { InstanceContext } from "@/project/instance-context"
@@ -98,7 +98,7 @@ export const ImportCommand = effectCmd({
const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: InstanceContext) {
const share = yield* ShareNext.Service
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const { db } = yield* Database.Service
let exportData: ExportData | undefined

View File

@@ -7,7 +7,7 @@
// so your last-used variant sticks. Cycling (ctrl+t) updates both the active
// variant and the persisted file.
import path from "path"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Context, Effect, Layer } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { Global } from "@opencode-ai/core/global"
@@ -135,12 +135,12 @@ function state(value: unknown): ModelState {
}
}
function createLayer(fs = AppFileSystem.defaultLayer) {
function createLayer(fs = FSUtil.defaultLayer) {
return Layer.fresh(
Layer.effect(
Service,
Effect.gen(function* () {
const file = yield* AppFileSystem.Service
const file = yield* FSUtil.Service
const read = Effect.fn("RunVariant.read")(function* () {
return yield* file.readJson(MODEL_FILE).pipe(
@@ -196,7 +196,7 @@ function createLayer(fs = AppFileSystem.defaultLayer) {
}
/** @internal Exported for testing. */
export function createVariantRuntime(fs = AppFileSystem.defaultLayer): VariantRuntime {
export function createVariantRuntime(fs = FSUtil.defaultLayer): VariantRuntime {
const runtime = makeRuntime(Service, createLayer(fs))
return {
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),

View File

@@ -12,7 +12,7 @@ import { Process } from "@/util/process"
import { NotFoundError } from "@/storage/storage"
import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"
import { which } from "@opencode-ai/core/util/which"
function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]

View File

@@ -11,7 +11,7 @@ import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from
import { Flag } from "@opencode-ai/core/flag/flag"
import { isRecord } from "@/util/record"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin"
import { TuiKeybind } from "./keybind"
@@ -97,7 +97,7 @@ function dropUnknownKeybinds(input: Record<string, unknown>, configFilepath: str
}
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
const afs = yield* AppFileSystem.Service
const afs = yield* FSUtil.Service
let appliedOrder = 0
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
@@ -295,7 +295,7 @@ export const layer = Layer.effect(
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(FSUtil.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@@ -20,7 +20,7 @@ const writeWithStdin = (cmd: string[], text: string): Promise<void> =>
// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
const getWhich = lazy(async () => {
const { which } = await import("../../../../util/which")
const { which } = await import("@opencode-ai/core/util/which")
return which
})

View File

@@ -16,7 +16,7 @@ import { existsSync } from "fs"
import { Account } from "@/account/account"
import { isRecord } from "@/util/record"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { InstanceState } from "@/effect/instance-state"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
@@ -384,7 +384,7 @@ export const ConfigDirectoryTypoError = NamedError.create("ConfigDirectoryTypoEr
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const authSvc = yield* Auth.Service
const accountSvc = yield* Account.Service
const env = yield* Env.Service
@@ -796,7 +796,7 @@ export const layer = Layer.effect(
},
}
},
Effect.provideService(AppFileSystem.Service, fs),
Effect.provideService(FSUtil.Service, fs),
)
const state = yield* InstanceState.make<State>(
@@ -876,7 +876,7 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer),

View File

@@ -5,14 +5,14 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { unique } from "remeda"
import * as Effect from "effect/Effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
name: string,
directory: string,
worktree?: string,
) {
const afs = yield* AppFileSystem.Service
const afs = yield* FSUtil.Service
return (yield* afs.up({
targets: [`${name}.jsonc`, `${name}.json`],
start: directory,
@@ -21,7 +21,7 @@ export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
})
export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
const afs = yield* AppFileSystem.Service
const afs = yield* FSUtil.Service
return unique([
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG

View File

@@ -11,7 +11,7 @@ import { Auth } from "@/auth"
import { EventV2 } from "@opencode-ai/core/event"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import * as Log from "@opencode-ai/core/util/log"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProjectV2 } from "@opencode-ai/core/project"
@@ -177,7 +177,7 @@ export const layer = Layer.effect(
const events = yield* EventV2Bridge.Service
const vcs = yield* Vcs.Service
const flags = yield* RuntimeFlags.Service
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const { db } = yield* Database.Service
const connections = new Map<WorkspaceV2.ID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceV2.ID, void, SyncLoopError>()
@@ -1005,7 +1005,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(FetchHttpClient.layer),

View File

@@ -2,15 +2,13 @@ import { Layer, ManagedRuntime } from "effect"
import { attach } from "./run-service"
import * as Observability from "@opencode-ai/core/effect/observability"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Database } from "@opencode-ai/core/database/database"
import { Auth } from "@/auth"
import { Account } from "@/account/account"
import { Config } from "@/config/config"
import { Git } from "@/git"
import { Ripgrep } from "@/file/ripgrep"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { Storage } from "@/storage/storage"
import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
@@ -59,15 +57,13 @@ import { EventV2Bridge } from "@/event-v2-bridge"
export const AppLayer = Layer.mergeAll(
Npm.defaultLayer,
AppFileSystem.defaultLayer,
FSUtil.defaultLayer,
Database.defaultLayer,
Auth.defaultLayer,
Account.defaultLayer,
Config.defaultLayer,
Git.defaultLayer,
Ripgrep.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Storage.defaultLayer,
Snapshot.defaultLayer,
Plugin.defaultLayer,

View File

@@ -2,10 +2,8 @@ import { Layer, ManagedRuntime } from "effect"
import { Plugin } from "@/plugin"
import { LSP } from "@/lsp/lsp"
import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format"
import { ShareNext } from "@/share/share-next"
import { File } from "@/file"
import { Vcs } from "@/project/vcs"
import { Snapshot } from "@/snapshot"
import { Config } from "@/config/config"
@@ -18,8 +16,6 @@ export const BootstrapLayer = Layer.mergeAll(
ShareNext.defaultLayer,
Format.defaultLayer,
LSP.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Vcs.defaultLayer,
Snapshot.defaultLayer,
).pipe(Layer.provide(Observability.layer))

View File

@@ -1,81 +0,0 @@
import { Glob } from "@opencode-ai/core/util/glob"
const FOLDERS = new Set([
"node_modules",
"bower_components",
".pnpm-store",
"vendor",
".npm",
"dist",
"build",
"out",
".next",
"target",
"bin",
"obj",
".git",
".svn",
".hg",
".vscode",
".idea",
".turbo",
".output",
"desktop",
".sst",
".cache",
".webkit-cache",
"__pycache__",
".pytest_cache",
"mypy_cache",
".history",
".gradle",
])
const FILES = [
"**/*.swp",
"**/*.swo",
"**/*.pyc",
// OS
"**/.DS_Store",
"**/Thumbs.db",
// Logs & temp
"**/logs/**",
"**/tmp/**",
"**/temp/**",
"**/*.log",
// Coverage/test outputs
"**/coverage/**",
"**/.nyc_output/**",
]
export const PATTERNS = [...FILES, ...FOLDERS]
export function match(
filepath: string,
opts?: {
extra?: string[]
whitelist?: string[]
},
) {
for (const pattern of opts?.whitelist || []) {
if (Glob.match(pattern, filepath)) return false
}
const parts = filepath.split(/[/\\]/)
for (let i = 0; i < parts.length; i++) {
if (FOLDERS.has(parts[i])) return true
}
const extra = opts?.extra || []
for (const pattern of [...FILES, ...extra]) {
if (Glob.match(pattern, filepath)) return true
}
return false
}
export * as FileIgnore from "./ignore"

View File

@@ -1,654 +0,0 @@
import { EventV2 } from "@opencode-ai/core/event"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Git } from "@/git"
import { Effect, Layer, Context, Schema, Scope } 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 { Global } from "@opencode-ai/core/global"
import { containsPath } from "../project/instance-context"
import * as Log from "@opencode-ai/core/util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
import { NonNegativeInt, type DeepMutable } from "@opencode-ai/core/schema"
export const Info = Schema.Struct({
path: Schema.String,
added: NonNegativeInt,
removed: NonNegativeInt,
status: Schema.Literals(["added", "deleted", "modified"]),
}).annotate({ identifier: "File" })
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
export const Node = Schema.Struct({
name: Schema.String,
path: Schema.String,
absolute: Schema.String,
type: Schema.Literals(["file", "directory"]),
ignored: Schema.Boolean,
}).annotate({ identifier: "FileNode" })
export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
const Hunk = Schema.Struct({
oldStart: NonNegativeInt,
oldLines: NonNegativeInt,
newStart: NonNegativeInt,
newLines: NonNegativeInt,
lines: Schema.Array(Schema.String),
})
const Patch = Schema.Struct({
oldFileName: Schema.String,
newFileName: Schema.String,
oldHeader: Schema.optional(Schema.String),
newHeader: Schema.optional(Schema.String),
hunks: Schema.Array(Hunk),
index: Schema.optional(Schema.String),
})
export const Content = Schema.Struct({
type: Schema.Literals(["text", "binary"]),
content: Schema.String,
diff: Schema.optional(Schema.String),
patch: Schema.optional(Patch),
encoding: Schema.optional(Schema.Literal("base64")),
mimeType: Schema.optional(Schema.String),
}).annotate({ identifier: "FileContent" })
export type Content = DeepMutable<Schema.Schema.Type<typeof Content>>
export const Event = {
Edited: EventV2.define({
type: "file.edited",
schema: {
file: Schema.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 use = serviceUse(Service)
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 scope = yield* Scope.Scope
const state = yield* InstanceState.make<State>(
Effect.fn("File.state")(() =>
Effect.succeed({
cache: { files: [], dirs: [] } as Entry,
}),
),
)
const scan = Effect.fn("File.scan")(function* () {
const ctx = yield* InstanceState.context
if (ctx.directory === path.parse(ctx.directory).root) return
const isGlobalHome = ctx.directory === Global.Path.home && ctx.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(ctx.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(ctx.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: ctx.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: (yield* InstanceState.context).directory })).text()
})
const init = Effect.fn("File.init")(function* () {
yield* ensure().pipe(Effect.forkIn(scope))
})
const status = Effect.fn("File.status")(function* () {
const ctx = yield* InstanceState.context
if (ctx.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(ctx.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(ctx.directory, item.path)
return {
...item,
path: path.relative(ctx.directory, full),
}
})
})
const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
using _ = log.time("read", { file })
const ctx = yield* InstanceState.context
const full = path.join(ctx.directory, file)
if (!containsPath(full, ctx)) {
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 (ctx.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(ctx.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 ctx = yield* InstanceState.context
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
if (ctx.project.vcs === "git") {
const ig = ignore()
const gitignore = path.join(ctx.worktree, ".gitignore")
const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
if (gitignoreText) ig.add(gitignoreText)
const ignoreFile = path.join(ctx.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(ctx.directory, dir) : ctx.directory
if (!containsPath(resolved, ctx)) {
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(ctx.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 "."

View File

@@ -1,59 +0,0 @@
import path from "path"
import os from "os"
const home = os.homedir()
// macOS directories that trigger TCC (Transparency, Consent, and Control)
// permission prompts when accessed by a non-sandboxed process.
const DARWIN_HOME = [
// Media
"Music",
"Pictures",
"Movies",
// User-managed folders synced via iCloud / subject to TCC
"Downloads",
"Desktop",
"Documents",
// Other system-managed
"Public",
"Applications",
"Library",
]
const DARWIN_LIBRARY = [
"Application Support/AddressBook",
"Calendars",
"Mail",
"Messages",
"Safari",
"Cookies",
"Application Support/com.apple.TCC",
"PersonalizationPortrait",
"Metadata/CoreSpotlight",
"Suggestions",
]
const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes", "/.fseventsd"]
const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"]
/** Directory basenames to skip when scanning the home directory. */
export function names(): ReadonlySet<string> {
if (process.platform === "darwin") return new Set(DARWIN_HOME)
if (process.platform === "win32") return new Set(WIN32_HOME)
return new Set()
}
/** Absolute paths that should never be watched, stated, or scanned. */
export function paths(): string[] {
if (process.platform === "darwin")
return [
...DARWIN_HOME.map((n) => path.join(home, n)),
...DARWIN_LIBRARY.map((n) => path.join(home, "Library", n)),
...DARWIN_ROOT,
]
if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n))
return []
}
export * as Protected from "./protected"

View File

@@ -1,484 +0,0 @@
import path from "path"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
import { which } from "@/util/which"
import { NonNegativeInt } from "@opencode-ai/core/schema"
const log = Log.create({ service: "ripgrep" })
const VERSION = "15.1.0"
const PLATFORM = {
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
"arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
"ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" },
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
} as const
const TimeStats = Schema.Struct({
secs: NonNegativeInt,
nanos: NonNegativeInt,
human: Schema.String,
})
const Stats = Schema.Struct({
elapsed: TimeStats,
searches: NonNegativeInt,
searches_with_match: NonNegativeInt,
bytes_searched: NonNegativeInt,
bytes_printed: NonNegativeInt,
matched_lines: NonNegativeInt,
matches: NonNegativeInt,
})
const PathText = Schema.Struct({
text: Schema.String,
})
const Begin = Schema.Struct({
type: Schema.Literal("begin"),
data: Schema.Struct({
path: PathText,
}),
})
export const SearchMatch = Schema.Struct({
path: PathText,
lines: Schema.Struct({
text: Schema.String,
}),
line_number: NonNegativeInt,
absolute_offset: NonNegativeInt,
submatches: Schema.Array(
Schema.Struct({
match: Schema.Struct({
text: Schema.String,
}),
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
})
export const Match = Schema.Struct({
type: Schema.Literal("match"),
data: SearchMatch,
})
const End = Schema.Struct({
type: Schema.Literal("end"),
data: Schema.Struct({
path: PathText,
binary_offset: Schema.NullOr(NonNegativeInt),
stats: Stats,
}),
})
const Summary = Schema.Struct({
type: Schema.Literal("summary"),
data: Schema.Struct({
elapsed_total: TimeStats,
stats: Stats,
}),
})
const Result = Schema.Union([Begin, Match, End, Summary])
const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result))
export type Result = Schema.Schema.Type<typeof Result>
export type Match = Schema.Schema.Type<typeof Match>
export type Item = Match["data"]
export type Begin = Schema.Schema.Type<typeof Begin>
export type End = Schema.Schema.Type<typeof End>
export type Summary = Schema.Schema.Type<typeof Summary>
export type Row = Match["data"]
export interface SearchResult {
items: Item[]
partial: boolean
}
export interface FilesInput {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
signal?: AbortSignal
}
export interface SearchInput {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
file?: string[]
signal?: AbortSignal
}
export interface TreeInput {
cwd: string
limit?: number
signal?: AbortSignal
}
export interface Interface {
readonly files: (input: FilesInput) => Stream.Stream<string, PlatformError | Error>
readonly tree: (input: TreeInput) => Effect.Effect<string, PlatformError | Error>
readonly search: (input: SearchInput) => Effect.Effect<SearchResult, PlatformError | Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
export const use = serviceUse(Service)
function env() {
const env = sanitizedProcessEnv()
delete env.RIPGREP_CONFIG_PATH
return env
}
function aborted(signal?: AbortSignal) {
const err = signal?.reason
if (err instanceof Error) return err
const out = new Error("Aborted")
out.name = "AbortError"
return out
}
function waitForAbort(signal?: AbortSignal) {
if (!signal) return Effect.never
if (signal.aborted) return Effect.fail(aborted(signal))
return Effect.callback<never, Error>((resume) => {
const onabort = () => resume(Effect.fail(aborted(signal)))
signal.addEventListener("abort", onabort, { once: true })
return Effect.sync(() => signal.removeEventListener("abort", onabort))
})
}
function error(stderr: string, code: number) {
const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`)
err.name = "RipgrepError"
return err
}
function clean(file: string) {
return path.normalize(file.replace(/^\.[\\/]/, ""))
}
function row(data: Row): Row {
return {
...data,
path: {
...data.path,
text: clean(data.path.text),
},
}
}
function parse(line: string) {
return decodeResult(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause })))
}
function fail(queue: Queue.Queue<string, PlatformError | Error | Cause.Done>, err: PlatformError | Error) {
Queue.failCauseUnsafe(queue, Cause.fail(err))
}
function filesArgs(input: FilesInput) {
const args = ["--no-config", "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
if (input.hidden === false) args.push("--glob=!.*")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const glob of input.glob) args.push(`--glob=${glob}`)
}
args.push(".")
return args
}
function searchArgs(input: SearchInput) {
const args = ["--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"]
if (input.follow) args.push("--follow")
if (input.glob) {
for (const glob of input.glob) args.push(`--glob=${glob}`)
}
if (input.limit) args.push(`--max-count=${input.limit}`)
args.push("--", input.pattern, ...(input.file ?? ["."]))
return args
}
function raceAbort<A, E, R>(effect: Effect.Effect<A, E, R>, signal?: AbortSignal) {
return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect
}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner | HttpClient.HttpClient> =
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const spawner = yield* ChildProcessSpawner
const run = Effect.fnUntraced(function* (command: string, args: string[], opts?: { cwd?: string }) {
const handle = yield* spawner.spawn(
ChildProcess.make(command, args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
)
const [stdout, stderr, code] = yield* Effect.all(
[
Stream.mkString(Stream.decodeText(handle.stdout)),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
return { stdout, stderr, code }
}, Effect.scoped)
const extract = Effect.fnUntraced(function* (
archive: string,
config: (typeof PLATFORM)[keyof typeof PLATFORM],
target: string,
) {
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
if (config.extension === "zip") {
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
const result = yield* run(shell, [
"-NoProfile",
"-NonInteractive",
"-Command",
`$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`,
])
if (result.code !== 0) {
return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
}
}
if (config.extension === "tar.gz") {
const result = yield* run("tar", ["-xzf", archive, "-C", dir])
if (result.code !== 0) {
return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
}
}
const extracted = path.join(
dir,
`ripgrep-${VERSION}-${config.platform}`,
process.platform === "win32" ? "rg.exe" : "rg",
)
if (!(yield* fs.isFile(extracted))) {
return yield* Effect.fail(new Error(`ripgrep archive did not contain executable: ${extracted}`))
}
yield* fs.copyFile(extracted, target)
if (process.platform === "win32") return
yield* fs.chmod(target, 0o755)
}, Effect.scoped)
const filepath = yield* Effect.cached(
Effect.gen(function* () {
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
if (!config) {
return yield* Effect.fail(new Error(`unsupported platform for ripgrep: ${platformKey}`))
}
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
const archive = path.join(Global.Path.bin, filename)
log.info("downloading ripgrep", { url })
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
const bytes = yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
)
if (bytes.byteLength === 0) {
return yield* Effect.fail(new Error(`failed to download ripgrep from ${url}`))
}
yield* fs.writeWithDirs(archive, new Uint8Array(bytes))
yield* extract(archive, config, target)
yield* fs.remove(archive, { force: true }).pipe(Effect.ignore)
return target
}),
)
const check = Effect.fnUntraced(function* (cwd: string) {
if (yield* fs.isDir(cwd).pipe(Effect.orDie)) return
return yield* Effect.fail(
Object.assign(new Error(`No such file or directory: '${cwd}'`), {
code: "ENOENT",
errno: -2,
path: cwd,
}),
)
})
const command = Effect.fnUntraced(function* (cwd: string, args: string[]) {
const binary = yield* filepath
return ChildProcess.make(binary, args, {
cwd,
env: env(),
extendEnv: true,
stdin: "ignore",
})
})
const files: Interface["files"] = (input) =>
Stream.callback<string, PlatformError | Error>((queue) =>
Effect.gen(function* () {
yield* Effect.forkScoped(
Effect.gen(function* () {
yield* check(input.cwd)
const handle = yield* spawner.spawn(yield* command(input.cwd, filesArgs(input)))
const stderr = yield* Stream.mkString(Stream.decodeText(handle.stderr)).pipe(Effect.forkScoped)
const stdout = yield* Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.runForEach((line) => Effect.sync(() => Queue.offerUnsafe(queue, clean(line)))),
Effect.forkScoped,
)
const code = yield* raceAbort(handle.exitCode, input.signal)
yield* Fiber.join(stdout)
if (code === 0 || code === 1) {
Queue.endUnsafe(queue)
return
}
fail(queue, error(yield* Fiber.join(stderr), code))
}).pipe(
Effect.catch((err) =>
Effect.sync(() => {
fail(queue, err)
}),
),
),
)
}),
)
const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
yield* check(input.cwd)
const program = Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(yield* command(input.cwd, searchArgs(input)))
const [items, stderr, code] = yield* Effect.all(
[
Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.mapEffect(parse),
Stream.filter((item): item is Match => item.type === "match"),
Stream.map((item) => row(item.data)),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
if (code !== 0 && code !== 1 && code !== 2) {
return yield* Effect.fail(error(stderr, code))
}
return {
items: code === 1 ? [] : items,
partial: code === 2,
}
}),
)
return yield* raceAbort(program, input.signal)
})
const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
log.info("tree", input)
const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
interface Node {
name: string
children: Map<string, Node>
}
function child(node: Node, name: string) {
const item = node.children.get(name)
if (item) return item
const next = { name, children: new Map() }
node.children.set(name, next)
return next
}
function count(node: Node): number {
return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
}
const root: Node = { name: "", children: new Map() }
for (const file of list) {
if (file.includes(".opencode")) continue
const parts = file.split(path.sep)
if (parts.length < 2) continue
let node = root
for (const part of parts.slice(0, -1)) {
node = child(node, part)
}
}
const total = count(root)
const limit = input.limit ?? total
const lines: string[] = []
const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((node) => ({ node, path: node.name }))
let used = 0
for (let i = 0; i < queue.length && used < limit; i++) {
const item = queue[i]
lines.push(item.path)
used++
queue.push(
...Array.from(item.node.children.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((node) => ({ node, path: `${item.path}/${node.name}` })),
)
}
if (total > used) lines.push(`[${total - used} truncated]`)
return lines.join("\n")
})
return Service.of({ files, tree, search })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
export * as Ripgrep from "./ripgrep"

View File

@@ -1,172 +0,0 @@
import { Cause, Effect, Layer, Context, Schema } from "effect"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
import { readdir, realpath } from "fs/promises"
import path from "path"
import { EventV2 } from "@opencode-ai/core/event"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Git } from "@/git"
import { lazy } from "@/util/lazy"
import { Config } from "@/config/config"
import { FileIgnore } from "./ignore"
import { Protected } from "./protected"
import * as Log from "@opencode-ai/core/util/log"
declare const OPENCODE_LIBC: string | undefined
const log = Log.create({ service: "file.watcher" })
const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = {
Updated: EventV2.define({
type: "file.watcher.updated",
schema: {
file: Schema.String,
event: Schema.Literals(["add", "change", "unlink"]),
},
}),
}
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
const binding = require(
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
return
}
})
function getBackend() {
if (process.platform === "win32") return "windows"
if (process.platform === "darwin") return "fs-events"
if (process.platform === "linux") return "inotify"
}
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const rel = path.relative(dir, item)
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
})
}
export const hasNativeBinding = () => !!watcher()
export interface Interface {
readonly init: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const git = yield* Git.Service
const events = yield* EventV2Bridge.Service
const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")(
function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
const ctx = yield* InstanceState.context
log.info("init", { directory: ctx.directory })
const backend = getBackend()
if (!backend) {
log.error("watcher backend not supported", { directory: ctx.directory, platform: process.platform })
return
}
const w = watcher()
if (!w) return
log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend })
const bridge = yield* EffectBridge.make()
const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
)
const cb: ParcelWatcher.SubscribeCallback = bridge.bind((err, evts) => {
// if (err) return
for (const evt of evts) {
if (evt.type === "create") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "add" }))
if (evt.type === "update") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "change" }))
if (evt.type === "delete") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "unlink" }))
}
})
const subscribe = (dir: string, ignore: string[]) => {
const pending = w.subscribe(dir, cb, { ignore, backend })
return Effect.gen(function* () {
const sub = yield* Effect.promise(() => pending)
subs.push(sub)
}).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void
}),
)
}
const cfg = yield* config.get()
const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* Effect.forkScoped(
subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]),
)
}
if (ctx.project.vcs === "git") {
const result = yield* git.run(["rev-parse", "--git-dir"], {
cwd: ctx.worktree,
})
const resolved = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
const vcsDir = resolved ? yield* Effect.promise(() => realpath(resolved).catch(() => resolved)) : undefined
if (
vcsDir &&
!cfgIgnores.includes(".git") &&
!cfgIgnores.includes(vcsDir) &&
(!resolved || !cfgIgnores.includes(resolved))
) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
)
yield* Effect.forkScoped(subscribe(vcsDir, ignore))
}
}
},
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.void
}),
),
)
return Service.of({
init: Effect.fn("FileWatcher.init")(function* () {
yield* InstanceState.get(state)
}),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
)
export * as FileWatcher from "./watcher"

View File

@@ -2,7 +2,7 @@ import { Npm } from "@opencode-ai/core/npm"
import type { InstanceContext } from "../project/instance-context"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { which } from "../util/which"
import { which } from "@opencode-ai/core/util/which"
export interface Context extends Pick<InstanceContext, "directory" | "worktree"> {
experimentalOxfmt: boolean

View File

@@ -9,7 +9,7 @@ import { Filesystem } from "@/util/filesystem"
import type { InstanceContext } from "../project/instance-context"
import { Archive } from "@/util/archive"
import { Process } from "@/util/process"
import { which } from "../util/which"
import { which } from "@opencode-ai/core/util/which"
import { Module } from "@opencode-ai/core/util/module"
import { spawn } from "./launch"
import { Npm } from "@opencode-ai/core/npm"

View File

@@ -2,7 +2,7 @@ import path from "path"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Global } from "@opencode-ai/core/global"
import { Effect, Layer, Context, Option, Schema } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
export const Tokens = Schema.Struct({
@@ -59,7 +59,7 @@ export const use = serviceUse(Service)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const flock = yield* EffectFlock.Service
const read = Effect.fn("McpAuth.read")(function* () {
@@ -166,9 +166,6 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
)
export const defaultLayer = layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.defaultLayer))
export * as McpAuth from "./auth"

View File

@@ -18,7 +18,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { withTimeout } from "@/util/timeout"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
import { McpOAuthCallback } from "./oauth-callback"
import { McpAuth } from "./auth"
@@ -975,7 +975,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
)
export * as MCP from "."

View File

@@ -1,6 +1,6 @@
import { Effect, Schema } from "effect"
import * as path from "path"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import * as Log from "@opencode-ai/core/util/log"
import * as Bom from "../util/bom"
@@ -519,7 +519,7 @@ export const applyHunksToFiles = Effect.fn("Patch.applyHunksToFiles")(function*
return yield* Effect.fail(new Error("No files were modified."))
}
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const added: string[] = []
const modified: string[] = []
@@ -574,7 +574,7 @@ type MaybeApplyPatchVerifiedResult =
| { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
| { type: MaybeApplyPatchVerified.NotApplyPatch }
// Effectful verified-parse: needs AppFileSystem.Service to read existing files
// Effectful verified-parse: needs FSUtil.Service to read existing files
export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatchVerified")(function* (
argv: string[],
cwd: string,
@@ -596,7 +596,7 @@ export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatc
switch (result.type) {
case MaybeApplyPatch.Body: {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const args = result.args
const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
const changes = new Map<string, ApplyPatchFileChange>()

View File

@@ -1,12 +1,10 @@
import { Plugin } from "../plugin"
import { Format } from "../format"
import { LSP } from "@/lsp/lsp"
import { File } from "../file"
import { Snapshot } from "../snapshot"
import * as Project from "./project"
import * as Vcs from "./vcs"
import { InstanceState } from "@/effect/instance-state"
import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next"
import { Effect, Layer } from "effect"
import { Config } from "@/config/config"
@@ -23,8 +21,6 @@ export const layer = Layer.effect(
// InstanceStore imports only the lightweight tag from bootstrap-service.ts,
// so it can depend on bootstrap without importing this implementation graph.
const config = yield* Config.Service
const file = yield* File.Service
const fileWatcher = yield* FileWatcher.Service
const format = yield* Format.Service
const lsp = yield* LSP.Service
const plugin = yield* Plugin.Service
@@ -44,7 +40,7 @@ export const layer = Layer.effect(
// Each service self-manages its own slow work via Effect.forkScoped against
// its per-instance state scope. We just await materialization here.
yield* Effect.forEach(
[reference, lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
[reference, lsp, shareNext, format, vcs, snapshot, project],
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
{ concurrency: "unbounded", discard: true },
).pipe(Effect.withSpan("InstanceBootstrap.init"))
@@ -57,8 +53,6 @@ export const layer = Layer.effect(
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide([
Config.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Format.defaultLayer,
LSP.defaultLayer,
Plugin.defaultLayer,

View File

@@ -1,5 +1,5 @@
import { LocalContext } from "@/util/local-context"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import type * as Project from "./project"
export interface InstanceContext {
@@ -16,9 +16,9 @@ export const context = LocalContext.create<InstanceContext>("instance")
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/
export function containsPath(filepath: string, ctx: InstanceContext): boolean {
if (AppFileSystem.contains(ctx.directory, filepath)) return true
if (FSUtil.contains(ctx.directory, filepath)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
if (ctx.worktree === "/") return false
return AppFileSystem.contains(ctx.worktree, filepath)
return FSUtil.contains(ctx.worktree, filepath)
}

View File

@@ -3,7 +3,7 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceRef } from "@/effect/instance-ref"
import { disposeInstance as runDisposers } from "@/effect/instance-registry"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
import { type InstanceContext } from "./instance-context"
import { InstanceBootstrap } from "./bootstrap-service"
@@ -104,7 +104,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
})
const load = (input: LoadInput): Effect.Effect<InstanceContext> => {
const directory = AppFileSystem.resolve(input.directory)
const directory = FSUtil.resolve(input.directory)
return Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const existing = cache.get(directory)
@@ -122,7 +122,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
}
const reload = (input: LoadInput): Effect.Effect<InstanceContext> => {
const directory = AppFileSystem.resolve(input.directory)
const directory = FSUtil.resolve(input.directory)
return Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const previous = cache.get(directory)
@@ -153,7 +153,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
})
const disposeDirectory = Effect.fn("InstanceStore.disposeDirectory")(function* (input: string) {
const directory = AppFileSystem.resolve(input)
const directory = FSUtil.resolve(input)
const entry = cache.get(directory)
if (!entry) return
const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit)

View File

@@ -6,12 +6,12 @@ import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { which } from "../util/which"
import { which } from "@opencode-ai/core/util/which"
import { Command } from "@/command"
import { InstanceState } from "@/effect/instance-state"
import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { AppProcess } from "@opencode-ai/core/process"
import { ProjectV2 } from "@opencode-ai/core/project"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -135,7 +135,7 @@ type GitResult = { code: number; text: string; stderr: string }
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const proc = yield* AppProcess.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const projectV2 = yield* ProjectV2.Service
@@ -326,7 +326,7 @@ export const layer = Layer.effect(
const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
const base64 = Buffer.from(buffer).toString("base64")
const mime = AppFileSystem.mimeType(shortest)
const mime = FSUtil.mimeType(shortest)
const url = `data:${mime};base64,${base64}`
yield* update({ projectID: input.id, icon: { url } }).pipe(
Effect.catchTag("Project.NotFoundError", () => Effect.void),
@@ -468,7 +468,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)

View File

@@ -1,7 +1,7 @@
import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
import { formatPatch, structuredPatch } from "diff"
import { InstanceState } from "@/effect/instance-state"
import { FileWatcher } from "@/file/watcher"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Git } from "@/git"
import * as Log from "@opencode-ai/core/util/log"
import { EventV2Bridge } from "@/event-v2-bridge"
@@ -328,9 +328,9 @@ export const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Serv
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
const unsubscribe = yield* events.listen((event) => {
if (event.type !== FileWatcher.Event.Updated.type || event.location?.directory !== ctx.directory)
if (event.type !== Watcher.Event.Updated.type || event.location?.directory !== ctx.directory)
return Effect.void
const data = event.data as EventV2.Data<typeof FileWatcher.Event.Updated>
const data = event.data as EventV2.Data<typeof Watcher.Event.Updated>
if (!data.file.endsWith("HEAD")) return Effect.void
return Effect.gen(function* () {
const next = yield* get()

View File

@@ -21,7 +21,7 @@ import { Effect, Layer, Context, Schema, Types } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { EffectPromise } from "@/effect/promise"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { isRecord } from "@/util/record"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
import { ProviderTransform } from "./transform"
@@ -1183,7 +1183,7 @@ function modelSuggestions(provider: Info | undefined, modelID: ProviderV2.ModelI
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const config = yield* Config.Service
const auth = yield* Auth.Service
const env = yield* Env.Service
@@ -1861,7 +1861,7 @@ export const layer = Layer.effect(
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Auth.defaultLayer),

View File

@@ -1,6 +1,6 @@
import path from "path"
import { Effect, Context, Layer, Scope } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Config } from "@/config/config"
import { ConfigReference } from "@/config/reference"
@@ -83,11 +83,11 @@ function branchLabel(branch: string | undefined) {
function normalizedTarget(target?: string) {
if (!target) return
return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
return process.platform === "win32" ? FSUtil.normalizePath(target) : target
}
function containsReferencePath(referencePath: string, target: string) {
return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target)
return FSUtil.contains(normalizedTarget(referencePath) ?? referencePath, target)
}
function uniqueGitReferences(references: Resolved[]) {

View File

@@ -1,6 +1,6 @@
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Flock } from "@opencode-ai/core/util/flock"
import { Git } from "@/git"
import {
@@ -168,7 +168,7 @@ export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(functi
const ensureWithServices = Effect.fn("RepositoryCache.ensureWithServices")(function* (
input: EnsureInput,
services: {
fs: AppFileSystem.Interface
fs: FSUtil.Interface
git: Git.Interface
},
) {
@@ -298,10 +298,10 @@ const ensureWithServices = Effect.fn("RepositoryCache.ensureWithServices")(funct
)
})
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service> = Layer.effect(
export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
return Service.of({
@@ -313,7 +313,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Serv
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
)

View File

@@ -1,5 +1,6 @@
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { LSP } from "@/lsp/lsp"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@@ -37,6 +38,47 @@ export const FindSymbolQuery = Schema.Struct({
query: Schema.String,
})
export const LegacyEntry = Schema.Struct({
name: Schema.String,
path: Schema.String,
absolute: Schema.String,
type: Schema.Literals(["file", "directory"]),
ignored: Schema.Boolean,
}).annotate({ identifier: "FileNode" })
export const LegacyContent = Schema.Struct({
type: Schema.Literals(["text", "binary"]),
content: Schema.String,
diff: Schema.optional(Schema.String),
patch: Schema.optional(
Schema.Struct({
oldFileName: Schema.String,
newFileName: Schema.String,
oldHeader: Schema.optional(Schema.String),
newHeader: Schema.optional(Schema.String),
hunks: Schema.Array(
Schema.Struct({
oldStart: NonNegativeInt,
oldLines: NonNegativeInt,
newStart: NonNegativeInt,
newLines: NonNegativeInt,
lines: Schema.Array(Schema.String),
}),
),
index: Schema.optional(Schema.String),
}),
),
encoding: Schema.optional(Schema.Literal("base64")),
mimeType: Schema.optional(Schema.String),
}).annotate({ identifier: "FileContent" })
export const LegacyStatus = Schema.Struct({
path: Schema.String,
added: NonNegativeInt,
removed: NonNegativeInt,
status: Schema.Literals(["added", "deleted", "modified"]),
}).annotate({ identifier: "File" })
export const FilePaths = {
findText: "/find",
findFile: "/find/file",
@@ -82,7 +124,7 @@ export const FileApi = HttpApi.make("file")
),
HttpApiEndpoint.get("list", FilePaths.list, {
query: FileQuery,
success: described(Schema.Array(File.Node), "Files and directories"),
success: described(Schema.Array(LegacyEntry), "Files and directories"),
}).annotateMerge(
OpenApi.annotations({
identifier: "file.list",
@@ -92,7 +134,7 @@ export const FileApi = HttpApi.make("file")
),
HttpApiEndpoint.get("content", FilePaths.content, {
query: FileQuery,
success: described(File.Content, "File content"),
success: described(LegacyContent, "File content"),
}).annotateMerge(
OpenApi.annotations({
identifier: "file.read",
@@ -102,7 +144,7 @@ export const FileApi = HttpApi.make("file")
),
HttpApiEndpoint.get("status", FilePaths.status, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(File.Info), "File status"),
success: described(Schema.Array(LegacyStatus), "File status"),
}).annotateMerge(
OpenApi.annotations({
identifier: "file.status",

View File

@@ -1,4 +1,4 @@
import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { RelativePath } from "@opencode-ai/core/schema"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@@ -21,7 +21,7 @@ export const FileSystemGroup = HttpApiGroup.make("v2.fs")
.add(
HttpApiEndpoint.get("read", "/api/fs/read", {
query: ReadQuery,
success: LocationFileSystem.Content,
success: FileSystem.Content,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
@@ -35,7 +35,7 @@ export const FileSystemGroup = HttpApiGroup.make("v2.fs")
.add(
HttpApiEndpoint.get("list", "/api/fs/list", {
query: ListQuery,
success: Schema.Array(LocationFileSystem.Entry),
success: Schema.Array(FileSystem.Entry),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(

View File

@@ -1,7 +1,7 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -42,7 +42,7 @@ export class V2LocationMiddleware extends HttpApiMiddleware.Service<
| PluginBoot.Service
| PermissionV2.Service
| ProjectReference.Service
| LocationFileSystem.Service
| FileSystem.Service
}
>()("@opencode/ExperimentalHttpApiV2Location") {}

View File

@@ -1,14 +1,24 @@
import * as InstanceState from "@/effect/instance-state"
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import { Effect } from "effect"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Effect, Layer } from "effect"
import path from "path"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
Effect.gen(function* () {
const svc = yield* File.Service
const ripgrep = yield* Ripgrep.Service
const locations = yield* LocationServiceMap
const filesystem = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
return yield* effect.pipe(
Effect.provide(locations.get({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })),
)
})
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
return (yield* ripgrep
@@ -19,12 +29,15 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
}) {
return yield* svc.search({
query: ctx.query.query,
limit: ctx.query.limit ?? 10,
dirs: ctx.query.dirs !== "false",
type: ctx.query.type,
})
return (yield* filesystem(
FileSystem.Service.use((fs) =>
fs.find({
query: ctx.query.query,
limit: ctx.query.limit ?? 10,
type: ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : undefined),
}),
),
)).map((item) => item.path)
})
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
@@ -32,15 +45,42 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
})
const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
return yield* svc.list(ctx.query.path)
const directory = (yield* InstanceState.context).directory
return yield* filesystem(
FileSystem.Service.use((fs) =>
fs.list({ path: RelativePath.make(ctx.query.path) }).pipe(
Effect.map((items) =>
items.map((item) => ({
name: path.basename(item.path),
path: item.path,
absolute: path.join(directory, item.path),
type: item.type,
ignored: fs.isIgnored(item.path, item.type),
})),
),
),
),
)
})
const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
return yield* svc.read(ctx.query.path)
const directory = (yield* InstanceState.context).directory
const file = path.resolve(directory, ctx.query.path)
if (!FSUtil.contains(directory, file)) return yield* Effect.die(new Error("Path escapes the location"))
if (!(yield* FSUtil.Service.use((fs) => fs.existsSafe(file)))) return { type: "text" as const, content: "" }
return yield* filesystem(
FileSystem.Service.use((fs) => fs.read({ path: RelativePath.make(ctx.query.path) })),
).pipe(
Effect.map((item) => ({
type: item.type,
content: item.type === "text" ? item.content.trim() : item.content,
...(item.type === "binary" ? { encoding: item.encoding, mimeType: item.mime } : {}),
})),
)
})
const status = Effect.fn("FileHttpApi.status")(function* () {
return yield* svc.status()
return []
})
return handlers
@@ -51,4 +91,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
.handle("content", content)
.handle("status", status)
}),
)
).pipe(Layer.provide(LocationServiceMap.layer))

View File

@@ -1,4 +1,4 @@
import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
@@ -6,7 +6,7 @@ import { InstanceHttpApi } from "../../api"
export const fileSystemHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.fs", (handlers) =>
Effect.gen(function* () {
return handlers
.handle("read", (ctx) => LocationFileSystem.Service.use((fs) => fs.read(ctx.query)))
.handle("list", (ctx) => LocationFileSystem.Service.use((fs) => fs.list(ctx.query)))
.handle("read", (ctx) => FileSystem.Service.use((fs) => fs.read(ctx.query)))
.handle("list", (ctx) => FileSystem.Service.use((fs) => fs.list(ctx.query)))
}),
)

View File

@@ -9,16 +9,14 @@ import {
HttpServerResponse,
} from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Auth } from "@/auth"
import { Config } from "@/config/config"
import { Command } from "@/command"
import * as Observability from "@opencode-ai/core/effect/observability"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
import { Ripgrep } from "@/file/ripgrep"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { Format } from "@/format"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { LSP } from "@/lsp/lsp"
@@ -166,7 +164,7 @@ const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effe
const uiRoute = HttpRouter.use((router) =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const client = yield* HttpClient.HttpClient
const flags = yield* RuntimeFlags.Service
yield* router.add("*", "/*", (request) =>
@@ -198,8 +196,6 @@ export function createRoutes(
Auth.defaultLayer,
Command.defaultLayer,
Config.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Format.defaultLayer,
LSP.defaultLayer,
Installation.defaultLayer,
@@ -232,7 +228,7 @@ export function createRoutes(
Vcs.defaultLayer,
Workspace.defaultLayer,
Worktree.appLayer,
AppFileSystem.defaultLayer,
FSUtil.defaultLayer,
FetchHttpClient.layer,
HttpServer.layerServices,
]),

View File

@@ -1,4 +1,4 @@
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect, Stream } from "effect"
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createHash } from "node:crypto"
@@ -53,7 +53,7 @@ function notFound() {
}
function embeddedUIResponse(file: string, body: Uint8Array) {
const mime = AppFileSystem.mimeType(file)
const mime = FSUtil.mimeType(file)
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) {
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
@@ -63,7 +63,7 @@ function embeddedUIResponse(file: string, body: Uint8Array) {
export function serveEmbeddedUIEffect(
requestPath: string,
fs: AppFileSystem.Interface,
fs: FSUtil.Interface,
embeddedWebUI: Record<string, string>,
) {
const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
@@ -77,7 +77,7 @@ export function serveEmbeddedUIEffect(
export function serveUIEffect(
request: HttpServerRequest.HttpServerRequest,
services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient; disableEmbeddedWebUi: boolean },
services: { fs: FSUtil.Interface; client: HttpClient.HttpClient; disableEmbeddedWebUi: boolean },
) {
return Effect.gen(function* () {
const embeddedWebUI = yield* Effect.promise(() => embeddedUI(services.disableEmbeddedWebUi))

View File

@@ -6,7 +6,7 @@ import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Flag } from "@opencode-ai/core/flag/flag"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "@opencode-ai/core/global"
import type { MessageV2 } from "./message-v2"
@@ -31,14 +31,14 @@ function extract(messages: SessionLegacy.WithParts[]) {
export interface Interface {
readonly clear: (messageID: MessageID) => Effect.Effect<void>
readonly systemPaths: () => Effect.Effect<Set<string>, AppFileSystem.Error>
readonly system: () => Effect.Effect<string[], AppFileSystem.Error>
readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
readonly systemPaths: () => Effect.Effect<Set<string>, FSUtil.Error>
readonly system: () => Effect.Effect<string[], FSUtil.Error>
readonly find: (dir: string) => Effect.Effect<string | undefined, FSUtil.Error>
readonly resolve: (
messages: SessionLegacy.WithParts[],
filepath: string,
messageID: MessageID,
) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error>
) => Effect.Effect<{ filepath: string; content: string }[], FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
@@ -46,12 +46,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/In
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service
FSUtil.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const flags = yield* RuntimeFlags.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
@@ -225,7 +225,7 @@ export const layer: Layer.Layer<
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Global.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(RuntimeFlags.defaultLayer),
)

View File

@@ -37,7 +37,7 @@ import { SessionStatus } from "./status"
import { LLM } from "./llm"
import { Shell } from "@/shell/shell"
import { ShellID } from "@/tool/shell/id"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Truncate } from "@/tool/truncate"
import { Image } from "@/image/image"
import { decodeDataUrl } from "@/util/data-url"
@@ -112,7 +112,7 @@ export const layer = Layer.effect(
const commands = yield* Command.Service
const config = yield* Config.Service
const permission = yield* Permission.Service
const fsys = yield* AppFileSystem.Service
const fsys = yield* FSUtil.Service
const mcp = yield* MCP.Service
const lsp = yield* LSP.Service
const registry = yield* ToolRegistry.Service
@@ -178,7 +178,7 @@ export const layer = Layer.effect(
const target = name.slice(slash + 1)
const targetPath = path.resolve(reference.path, target)
if (!AppFileSystem.contains(reference.path, targetPath)) {
if (!FSUtil.contains(reference.path, targetPath)) {
parts.push(
referenceTextPart({
reference,
@@ -777,7 +777,7 @@ export const layer = Layer.effect(
const reference = yield* references.get(name.slice(0, slash))
if (!reference || reference.kind === "invalid") return
if (!AppFileSystem.contains(reference.path, filepath)) return
if (!FSUtil.contains(reference.path, filepath)) return
const target = path.relative(reference.path, filepath).split(path.sep).join("/")
if (!target || target.startsWith("../") || target === "..") return
@@ -1343,7 +1343,7 @@ export const layer = Layer.effect(
const isLastStep = step >= maxSteps
msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(
Effect.provideService(RuntimeFlags.Service, flags),
Effect.provideService(AppFileSystem.Service, fsys),
Effect.provideService(FSUtil.Service, fsys),
Effect.provideService(Session.Service, sessions),
)
@@ -1656,7 +1656,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Provider.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionRevert.defaultLayer),

View File

@@ -2,7 +2,7 @@ import path from "path"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Effect } from "effect"
import { Agent } from "@/agent/agent"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { PartID } from "./schema"
@@ -18,7 +18,7 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
session: Session.Info
}) {
const flags = yield* RuntimeFlags.Service
const fsys = yield* AppFileSystem.Service
const fsys = yield* FSUtil.Service
const sessions = yield* Session.Service
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
if (!userMessage) return input.messages

View File

@@ -1,7 +1,7 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import { which } from "@/util/which"
import { which } from "@opencode-ai/core/util/which"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { setTimeout as sleep } from "node:timers/promises"

View File

@@ -2,7 +2,7 @@ import { NodePath } from "@effect/platform-node"
import { Effect, Layer, Path, Schema, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
@@ -24,92 +24,91 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Path.Path | HttpClient.HttpClient> =
Layer.effect(
Service,
Effect.gen(function* () {
const log = Log.create({ service: "skill-discovery" })
const fs = yield* AppFileSystem.Service
const path = yield* Path.Path
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const cache = path.join(Global.Path.cache, "skills")
export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const log = Log.create({ service: "skill-discovery" })
const fs = yield* FSUtil.Service
const path = yield* Path.Path
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const cache = path.join(Global.Path.cache, "skills")
const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
return yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((res) => res.arrayBuffer),
Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
Effect.as(true),
Effect.catch((err) =>
Effect.sync(() => {
log.error("failed to download", { url, err })
return false
}),
),
)
})
const pull = Effect.fn("Discovery.pull")(function* (url: string) {
const base = url.endsWith("/") ? url : `${url}/`
const index = new URL("index.json", base).href
const host = base.slice(0, -1)
log.info("fetching index", { url: index })
const data = yield* HttpClientRequest.get(index).pipe(
HttpClientRequest.acceptJson,
http.execute,
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
Effect.catch((err) =>
Effect.sync(() => {
log.error("failed to fetch index", { url: index, err })
return null
}),
),
)
if (!data) return []
const list = data.skills.filter((skill) => {
if (!skill.files.includes("SKILL.md")) {
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
return yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((res) => res.arrayBuffer),
Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
Effect.as(true),
Effect.catch((err) =>
Effect.sync(() => {
log.error("failed to download", { url, err })
return false
}
return true
})
}),
),
)
})
const dirs = yield* Effect.forEach(
list,
(skill) =>
Effect.gen(function* () {
const root = path.join(cache, skill.name)
const pull = Effect.fn("Discovery.pull")(function* (url: string) {
const base = url.endsWith("/") ? url : `${url}/`
const index = new URL("index.json", base).href
const host = base.slice(0, -1)
yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{
concurrency: fileConcurrency,
},
)
log.info("fetching index", { url: index })
const md = path.join(root, "SKILL.md")
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
}),
{ concurrency: skillConcurrency },
)
const data = yield* HttpClientRequest.get(index).pipe(
HttpClientRequest.acceptJson,
http.execute,
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
Effect.catch((err) =>
Effect.sync(() => {
log.error("failed to fetch index", { url: index, err })
return null
}),
),
)
return dirs.filter((dir): dir is string => dir !== null)
if (!data) return []
const list = data.skills.filter((skill) => {
if (!skill.files.includes("SKILL.md")) {
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
return false
}
return true
})
return Service.of({ pull })
}),
)
const dirs = yield* Effect.forEach(
list,
(skill) =>
Effect.gen(function* () {
const root = path.join(cache, skill.name)
yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{
concurrency: fileConcurrency,
},
)
const md = path.join(root, "SKILL.md")
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
}),
{ concurrency: skillConcurrency },
)
return dirs.filter((dir): dir is string => dir !== null)
})
return Service.of({ pull })
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(NodePath.layer),
)

View File

@@ -7,7 +7,7 @@ import { EventV2Bridge } from "@/event-v2-bridge"
import { InstanceState } from "@/effect/instance-state"
import { Global } from "@opencode-ai/core/global"
import { Permission } from "@/permission"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Config } from "@/config/config"
import { ConfigMarkdown } from "@/config/markdown"
import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -173,7 +173,7 @@ const scan = Effect.fnUntraced(function* (
const discoverSkills = Effect.fnUntraced(function* (
config: Config.Interface,
discovery: Discovery.Interface,
fsys: AppFileSystem.Interface,
fsys: FSUtil.Interface,
global: Global.Interface,
disableExternalSkills: boolean,
disableClaudeCodeSkills: boolean,
@@ -253,7 +253,7 @@ export const layer = Layer.effect(
const discovery = yield* Discovery.Service
const config = yield* Config.Service
const events = yield* EventV2Bridge.Service
const fsys = yield* AppFileSystem.Service
const fsys = yield* FSUtil.Service
const global = yield* Global.Service
const flags = yield* RuntimeFlags.Service
const discovered = yield* InstanceState.make(
@@ -322,7 +322,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Discovery.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Global.layer),
Layer.provide(RuntimeFlags.defaultLayer),
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,14 @@
import * as Log from "@opencode-ai/core/util/log"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { Git } from "@/git"
const log = Log.create({ service: "storage" })
type Migration = (
dir: string,
fs: AppFileSystem.Interface,
git: Git.Interface,
) => Effect.Effect<void, AppFileSystem.Error>
type Migration = (dir: string, fs: FSUtil.Interface, git: Git.Interface) => Effect.Effect<void, FSUtil.Error>
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("NotFoundError", {
message: Schema.String,
@@ -22,7 +18,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Not
}
}
export type Error = AppFileSystem.Error | NotFoundError
export type Error = FSUtil.Error | NotFoundError
const RootFile = Schema.Struct({
path: Schema.optional(
@@ -57,11 +53,11 @@ const decodeMessage = Schema.decodeUnknownOption(MessageFile)
const decodeSummary = Schema.decodeUnknownOption(SummaryFile)
export interface Interface {
readonly remove: (key: string[]) => Effect.Effect<void, AppFileSystem.Error>
readonly remove: (key: string[]) => Effect.Effect<void, FSUtil.Error>
readonly read: <T>(key: string[]) => Effect.Effect<T, Error>
readonly update: <T>(key: string[], fn: (draft: T) => void) => Effect.Effect<T, Error>
readonly write: <T>(key: string[], content: T) => Effect.Effect<void, AppFileSystem.Error>
readonly list: (prefix: string[]) => Effect.Effect<string[][], AppFileSystem.Error>
readonly write: <T>(key: string[], content: T) => Effect.Effect<void, FSUtil.Error>
readonly list: (prefix: string[]) => Effect.Effect<string[][], FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Storage") {}
@@ -85,7 +81,7 @@ function parseMigration(text: string) {
}
const MIGRATIONS: Migration[] = [
Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface, git: Git.Interface) {
Effect.fn("Storage.migration.1")(function* (dir: string, fs: FSUtil.Interface, git: Git.Interface) {
const project = path.resolve(dir, "../project")
if (!(yield* fs.isDir(project))) return
const projectDirs = yield* fs.glob("*", {
@@ -185,7 +181,7 @@ const MIGRATIONS: Migration[] = [
}
}
}),
Effect.fn("Storage.migration.2")(function* (dir: string, fs: AppFileSystem.Interface) {
Effect.fn("Storage.migration.2")(function* (dir: string, fs: FSUtil.Interface) {
for (const item of yield* fs.glob("session/*/*.json", {
cwd: dir,
absolute: true,
@@ -219,7 +215,7 @@ const MIGRATIONS: Migration[] = [
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const locks = yield* RcMap.make({
lookup: () => TxReentrantLock.make(),
@@ -251,7 +247,7 @@ export const layer = Layer.effect(
const fail = (target: string): Effect.Effect<never, NotFoundError> =>
Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` }))
const wrap = <A>(target: string, body: Effect.Effect<A, AppFileSystem.Error>) =>
const wrap = <A>(target: string, body: Effect.Effect<A, FSUtil.Error>) =>
body.pipe(Effect.catchIf(missing, () => fail(target)))
const writeJson = Effect.fnUntraced(function* (target: string, content: unknown) {
@@ -261,7 +257,7 @@ export const layer = Layer.effect(
const withResolved = <A, E>(
key: string[],
fn: (target: string, rw: TxReentrantLock.TxReentrantLock) => Effect.Effect<A, E>,
): Effect.Effect<A, E | AppFileSystem.Error> =>
): Effect.Effect<A, E | FSUtil.Error> =>
Effect.scoped(
Effect.gen(function* () {
const target = file((yield* state).dir, key)
@@ -328,6 +324,6 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer))
export * as Storage from "./storage"

View File

@@ -2,16 +2,16 @@ import * as path from "path"
import { Effect, Schema } from "effect"
import * as Tool from "./tool"
import { EventV2Bridge } from "@/event-v2-bridge"
import { FileWatcher } from "../file/watcher"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { InstanceState } from "@/effect/instance-state"
import { Patch } from "../patch"
import { createTwoFilesPatch, diffLines } from "diff"
import { assertExternalDirectoryEffect } from "./external-directory"
import { trimDiff } from "./edit"
import { LSP } from "@/lsp/lsp"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import DESCRIPTION from "./apply_patch.txt"
import { File } from "../file"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Format } from "../format"
import * as Bom from "@/util/bom"
@@ -23,7 +23,7 @@ export const ApplyPatchTool = Tool.define(
"apply_patch",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const afs = yield* AppFileSystem.Service
const afs = yield* FSUtil.Service
const format = yield* Format.Service
const events = yield* EventV2Bridge.Service
@@ -253,13 +253,13 @@ export const ApplyPatchTool = Tool.define(
if (yield* format.file(edited)) {
yield* Bom.syncFile(afs, edited, change.bom)
}
yield* events.publish(File.Event.Edited, { file: edited })
yield* events.publish(FileSystem.Event.Edited, { file: edited })
}
}
// Publish file change events
for (const update of updates) {
yield* events.publish(FileWatcher.Event.Updated, update)
yield* events.publish(Watcher.Event.Updated, update)
}
// Notify LSP of file changes and collect diagnostics
@@ -286,7 +286,7 @@ export const ApplyPatchTool = Tool.define(
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
const block = LSP.Diagnostic.report(target, diagnostics[AppFileSystem.normalizePath(target)] ?? [])
const block = LSP.Diagnostic.report(target, diagnostics[FSUtil.normalizePath(target)] ?? [])
if (!block) continue
const rel = path.relative(instance.worktree, target).replaceAll("\\", "/")
output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}`

View File

@@ -9,14 +9,14 @@ import * as Tool from "./tool"
import { LSP } from "@/lsp/lsp"
import { createTwoFilesPatch, diffLines } from "diff"
import DESCRIPTION from "./edit.txt"
import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import * as Bom from "@/util/bom"
function normalizeLineEndings(text: string): string {
@@ -35,7 +35,7 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
const locks = new Map<string, Semaphore.Semaphore>()
function lock(filePath: string) {
const resolvedFilePath = AppFileSystem.resolve(filePath)
const resolvedFilePath = FSUtil.resolve(filePath)
const hit = locks.get(resolvedFilePath)
if (hit) return hit
@@ -59,7 +59,7 @@ export const EditTool = Tool.define(
"edit",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const afs = yield* AppFileSystem.Service
const afs = yield* FSUtil.Service
const format = yield* Format.Service
const events = yield* EventV2Bridge.Service
@@ -108,8 +108,8 @@ export const EditTool = Tool.define(
if (yield* format.file(filePath)) {
contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
}
yield* events.publish(File.Event.Edited, { file: filePath })
yield* events.publish(FileWatcher.Event.Updated, {
yield* events.publish(FileSystem.Event.Edited, { file: filePath })
yield* events.publish(Watcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
})
@@ -152,8 +152,8 @@ export const EditTool = Tool.define(
if (yield* format.file(filePath)) {
contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
}
yield* events.publish(File.Event.Edited, { file: filePath })
yield* events.publish(FileWatcher.Event.Updated, {
yield* events.publish(FileSystem.Event.Edited, { file: filePath })
yield* events.publish(Watcher.Event.Updated, {
file: filePath,
event: "change",
})
@@ -192,7 +192,7 @@ export const EditTool = Tool.define(
let output = "Edit applied successfully."
yield* lsp.touchFile(filePath, "document")
const diagnostics = yield* lsp.diagnostics()
const normalizedFilePath = AppFileSystem.normalizePath(filePath)
const normalizedFilePath = FSUtil.normalizePath(filePath)
const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])
if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`

View File

@@ -4,7 +4,7 @@ import * as EffectLogger from "@opencode-ai/core/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import type * as Tool from "./tool"
import { containsPath } from "../project/instance-context"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
type Kind = "file" | "directory"
@@ -23,14 +23,14 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
if (options?.bypass) return
const ins = yield* InstanceState.context
const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
const full = process.platform === "win32" ? FSUtil.normalizePath(target) : target
if (containsPath(full, ins)) return
const kind = options?.kind ?? "file"
const dir = kind === "directory" ? full : path.dirname(full)
const glob =
process.platform === "win32"
? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
? FSUtil.normalizePathPattern(path.join(dir, "*"))
: path.join(dir, "*").replaceAll("\\", "/")
yield* ctx.ask({

View File

@@ -2,8 +2,8 @@ import path from "path"
import { Effect, Option, Schema } from "effect"
import * as Stream from "effect/Stream"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Ripgrep } from "../file/ripgrep"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./glob.txt"
import * as Tool from "./tool"
@@ -20,7 +20,7 @@ export const GlobTool = Tool.define(
"glob",
Effect.gen(function* () {
const rg = yield* Ripgrep.Service
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const reference = yield* Reference.Service
return {

View File

@@ -2,8 +2,8 @@ import path from "path"
import { Schema } from "effect"
import { Effect, Option } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Ripgrep } from "../file/ripgrep"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./grep.txt"
import * as Tool from "./tool"
@@ -24,7 +24,7 @@ export const Parameters = Schema.Struct({
export const GrepTool = Tool.define(
"grep",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const rg = yield* Ripgrep.Service
const reference = yield* Reference.Service
@@ -64,7 +64,7 @@ export const GrepTool = Tool.define(
kind: requestedInfo?.type === "Directory" ? "directory" : "file",
})
const search = AppFileSystem.resolve(requested)
const search = FSUtil.resolve(requested)
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
const cwd = info?.type === "Directory" ? search : path.dirname(search)
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
@@ -79,9 +79,7 @@ export const GrepTool = Tool.define(
if (result.items.length === 0) return empty
const rows = result.items.map((item) => ({
path: AppFileSystem.resolve(
path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text),
),
path: FSUtil.resolve(path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text)),
line: item.line_number,
text: item.lines.text,
}))

View File

@@ -6,7 +6,7 @@ import DESCRIPTION from "./lsp.txt"
import { InstanceState } from "@/effect/instance-state"
import { pathToFileURL } from "url"
import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
const operations = [
"goToDefinition",
@@ -38,7 +38,7 @@ export const LspTool = Tool.define(
"lsp",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
return {
description: DESCRIPTION,
parameters: Parameters,

View File

@@ -2,7 +2,7 @@ import { Effect, Option, Schema, Scope, Stream } from "effect"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import * as path from "path"
import * as Tool from "./tool"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { LSP } from "@/lsp/lsp"
import DESCRIPTION from "./read.txt"
import { InstanceState } from "@/effect/instance-state"
@@ -39,7 +39,7 @@ export const Parameters = Schema.Struct({
export const ReadTool = Tool.define(
"read",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const instruction = yield* Instruction.Service
const lsp = yield* LSP.Service
const reference = yield* Reference.Service
@@ -208,7 +208,7 @@ export const ReadTool = Tool.define(
filepath = path.resolve(instance.directory, filepath)
}
if (process.platform === "win32") {
filepath = AppFileSystem.normalizePath(filepath)
filepath = FSUtil.normalizePath(filepath)
}
yield* reference.ensure(filepath)
const title = path.relative(instance.worktree, filepath)
@@ -265,7 +265,7 @@ export const ReadTool = Tool.define(
const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)
const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES)
const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath))
const mime = sniffAttachmentMime(sample, FSUtil.mimeType(filepath))
const isImage = SUPPORTED_IMAGE_MIMES.has(mime)
if (isImage || isPdfAttachment(mime)) {

View File

@@ -34,7 +34,7 @@ import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
@@ -42,7 +42,7 @@ import { Question } from "../question"
import { Todo } from "../session/todo"
import { LSP } from "@/lsp/lsp"
import { Instruction } from "../session/instruction"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Agent } from "../agent/agent"
import { Skill } from "../skill"
@@ -96,7 +96,7 @@ export const layer: Layer.Layer<
| Reference.Service
| LSP.Service
| Instruction.Service
| AppFileSystem.Service
| FSUtil.Service
| EventV2Bridge.Service
| HttpClient.HttpClient
| ChildProcessSpawner
@@ -380,7 +380,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Reference.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer),

View File

@@ -9,7 +9,7 @@ import { InstanceState } from "@/effect/instance-state"
import { lazy } from "@/util/lazy"
import { Language, type Node } from "web-tree-sitter"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { fileURLToPath } from "url"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -266,7 +266,7 @@ const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boole
const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan) {
if (scan.dirs.size > 0) {
const globs = Array.from(scan.dirs).map((dir) => {
if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
if (process.platform === "win32") return FSUtil.normalizePathPattern(path.join(dir, "*"))
return path.join(dir, "*")
})
yield* ctx.ask({
@@ -336,7 +336,7 @@ export const ShellTool = Tool.define(
Effect.gen(function* () {
const config = yield* Config.Service
const spawner = yield* ChildProcessSpawner
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const trunc = yield* Truncate.Service
const plugin = yield* Plugin.Service
const flags = yield* RuntimeFlags.Service
@@ -348,16 +348,16 @@ export const ShellTool = Tool.define(
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
const file = lines[0]?.trim()
if (!file) return
return AppFileSystem.normalizePath(file)
return FSUtil.normalizePath(file)
})
const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) {
if (process.platform === "win32") {
if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) {
if (Shell.posix(shell) && text.startsWith("/") && FSUtil.windowsPath(text) === text) {
const file = yield* cygpath(shell, text)
if (file) return file
}
return AppFileSystem.normalizePath(path.resolve(root, AppFileSystem.windowsPath(text)))
return FSUtil.normalizePath(path.resolve(root, FSUtil.windowsPath(text)))
}
return path.resolve(root, text)
})

View File

@@ -2,7 +2,7 @@ import path from "path"
import { pathToFileURL } from "url"
import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream"
import { Ripgrep } from "../file/ripgrep"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { Skill } from "../skill"
import * as Tool from "./tool"
import DESCRIPTION from "./skill.txt"

View File

@@ -2,7 +2,7 @@ import { NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { evaluate } from "@/permission/evaluate"
import { Config } from "@/config/config"
import { Identifier } from "../id/id"
@@ -50,7 +50,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Tr
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
const cutoff = Identifier.timestamp(
@@ -155,6 +155,6 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer))
export * as Truncate from "./truncate"

View File

@@ -6,10 +6,10 @@ import { LSP } from "@/lsp/lsp"
import { createTwoFilesPatch } from "diff"
import DESCRIPTION from "./write.txt"
import { EventV2Bridge } from "@/event-v2-bridge"
import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Format } from "../format"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { InstanceState } from "@/effect/instance-state"
import { trimDiff } from "./edit"
import { assertExternalDirectoryEffect } from "./external-directory"
@@ -28,7 +28,7 @@ export const WriteTool = Tool.define(
"write",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const events = yield* EventV2Bridge.Service
const format = yield* Format.Service
@@ -65,8 +65,8 @@ export const WriteTool = Tool.define(
if (yield* format.file(filepath)) {
yield* Bom.syncFile(fs, filepath, desiredBom)
}
yield* events.publish(File.Event.Edited, { file: filepath })
yield* events.publish(FileWatcher.Event.Updated, {
yield* events.publish(FileSystem.Event.Edited, { file: filepath })
yield* events.publish(Watcher.Event.Updated, {
file: filepath,
event: exists ? "change" : "add",
})
@@ -74,7 +74,7 @@ export const WriteTool = Tool.define(
let output = "Wrote file successfully."
yield* lsp.touchFile(filepath, "document")
const diagnostics = yield* lsp.diagnostics()
const normalizedFilepath = AppFileSystem.normalizePath(filepath)
const normalizedFilepath = FSUtil.normalizePath(filepath)
let projectDiagnosticsCount = 0
for (const [file, issues] of Object.entries(diagnostics)) {
const current = file === normalizedFilepath

View File

@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
const BOM_CODE = 0xfeff
const BOM = String.fromCharCode(BOM_CODE)
@@ -15,15 +15,11 @@ export function join(text: string, bom: boolean) {
return BOM + stripped
}
export const readFile = Effect.fn("Bom.readFile")(function* (fs: AppFileSystem.Interface, filePath: string) {
export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filePath: string) {
return split(new TextDecoder("utf-8", { ignoreBOM: true }).decode(yield* fs.readFile(filePath)))
})
export const syncFile = Effect.fn("Bom.syncFile")(function* (
fs: AppFileSystem.Interface,
filePath: string,
bom: boolean,
) {
export const syncFile = Effect.fn("Bom.syncFile")(function* (fs: FSUtil.Interface, filePath: string, bom: boolean) {
const current = yield* readFile(fs, filePath)
if (current.bom === bom) return current.text
yield* fs.writeWithDirs(filePath, join(current.text, bom))

View File

@@ -1,14 +0,0 @@
import whichPkg from "which"
import path from "path"
import { Global } from "@opencode-ai/core/global"
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
const result = whichPkg.sync(cmd, {
nothrow: true,
path: full,
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
})
return typeof result === "string" ? result : null
}

View File

@@ -15,7 +15,7 @@ import { Git } from "@/git"
import { Effect, Layer, Path, Schema, Scope, Context } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { AppProcess } from "@opencode-ai/core/process"
import { InstanceState } from "@/effect/instance-state"
@@ -149,7 +149,7 @@ type GitResult = { code: number; text: string; stderr: string }
export const layer: Layer.Layer<
Service,
never,
| AppFileSystem.Service
| FSUtil.Service
| Path.Path
| AppProcess.Service
| Git.Service
@@ -160,7 +160,7 @@ export const layer: Layer.Layer<
Service,
Effect.gen(function* () {
const scope = yield* Scope.Scope
const fs = yield* AppFileSystem.Service
const fs = yield* FSUtil.Service
const pathSvc = yield* Path.Path
const appProcess = yield* AppProcess.Service
const { db } = yield* Database.Service
@@ -636,7 +636,7 @@ export const appLayer = layer.pipe(
Layer.provide(AppProcess.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(NodePath.layer),
)