refactor(core): consolidate filesystem services (#30447)
This commit is contained in:
@@ -269,13 +269,16 @@
|
|||||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||||
|
"@parcel/watcher": "2.5.1",
|
||||||
"ai-gateway-provider": "3.1.2",
|
"ai-gateway-provider": "3.1.2",
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.8.0",
|
"gitlab-ai-provider": "6.8.0",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
|
"ignore": "7.0.5",
|
||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
@@ -283,10 +286,19 @@
|
|||||||
"npm-package-arg": "13.0.2",
|
"npm-package-arg": "13.0.2",
|
||||||
"semver": "^7.6.3",
|
"semver": "^7.6.3",
|
||||||
"venice-ai-sdk-provider": "2.0.2",
|
"venice-ai-sdk-provider": "2.0.2",
|
||||||
|
"which": "6.0.1",
|
||||||
"xdg-basedir": "5.1.0",
|
"xdg-basedir": "5.1.0",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
|
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-arm64-musl": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-x64-musl": "2.5.1",
|
||||||
|
"@parcel/watcher-win32-arm64": "2.5.1",
|
||||||
|
"@parcel/watcher-win32-x64": "2.5.1",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/cross-spawn": "catalog:",
|
"@types/cross-spawn": "catalog:",
|
||||||
@@ -294,6 +306,7 @@
|
|||||||
"@types/npm-package-arg": "6.1.4",
|
"@types/npm-package-arg": "6.1.4",
|
||||||
"@types/npmcli__arborist": "6.3.3",
|
"@types/npmcli__arborist": "6.3.3",
|
||||||
"@types/semver": "catalog:",
|
"@types/semver": "catalog:",
|
||||||
|
"@types/which": "3.0.4",
|
||||||
"drizzle-kit": "catalog:",
|
"drizzle-kit": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -533,7 +546,6 @@
|
|||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
"htmlparser2": "8.0.2",
|
"htmlparser2": "8.0.2",
|
||||||
"ignore": "7.0.5",
|
|
||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
@@ -555,7 +567,6 @@
|
|||||||
"venice-ai-sdk-provider": "2.0.2",
|
"venice-ai-sdk-provider": "2.0.2",
|
||||||
"vscode-jsonrpc": "8.2.1",
|
"vscode-jsonrpc": "8.2.1",
|
||||||
"web-tree-sitter": "0.25.10",
|
"web-tree-sitter": "0.25.10",
|
||||||
"which": "6.0.1",
|
|
||||||
"ws": "8.21.0",
|
"ws": "8.21.0",
|
||||||
"xdg-basedir": "5.1.0",
|
"xdg-basedir": "5.1.0",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
@@ -567,14 +578,6 @@
|
|||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/http-recorder": "workspace:*",
|
"@opencode-ai/http-recorder": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
|
||||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-arm64-musl": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-x64-musl": "2.5.1",
|
|
||||||
"@parcel/watcher-win32-arm64": "2.5.1",
|
|
||||||
"@parcel/watcher-win32-x64": "2.5.1",
|
|
||||||
"@standard-schema/spec": "1.0.0",
|
"@standard-schema/spec": "1.0.0",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/babel__core": "7.20.5",
|
"@types/babel__core": "7.20.5",
|
||||||
@@ -584,7 +587,6 @@
|
|||||||
"@types/npm-package-arg": "6.1.4",
|
"@types/npm-package-arg": "6.1.4",
|
||||||
"@types/semver": "^7.5.8",
|
"@types/semver": "^7.5.8",
|
||||||
"@types/turndown": "5.0.5",
|
"@types/turndown": "5.0.5",
|
||||||
"@types/which": "3.0.4",
|
|
||||||
"@types/yargs": "17.0.33",
|
"@types/yargs": "17.0.33",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
|
|||||||
@@ -1644,7 +1644,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
serverSDK.client.file
|
serverSDK.client.vcs
|
||||||
.status({ directory: props.directory })
|
.status({ directory: props.directory })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
const files = x.data ?? []
|
const files = x.data ?? []
|
||||||
@@ -1712,7 +1712,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
serverSDK.client.file
|
serverSDK.client.vcs
|
||||||
.status({ directory: props.directory })
|
.status({ directory: props.directory })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
const files = x.data ?? []
|
const files = x.data ?? []
|
||||||
|
|||||||
@@ -33,6 +33,15 @@
|
|||||||
"@types/npm-package-arg": "6.1.4",
|
"@types/npm-package-arg": "6.1.4",
|
||||||
"@types/npmcli__arborist": "6.3.3",
|
"@types/npmcli__arborist": "6.3.3",
|
||||||
"@types/semver": "catalog:",
|
"@types/semver": "catalog:",
|
||||||
|
"@types/which": "3.0.4",
|
||||||
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
|
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-arm64-musl": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-x64-musl": "2.5.1",
|
||||||
|
"@parcel/watcher-win32-arm64": "2.5.1",
|
||||||
|
"@parcel/watcher-win32-x64": "2.5.1",
|
||||||
"drizzle-kit": "catalog:"
|
"drizzle-kit": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -68,21 +77,25 @@
|
|||||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||||
|
"@parcel/watcher": "2.5.1",
|
||||||
"@openrouter/ai-sdk-provider": "2.8.1",
|
"@openrouter/ai-sdk-provider": "2.8.1",
|
||||||
"ai-gateway-provider": "3.1.2",
|
"ai-gateway-provider": "3.1.2",
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.8.0",
|
"gitlab-ai-provider": "6.8.0",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
|
"ignore": "7.0.5",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.5",
|
||||||
"npm-package-arg": "13.0.2",
|
"npm-package-arg": "13.0.2",
|
||||||
"semver": "^7.6.3",
|
"semver": "^7.6.3",
|
||||||
"venice-ai-sdk-provider": "2.0.2",
|
"venice-ai-sdk-provider": "2.0.2",
|
||||||
|
"which": "6.0.1",
|
||||||
"xdg-basedir": "5.1.0",
|
"xdg-basedir": "5.1.0",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
|
|||||||
import { Identifier } from "./util/identifier"
|
import { Identifier } from "./util/identifier"
|
||||||
import { NonNegativeInt, withStatics } from "./schema"
|
import { NonNegativeInt, withStatics } from "./schema"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { AppFileSystem } from "./filesystem"
|
import { FSUtil } from "./fs-util"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
|
|
||||||
export const ID = Schema.String.pipe(
|
export const ID = Schema.String.pipe(
|
||||||
@@ -131,7 +131,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fsys = yield* AppFileSystem.Service
|
const fsys = yield* FSUtil.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const file = path.join(global.data, "account.json")
|
const file = path.join(global.data, "account.json")
|
||||||
@@ -334,7 +334,7 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Global.defaultLayer),
|
Layer.provide(Global.defaultLayer),
|
||||||
Layer.provide(EventV2.defaultLayer),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export * as Config from "./config"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { type ParseError, parse } from "jsonc-parser"
|
import { type ParseError, parse } from "jsonc-parser"
|
||||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||||
import { AppFileSystem } from "./filesystem"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { PermissionV2 } from "./permission"
|
import { PermissionV2 } from "./permission"
|
||||||
@@ -127,7 +127,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const policy = yield* Policy.Service
|
const policy = yield* Policy.Service
|
||||||
|
|||||||
+253
-231
@@ -1,247 +1,269 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
export * as FileSystem from "./filesystem"
|
||||||
import { dirname, join, relative, resolve as pathResolve } from "path"
|
|
||||||
import { realpathSync } from "fs"
|
|
||||||
import * as NFS from "fs/promises"
|
|
||||||
import { lookup } from "mime-types"
|
|
||||||
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
|
||||||
import type { PlatformError } from "effect/PlatformError"
|
|
||||||
import { Glob } from "./util/glob"
|
|
||||||
import { serviceUse } from "./effect/service-use"
|
|
||||||
|
|
||||||
export namespace AppFileSystem {
|
import path from "path"
|
||||||
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
import { pathToFileURL } from "url"
|
||||||
method: Schema.String,
|
import fuzzysort from "fuzzysort"
|
||||||
cause: Schema.optional(Schema.Defect),
|
import ignore from "ignore"
|
||||||
}) {}
|
import { Context, Effect, Layer, Schema, Stream } from "effect"
|
||||||
|
import { EventV2 } from "./event"
|
||||||
|
import { FSUtil } from "./fs-util"
|
||||||
|
import { Global } from "./global"
|
||||||
|
import { Location } from "./location"
|
||||||
|
import { ProjectReference } from "./project-reference"
|
||||||
|
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||||
|
import { Protected } from "./filesystem/protected"
|
||||||
|
import { Ripgrep } from "./filesystem/ripgrep"
|
||||||
|
|
||||||
export type Error = PlatformError | FileSystemError
|
export const ReadInput = Schema.Struct({
|
||||||
|
path: RelativePath,
|
||||||
|
reference: Schema.String.pipe(Schema.optional),
|
||||||
|
})
|
||||||
|
export type ReadInput = typeof ReadInput.Type
|
||||||
|
|
||||||
export interface DirEntry {
|
export class TextContent extends Schema.Class<TextContent>("LocationFileSystem.TextContent")({
|
||||||
readonly name: string
|
type: Schema.Literal("text"),
|
||||||
readonly type: "file" | "directory" | "symlink" | "other"
|
content: Schema.String,
|
||||||
}
|
mime: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
export interface Interface extends FileSystem.FileSystem {
|
export class BinaryContent extends Schema.Class<BinaryContent>("LocationFileSystem.BinaryContent")({
|
||||||
readonly isDir: (path: string) => Effect.Effect<boolean>
|
type: Schema.Literal("binary"),
|
||||||
readonly isFile: (path: string) => Effect.Effect<boolean>
|
content: Schema.String,
|
||||||
readonly existsSafe: (path: string) => Effect.Effect<boolean>
|
encoding: Schema.Literal("base64"),
|
||||||
readonly readFileStringSafe: (path: string) => Effect.Effect<string | undefined, Error>
|
mime: Schema.String,
|
||||||
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
|
}) {}
|
||||||
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
|
|
||||||
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
|
|
||||||
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
|
|
||||||
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
|
|
||||||
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
|
||||||
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
|
|
||||||
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
|
||||||
readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
|
|
||||||
readonly globMatch: (pattern: string, filepath: string) => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type"))
|
||||||
|
export type Content = typeof Content.Type
|
||||||
|
|
||||||
export const use = serviceUse(Service)
|
export const ListInput = Schema.Struct({
|
||||||
|
path: RelativePath.pipe(Schema.optional),
|
||||||
|
reference: Schema.String.pipe(Schema.optional),
|
||||||
|
})
|
||||||
|
export type ListInput = typeof ListInput.Type
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export class Entry extends Schema.Class<Entry>("LocationFileSystem.Entry")({
|
||||||
Service,
|
path: RelativePath,
|
||||||
Effect.gen(function* () {
|
uri: Schema.String,
|
||||||
const fs = yield* FileSystem.FileSystem
|
type: Schema.Literals(["file", "directory"]),
|
||||||
|
mime: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
|
export const FindInput = Schema.Struct({
|
||||||
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
|
query: Schema.String,
|
||||||
|
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
|
||||||
|
limit: PositiveInt.pipe(Schema.optional),
|
||||||
|
})
|
||||||
|
export type FindInput = typeof FindInput.Type
|
||||||
|
|
||||||
|
export const GrepInput = Schema.Struct({
|
||||||
|
pattern: Schema.String,
|
||||||
|
include: Schema.String.pipe(Schema.optional),
|
||||||
|
limit: PositiveInt.pipe(Schema.optional),
|
||||||
|
})
|
||||||
|
export type GrepInput = typeof GrepInput.Type
|
||||||
|
|
||||||
|
export class GrepMatch extends Schema.Class<GrepMatch>("LocationFileSystem.GrepMatch")({
|
||||||
|
path: RelativePath,
|
||||||
|
lines: Schema.String,
|
||||||
|
line: PositiveInt,
|
||||||
|
offset: NonNegativeInt,
|
||||||
|
submatches: Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
text: Schema.String,
|
||||||
|
start: NonNegativeInt,
|
||||||
|
end: NonNegativeInt,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Edited: EventV2.define({
|
||||||
|
type: "file.edited",
|
||||||
|
schema: {
|
||||||
|
file: Schema.String,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly read: (input: ReadInput) => Effect.Effect<Content>
|
||||||
|
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||||
|
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||||
|
readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
|
||||||
|
readonly isIgnored: (path: RelativePath, type: "file" | "directory") => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const references = yield* ProjectReference.Service
|
||||||
|
const ripgrep = yield* Ripgrep.Service
|
||||||
|
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
|
||||||
|
const ignored = ignore()
|
||||||
|
const gitignore = yield* fs
|
||||||
|
.readFileString(path.join(location.project.directory, ".gitignore"))
|
||||||
|
.pipe(Effect.catch(() => Effect.succeed("")))
|
||||||
|
if (gitignore) ignored.add(gitignore)
|
||||||
|
const ignorefile = yield* fs
|
||||||
|
.readFileString(path.join(location.project.directory, ".ignore"))
|
||||||
|
.pipe(Effect.catch(() => Effect.succeed("")))
|
||||||
|
if (ignorefile) ignored.add(ignorefile)
|
||||||
|
const select = Effect.fnUntraced(function* (reference?: string) {
|
||||||
|
if (!reference) return { directory: location.directory, root }
|
||||||
|
const resolved = yield* references.get(reference)
|
||||||
|
if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`))
|
||||||
|
if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message))
|
||||||
|
if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
|
||||||
|
return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
|
||||||
|
})
|
||||||
|
const resolve = Effect.fnUntraced(function* (input?: RelativePath, reference?: string) {
|
||||||
|
if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location"))
|
||||||
|
const selected = yield* select(reference)
|
||||||
|
const absolute = path.resolve(selected.directory, input ?? ".")
|
||||||
|
if (!FSUtil.contains(selected.directory, absolute))
|
||||||
|
return yield* Effect.die(new Error("Path escapes the location"))
|
||||||
|
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
||||||
|
if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
||||||
|
return { absolute, real, ...selected }
|
||||||
|
})
|
||||||
|
const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) {
|
||||||
|
const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
|
||||||
|
if (!real) return
|
||||||
|
if (!FSUtil.contains(selected.root, real)) return
|
||||||
|
const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
|
||||||
|
if (!info) return
|
||||||
|
const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined
|
||||||
|
if (!type) return
|
||||||
|
return new Entry({
|
||||||
|
path: RelativePath.make(path.relative(selected.directory, absolute)),
|
||||||
|
uri: pathToFileURL(real).href,
|
||||||
|
type,
|
||||||
|
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(real),
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
|
const scan = Effect.fnUntraced(function* () {
|
||||||
return yield* fs
|
if (location.directory === Global.Path.home && location.project.id === "global") {
|
||||||
.readFileString(path)
|
const protectedNames = Protected.names()
|
||||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
const nested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||||
})
|
return (yield* Effect.forEach(
|
||||||
|
yield* fs.readDirectoryEntries(location.directory).pipe(Effect.orElseSucceed(() => [])),
|
||||||
|
(item) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (item.type !== "directory" || item.name.startsWith(".") || protectedNames.has(item.name)) return []
|
||||||
|
const directory = path.join(location.directory, item.name)
|
||||||
|
return [
|
||||||
|
item.name + "/",
|
||||||
|
...(yield* fs.readDirectoryEntries(directory).pipe(Effect.orElseSucceed(() => []))).flatMap((child) =>
|
||||||
|
child.type === "directory" && !child.name.startsWith(".") && !nested.has(child.name)
|
||||||
|
? [`${item.name}/${child.name}/`]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
)).flat()
|
||||||
|
}
|
||||||
|
|
||||||
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
|
const files = Array.from(yield* ripgrep.files({ cwd: location.directory }).pipe(Stream.runCollect, Effect.orDie))
|
||||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
const dirs = new Set<string>()
|
||||||
return info?.type === "Directory"
|
for (const file of files) {
|
||||||
})
|
let current = file
|
||||||
|
while (true) {
|
||||||
|
const directory = path.dirname(current)
|
||||||
|
if (directory === "." || directory === current) break
|
||||||
|
current = directory
|
||||||
|
dirs.add(directory + "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...files, ...dirs]
|
||||||
|
})
|
||||||
|
|
||||||
const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
|
return Service.of({
|
||||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
read: Effect.fn("FileSystem.read")(function* (input) {
|
||||||
return info?.type === "File"
|
const file = yield* resolve(input.path, input.reference)
|
||||||
})
|
const info = yield* fs.stat(file.real).pipe(Effect.orDie)
|
||||||
|
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||||
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
|
const bytes = yield* fs.readFile(file.real).pipe(Effect.orDie)
|
||||||
return yield* Effect.tryPromise({
|
const mime = FSUtil.mimeType(file.real)
|
||||||
try: async () => {
|
if (!bytes.includes(0)) {
|
||||||
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
|
const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
|
||||||
return entries.map(
|
Effect.option,
|
||||||
(e): DirEntry => ({
|
)
|
||||||
name: e.name,
|
if (content._tag === "Some") return new TextContent({ type: "text", content: content.value, mime })
|
||||||
type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
|
}
|
||||||
}),
|
return new BinaryContent({
|
||||||
)
|
type: "binary",
|
||||||
},
|
content: Buffer.from(bytes).toString("base64"),
|
||||||
catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
|
encoding: "base64",
|
||||||
|
mime,
|
||||||
})
|
})
|
||||||
})
|
}),
|
||||||
|
list: Effect.fn("FileSystem.list")(function* (input = {}) {
|
||||||
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
|
const directory = yield* resolve(input.path, input.reference)
|
||||||
const text = yield* fs.readFileString(path)
|
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
|
||||||
return JSON.parse(text)
|
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||||
})
|
return yield* fs.readDirectoryEntries(directory.real).pipe(
|
||||||
|
Effect.orDie,
|
||||||
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
|
Effect.flatMap((items) =>
|
||||||
const content = JSON.stringify(data, null, 2)
|
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
|
||||||
yield* fs.writeFileString(path, content)
|
concurrency: "unbounded",
|
||||||
if (mode) yield* fs.chmod(path, mode)
|
}),
|
||||||
})
|
),
|
||||||
|
Effect.map((items) =>
|
||||||
const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
|
items
|
||||||
yield* fs.makeDirectory(path, { recursive: true })
|
.filter((item): item is Entry => item !== undefined)
|
||||||
})
|
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
|
||||||
|
|
||||||
const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
|
|
||||||
path: string,
|
|
||||||
content: string | Uint8Array,
|
|
||||||
mode?: number,
|
|
||||||
) {
|
|
||||||
const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
|
|
||||||
|
|
||||||
yield* write.pipe(
|
|
||||||
Effect.catchIf(
|
|
||||||
(e) => e.reason._tag === "NotFound",
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* fs.makeDirectory(dirname(path), { recursive: true })
|
|
||||||
yield* write
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (mode) yield* fs.chmod(path, mode)
|
}),
|
||||||
})
|
find: Effect.fn("FileSystem.find")(function* (input) {
|
||||||
|
const items = (yield* scan()).filter((item) => input.type !== "file" || !item.endsWith("/"))
|
||||||
|
const filtered = items.filter((item) => input.type !== "directory" || item.endsWith("/"))
|
||||||
|
const sorted = input.query.trim()
|
||||||
|
? fuzzysort.go(input.query.trim(), filtered, { limit: input.limit ?? 100 }).map((item) => item.target)
|
||||||
|
: filtered.slice(0, input.limit)
|
||||||
|
return yield* Effect.forEach(sorted, (item) => entry(path.join(location.directory, item))).pipe(
|
||||||
|
Effect.map((items) => items.filter((item): item is Entry => item !== undefined)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
grep: Effect.fn("FileSystem.grep")(function* (input) {
|
||||||
|
return (yield* ripgrep
|
||||||
|
.search({
|
||||||
|
cwd: location.directory,
|
||||||
|
pattern: input.pattern,
|
||||||
|
glob: input.include ? [input.include] : undefined,
|
||||||
|
limit: input.limit,
|
||||||
|
})
|
||||||
|
.pipe(Effect.orDie)).items.map(
|
||||||
|
(item) =>
|
||||||
|
new GrepMatch({
|
||||||
|
path: RelativePath.make(item.path.text),
|
||||||
|
lines: item.lines.text,
|
||||||
|
line: item.line_number,
|
||||||
|
offset: item.absolute_offset,
|
||||||
|
submatches: item.submatches.map((submatch) => ({
|
||||||
|
text: submatch.match.text,
|
||||||
|
start: submatch.start,
|
||||||
|
end: submatch.end,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
isIgnored: (input, type) =>
|
||||||
|
ignored.ignores(
|
||||||
|
path.relative(location.project.directory, path.join(location.directory, input)) +
|
||||||
|
(type === "directory" ? "/" : ""),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
|
export const locationLayer = layer.pipe(
|
||||||
return yield* Effect.tryPromise({
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
try: () => Glob.scan(pattern, options),
|
Layer.provideMerge(ProjectReference.locationLayer),
|
||||||
catch: (cause) => new FileSystemError({ method: "glob", cause }),
|
)
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
|
|
||||||
const result: string[] = []
|
|
||||||
let current = start
|
|
||||||
while (true) {
|
|
||||||
const search = join(current, target)
|
|
||||||
if (yield* fs.exists(search)) result.push(search)
|
|
||||||
if (stop === current) break
|
|
||||||
const parent = dirname(current)
|
|
||||||
if (parent === current) break
|
|
||||||
current = parent
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
|
|
||||||
const result: string[] = []
|
|
||||||
let current = options.start
|
|
||||||
while (true) {
|
|
||||||
for (const target of options.targets) {
|
|
||||||
const search = join(current, target)
|
|
||||||
if (yield* fs.exists(search)) result.push(search)
|
|
||||||
}
|
|
||||||
if (options.stop === current) break
|
|
||||||
const parent = dirname(current)
|
|
||||||
if (parent === current) break
|
|
||||||
current = parent
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
|
|
||||||
const result: string[] = []
|
|
||||||
let current = start
|
|
||||||
while (true) {
|
|
||||||
const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
|
|
||||||
Effect.catch(() => Effect.succeed([] as string[])),
|
|
||||||
)
|
|
||||||
result.push(...matches)
|
|
||||||
if (stop === current) break
|
|
||||||
const parent = dirname(current)
|
|
||||||
if (parent === current) break
|
|
||||||
current = parent
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
...fs,
|
|
||||||
existsSafe,
|
|
||||||
readFileStringSafe,
|
|
||||||
isDir,
|
|
||||||
isFile,
|
|
||||||
readDirectoryEntries,
|
|
||||||
readJson,
|
|
||||||
writeJson,
|
|
||||||
ensureDir,
|
|
||||||
writeWithDirs,
|
|
||||||
findUp,
|
|
||||||
up,
|
|
||||||
globUp,
|
|
||||||
glob,
|
|
||||||
globMatch: Glob.match,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
|
||||||
|
|
||||||
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
|
||||||
export function mimeType(p: string): string {
|
|
||||||
return lookup(p) || "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePath(p: string): string {
|
|
||||||
if (process.platform !== "win32") return p
|
|
||||||
const resolved = pathResolve(windowsPath(p))
|
|
||||||
try {
|
|
||||||
return realpathSync.native(resolved)
|
|
||||||
} catch {
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePathPattern(p: string): string {
|
|
||||||
if (process.platform !== "win32") return p
|
|
||||||
if (p === "*") return p
|
|
||||||
const match = p.match(/^(.*)[\\/]\*$/)
|
|
||||||
if (!match) return normalizePath(p)
|
|
||||||
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
|
|
||||||
return join(normalizePath(dir), "*")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolve(p: string): string {
|
|
||||||
const resolved = pathResolve(windowsPath(p))
|
|
||||||
try {
|
|
||||||
return normalizePath(realpathSync(resolved))
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.code === "ENOENT") return normalizePath(resolved)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function windowsPath(p: string): string {
|
|
||||||
if (process.platform !== "win32") return p
|
|
||||||
return p
|
|
||||||
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
|
||||||
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
|
||||||
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
|
||||||
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function overlaps(a: string, b: string) {
|
|
||||||
const relA = relative(a, b)
|
|
||||||
const relB = relative(b, a)
|
|
||||||
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function contains(parent: string, child: string) {
|
|
||||||
return !relative(parent, child).startsWith("..")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Glob } from "@opencode-ai/core/util/glob"
|
import { Glob } from "../util/glob"
|
||||||
|
|
||||||
const FOLDERS = new Set([
|
const FOLDERS = new Set([
|
||||||
"node_modules",
|
"node_modules",
|
||||||
@@ -34,48 +34,34 @@ const FOLDERS = new Set([
|
|||||||
const FILES = [
|
const FILES = [
|
||||||
"**/*.swp",
|
"**/*.swp",
|
||||||
"**/*.swo",
|
"**/*.swo",
|
||||||
|
|
||||||
"**/*.pyc",
|
"**/*.pyc",
|
||||||
|
|
||||||
// OS
|
|
||||||
"**/.DS_Store",
|
"**/.DS_Store",
|
||||||
"**/Thumbs.db",
|
"**/Thumbs.db",
|
||||||
|
|
||||||
// Logs & temp
|
|
||||||
"**/logs/**",
|
"**/logs/**",
|
||||||
"**/tmp/**",
|
"**/tmp/**",
|
||||||
"**/temp/**",
|
"**/temp/**",
|
||||||
"**/*.log",
|
"**/*.log",
|
||||||
|
|
||||||
// Coverage/test outputs
|
|
||||||
"**/coverage/**",
|
"**/coverage/**",
|
||||||
"**/.nyc_output/**",
|
"**/.nyc_output/**",
|
||||||
]
|
]
|
||||||
|
|
||||||
export const PATTERNS = [...FILES, ...FOLDERS]
|
export const PATTERNS = [...FILES, ...FOLDERS]
|
||||||
|
|
||||||
export function match(
|
export function match(filepath: string, opts?: { extra?: string[]; whitelist?: string[] }) {
|
||||||
filepath: string,
|
|
||||||
opts?: {
|
|
||||||
extra?: string[]
|
|
||||||
whitelist?: string[]
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
for (const pattern of opts?.whitelist || []) {
|
for (const pattern of opts?.whitelist || []) {
|
||||||
if (Glob.match(pattern, filepath)) return false
|
if (Glob.match(pattern, filepath)) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = filepath.split(/[/\\]/)
|
const parts = filepath.split(/[/\\]/)
|
||||||
for (let i = 0; i < parts.length; i++) {
|
for (const part of parts) {
|
||||||
if (FOLDERS.has(parts[i])) return true
|
if (FOLDERS.has(part)) return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const extra = opts?.extra || []
|
for (const pattern of [...FILES, ...(opts?.extra || [])]) {
|
||||||
for (const pattern of [...FILES, ...extra]) {
|
|
||||||
if (Glob.match(pattern, filepath)) return true
|
if (Glob.match(pattern, filepath)) return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as FileIgnore from "./ignore"
|
export * as Ignore from "./ignore"
|
||||||
+4
-10
@@ -1,20 +1,15 @@
|
|||||||
import path from "path"
|
|
||||||
import os from "os"
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
const home = os.homedir()
|
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 = [
|
const DARWIN_HOME = [
|
||||||
// Media
|
|
||||||
"Music",
|
"Music",
|
||||||
"Pictures",
|
"Pictures",
|
||||||
"Movies",
|
"Movies",
|
||||||
// User-managed folders synced via iCloud / subject to TCC
|
|
||||||
"Downloads",
|
"Downloads",
|
||||||
"Desktop",
|
"Desktop",
|
||||||
"Documents",
|
"Documents",
|
||||||
// Other system-managed
|
|
||||||
"Public",
|
"Public",
|
||||||
"Applications",
|
"Applications",
|
||||||
"Library",
|
"Library",
|
||||||
@@ -34,7 +29,6 @@ const DARWIN_LIBRARY = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes", "/.fseventsd"]
|
const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes", "/.fseventsd"]
|
||||||
|
|
||||||
const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"]
|
const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"]
|
||||||
|
|
||||||
/** Directory basenames to skip when scanning the home directory. */
|
/** Directory basenames to skip when scanning the home directory. */
|
||||||
@@ -48,11 +42,11 @@ export function names(): ReadonlySet<string> {
|
|||||||
export function paths(): string[] {
|
export function paths(): string[] {
|
||||||
if (process.platform === "darwin")
|
if (process.platform === "darwin")
|
||||||
return [
|
return [
|
||||||
...DARWIN_HOME.map((n) => path.join(home, n)),
|
...DARWIN_HOME.map((name) => path.join(home, name)),
|
||||||
...DARWIN_LIBRARY.map((n) => path.join(home, "Library", n)),
|
...DARWIN_LIBRARY.map((name) => path.join(home, "Library", name)),
|
||||||
...DARWIN_ROOT,
|
...DARWIN_ROOT,
|
||||||
]
|
]
|
||||||
if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n))
|
if (process.platform === "win32") return WIN32_HOME.map((name) => path.join(home, name))
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
import { serviceUse } from "../effect/service-use"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { FSUtil } from "../fs-util"
|
||||||
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
|
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
|
||||||
import type { PlatformError } from "effect/PlatformError"
|
import type { PlatformError } from "effect/PlatformError"
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
|
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "../global"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import { NonNegativeInt } from "../schema"
|
||||||
import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
|
import * as Log from "../util/log"
|
||||||
import { which } from "@/util/which"
|
import { sanitizedProcessEnv } from "../util/opencode-process"
|
||||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
import { which } from "../util/which"
|
||||||
|
|
||||||
const log = Log.create({ service: "ripgrep" })
|
const log = Log.create({ service: "ripgrep" })
|
||||||
const VERSION = "15.1.0"
|
const VERSION = "15.1.0"
|
||||||
@@ -224,11 +224,11 @@ function raceAbort<A, E, R>(effect: Effect.Effect<A, E, R>, signal?: AbortSignal
|
|||||||
return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect
|
return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect
|
||||||
}
|
}
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner | HttpClient.HttpClient> =
|
export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpawner | HttpClient.HttpClient> =
|
||||||
Layer.effect(
|
Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||||
const spawner = yield* ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner
|
||||||
|
|
||||||
@@ -477,7 +477,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildPro
|
|||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
export * as Watcher from "./watcher"
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||||
|
import type ParcelWatcher from "@parcel/watcher"
|
||||||
|
import { Cause, Context, Effect, Layer, Schema } from "effect"
|
||||||
|
import path from "path"
|
||||||
|
import { Config } from "../config"
|
||||||
|
import { EventV2 } from "../event"
|
||||||
|
import { Flag } from "../flag/flag"
|
||||||
|
import { FSUtil } from "../fs-util"
|
||||||
|
import { Git } from "../git"
|
||||||
|
import { Location } from "../location"
|
||||||
|
import { lazy } from "../util/lazy"
|
||||||
|
import * as Log from "../util/log"
|
||||||
|
import { Ignore } from "./ignore"
|
||||||
|
import { Protected } from "./protected"
|
||||||
|
|
||||||
|
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 libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
|
||||||
|
const binding = require(
|
||||||
|
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${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 relative = path.relative(dir, item)
|
||||||
|
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasNativeBinding = () => !!watcher()
|
||||||
|
|
||||||
|
export interface Interface {}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileWatcher") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
|
||||||
|
|
||||||
|
const backend = getBackend()
|
||||||
|
const location = yield* Location.Service
|
||||||
|
if (!backend) {
|
||||||
|
log.error("watcher backend not supported", { directory: location.directory, platform: process.platform })
|
||||||
|
return Service.of({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = watcher()
|
||||||
|
if (!w) return Service.of({})
|
||||||
|
|
||||||
|
log.info("watcher backend", { directory: location.directory, platform: process.platform, backend })
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const git = yield* Git.Service
|
||||||
|
const context = yield* Effect.context()
|
||||||
|
const runFork = Effect.runForkWith(context)
|
||||||
|
const subscriptions: ParcelWatcher.AsyncSubscription[] = []
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => {
|
||||||
|
for (const update of updates) {
|
||||||
|
if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" }))
|
||||||
|
if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" }))
|
||||||
|
if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribe = (directory: string, ignore: string[]) => {
|
||||||
|
const pending = w.subscribe(directory, callback, { ignore, backend })
|
||||||
|
return Effect.promise(() => pending).pipe(
|
||||||
|
Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))),
|
||||||
|
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||||
|
Effect.catchCause((cause) => {
|
||||||
|
log.error("failed to subscribe", { directory, cause: Cause.pretty(cause) })
|
||||||
|
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
|
||||||
|
return Effect.void
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = (yield* (yield* Config.Service).get()).flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||||
|
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||||
|
yield* Effect.forkScoped(
|
||||||
|
subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.vcs?.type === "git") {
|
||||||
|
const resolved = yield* git.dir(location.directory)
|
||||||
|
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
|
||||||
|
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
|
||||||
|
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
|
||||||
|
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
|
||||||
|
)
|
||||||
|
yield* Effect.forkScoped(subscribe(vcs, ignore))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Service.of({})
|
||||||
|
}).pipe(
|
||||||
|
Effect.catchCause((cause) => {
|
||||||
|
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||||
|
return Effect.succeed(Service.of({}))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
|
import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||||
|
import { realpathSync } from "fs"
|
||||||
|
import * as NFS from "fs/promises"
|
||||||
|
import { lookup } from "mime-types"
|
||||||
|
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||||
|
import type { PlatformError } from "effect/PlatformError"
|
||||||
|
import { Glob } from "./util/glob"
|
||||||
|
import { serviceUse } from "./effect/service-use"
|
||||||
|
|
||||||
|
export namespace FSUtil {
|
||||||
|
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||||
|
method: Schema.String,
|
||||||
|
cause: Schema.optional(Schema.Defect),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export type Error = PlatformError | FileSystemError
|
||||||
|
|
||||||
|
export interface DirEntry {
|
||||||
|
readonly name: string
|
||||||
|
readonly type: "file" | "directory" | "symlink" | "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface extends FileSystem.FileSystem {
|
||||||
|
readonly isDir: (path: string) => Effect.Effect<boolean>
|
||||||
|
readonly isFile: (path: string) => Effect.Effect<boolean>
|
||||||
|
readonly existsSafe: (path: string) => Effect.Effect<boolean>
|
||||||
|
readonly readFileStringSafe: (path: string) => Effect.Effect<string | undefined, Error>
|
||||||
|
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
|
||||||
|
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
|
||||||
|
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
|
||||||
|
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
|
||||||
|
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
|
||||||
|
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||||
|
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
|
||||||
|
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||||
|
readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
|
||||||
|
readonly globMatch: (pattern: string, filepath: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
||||||
|
|
||||||
|
export const use = serviceUse(Service)
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
|
||||||
|
const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
|
||||||
|
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
|
||||||
|
})
|
||||||
|
|
||||||
|
const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
|
||||||
|
return yield* fs
|
||||||
|
.readFileString(path)
|
||||||
|
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
|
||||||
|
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||||
|
return info?.type === "Directory"
|
||||||
|
})
|
||||||
|
|
||||||
|
const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
|
||||||
|
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||||
|
return info?.type === "File"
|
||||||
|
})
|
||||||
|
|
||||||
|
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
|
||||||
|
return yield* Effect.tryPromise({
|
||||||
|
try: async () => {
|
||||||
|
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
|
||||||
|
return entries.map(
|
||||||
|
(e): DirEntry => ({
|
||||||
|
name: e.name,
|
||||||
|
type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
|
||||||
|
const text = yield* fs.readFileString(path)
|
||||||
|
return JSON.parse(text)
|
||||||
|
})
|
||||||
|
|
||||||
|
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
|
||||||
|
const content = JSON.stringify(data, null, 2)
|
||||||
|
yield* fs.writeFileString(path, content)
|
||||||
|
if (mode) yield* fs.chmod(path, mode)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
|
||||||
|
yield* fs.makeDirectory(path, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
|
||||||
|
path: string,
|
||||||
|
content: string | Uint8Array,
|
||||||
|
mode?: number,
|
||||||
|
) {
|
||||||
|
const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
|
||||||
|
|
||||||
|
yield* write.pipe(
|
||||||
|
Effect.catchIf(
|
||||||
|
(e) => e.reason._tag === "NotFound",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* fs.makeDirectory(dirname(path), { recursive: true })
|
||||||
|
yield* write
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (mode) yield* fs.chmod(path, mode)
|
||||||
|
})
|
||||||
|
|
||||||
|
const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
|
||||||
|
return yield* Effect.tryPromise({
|
||||||
|
try: () => Glob.scan(pattern, options),
|
||||||
|
catch: (cause) => new FileSystemError({ method: "glob", cause }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
|
||||||
|
const result: string[] = []
|
||||||
|
let current = start
|
||||||
|
while (true) {
|
||||||
|
const search = join(current, target)
|
||||||
|
if (yield* fs.exists(search)) result.push(search)
|
||||||
|
if (stop === current) break
|
||||||
|
const parent = dirname(current)
|
||||||
|
if (parent === current) break
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
|
||||||
|
const result: string[] = []
|
||||||
|
let current = options.start
|
||||||
|
while (true) {
|
||||||
|
for (const target of options.targets) {
|
||||||
|
const search = join(current, target)
|
||||||
|
if (yield* fs.exists(search)) result.push(search)
|
||||||
|
}
|
||||||
|
if (options.stop === current) break
|
||||||
|
const parent = dirname(current)
|
||||||
|
if (parent === current) break
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
|
||||||
|
const result: string[] = []
|
||||||
|
let current = start
|
||||||
|
while (true) {
|
||||||
|
const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
|
||||||
|
Effect.catch(() => Effect.succeed([] as string[])),
|
||||||
|
)
|
||||||
|
result.push(...matches)
|
||||||
|
if (stop === current) break
|
||||||
|
const parent = dirname(current)
|
||||||
|
if (parent === current) break
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
...fs,
|
||||||
|
existsSafe,
|
||||||
|
readFileStringSafe,
|
||||||
|
isDir,
|
||||||
|
isFile,
|
||||||
|
readDirectoryEntries,
|
||||||
|
readJson,
|
||||||
|
writeJson,
|
||||||
|
ensureDir,
|
||||||
|
writeWithDirs,
|
||||||
|
findUp,
|
||||||
|
up,
|
||||||
|
globUp,
|
||||||
|
glob,
|
||||||
|
globMatch: Glob.match,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
||||||
|
|
||||||
|
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
||||||
|
export function mimeType(p: string): string {
|
||||||
|
return lookup(p) || "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePath(p: string): string {
|
||||||
|
if (process.platform !== "win32") return p
|
||||||
|
const resolved = pathResolve(windowsPath(p))
|
||||||
|
try {
|
||||||
|
return realpathSync.native(resolved)
|
||||||
|
} catch {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePathPattern(p: string): string {
|
||||||
|
if (process.platform !== "win32") return p
|
||||||
|
if (p === "*") return p
|
||||||
|
const match = p.match(/^(.*)[\\/]\*$/)
|
||||||
|
if (!match) return normalizePath(p)
|
||||||
|
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
|
||||||
|
return join(normalizePath(dir), "*")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolve(p: string): string {
|
||||||
|
const resolved = pathResolve(windowsPath(p))
|
||||||
|
try {
|
||||||
|
return normalizePath(realpathSync(resolved))
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === "ENOENT") return normalizePath(resolved)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function windowsPath(p: string): string {
|
||||||
|
if (process.platform !== "win32") return p
|
||||||
|
return p
|
||||||
|
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||||
|
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||||
|
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||||
|
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overlaps(a: string, b: string) {
|
||||||
|
const relA = relative(a, b)
|
||||||
|
const relB = relative(b, a)
|
||||||
|
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contains(parent: string, child: string) {
|
||||||
|
return !relative(parent, child).startsWith("..")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import path from "path"
|
|||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
import { AppFileSystem } from "./filesystem"
|
import { FSUtil } from "./fs-util"
|
||||||
import { AppProcess } from "./process"
|
import { AppProcess } from "./process"
|
||||||
|
|
||||||
export interface Repo {
|
export interface Repo {
|
||||||
@@ -32,6 +32,7 @@ export interface Interface {
|
|||||||
readonly roots: (repo: Repo) => Effect.Effect<string[]>
|
readonly roots: (repo: Repo) => Effect.Effect<string[]>
|
||||||
readonly origin: (directory: string) => Effect.Effect<string | undefined>
|
readonly origin: (directory: string) => Effect.Effect<string | undefined>
|
||||||
readonly head: (directory: string) => Effect.Effect<string | undefined>
|
readonly head: (directory: string) => Effect.Effect<string | undefined>
|
||||||
|
readonly dir: (directory: string) => Effect.Effect<string | undefined>
|
||||||
readonly branch: (directory: string) => Effect.Effect<string | undefined>
|
readonly branch: (directory: string) => Effect.Effect<string | undefined>
|
||||||
readonly remoteHead: (directory: string) => Effect.Effect<string | undefined>
|
readonly remoteHead: (directory: string) => Effect.Effect<string | undefined>
|
||||||
readonly clone: (input: {
|
readonly clone: (input: {
|
||||||
@@ -51,7 +52,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Gi
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const proc = yield* AppProcess.Service
|
const proc = yield* AppProcess.Service
|
||||||
|
|
||||||
const find = Effect.fn("Git.find")(function* (input: AbsolutePath) {
|
const find = Effect.fn("Git.find")(function* (input: AbsolutePath) {
|
||||||
@@ -101,6 +102,12 @@ export const layer = Layer.effect(
|
|||||||
return result.text.trim() || undefined
|
return result.text.trim() || undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const dir = Effect.fn("Git.dir")(function* (directory: string) {
|
||||||
|
const result = yield* run(directory, proc)(["rev-parse", "--git-dir"])
|
||||||
|
if (result.exitCode !== 0) return undefined
|
||||||
|
return AbsolutePath.make(resolvePath(directory, result.text))
|
||||||
|
})
|
||||||
|
|
||||||
const branch = Effect.fn("Git.branch")(function* (directory: string) {
|
const branch = Effect.fn("Git.branch")(function* (directory: string) {
|
||||||
const result = yield* run(directory, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
const result = yield* run(directory, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||||
if (result.exitCode !== 0) return undefined
|
if (result.exitCode !== 0) return undefined
|
||||||
@@ -148,6 +155,7 @@ export const layer = Layer.effect(
|
|||||||
roots,
|
roots,
|
||||||
origin,
|
origin,
|
||||||
head,
|
head,
|
||||||
|
dir,
|
||||||
branch,
|
branch,
|
||||||
remoteHead,
|
remoteHead,
|
||||||
clone,
|
clone,
|
||||||
@@ -159,10 +167,7 @@ export const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(AppProcess.defaultLayer),
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface Result {
|
export interface Result {
|
||||||
readonly exitCode: number
|
readonly exitCode: number
|
||||||
@@ -200,7 +205,7 @@ function execute(cwd: string, proc: AppProcess.Interface) {
|
|||||||
function resolvePath(cwd: string, value: string) {
|
function resolvePath(cwd: string, value: string) {
|
||||||
const trimmed = value.replace(/[\r\n]+$/, "")
|
const trimmed = value.replace(/[\r\n]+$/, "")
|
||||||
if (!trimmed) return cwd
|
if (!trimmed) return cwd
|
||||||
const normalized = AppFileSystem.windowsPath(trimmed)
|
const normalized = FSUtil.windowsPath(trimmed)
|
||||||
if (path.isAbsolute(normalized)) return path.normalize(normalized)
|
if (path.isAbsolute(normalized)) return path.normalize(normalized)
|
||||||
return path.resolve(cwd, normalized)
|
return path.resolve(cwd, normalized)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
export * as LocationFileSystem from "./location-filesystem"
|
|
||||||
|
|
||||||
import path from "path"
|
|
||||||
import { pathToFileURL } from "url"
|
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
|
||||||
import { AppFileSystem } from "./filesystem"
|
|
||||||
import { Location } from "./location"
|
|
||||||
import { ProjectReference } from "./project-reference"
|
|
||||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
|
||||||
|
|
||||||
export const ReadInput = Schema.Struct({
|
|
||||||
path: RelativePath,
|
|
||||||
reference: Schema.String.pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
export type ReadInput = typeof ReadInput.Type
|
|
||||||
|
|
||||||
export class TextContent extends Schema.Class<TextContent>("LocationFileSystem.TextContent")({
|
|
||||||
type: Schema.Literal("text"),
|
|
||||||
content: Schema.String,
|
|
||||||
mime: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class BinaryContent extends Schema.Class<BinaryContent>("LocationFileSystem.BinaryContent")({
|
|
||||||
type: Schema.Literal("binary"),
|
|
||||||
content: Schema.String,
|
|
||||||
encoding: Schema.Literal("base64"),
|
|
||||||
mime: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type"))
|
|
||||||
export type Content = typeof Content.Type
|
|
||||||
|
|
||||||
export const ListInput = Schema.Struct({
|
|
||||||
path: RelativePath.pipe(Schema.optional),
|
|
||||||
reference: Schema.String.pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
export type ListInput = typeof ListInput.Type
|
|
||||||
|
|
||||||
export class Entry extends Schema.Class<Entry>("LocationFileSystem.Entry")({
|
|
||||||
path: RelativePath,
|
|
||||||
uri: Schema.String,
|
|
||||||
type: Schema.Literals(["file", "directory"]),
|
|
||||||
mime: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export const FindInput = Schema.Struct({
|
|
||||||
query: Schema.String,
|
|
||||||
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
|
|
||||||
limit: PositiveInt.pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
export type FindInput = typeof FindInput.Type
|
|
||||||
|
|
||||||
export const GrepInput = Schema.Struct({
|
|
||||||
pattern: Schema.String,
|
|
||||||
include: Schema.String.pipe(Schema.optional),
|
|
||||||
limit: PositiveInt.pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
export type GrepInput = typeof GrepInput.Type
|
|
||||||
|
|
||||||
export class GrepMatch extends Schema.Class<GrepMatch>("LocationFileSystem.GrepMatch")({
|
|
||||||
path: RelativePath,
|
|
||||||
lines: Schema.String,
|
|
||||||
line: PositiveInt,
|
|
||||||
offset: NonNegativeInt,
|
|
||||||
submatches: Schema.Array(
|
|
||||||
Schema.Struct({
|
|
||||||
text: Schema.String,
|
|
||||||
start: NonNegativeInt,
|
|
||||||
end: NonNegativeInt,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly read: (input: ReadInput) => Effect.Effect<Content>
|
|
||||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
|
||||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
|
||||||
readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationFileSystem") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fs = yield* AppFileSystem.Service
|
|
||||||
const location = yield* Location.Service
|
|
||||||
const references = yield* ProjectReference.Service
|
|
||||||
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
|
|
||||||
const select = Effect.fnUntraced(function* (reference?: string) {
|
|
||||||
if (!reference) return { directory: location.directory, root }
|
|
||||||
const resolved = yield* references.get(reference)
|
|
||||||
if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`))
|
|
||||||
if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message))
|
|
||||||
if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
|
|
||||||
return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
|
|
||||||
})
|
|
||||||
const resolve = Effect.fnUntraced(function* (input?: RelativePath, reference?: string) {
|
|
||||||
if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location"))
|
|
||||||
const selected = yield* select(reference)
|
|
||||||
const absolute = path.resolve(selected.directory, input ?? ".")
|
|
||||||
if (!AppFileSystem.contains(selected.directory, absolute))
|
|
||||||
return yield* Effect.die(new Error("Path escapes the location"))
|
|
||||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
|
||||||
if (!AppFileSystem.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
|
||||||
return { absolute, real, ...selected }
|
|
||||||
})
|
|
||||||
const entry = Effect.fnUntraced(function* (absolute: string, selected: { directory: string; root: string }) {
|
|
||||||
const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
|
|
||||||
if (!real) return
|
|
||||||
if (!AppFileSystem.contains(selected.root, real)) return
|
|
||||||
const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
|
|
||||||
if (!info) return
|
|
||||||
const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined
|
|
||||||
if (!type) return
|
|
||||||
return new Entry({
|
|
||||||
path: RelativePath.make(path.relative(selected.directory, absolute)),
|
|
||||||
uri: pathToFileURL(real).href,
|
|
||||||
type,
|
|
||||||
mime: type === "directory" ? "application/x-directory" : AppFileSystem.mimeType(real),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const entries = [
|
|
||||||
new Entry({
|
|
||||||
path: RelativePath.make("README.md"),
|
|
||||||
uri: pathToFileURL(path.join(location.directory, "README.md")).href,
|
|
||||||
type: "file",
|
|
||||||
mime: "text/markdown",
|
|
||||||
}),
|
|
||||||
new Entry({
|
|
||||||
path: RelativePath.make("src"),
|
|
||||||
uri: pathToFileURL(path.join(location.directory, "src")).href,
|
|
||||||
type: "directory",
|
|
||||||
mime: "application/x-directory",
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
read: Effect.fn("LocationFileSystem.read")(function* (input) {
|
|
||||||
const file = yield* resolve(input.path, input.reference)
|
|
||||||
const info = yield* fs.stat(file.real).pipe(Effect.orDie)
|
|
||||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
|
||||||
const bytes = yield* fs.readFile(file.real).pipe(Effect.orDie)
|
|
||||||
const mime = AppFileSystem.mimeType(file.real)
|
|
||||||
if (!bytes.includes(0)) {
|
|
||||||
const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
|
|
||||||
Effect.option,
|
|
||||||
)
|
|
||||||
if (content._tag === "Some") return new TextContent({ type: "text", content: content.value, mime })
|
|
||||||
}
|
|
||||||
return new BinaryContent({
|
|
||||||
type: "binary",
|
|
||||||
content: Buffer.from(bytes).toString("base64"),
|
|
||||||
encoding: "base64",
|
|
||||||
mime,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
list: Effect.fn("LocationFileSystem.list")(function* (input = {}) {
|
|
||||||
const directory = yield* resolve(input.path, input.reference)
|
|
||||||
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
|
|
||||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
|
||||||
return yield* fs.readDirectoryEntries(directory.real).pipe(
|
|
||||||
Effect.orDie,
|
|
||||||
Effect.flatMap((items) =>
|
|
||||||
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
|
|
||||||
concurrency: "unbounded",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Effect.map((items) =>
|
|
||||||
items
|
|
||||||
.filter((item): item is Entry => item !== undefined)
|
|
||||||
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
find: Effect.fn("LocationFileSystem.find")(function* (input) {
|
|
||||||
return entries.filter((entry) => input.type === undefined || entry.type === input.type).slice(0, input.limit)
|
|
||||||
}),
|
|
||||||
grep: Effect.fn("LocationFileSystem.grep")(function* (input) {
|
|
||||||
return [
|
|
||||||
new GrepMatch({
|
|
||||||
path: RelativePath.make("README.md"),
|
|
||||||
lines: "# opencode",
|
|
||||||
line: 1,
|
|
||||||
offset: 0,
|
|
||||||
submatches: [{ text: input.pattern, start: 0, end: input.pattern.length }],
|
|
||||||
}),
|
|
||||||
].slice(0, input.limit)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const locationLayer = layer.pipe(Layer.provideMerge(ProjectReference.locationLayer))
|
|
||||||
@@ -11,13 +11,14 @@ import { EventV2 } from "./event"
|
|||||||
import { Auth } from "./auth"
|
import { Auth } from "./auth"
|
||||||
import { Npm } from "./npm"
|
import { Npm } from "./npm"
|
||||||
import { ModelsDev } from "./models-dev"
|
import { ModelsDev } from "./models-dev"
|
||||||
import { AppFileSystem } from "./filesystem"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
import { PermissionV2 } from "./permission"
|
import { PermissionV2 } from "./permission"
|
||||||
import { PermissionSaved } from "./permission/saved"
|
import { PermissionSaved } from "./permission/saved"
|
||||||
import { SessionV2 } from "./session"
|
import { SessionV2 } from "./session"
|
||||||
import { LocationFileSystem } from "./location-filesystem"
|
import { FileSystem } from "./filesystem"
|
||||||
|
import { Watcher } from "./filesystem/watcher"
|
||||||
import { ProjectReference } from "./project-reference"
|
import { ProjectReference } from "./project-reference"
|
||||||
import { RepositoryCache } from "./repository-cache"
|
import { RepositoryCache } from "./repository-cache"
|
||||||
|
|
||||||
@@ -34,7 +35,8 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||||||
AgentV2.locationLayer,
|
AgentV2.locationLayer,
|
||||||
PluginBoot.locationLayer,
|
PluginBoot.locationLayer,
|
||||||
PermissionV2.locationLayer,
|
PermissionV2.locationLayer,
|
||||||
LocationFileSystem.locationLayer,
|
FileSystem.locationLayer,
|
||||||
|
Watcher.locationLayer,
|
||||||
).pipe(Layer.provideMerge(location), Layer.fresh)
|
).pipe(Layer.provideMerge(location), Layer.fresh)
|
||||||
},
|
},
|
||||||
idleTimeToLive: "60 minutes",
|
idleTimeToLive: "60 minutes",
|
||||||
@@ -44,7 +46,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||||||
Auth.defaultLayer,
|
Auth.defaultLayer,
|
||||||
Npm.defaultLayer,
|
Npm.defaultLayer,
|
||||||
ModelsDev.defaultLayer,
|
ModelsDev.defaultLayer,
|
||||||
AppFileSystem.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
Global.defaultLayer,
|
Global.defaultLayer,
|
||||||
Database.defaultLayer,
|
Database.defaultLayer,
|
||||||
SessionV2.defaultLayer,
|
SessionV2.defaultLayer,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Global } from "./global"
|
|||||||
import { Flag } from "./flag/flag"
|
import { Flag } from "./flag/flag"
|
||||||
import { Flock } from "./util/flock"
|
import { Flock } from "./util/flock"
|
||||||
import { Hash } from "./util/hash"
|
import { Hash } from "./util/hash"
|
||||||
import { AppFileSystem } from "./filesystem"
|
import { FSUtil } from "./fs-util"
|
||||||
import { InstallationChannel, InstallationVersion } from "./installation/version"
|
import { InstallationChannel, InstallationVersion } from "./installation/version"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mo
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const http = HttpClient.filterStatusOk(
|
const http = HttpClient.filterStatusOk(
|
||||||
(yield* HttpClient.HttpClient).pipe(
|
(yield* HttpClient.HttpClient).pipe(
|
||||||
@@ -227,7 +227,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(EventV2.defaultLayer),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path"
|
|||||||
import npa from "npm-package-arg"
|
import npa from "npm-package-arg"
|
||||||
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { AppFileSystem } from "./filesystem"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { EffectFlock } from "./util/effect-flock"
|
import { EffectFlock } from "./util/effect-flock"
|
||||||
import { makeRuntime } from "./effect/runtime"
|
import { makeRuntime } from "./effect/runtime"
|
||||||
@@ -70,7 +70,7 @@ interface ArboristTree {
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const afs = yield* AppFileSystem.Service
|
const afs = yield* FSUtil.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
const flock = yield* EffectFlock.Service
|
const flock = yield* EffectFlock.Service
|
||||||
@@ -246,7 +246,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(EffectFlock.layer),
|
Layer.provide(EffectFlock.layer),
|
||||||
Layer.provide(AppFileSystem.layer),
|
Layer.provide(FSUtil.layer),
|
||||||
Layer.provide(Global.layer),
|
Layer.provide(Global.layer),
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(NodeFileSystem.layer),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path"
|
|||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import { ConfigReference } from "./config/reference"
|
import { ConfigReference } from "./config/reference"
|
||||||
import { AppFileSystem } from "./filesystem"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Flag } from "./flag/flag"
|
import { Flag } from "./flag/flag"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
@@ -65,7 +65,7 @@ export const layer = Layer.effect(
|
|||||||
if (!Flag.OPENCODE_EXPERIMENTAL_REFERENCES) return Service.of(inert)
|
if (!Flag.OPENCODE_EXPERIMENTAL_REFERENCES) return Service.of(inert)
|
||||||
|
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const cache = yield* RepositoryCache.Service
|
const cache = yield* RepositoryCache.Service
|
||||||
@@ -137,7 +137,7 @@ export const layer = Layer.effect(
|
|||||||
if (!target) return { name, kind: "reference", reference, path: reference.path }
|
if (!target) return { name, kind: "reference", reference, path: reference.path }
|
||||||
|
|
||||||
const resolved = path.resolve(reference.path, target)
|
const resolved = path.resolve(reference.path, target)
|
||||||
if (!AppFileSystem.contains(reference.path, resolved))
|
if (!FSUtil.contains(reference.path, resolved))
|
||||||
return { name, kind: "invalid", target, message: "Reference target escapes its root" }
|
return { name, kind: "invalid", target, message: "Reference target escapes its root" }
|
||||||
if (!(yield* fs.existsSafe(resolved)))
|
if (!(yield* fs.existsSafe(resolved)))
|
||||||
return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" }
|
return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" }
|
||||||
@@ -228,9 +228,9 @@ function uniqueGitReferences(references: Resolved[]) {
|
|||||||
|
|
||||||
function normalizePath(target?: string) {
|
function normalizePath(target?: string) {
|
||||||
if (!target) return
|
if (!target) return
|
||||||
return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
|
return process.platform === "win32" ? FSUtil.normalizePath(target) : target
|
||||||
}
|
}
|
||||||
|
|
||||||
function contains(parent: string, child: string) {
|
function contains(parent: string, child: string) {
|
||||||
return AppFileSystem.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child)
|
return FSUtil.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export * as Project from "./project"
|
|||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { AbsolutePath, withStatics } from "./schema"
|
import { AbsolutePath, withStatics } from "./schema"
|
||||||
import { AppFileSystem } from "./filesystem"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Git } from "./git"
|
import { Git } from "./git"
|
||||||
import { Hash } from "./util/hash"
|
import { Hash } from "./util/hash"
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const git = yield* Git.Service
|
const git = yield* Git.Service
|
||||||
|
|
||||||
const cached = Effect.fnUntraced(function* (dir: string) {
|
const cached = Effect.fnUntraced(function* (dir: string) {
|
||||||
@@ -126,4 +126,4 @@ 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))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { AppFileSystem } from "./filesystem"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Git } from "./git"
|
import { Git } from "./git"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { Repository } from "./repository"
|
import { Repository } from "./repository"
|
||||||
@@ -119,139 +119,142 @@ export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(functi
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
export const layer: Layer.Layer<
|
export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | EffectFlock.Service | Global.Service> =
|
||||||
Service,
|
Layer.effect(
|
||||||
never,
|
Service,
|
||||||
AppFileSystem.Service | Git.Service | EffectFlock.Service | Global.Service
|
Effect.gen(function* () {
|
||||||
> = Layer.effect(
|
const fs = yield* FSUtil.Service
|
||||||
Service,
|
const git = yield* Git.Service
|
||||||
Effect.gen(function* () {
|
const flock = yield* EffectFlock.Service
|
||||||
const fs = yield* AppFileSystem.Service
|
const global = yield* Global.Service
|
||||||
const git = yield* Git.Service
|
|
||||||
const flock = yield* EffectFlock.Service
|
|
||||||
const global = yield* Global.Service
|
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
|
ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
|
||||||
if (input.branch) yield* validateBranch(input.branch)
|
if (input.branch) yield* validateBranch(input.branch)
|
||||||
|
|
||||||
const repository = input.reference.label
|
const repository = input.reference.label
|
||||||
const localPath = Repository.cachePath(global.repos, input.reference)
|
const localPath = Repository.cachePath(global.repos, input.reference)
|
||||||
const cloneTarget = Repository.parse(input.reference.remote) ?? input.reference
|
const cloneTarget = Repository.parse(input.reference.remote) ?? input.reference
|
||||||
|
|
||||||
return yield* flock
|
return yield* flock
|
||||||
.withLock(
|
.withLock(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
|
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
|
||||||
|
|
||||||
const exists = yield* fs.existsSafe(localPath)
|
const exists = yield* fs.existsSafe(localPath)
|
||||||
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
|
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
|
||||||
const origin = hasGitDir ? yield* git.origin(localPath) : undefined
|
const origin = hasGitDir ? yield* git.origin(localPath) : undefined
|
||||||
const originReference = origin ? Repository.parse(origin) : undefined
|
const originReference = origin ? Repository.parse(origin) : undefined
|
||||||
const reuse = hasGitDir && Boolean(originReference && Repository.same(originReference, cloneTarget))
|
const reuse = hasGitDir && Boolean(originReference && Repository.same(originReference, cloneTarget))
|
||||||
if (exists && !reuse) {
|
if (exists && !reuse) {
|
||||||
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
|
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
|
||||||
}
|
|
||||||
|
|
||||||
const currentBranch = reuse ? yield* git.branch(localPath) : undefined
|
|
||||||
const status = statusForRepository({
|
|
||||||
reuse,
|
|
||||||
refresh: input.refresh,
|
|
||||||
branchMatches: input.branch ? currentBranch === input.branch : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (status === "cloned") {
|
|
||||||
const result = yield* git
|
|
||||||
.clone({ remote: input.reference.remote, target: localPath, branch: input.branch })
|
|
||||||
.pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: errorMessage(error) })))
|
|
||||||
if (result.exitCode !== 0) {
|
|
||||||
return yield* new CloneFailedError({
|
|
||||||
repository,
|
|
||||||
message: resultMessage(result, `Failed to clone ${repository}`),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "refreshed") {
|
|
||||||
const fetch = yield* git
|
|
||||||
.fetch(localPath)
|
|
||||||
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })))
|
|
||||||
if (fetch.exitCode !== 0) {
|
|
||||||
return yield* new FetchFailedError({
|
|
||||||
repository,
|
|
||||||
message: resultMessage(fetch, `Failed to refresh ${repository}`),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.branch) {
|
const currentBranch = reuse ? yield* git.branch(localPath) : undefined
|
||||||
const requestedBranch = input.branch
|
const status = statusForRepository({
|
||||||
const fetchBranch = yield* git
|
reuse,
|
||||||
.fetchBranch(localPath, requestedBranch)
|
refresh: input.refresh,
|
||||||
|
branchMatches: input.branch ? currentBranch === input.branch : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (status === "cloned") {
|
||||||
|
const result = yield* git
|
||||||
|
.clone({ remote: input.reference.remote, target: localPath, branch: input.branch })
|
||||||
|
.pipe(
|
||||||
|
Effect.mapError((error) => new CloneFailedError({ repository, message: errorMessage(error) })),
|
||||||
|
)
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return yield* new CloneFailedError({
|
||||||
|
repository,
|
||||||
|
message: resultMessage(result, `Failed to clone ${repository}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "refreshed") {
|
||||||
|
const fetch = yield* git
|
||||||
|
.fetch(localPath)
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
|
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
|
||||||
)
|
)
|
||||||
if (fetchBranch.exitCode !== 0) {
|
if (fetch.exitCode !== 0) {
|
||||||
return yield* new FetchFailedError({
|
return yield* new FetchFailedError({
|
||||||
repository,
|
repository,
|
||||||
message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`),
|
message: resultMessage(fetch, `Failed to refresh ${repository}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkout = yield* git.checkout(localPath, requestedBranch).pipe(
|
if (input.branch) {
|
||||||
Effect.mapError(
|
const requestedBranch = input.branch
|
||||||
(error) =>
|
const fetchBranch = yield* git
|
||||||
new CheckoutFailedError({
|
.fetchBranch(localPath, requestedBranch)
|
||||||
repository,
|
.pipe(
|
||||||
branch: requestedBranch,
|
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
|
||||||
message: errorMessage(error),
|
)
|
||||||
}),
|
if (fetchBranch.exitCode !== 0) {
|
||||||
),
|
return yield* new FetchFailedError({
|
||||||
)
|
repository,
|
||||||
if (checkout.exitCode !== 0) {
|
message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`),
|
||||||
return yield* new CheckoutFailedError({
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkout = yield* git.checkout(localPath, requestedBranch).pipe(
|
||||||
|
Effect.mapError(
|
||||||
|
(error) =>
|
||||||
|
new CheckoutFailedError({
|
||||||
|
repository,
|
||||||
|
branch: requestedBranch,
|
||||||
|
message: errorMessage(error),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (checkout.exitCode !== 0) {
|
||||||
|
return yield* new CheckoutFailedError({
|
||||||
|
repository,
|
||||||
|
branch: requestedBranch,
|
||||||
|
message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = yield* git
|
||||||
|
.reset(localPath, yield* resetTarget(git, localPath, input.branch))
|
||||||
|
.pipe(
|
||||||
|
Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })),
|
||||||
|
)
|
||||||
|
if (reset.exitCode !== 0) {
|
||||||
|
return yield* new ResetFailedError({
|
||||||
repository,
|
repository,
|
||||||
branch: requestedBranch,
|
message: resultMessage(reset, `Failed to reset ${repository}`),
|
||||||
message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reset = yield* git
|
return {
|
||||||
.reset(localPath, yield* resetTarget(git, localPath, input.branch))
|
repository,
|
||||||
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })))
|
host: input.reference.host,
|
||||||
if (reset.exitCode !== 0) {
|
remote: input.reference.remote,
|
||||||
return yield* new ResetFailedError({
|
localPath,
|
||||||
repository,
|
status,
|
||||||
message: resultMessage(reset, `Failed to reset ${repository}`),
|
head: yield* git.head(localPath),
|
||||||
})
|
branch: yield* git.branch(localPath),
|
||||||
}
|
} satisfies Result
|
||||||
}
|
}),
|
||||||
|
`repository-cache:${localPath}`,
|
||||||
return {
|
)
|
||||||
repository,
|
.pipe(
|
||||||
host: input.reference.host,
|
Effect.mapError((error) =>
|
||||||
remote: input.reference.remote,
|
isError(error) ? error : new LockFailedError({ localPath, message: errorMessage(error) }),
|
||||||
localPath,
|
),
|
||||||
status,
|
)
|
||||||
head: yield* git.head(localPath),
|
}),
|
||||||
branch: yield* git.branch(localPath),
|
})
|
||||||
} satisfies Result
|
}),
|
||||||
}),
|
)
|
||||||
`repository-cache:${localPath}`,
|
|
||||||
)
|
|
||||||
.pipe(
|
|
||||||
Effect.mapError((error) =>
|
|
||||||
isError(error) ? error : new LockFailedError({ localPath, message: errorMessage(error) }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||||
Layer.provide(EffectFlock.defaultLayer),
|
Layer.provide(EffectFlock.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provide(Global.defaultLayer),
|
Layer.provide(Global.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { randomUUID } from "crypto"
|
|||||||
import { Context, Effect, Function, Layer, Option, Schedule, Schema } from "effect"
|
import { Context, Effect, Function, Layer, Option, Schedule, Schema } from "effect"
|
||||||
import type { FileSystem, Scope } from "effect"
|
import type { FileSystem, Scope } from "effect"
|
||||||
import type { PlatformError } from "effect/PlatformError"
|
import type { PlatformError } from "effect/PlatformError"
|
||||||
import { AppFileSystem } from "../filesystem"
|
import { FSUtil } from "../fs-util"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Hash } from "./hash"
|
import { Hash } from "./hash"
|
||||||
|
|
||||||
@@ -93,11 +93,11 @@ export namespace EffectFlock {
|
|||||||
|
|
||||||
const isPathGone = (e: PlatformError) => e.reason._tag === "NotFound" || e.reason._tag === "Unknown"
|
const isPathGone = (e: PlatformError) => e.reason._tag === "NotFound" || e.reason._tag === "Unknown"
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, Global.Service | AppFileSystem.Service> = Layer.effect(
|
export const layer: Layer.Layer<Service, never, Global.Service | FSUtil.Service> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const lockRoot = path.join(global.state, "locks")
|
const lockRoot = path.join(global.state, "locks")
|
||||||
const hostname = os.hostname()
|
const hostname = os.hostname()
|
||||||
const ensuredDirs = new Set<string>()
|
const ensuredDirs = new Set<string>()
|
||||||
@@ -279,5 +279,5 @@ export namespace EffectFlock {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import whichPkg from "which"
|
import whichPkg from "which"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "../global"
|
||||||
|
|
||||||
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
|
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
|
||||||
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
|
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
|
||||||
@@ -5,7 +5,7 @@ import { Effect, Fiber, Layer, Option, Stream } from "effect"
|
|||||||
import { Auth } from "@opencode-ai/core/auth"
|
import { Auth } from "@opencode-ai/core/auth"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
|
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
|
||||||
@@ -57,7 +57,7 @@ function context(
|
|||||||
|
|
||||||
function testLayer(dir: string) {
|
function testLayer(dir: string) {
|
||||||
return Auth.layer.pipe(
|
return Auth.layer.pipe(
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provideMerge(EventV2.defaultLayer),
|
Layer.provideMerge(EventV2.defaultLayer),
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
Global.layerWith({
|
Global.layerWith({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
|
|||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { Config } from "@opencode-ai/core/config"
|
import { Config } from "@opencode-ai/core/config"
|
||||||
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { Policy } from "@opencode-ai/core/policy"
|
import { Policy } from "@opencode-ai/core/policy"
|
||||||
@@ -23,7 +23,7 @@ function testLayer(
|
|||||||
vcs?: Project.Vcs,
|
vcs?: Project.Vcs,
|
||||||
) {
|
) {
|
||||||
return Config.locationLayer.pipe(
|
return Config.locationLayer.pipe(
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Global.layerWith({ config: globalDirectory })),
|
Layer.provide(Global.layerWith({ config: globalDirectory })),
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.succeed(
|
Layer.succeed(
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { describe, test, expect } from "bun:test"
|
import { describe, test, expect } from "bun:test"
|
||||||
import { Effect, Layer, FileSystem } from "effect"
|
import { Effect, Layer, FileSystem } from "effect"
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
const live = AppFileSystem.layer.pipe(Layer.provideMerge(NodeFileSystem.layer))
|
const live = FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer))
|
||||||
const { effect: it } = testEffect(live)
|
const { effect: it } = testEffect(live)
|
||||||
|
|
||||||
describe("AppFileSystem", () => {
|
describe("FSUtil", () => {
|
||||||
describe("isDir", () => {
|
describe("isDir", () => {
|
||||||
it(
|
it(
|
||||||
"returns true for directories",
|
"returns true for directories",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
expect(yield* fs.isDir(tmp)).toBe(true)
|
expect(yield* fs.isDir(tmp)).toBe(true)
|
||||||
@@ -23,7 +23,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"returns false for files",
|
"returns false for files",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
const file = path.join(tmp, "test.txt")
|
const file = path.join(tmp, "test.txt")
|
||||||
@@ -35,7 +35,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"returns false for non-existent paths",
|
"returns false for non-existent paths",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false)
|
expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -45,7 +45,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"returns true for files",
|
"returns true for files",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
const file = path.join(tmp, "test.txt")
|
const file = path.join(tmp, "test.txt")
|
||||||
@@ -57,7 +57,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"returns false for directories",
|
"returns false for directories",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
expect(yield* fs.isFile(tmp)).toBe(false)
|
expect(yield* fs.isFile(tmp)).toBe(false)
|
||||||
@@ -69,7 +69,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"returns file contents when file exists",
|
"returns file contents when file exists",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
const file = path.join(tmp, "exists.txt")
|
const file = path.join(tmp, "exists.txt")
|
||||||
@@ -83,7 +83,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"returns undefined for missing file (NotFound)",
|
"returns undefined for missing file (NotFound)",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"round-trips JSON data",
|
"round-trips JSON data",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
const file = path.join(tmp, "data.json")
|
const file = path.join(tmp, "data.json")
|
||||||
@@ -115,7 +115,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"creates nested directories",
|
"creates nested directories",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
const nested = path.join(tmp, "a", "b", "c")
|
const nested = path.join(tmp, "a", "b", "c")
|
||||||
@@ -130,7 +130,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"is idempotent",
|
"is idempotent",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
const dir = path.join(tmp, "existing")
|
const dir = path.join(tmp, "existing")
|
||||||
@@ -148,7 +148,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"creates parent directories if missing",
|
"creates parent directories if missing",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
const file = path.join(tmp, "deep", "nested", "file.txt")
|
const file = path.join(tmp, "deep", "nested", "file.txt")
|
||||||
@@ -162,7 +162,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"writes directly when parent exists",
|
"writes directly when parent exists",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
const file = path.join(tmp, "direct.txt")
|
const file = path.join(tmp, "direct.txt")
|
||||||
@@ -176,7 +176,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"writes Uint8Array content",
|
"writes Uint8Array content",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
const file = path.join(tmp, "binary.bin")
|
const file = path.join(tmp, "binary.bin")
|
||||||
@@ -194,7 +194,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"finds target in start directory",
|
"finds target in start directory",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
yield* filesys.writeFileString(path.join(tmp, "target.txt"), "found")
|
yield* filesys.writeFileString(path.join(tmp, "target.txt"), "found")
|
||||||
@@ -207,7 +207,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"finds target in parent directories",
|
"finds target in parent directories",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
yield* filesys.writeFileString(path.join(tmp, "marker"), "root")
|
yield* filesys.writeFileString(path.join(tmp, "marker"), "root")
|
||||||
@@ -222,7 +222,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"returns empty array when not found",
|
"returns empty array when not found",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
const result = yield* fs.findUp("nonexistent", tmp, tmp)
|
const result = yield* fs.findUp("nonexistent", tmp, tmp)
|
||||||
@@ -235,7 +235,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"finds multiple targets walking up",
|
"finds multiple targets walking up",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
yield* filesys.writeFileString(path.join(tmp, "a.txt"), "a")
|
yield* filesys.writeFileString(path.join(tmp, "a.txt"), "a")
|
||||||
@@ -257,7 +257,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"finds files matching pattern",
|
"finds files matching pattern",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
yield* filesys.writeFileString(path.join(tmp, "a.ts"), "a")
|
yield* filesys.writeFileString(path.join(tmp, "a.ts"), "a")
|
||||||
@@ -272,7 +272,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"supports absolute paths",
|
"supports absolute paths",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
yield* filesys.writeFileString(path.join(tmp, "file.txt"), "hello")
|
yield* filesys.writeFileString(path.join(tmp, "file.txt"), "hello")
|
||||||
@@ -287,7 +287,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"matches patterns",
|
"matches patterns",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
expect(fs.globMatch("*.ts", "foo.ts")).toBe(true)
|
expect(fs.globMatch("*.ts", "foo.ts")).toBe(true)
|
||||||
expect(fs.globMatch("*.ts", "foo.json")).toBe(false)
|
expect(fs.globMatch("*.ts", "foo.json")).toBe(false)
|
||||||
expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true)
|
expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true)
|
||||||
@@ -299,7 +299,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"finds files walking up directories",
|
"finds files walking up directories",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
yield* filesys.writeFileString(path.join(tmp, "root.md"), "root")
|
yield* filesys.writeFileString(path.join(tmp, "root.md"), "root")
|
||||||
@@ -318,7 +318,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"exists works",
|
"exists works",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* AppFileSystem.Service
|
yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
const file = path.join(tmp, "exists.txt")
|
const file = path.join(tmp, "exists.txt")
|
||||||
@@ -332,7 +332,7 @@ describe("AppFileSystem", () => {
|
|||||||
it(
|
it(
|
||||||
"remove works",
|
"remove works",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* AppFileSystem.Service
|
yield* FSUtil.Service
|
||||||
const filesys = yield* FileSystem.FileSystem
|
const filesys = yield* FileSystem.FileSystem
|
||||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||||
const file = path.join(tmp, "delete-me.txt")
|
const file = path.join(tmp, "delete-me.txt")
|
||||||
@@ -347,20 +347,20 @@ describe("AppFileSystem", () => {
|
|||||||
|
|
||||||
describe("pure helpers", () => {
|
describe("pure helpers", () => {
|
||||||
test("mimeType returns correct types", () => {
|
test("mimeType returns correct types", () => {
|
||||||
expect(AppFileSystem.mimeType("file.json")).toBe("application/json")
|
expect(FSUtil.mimeType("file.json")).toBe("application/json")
|
||||||
expect(AppFileSystem.mimeType("image.png")).toBe("image/png")
|
expect(FSUtil.mimeType("image.png")).toBe("image/png")
|
||||||
expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream")
|
expect(FSUtil.mimeType("unknown.qzx")).toBe("application/octet-stream")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("contains checks path containment", () => {
|
test("contains checks path containment", () => {
|
||||||
expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true)
|
expect(FSUtil.contains("/a/b", "/a/b/c")).toBe(true)
|
||||||
expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false)
|
expect(FSUtil.contains("/a/b", "/a/c")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("overlaps detects overlapping paths", () => {
|
test("overlaps detects overlapping paths", () => {
|
||||||
expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true)
|
expect(FSUtil.overlaps("/a/b", "/a/b/c")).toBe(true)
|
||||||
expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true)
|
expect(FSUtil.overlaps("/a/b/c", "/a/b")).toBe(true)
|
||||||
expect(AppFileSystem.overlaps("/a", "/b")).toBe(false)
|
expect(FSUtil.overlaps("/a", "/b")).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { Ignore } from "@opencode-ai/core/filesystem/ignore"
|
||||||
|
|
||||||
|
test("match nested and non-nested", () => {
|
||||||
|
expect(Ignore.match("node_modules/index.js")).toBe(true)
|
||||||
|
expect(Ignore.match("node_modules")).toBe(true)
|
||||||
|
expect(Ignore.match("node_modules/")).toBe(true)
|
||||||
|
expect(Ignore.match("node_modules/bar")).toBe(true)
|
||||||
|
expect(Ignore.match("node_modules/bar/")).toBe(true)
|
||||||
|
})
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import * as Stream from "effect/Stream"
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Ripgrep } from "../../src/file/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
const it = testEffect(Ripgrep.defaultLayer)
|
const it = testEffect(Ripgrep.defaultLayer)
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { $ } from "bun"
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||||
|
import { Config } from "@opencode-ai/core/config"
|
||||||
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
|
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||||
|
import { Git } from "@opencode-ai/core/git"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { location } from "../fixture/location"
|
||||||
|
import { tmpdir } from "../fixture/tmpdir"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||||
|
|
||||||
|
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||||
|
|
||||||
|
const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))
|
||||||
|
|
||||||
|
const configLayer = Layer.succeed(
|
||||||
|
Config.Service,
|
||||||
|
Config.Service.of({
|
||||||
|
directories: () => Effect.succeed([]),
|
||||||
|
get: () => Effect.succeed([]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const flagsLayer = ConfigProvider.layer(
|
||||||
|
ConfigProvider.fromUnknown({
|
||||||
|
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function provide(directory: string, vcs?: Location.Interface["vcs"]) {
|
||||||
|
const locationLayer = Layer.succeed(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
|
||||||
|
)
|
||||||
|
return Effect.provide(
|
||||||
|
Watcher.layer.pipe(
|
||||||
|
Layer.provide(configLayer),
|
||||||
|
Layer.provide(Git.defaultLayer),
|
||||||
|
Layer.provide(locationLayer),
|
||||||
|
Layer.provide(flagsLayer),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTmp<A, E, R>(
|
||||||
|
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
|
||||||
|
options?: { git?: boolean; init?: (directory: string) => Promise<void> },
|
||||||
|
) {
|
||||||
|
return Effect.acquireRelease(
|
||||||
|
Effect.promise(async () => {
|
||||||
|
const tmp = await tmpdir()
|
||||||
|
if (!options?.git) return { tmp, vcs: undefined }
|
||||||
|
await $`git init`.cwd(tmp.path).quiet()
|
||||||
|
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
|
||||||
|
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
|
||||||
|
await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
|
||||||
|
await $`git config user.name Test`.cwd(tmp.path).quiet()
|
||||||
|
await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
|
||||||
|
await options.init?.(tmp.path)
|
||||||
|
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
|
||||||
|
}),
|
||||||
|
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs))))
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(check: (event: WatcherEvent) => boolean) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const deferred = yield* Deferred.make<WatcherEvent>()
|
||||||
|
const fiber = yield* events.subscribe(Watcher.Event.Updated).pipe(
|
||||||
|
Stream.runForEach((event) => {
|
||||||
|
if (!check(event.data)) return Effect.void
|
||||||
|
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
|
||||||
|
}),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
return { deferred, fiber }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeNextUpdate<E>(
|
||||||
|
check: (event: WatcherEvent) => boolean,
|
||||||
|
trigger: Effect.Effect<void, E>,
|
||||||
|
timeout: Duration.Input = "5 seconds",
|
||||||
|
) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
wait(check),
|
||||||
|
({ deferred }) => trigger.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeoutOption(timeout)),
|
||||||
|
({ fiber }) => Fiber.interrupt(fiber),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const result = yield* maybeNextUpdate(check, trigger)
|
||||||
|
if (Option.isSome(result)) return result.value
|
||||||
|
return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: () => Effect.Effect<void, E>) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
while (true) {
|
||||||
|
const result = yield* maybeNextUpdate(check, trigger(), "250 millis")
|
||||||
|
if (Option.isSome(result)) return result.value
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
Effect.timeoutOrElse({
|
||||||
|
duration: "5 seconds",
|
||||||
|
orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function noUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>, timeout = 500) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
wait(check),
|
||||||
|
({ deferred }) =>
|
||||||
|
trigger.pipe(
|
||||||
|
Effect.andThen(Deferred.await(deferred)),
|
||||||
|
Effect.timeoutOption(`${timeout} millis`),
|
||||||
|
Effect.tap((result) => Effect.sync(() => expect(result).toEqual(Option.none()))),
|
||||||
|
),
|
||||||
|
({ fiber }) => Fiber.interrupt(fiber),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready(directory: string) {
|
||||||
|
const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
yield* eventuallyUpdate(
|
||||||
|
(event) => event.file === file,
|
||||||
|
() => fs.writeFileString(file, `ready-${Math.random()}`),
|
||||||
|
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describeWatcher("Watcher", () => {
|
||||||
|
it.live("publishes root create, update, and delete events", () =>
|
||||||
|
withTmp(
|
||||||
|
(directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const file = path.join(directory, "watch.txt")
|
||||||
|
yield* ready(directory)
|
||||||
|
for (const item of [
|
||||||
|
{ event: "add" as const, trigger: fs.writeFileString(file, "a") },
|
||||||
|
{ event: "change" as const, trigger: fs.writeFileString(file, "b") },
|
||||||
|
{ event: "unlink" as const, trigger: fs.remove(file) },
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
yield* nextUpdate((event) => event.file === file && event.event === item.event, item.trigger),
|
||||||
|
).toEqual({
|
||||||
|
file,
|
||||||
|
event: item.event,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ git: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("watches non-git roots", () =>
|
||||||
|
withTmp((directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const file = path.join(directory, "plain.txt")
|
||||||
|
yield* ready(directory)
|
||||||
|
expect(yield* nextUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))).toEqual({
|
||||||
|
file,
|
||||||
|
event: "add",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("cleanup stops publishing events", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const tmp = yield* Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
)
|
||||||
|
yield* ready(tmp.path).pipe(provide(tmp.path), Effect.scoped)
|
||||||
|
const file = path.join(tmp.path, "after-dispose.txt")
|
||||||
|
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
|
||||||
|
Effect.provideService(EventV2.Service, events),
|
||||||
|
)
|
||||||
|
}).pipe(Effect.provide(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("ignores .git/index changes", () =>
|
||||||
|
withTmp(
|
||||||
|
(directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const index = path.join(directory, ".git", "index")
|
||||||
|
yield* ready(directory)
|
||||||
|
yield* noUpdate(
|
||||||
|
(event) => event.file === index,
|
||||||
|
fs
|
||||||
|
.writeFileString(path.join(directory, "tracked.txt"), "a")
|
||||||
|
.pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
{ git: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("publishes .git/HEAD events", () =>
|
||||||
|
withTmp(
|
||||||
|
(directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const head = path.join(directory, ".git", "HEAD")
|
||||||
|
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||||
|
yield* ready(directory)
|
||||||
|
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
||||||
|
expect(
|
||||||
|
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
|
||||||
|
).toEqual({
|
||||||
|
file: head,
|
||||||
|
event: "change",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
{ git: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const describeSymlink = process.platform !== "win32" ? describe : describe.skip
|
||||||
|
describeSymlink("symlinked .git", () => {
|
||||||
|
it.live("publishes .git/HEAD events through a symlinked .git directory", () =>
|
||||||
|
withTmp(
|
||||||
|
(directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const afs = yield* FSUtil.Service
|
||||||
|
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
|
||||||
|
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
|
||||||
|
yield* ready(directory)
|
||||||
|
const head = path.join(directory, ".git", "HEAD")
|
||||||
|
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||||
|
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
||||||
|
expect(
|
||||||
|
yield* nextUpdate(
|
||||||
|
(event) => event.file === path.join(actual, "HEAD"),
|
||||||
|
afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
|
||||||
|
),
|
||||||
|
).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
git: true,
|
||||||
|
init: async (directory) => {
|
||||||
|
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
|
||||||
|
await fs.rename(path.join(directory, ".git"), actual)
|
||||||
|
await fs.symlink(actual, path.join(directory, ".git"))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } 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"
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
|
|||||||
log: os.tmpdir(),
|
log: os.tmpdir(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
|
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(FSUtil.defaultLayer))
|
||||||
|
|
||||||
async function job() {
|
async function job() {
|
||||||
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
|
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import path from "path"
|
|||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Exit, Layer } from "effect"
|
import { Effect, Exit, Layer } from "effect"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||||
import { Repository } from "@opencode-ai/core/repository"
|
import { Repository } from "@opencode-ai/core/repository"
|
||||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
@@ -23,10 +24,11 @@ const inertReferences = ProjectReference.Service.of({
|
|||||||
|
|
||||||
function provide(directory: string, references = inertReferences) {
|
function provide(directory: string, references = inertReferences) {
|
||||||
return Effect.provide(
|
return Effect.provide(
|
||||||
LocationFileSystem.layer.pipe(
|
FileSystem.layer.pipe(
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
AppFileSystem.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
|
Ripgrep.defaultLayer,
|
||||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||||
Layer.succeed(ProjectReference.Service, references),
|
Layer.succeed(ProjectReference.Service, references),
|
||||||
),
|
),
|
||||||
@@ -42,13 +44,13 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
|||||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("LocationFileSystem", () => {
|
describe("FileSystem", () => {
|
||||||
it.live("reads text and binary files", () =>
|
it.live("reads text and binary files", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "hello.txt"), "hello"))
|
yield* Effect.promise(() => fs.writeFile(path.join(directory, "hello.txt"), "hello"))
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
|
yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
|
||||||
const service = yield* LocationFileSystem.Service
|
const service = yield* FileSystem.Service
|
||||||
|
|
||||||
expect(yield* service.read({ path: RelativePath.make("hello.txt") })).toEqual({
|
expect(yield* service.read({ path: RelativePath.make("hello.txt") })).toEqual({
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -70,7 +72,7 @@ describe("LocationFileSystem", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
|
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
|
||||||
const service = yield* LocationFileSystem.Service
|
const service = yield* FileSystem.Service
|
||||||
|
|
||||||
const entries = yield* service.list()
|
const entries = yield* service.list()
|
||||||
expect(entries.map(({ uri: _uri, ...entry }) => entry)).toEqual([
|
expect(entries.map(({ uri: _uri, ...entry }) => entry)).toEqual([
|
||||||
@@ -99,7 +101,7 @@ describe("LocationFileSystem", () => {
|
|||||||
it.live("rejects paths outside the location", () =>
|
it.live("rejects paths outside the location", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* LocationFileSystem.Service
|
const service = yield* FileSystem.Service
|
||||||
expect(
|
expect(
|
||||||
Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
|
Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
@@ -107,6 +109,42 @@ describe("LocationFileSystem", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("finds files and directories", () =>
|
||||||
|
withTmp((directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.join(directory, "src", "index.ts"), "const needle = true\n"))
|
||||||
|
const service = yield* FileSystem.Service
|
||||||
|
|
||||||
|
expect((yield* service.find({ query: "index", type: "file" })).map((item) => item.path)).toEqual([
|
||||||
|
RelativePath.make(path.join("src", "index.ts")),
|
||||||
|
])
|
||||||
|
expect((yield* service.find({ query: "src", type: "directory" })).map((item) => item.path)).toEqual([
|
||||||
|
RelativePath.make("src"),
|
||||||
|
])
|
||||||
|
}).pipe(provide(directory)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("greps file contents", () =>
|
||||||
|
withTmp((directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.join(directory, "index.ts"), "const needle = true\n"))
|
||||||
|
const service = yield* FileSystem.Service
|
||||||
|
|
||||||
|
expect(yield* service.grep({ pattern: "needle" })).toEqual([
|
||||||
|
{
|
||||||
|
path: RelativePath.make("index.ts"),
|
||||||
|
lines: "const needle = true\n",
|
||||||
|
line: 1,
|
||||||
|
offset: 0,
|
||||||
|
submatches: [{ text: "needle", start: 6, end: 12 }],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}).pipe(provide(directory)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("reads and lists paths relative to a local project reference", () =>
|
it.live("reads and lists paths relative to a local project reference", () =>
|
||||||
withTmp((directory) => {
|
withTmp((directory) => {
|
||||||
const docs = path.join(directory, "docs")
|
const docs = path.join(directory, "docs")
|
||||||
@@ -115,7 +153,7 @@ describe("LocationFileSystem", () => {
|
|||||||
await fs.mkdir(docs)
|
await fs.mkdir(docs)
|
||||||
await fs.writeFile(path.join(docs, "README.md"), "docs")
|
await fs.writeFile(path.join(docs, "README.md"), "docs")
|
||||||
})
|
})
|
||||||
const service = yield* LocationFileSystem.Service
|
const service = yield* FileSystem.Service
|
||||||
|
|
||||||
expect(yield* service.read({ reference: "docs", path: RelativePath.make("README.md") })).toMatchObject({
|
expect(yield* service.read({ reference: "docs", path: RelativePath.make("README.md") })).toMatchObject({
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -136,7 +174,7 @@ describe("LocationFileSystem", () => {
|
|||||||
await fs.writeFile(path.join(docs, "README.md"), "docs")
|
await fs.writeFile(path.join(docs, "README.md"), "docs")
|
||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
yield* (yield* LocationFileSystem.Service).read({ reference: "sdk", path: RelativePath.make("README.md") }),
|
yield* (yield* FileSystem.Service).read({ reference: "sdk", path: RelativePath.make("README.md") }),
|
||||||
).toMatchObject({ content: "docs" })
|
).toMatchObject({ content: "docs" })
|
||||||
expect(ensured).toEqual([docs])
|
expect(ensured).toEqual([docs])
|
||||||
}).pipe(
|
}).pipe(
|
||||||
@@ -164,7 +202,7 @@ describe("LocationFileSystem", () => {
|
|||||||
const docs = path.join(directory, "docs")
|
const docs = path.join(directory, "docs")
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
yield* Effect.promise(() => fs.mkdir(docs))
|
yield* Effect.promise(() => fs.mkdir(docs))
|
||||||
const service = yield* LocationFileSystem.Service
|
const service = yield* FileSystem.Service
|
||||||
expect(Exit.isFailure(yield* service.list({ reference: "unknown" }).pipe(Effect.exit))).toBe(true)
|
expect(Exit.isFailure(yield* service.list({ reference: "unknown" }).pipe(Effect.exit))).toBe(true)
|
||||||
expect(Exit.isFailure(yield* service.list({ reference: "invalid" }).pipe(Effect.exit))).toBe(true)
|
expect(Exit.isFailure(yield* service.list({ reference: "invalid" }).pipe(Effect.exit))).toBe(true)
|
||||||
expect(
|
expect(
|
||||||
@@ -188,7 +226,7 @@ describe("LocationFileSystem", () => {
|
|||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
expect(
|
expect(
|
||||||
Exit.isFailure(yield* (yield* LocationFileSystem.Service).list({ reference: "docs" }).pipe(Effect.exit)),
|
Exit.isFailure(yield* (yield* FileSystem.Service).list({ reference: "docs" }).pipe(Effect.exit)),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
@@ -207,7 +245,7 @@ describe("LocationFileSystem", () => {
|
|||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
Exit.isFailure(
|
Exit.isFailure(
|
||||||
yield* (yield* LocationFileSystem.Service)
|
yield* (yield* FileSystem.Service)
|
||||||
.read({ reference: "docs", path: RelativePath.make("link.txt") })
|
.read({ reference: "docs", path: RelativePath.make("link.txt") })
|
||||||
.pipe(Effect.exit),
|
.pipe(Effect.exit),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
|||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { AppFileSystem } from "../src/filesystem"
|
import { FSUtil } from "../src/fs-util"
|
||||||
import { Auth } from "../src/auth"
|
import { Auth } from "../src/auth"
|
||||||
import { EventV2 } from "../src/event"
|
import { EventV2 } from "../src/event"
|
||||||
import { Global } from "../src/global"
|
import { Global } from "../src/global"
|
||||||
@@ -27,7 +27,7 @@ const it = testEffect(
|
|||||||
Auth.defaultLayer,
|
Auth.defaultLayer,
|
||||||
Npm.defaultLayer,
|
Npm.defaultLayer,
|
||||||
ModelsDev.defaultLayer,
|
ModelsDev.defaultLayer,
|
||||||
AppFileSystem.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
Global.defaultLayer,
|
Global.defaultLayer,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
|
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
|
||||||
import { Effect, Layer, Ref } from "effect"
|
import { Effect, Layer, Ref } from "effect"
|
||||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||||
@@ -92,7 +92,7 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
|
|||||||
// every test would reuse the cachedInvalidateWithTTL state from the first run.
|
// every test would reuse the cachedInvalidateWithTTL state from the first run.
|
||||||
Layer.fresh(ModelsDev.layer).pipe(
|
Layer.fresh(ModelsDev.layer).pipe(
|
||||||
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
|
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(EventV2.defaultLayer),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from "path"
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { Effect, Layer, Option } from "effect"
|
import { Effect, Layer, Option } 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 { Global } from "@opencode-ai/core/global"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { Npm } from "@opencode-ai/core/npm"
|
||||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
@@ -23,7 +23,7 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
|
|||||||
const npmLayer = (cache: string) =>
|
const npmLayer = (cache: string) =>
|
||||||
Npm.layer.pipe(
|
Npm.layer.pipe(
|
||||||
Layer.provide(EffectFlock.layer),
|
Layer.provide(EffectFlock.layer),
|
||||||
Layer.provide(AppFileSystem.layer),
|
Layer.provide(FSUtil.layer),
|
||||||
Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })),
|
Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })),
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(NodeFileSystem.layer),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path"
|
|||||||
import { Deferred, Effect, Layer, Schema } from "effect"
|
import { Deferred, Effect, Layer, Schema } from "effect"
|
||||||
import { Config } from "@opencode-ai/core/config"
|
import { Config } from "@opencode-ai/core/config"
|
||||||
import { ConfigReference } from "@opencode-ai/core/config/reference"
|
import { ConfigReference } from "@opencode-ai/core/config/reference"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
@@ -243,7 +243,7 @@ function testLayer(input: {
|
|||||||
return ProjectReference.layer.pipe(
|
return ProjectReference.layer.pipe(
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
AppFileSystem.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
Global.layerWith({ home: path.join(input.directory, "home"), repos: input.repos }),
|
Global.layerWith({ home: path.join(input.directory, "home"), repos: input.repos }),
|
||||||
Layer.succeed(
|
Layer.succeed(
|
||||||
Location.Service,
|
Location.Service,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import fs from "fs/promises"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Git } from "@opencode-ai/core/git"
|
import { Git } from "@opencode-ai/core/git"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Repository } from "@opencode-ai/core/repository"
|
import { Repository } from "@opencode-ai/core/repository"
|
||||||
@@ -91,7 +91,7 @@ describe("RepositoryCache", () => {
|
|||||||
function cacheLayer(root: string) {
|
function cacheLayer(root: string) {
|
||||||
const dependencies = Layer.mergeAll(
|
const dependencies = Layer.mergeAll(
|
||||||
Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") }),
|
Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") }),
|
||||||
AppFileSystem.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
)
|
)
|
||||||
return RepositoryCache.layer.pipe(
|
return RepositoryCache.layer.pipe(
|
||||||
Layer.provide(EffectFlock.layer.pipe(Layer.provide(dependencies))),
|
Layer.provide(EffectFlock.layer.pipe(Layer.provide(dependencies))),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from "path"
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
import { Cause, Effect, Exit, Layer } from "effect"
|
import { Cause, Effect, Exit, Layer } from "effect"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/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"
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Hash } from "@opencode-ai/core/util/hash"
|
import { Hash } from "@opencode-ai/core/util/hash"
|
||||||
@@ -103,7 +103,7 @@ const testGlobal = Global.layerWith({
|
|||||||
log: os.tmpdir(),
|
log: os.tmpdir(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
|
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(FSUtil.defaultLayer))
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { which } from "../../src/util/which"
|
import { which } from "@opencode-ai/core/util/which"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/tmpdir"
|
||||||
|
|
||||||
async function cmd(dir: string, name: string, exec = true) {
|
async function cmd(dir: string, name: string, exec = true) {
|
||||||
const ext = process.platform === "win32" ? ".cmd" : ""
|
const ext = process.platform === "win32" ? ".cmd" : ""
|
||||||
@@ -90,9 +90,9 @@ Within each file, migrate git paths first where applicable.
|
|||||||
|
|
||||||
Migrate git-centric call sites to `Process.git*` helpers:
|
Migrate git-centric call sites to `Process.git*` helpers:
|
||||||
|
|
||||||
- `src/file/index.ts`
|
- `../core/src/filesystem.ts`
|
||||||
- `src/project/vcs.ts`
|
- `src/project/vcs.ts`
|
||||||
- `src/file/watcher.ts`
|
- `../core/src/filesystem/watcher.ts`
|
||||||
- `src/storage/storage.ts`
|
- `src/storage/storage.ts`
|
||||||
- `src/cli/cmd/pr.ts`
|
- `src/cli/cmd/pr.ts`
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ Migrate residual non-git usages:
|
|||||||
|
|
||||||
- `src/cli/cmd/tui/util/clipboard.ts`
|
- `src/cli/cmd/tui/util/clipboard.ts`
|
||||||
- `src/util/archive.ts`
|
- `src/util/archive.ts`
|
||||||
- `src/file/ripgrep.ts`
|
- `../core/src/filesystem/ripgrep.ts`
|
||||||
- `src/tool/bash.ts`
|
- `src/tool/bash.ts`
|
||||||
- `src/cli/cmd/uninstall.ts`
|
- `src/cli/cmd/uninstall.ts`
|
||||||
|
|
||||||
|
|||||||
@@ -41,14 +41,6 @@
|
|||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/http-recorder": "workspace:*",
|
"@opencode-ai/http-recorder": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
|
||||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-arm64-musl": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-x64-musl": "2.5.1",
|
|
||||||
"@parcel/watcher-win32-arm64": "2.5.1",
|
|
||||||
"@parcel/watcher-win32-x64": "2.5.1",
|
|
||||||
"@standard-schema/spec": "1.0.0",
|
"@standard-schema/spec": "1.0.0",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/babel__core": "7.20.5",
|
"@types/babel__core": "7.20.5",
|
||||||
@@ -58,7 +50,6 @@
|
|||||||
"@types/npm-package-arg": "6.1.4",
|
"@types/npm-package-arg": "6.1.4",
|
||||||
"@types/semver": "^7.5.8",
|
"@types/semver": "^7.5.8",
|
||||||
"@types/turndown": "5.0.5",
|
"@types/turndown": "5.0.5",
|
||||||
"@types/which": "3.0.4",
|
|
||||||
"@types/yargs": "17.0.33",
|
"@types/yargs": "17.0.33",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
@@ -139,7 +130,6 @@
|
|||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
"htmlparser2": "8.0.2",
|
"htmlparser2": "8.0.2",
|
||||||
"ignore": "7.0.5",
|
|
||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
@@ -161,7 +151,6 @@
|
|||||||
"venice-ai-sdk-provider": "2.0.2",
|
"venice-ai-sdk-provider": "2.0.2",
|
||||||
"vscode-jsonrpc": "8.2.1",
|
"vscode-jsonrpc": "8.2.1",
|
||||||
"web-tree-sitter": "0.25.10",
|
"web-tree-sitter": "0.25.10",
|
||||||
"which": "6.0.1",
|
|
||||||
"ws": "8.21.0",
|
"ws": "8.21.0",
|
||||||
"xdg-basedir": "5.1.0",
|
"xdg-basedir": "5.1.0",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Caller-heavy batch, all merged:
|
|||||||
|
|
||||||
1. `src/config/config.ts`
|
1. `src/config/config.ts`
|
||||||
2. `src/provider/provider.ts`
|
2. `src/provider/provider.ts`
|
||||||
3. `src/file/index.ts`
|
3. `../core/src/filesystem.ts`
|
||||||
4. `src/lsp/index.ts`
|
4. `src/lsp/index.ts`
|
||||||
5. `src/mcp/index.ts`
|
5. `src/mcp/index.ts`
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ Usually no.
|
|||||||
Prefer the direct form when there is only one expression:
|
Prefer the direct form when there is only one expression:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
await AppRuntime.runPromise(File.Service.use((svc) => svc.read(path)))
|
await Effect.runPromise(FileSystem.Service.use((svc) => svc.read({ path })))
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `Effect.gen(...)` when the workflow actually needs multiple yielded values or branching.
|
Use `Effect.gen(...)` when the workflow actually needs multiple yielded values or branching.
|
||||||
@@ -179,7 +179,7 @@ These were the recurring mistakes and useful corrections from the first two batc
|
|||||||
|
|
||||||
1. Tests should usually provide the specific service layer, not `AppRuntime`.
|
1. Tests should usually provide the specific service layer, not `AppRuntime`.
|
||||||
2. If a test uses `provideTmpdirInstance(...)` and needs child processes, prefer `CrossSpawnSpawner.defaultLayer`.
|
2. If a test uses `provideTmpdirInstance(...)` and needs child processes, prefer `CrossSpawnSpawner.defaultLayer`.
|
||||||
3. Instance-scoped services may need both the service layer and the right instance fixture. `File` tests, for example, needed `provideInstance(...)` plus `File.defaultLayer`.
|
3. Location-scoped services may need both the service layer and the right location fixture. `FileSystem` tests, for example, provide `Location.Service` plus `FileSystem.locationLayer`.
|
||||||
4. Do not wrap a single `Service.use(...)` call in `Effect.gen(...)` just to return it. Use the direct form.
|
4. Do not wrap a single `Service.use(...)` call in `Effect.gen(...)` just to return it. Use the direct form.
|
||||||
5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
|
5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
|
||||||
6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
|
6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
|
||||||
@@ -201,7 +201,7 @@ Most of the original facade-removal backlog is already done. The practical remai
|
|||||||
- [x] `src/worktree/index.ts` (`Worktree`) - service-local facades removed
|
- [x] `src/worktree/index.ts` (`Worktree`) - service-local facades removed
|
||||||
- [x] `src/plugin/index.ts` (`Plugin`) - service-local facades removed
|
- [x] `src/plugin/index.ts` (`Plugin`) - service-local facades removed
|
||||||
- [x] `src/snapshot/index.ts` (`Snapshot`) - service-local facades removed
|
- [x] `src/snapshot/index.ts` (`Snapshot`) - service-local facades removed
|
||||||
- [x] `src/file/index.ts` (`File`) - facades removed and merged
|
- [x] `../core/src/filesystem.ts` (`FileSystem`) - legacy opencode service removed
|
||||||
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged
|
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged
|
||||||
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged
|
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged
|
||||||
- [x] `src/config/config.ts` (`Config`) - facades removed and merged
|
- [x] `src/config/config.ts` (`Config`) - facades removed and merged
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ Intentional boundaries:
|
|||||||
In effectified code, yield existing services instead of dropping to ad hoc
|
In effectified code, yield existing services instead of dropping to ad hoc
|
||||||
platform APIs.
|
platform APIs.
|
||||||
|
|
||||||
- Use `AppFileSystem.Service` instead of raw `fs/promises` for app file IO.
|
- Use `FSUtil.Service` instead of raw `fs/promises` for app file IO.
|
||||||
- Use `AppProcess.Service` instead of direct `ChildProcessSpawner.spawn` or
|
- Use `AppProcess.Service` instead of direct `ChildProcessSpawner.spawn` or
|
||||||
legacy process helpers.
|
legacy process helpers.
|
||||||
- Use `HttpClient.HttpClient` instead of raw `fetch` inside Effect code.
|
- Use `HttpClient.HttpClient` instead of raw `fetch` inside Effect code.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
|
|||||||
|
|
||||||
- [ ] `config/paths.ts` - split pure helpers from effectful helpers.
|
- [ ] `config/paths.ts` - split pure helpers from effectful helpers.
|
||||||
Keep `fileInDirectory(...)` as a plain function.
|
Keep `fileInDirectory(...)` as a plain function.
|
||||||
- [ ] `config/paths.ts` - add a `ConfigPaths.Service` for the effectful operations so callers do not inherit `AppFileSystem.Service` directly.
|
- [ ] `config/paths.ts` - add a `ConfigPaths.Service` for the effectful operations so callers do not inherit `FSUtil.Service` directly.
|
||||||
Initial service surface should cover:
|
Initial service surface should cover:
|
||||||
- `projectFiles(...)`
|
- `projectFiles(...)`
|
||||||
- `directories(...)`
|
- `directories(...)`
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ genuinely outside `AppLayer`.
|
|||||||
|
|
||||||
## Platform Edges
|
## Platform Edges
|
||||||
|
|
||||||
- Use `AppFileSystem.Service` instead of raw filesystem APIs in
|
- Use `FSUtil.Service` instead of raw filesystem APIs in
|
||||||
effectified services.
|
effectified services.
|
||||||
- Use `AppProcess.Service` instead of raw process wrappers.
|
- Use `AppProcess.Service` instead of raw process wrappers.
|
||||||
- Use `HttpClient.HttpClient` instead of raw `fetch` in Effect code.
|
- Use `HttpClient.HttpClient` instead of raw `fetch` in Effect code.
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ P6 OA
|
|||||||
- `PROC` AppProcess migration — prefer `AppProcess.Service` over raw
|
- `PROC` AppProcess migration — prefer `AppProcess.Service` over raw
|
||||||
process wrappers.
|
process wrappers.
|
||||||
Shrinks: direct spawn callsites and legacy process helpers.
|
Shrinks: direct spawn callsites and legacy process helpers.
|
||||||
- `FS` AppFileSystem migration — prefer `AppFileSystem.Service` over raw
|
- `FS` FSUtil migration — prefer `FSUtil.Service` over raw
|
||||||
filesystem APIs.
|
filesystem APIs.
|
||||||
Shrinks: direct `fs` / `Bun.file` service callsites where inappropriate.
|
Shrinks: direct `fs` / `Bun.file` service callsites where inappropriate.
|
||||||
- `RT` Runtime/facade cleanup — remove service-local `makeRuntime`
|
- `RT` Runtime/facade cleanup — remove service-local `makeRuntime`
|
||||||
@@ -229,7 +229,7 @@ Current rules:
|
|||||||
|
|
||||||
## Lower Priority Tracks
|
## Lower Priority Tracks
|
||||||
|
|
||||||
- `PROC` / `FS` — continue AppProcess and AppFileSystem migrations as
|
- `PROC` / `FS` — continue AppProcess and FSUtil migrations as
|
||||||
focused PRs when touching relevant files.
|
focused PRs when touching relevant files.
|
||||||
- `RT` — remove service-local runtime facades only when they are not an
|
- `RT` — remove service-local runtime facades only when they are not an
|
||||||
intentional boundary.
|
intentional boundary.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ The current exported tools in `src/tool` all use `Tool.define(...)` with Effect-
|
|||||||
So the remaining work is no longer "convert tools to Effect at all". The remaining work is mostly:
|
So the remaining work is no longer "convert tools to Effect at all". The remaining work is mostly:
|
||||||
|
|
||||||
1. remove Promise and raw platform bridges inside individual tool bodies
|
1. remove Promise and raw platform bridges inside individual tool bodies
|
||||||
2. swap tool internals to Effect-native services like `AppFileSystem`, `HttpClient`, and `ChildProcessSpawner`
|
2. swap tool internals to Effect-native services like `FSUtil`, `HttpClient`, and `ChildProcessSpawner`
|
||||||
3. keep tests and callers aligned with `yield* info.init()` and real service graphs
|
3. keep tests and callers aligned with `yield* info.init()` and real service graphs
|
||||||
|
|
||||||
## Current shape
|
## Current shape
|
||||||
@@ -67,11 +67,11 @@ Most exported tools are already on the intended Effect-native shape. The remaini
|
|||||||
|
|
||||||
Current spot cleanups worth tracking:
|
Current spot cleanups worth tracking:
|
||||||
|
|
||||||
- [x] `read.ts` — streams through `AppFileSystem.Service.stream` with `Stream.splitLines`; the legacy Node stream / `readline` helper is gone
|
- [x] `read.ts` — streams through `FSUtil.Service.stream` with `Stream.splitLines`; the legacy Node stream / `readline` helper is gone
|
||||||
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
|
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
|
||||||
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
|
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
|
||||||
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
|
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
|
||||||
- [x] `patch/index.ts` — apply path now returns `Effect` over `AppFileSystem.Service`; the parser and chunk replacer stay pure
|
- [x] `patch/index.ts` — apply path now returns `Effect` over `FSUtil.Service`; the parser and chunk replacer stay pure
|
||||||
|
|
||||||
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
|
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path"
|
|||||||
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
||||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
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"
|
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(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fsys = yield* AppFileSystem.Service
|
const fsys = yield* FSUtil.Service
|
||||||
const decode = Schema.decodeUnknownOption(Info)
|
const decode = Schema.decodeUnknownOption(Info)
|
||||||
|
|
||||||
const all = Effect.fn("Auth.all")(function* () {
|
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 "."
|
export * as Auth from "."
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { File } from "../../../file"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Ripgrep } from "@/file/ripgrep"
|
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 { effectCmd } from "../../effect-cmd"
|
||||||
import { cmd } from "../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({
|
const FileSearchCommand = effectCmd({
|
||||||
command: "search <query>",
|
command: "search <query>",
|
||||||
describe: "search files by query",
|
describe: "search files by query",
|
||||||
@@ -15,8 +23,8 @@ const FileSearchCommand = effectCmd({
|
|||||||
description: "Search query",
|
description: "Search query",
|
||||||
}),
|
}),
|
||||||
handler: Effect.fn("Cli.debug.file.search")(function* (args) {
|
handler: Effect.fn("Cli.debug.file.search")(function* (args) {
|
||||||
const results = yield* File.Service.use((svc) => svc.search({ query: args.query }))
|
const results = yield* filesystem(FileSystem.Service.use((svc) => svc.find({ query: args.query })))
|
||||||
process.stdout.write(results.join(EOL) + EOL)
|
process.stdout.write(results.map((item) => item.path).join(EOL) + EOL)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,21 +38,11 @@ const FileReadCommand = effectCmd({
|
|||||||
description: "File path to read",
|
description: "File path to read",
|
||||||
}),
|
}),
|
||||||
handler: Effect.fn("Cli.debug.file.read")(function* (args) {
|
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)
|
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({
|
const FileListCommand = effectCmd({
|
||||||
command: "list <path>",
|
command: "list <path>",
|
||||||
describe: "list files in a directory",
|
describe: "list files in a directory",
|
||||||
@@ -55,7 +53,7 @@ const FileListCommand = effectCmd({
|
|||||||
description: "File path to list",
|
description: "File path to list",
|
||||||
}),
|
}),
|
||||||
handler: Effect.fn("Cli.debug.file.list")(function* (args) {
|
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)
|
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -81,7 +79,6 @@ export const FileCommand = cmd({
|
|||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs
|
yargs
|
||||||
.command(FileReadCommand)
|
.command(FileReadCommand)
|
||||||
.command(FileStatusCommand)
|
|
||||||
.command(FileListCommand)
|
.command(FileListCommand)
|
||||||
.command(FileSearchCommand)
|
.command(FileSearchCommand)
|
||||||
.command(FileTreeCommand)
|
.command(FileTreeCommand)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
import { Ripgrep } from "../../../file/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
import { effectCmd } from "../../effect-cmd"
|
import { effectCmd } from "../../effect-cmd"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
import { InstanceRef } from "@/effect/instance-ref"
|
import { InstanceRef } from "@/effect/instance-ref"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { InstanceRef } from "@/effect/instance-ref"
|
|||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import path from "path"
|
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 { Effect, Schema } from "effect"
|
||||||
import type { InstanceContext } from "@/project/instance-context"
|
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 runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: InstanceContext) {
|
||||||
const share = yield* ShareNext.Service
|
const share = yield* ShareNext.Service
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
|
|
||||||
let exportData: ExportData | undefined
|
let exportData: ExportData | undefined
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// so your last-used variant sticks. Cycling (ctrl+t) updates both the active
|
// so your last-used variant sticks. Cycling (ctrl+t) updates both the active
|
||||||
// variant and the persisted file.
|
// variant and the persisted file.
|
||||||
import path from "path"
|
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 { Context, Effect, Layer } from "effect"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
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(
|
return Layer.fresh(
|
||||||
Layer.effect(
|
Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const file = yield* AppFileSystem.Service
|
const file = yield* FSUtil.Service
|
||||||
|
|
||||||
const read = Effect.fn("RunVariant.read")(function* () {
|
const read = Effect.fn("RunVariant.read")(function* () {
|
||||||
return yield* file.readJson(MODEL_FILE).pipe(
|
return yield* file.readJson(MODEL_FILE).pipe(
|
||||||
@@ -196,7 +196,7 @@ function createLayer(fs = AppFileSystem.defaultLayer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @internal Exported for testing. */
|
/** @internal Exported for testing. */
|
||||||
export function createVariantRuntime(fs = AppFileSystem.defaultLayer): VariantRuntime {
|
export function createVariantRuntime(fs = FSUtil.defaultLayer): VariantRuntime {
|
||||||
const runtime = makeRuntime(Service, createLayer(fs))
|
const runtime = makeRuntime(Service, createLayer(fs))
|
||||||
return {
|
return {
|
||||||
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
|
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Process } from "@/util/process"
|
|||||||
import { NotFoundError } from "@/storage/storage"
|
import { NotFoundError } from "@/storage/storage"
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { which } from "../../util/which"
|
import { which } from "@opencode-ai/core/util/which"
|
||||||
|
|
||||||
function pagerCmd(): string[] {
|
function pagerCmd(): string[] {
|
||||||
const lessOptions = ["-R", "-S"]
|
const lessOptions = ["-R", "-S"]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from
|
|||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
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 { CurrentWorkingDirectory } from "./cwd"
|
||||||
import { ConfigPlugin } from "@/config/plugin"
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
import { TuiKeybind } from "./keybind"
|
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 loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
|
||||||
const afs = yield* AppFileSystem.Service
|
const afs = yield* FSUtil.Service
|
||||||
let appliedOrder = 0
|
let appliedOrder = 0
|
||||||
|
|
||||||
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
|
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
|
||||||
@@ -295,7 +295,7 @@ export const layer = Layer.effect(
|
|||||||
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
}).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)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
|
||||||
const getWhich = lazy(async () => {
|
const getWhich = lazy(async () => {
|
||||||
const { which } = await import("../../../../util/which")
|
const { which } = await import("@opencode-ai/core/util/which")
|
||||||
return which
|
return which
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { existsSync } from "fs"
|
|||||||
import { Account } from "@/account/account"
|
import { Account } from "@/account/account"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import type { ConsoleState } from "./console-state"
|
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 { InstanceState } from "@/effect/instance-state"
|
||||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
@@ -384,7 +384,7 @@ export const ConfigDirectoryTypoError = NamedError.create("ConfigDirectoryTypoEr
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const authSvc = yield* Auth.Service
|
const authSvc = yield* Auth.Service
|
||||||
const accountSvc = yield* Account.Service
|
const accountSvc = yield* Account.Service
|
||||||
const env = yield* Env.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>(
|
const state = yield* InstanceState.make<State>(
|
||||||
@@ -876,7 +876,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(EffectFlock.defaultLayer),
|
Layer.provide(EffectFlock.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Env.defaultLayer),
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Account.defaultLayer),
|
Layer.provide(Account.defaultLayer),
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
|||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { unique } from "remeda"
|
import { unique } from "remeda"
|
||||||
import * as Effect from "effect/Effect"
|
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* (
|
export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
|
||||||
name: string,
|
name: string,
|
||||||
directory: string,
|
directory: string,
|
||||||
worktree?: string,
|
worktree?: string,
|
||||||
) {
|
) {
|
||||||
const afs = yield* AppFileSystem.Service
|
const afs = yield* FSUtil.Service
|
||||||
return (yield* afs.up({
|
return (yield* afs.up({
|
||||||
targets: [`${name}.jsonc`, `${name}.json`],
|
targets: [`${name}.jsonc`, `${name}.json`],
|
||||||
start: directory,
|
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) {
|
export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
|
||||||
const afs = yield* AppFileSystem.Service
|
const afs = yield* FSUtil.Service
|
||||||
return unique([
|
return unique([
|
||||||
Global.Path.config,
|
Global.Path.config,
|
||||||
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Auth } from "@/auth"
|
|||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
|
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 * as Log from "@opencode-ai/core/util/log"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
@@ -177,7 +177,7 @@ export const layer = Layer.effect(
|
|||||||
const events = yield* EventV2Bridge.Service
|
const events = yield* EventV2Bridge.Service
|
||||||
const vcs = yield* Vcs.Service
|
const vcs = yield* Vcs.Service
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
const connections = new Map<WorkspaceV2.ID, ConnectionStatus>()
|
const connections = new Map<WorkspaceV2.ID, ConnectionStatus>()
|
||||||
const syncFibers = yield* FiberMap.make<WorkspaceV2.ID, void, SyncLoopError>()
|
const syncFibers = yield* FiberMap.make<WorkspaceV2.ID, void, SyncLoopError>()
|
||||||
@@ -1005,7 +1005,7 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(SessionPrompt.defaultLayer),
|
Layer.provide(SessionPrompt.defaultLayer),
|
||||||
Layer.provide(Project.defaultLayer),
|
Layer.provide(Project.defaultLayer),
|
||||||
Layer.provide(Vcs.defaultLayer),
|
Layer.provide(Vcs.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Database.defaultLayer),
|
Layer.provide(Database.defaultLayer),
|
||||||
Layer.provide(EventV2Bridge.defaultLayer),
|
Layer.provide(EventV2Bridge.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
|
|||||||
@@ -2,15 +2,13 @@ import { Layer, ManagedRuntime } from "effect"
|
|||||||
import { attach } from "./run-service"
|
import { attach } from "./run-service"
|
||||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
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 { Database } from "@opencode-ai/core/database/database"
|
||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
import { Account } from "@/account/account"
|
import { Account } from "@/account/account"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import { Ripgrep } from "@/file/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
import { File } from "@/file"
|
|
||||||
import { FileWatcher } from "@/file/watcher"
|
|
||||||
import { Storage } from "@/storage/storage"
|
import { Storage } from "@/storage/storage"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
@@ -59,15 +57,13 @@ import { EventV2Bridge } from "@/event-v2-bridge"
|
|||||||
|
|
||||||
export const AppLayer = Layer.mergeAll(
|
export const AppLayer = Layer.mergeAll(
|
||||||
Npm.defaultLayer,
|
Npm.defaultLayer,
|
||||||
AppFileSystem.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
Database.defaultLayer,
|
Database.defaultLayer,
|
||||||
Auth.defaultLayer,
|
Auth.defaultLayer,
|
||||||
Account.defaultLayer,
|
Account.defaultLayer,
|
||||||
Config.defaultLayer,
|
Config.defaultLayer,
|
||||||
Git.defaultLayer,
|
Git.defaultLayer,
|
||||||
Ripgrep.defaultLayer,
|
Ripgrep.defaultLayer,
|
||||||
File.defaultLayer,
|
|
||||||
FileWatcher.defaultLayer,
|
|
||||||
Storage.defaultLayer,
|
Storage.defaultLayer,
|
||||||
Snapshot.defaultLayer,
|
Snapshot.defaultLayer,
|
||||||
Plugin.defaultLayer,
|
Plugin.defaultLayer,
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import { Layer, ManagedRuntime } from "effect"
|
|||||||
|
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { FileWatcher } from "@/file/watcher"
|
|
||||||
import { Format } from "@/format"
|
import { Format } from "@/format"
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
import { File } from "@/file"
|
|
||||||
import { Vcs } from "@/project/vcs"
|
import { Vcs } from "@/project/vcs"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
@@ -18,8 +16,6 @@ export const BootstrapLayer = Layer.mergeAll(
|
|||||||
ShareNext.defaultLayer,
|
ShareNext.defaultLayer,
|
||||||
Format.defaultLayer,
|
Format.defaultLayer,
|
||||||
LSP.defaultLayer,
|
LSP.defaultLayer,
|
||||||
File.defaultLayer,
|
|
||||||
FileWatcher.defaultLayer,
|
|
||||||
Vcs.defaultLayer,
|
Vcs.defaultLayer,
|
||||||
Snapshot.defaultLayer,
|
Snapshot.defaultLayer,
|
||||||
).pipe(Layer.provide(Observability.layer))
|
).pipe(Layer.provide(Observability.layer))
|
||||||
|
|||||||
@@ -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 "."
|
|
||||||
@@ -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"
|
|
||||||
@@ -2,7 +2,7 @@ import { Npm } from "@opencode-ai/core/npm"
|
|||||||
import type { InstanceContext } from "../project/instance-context"
|
import type { InstanceContext } from "../project/instance-context"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Process } from "@/util/process"
|
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"> {
|
export interface Context extends Pick<InstanceContext, "directory" | "worktree"> {
|
||||||
experimentalOxfmt: boolean
|
experimentalOxfmt: boolean
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Filesystem } from "@/util/filesystem"
|
|||||||
import type { InstanceContext } from "../project/instance-context"
|
import type { InstanceContext } from "../project/instance-context"
|
||||||
import { Archive } from "@/util/archive"
|
import { Archive } from "@/util/archive"
|
||||||
import { Process } from "@/util/process"
|
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 { Module } from "@opencode-ai/core/util/module"
|
||||||
import { spawn } from "./launch"
|
import { spawn } from "./launch"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { Npm } from "@opencode-ai/core/npm"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path"
|
|||||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Effect, Layer, Context, Option, Schema } from "effect"
|
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"
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
|
|
||||||
export const Tokens = Schema.Struct({
|
export const Tokens = Schema.Struct({
|
||||||
@@ -59,7 +59,7 @@ export const use = serviceUse(Service)
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const flock = yield* EffectFlock.Service
|
const flock = yield* EffectFlock.Service
|
||||||
|
|
||||||
const read = Effect.fn("McpAuth.read")(function* () {
|
const read = Effect.fn("McpAuth.read")(function* () {
|
||||||
@@ -166,9 +166,6 @@ export const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.defaultLayer))
|
||||||
Layer.provide(EffectFlock.defaultLayer),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
)
|
|
||||||
|
|
||||||
export * as McpAuth from "./auth"
|
export * as McpAuth from "./auth"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
|||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { withTimeout } from "@/util/timeout"
|
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 { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||||
import { McpOAuthCallback } from "./oauth-callback"
|
import { McpOAuthCallback } from "./oauth-callback"
|
||||||
import { McpAuth } from "./auth"
|
import { McpAuth } from "./auth"
|
||||||
@@ -975,7 +975,7 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(EventV2Bridge.defaultLayer),
|
Layer.provide(EventV2Bridge.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export * as MCP from "."
|
export * as MCP from "."
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import * as path from "path"
|
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 Log from "@opencode-ai/core/util/log"
|
||||||
import * as Bom from "../util/bom"
|
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."))
|
return yield* Effect.fail(new Error("No files were modified."))
|
||||||
}
|
}
|
||||||
|
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
|
|
||||||
const added: string[] = []
|
const added: string[] = []
|
||||||
const modified: string[] = []
|
const modified: string[] = []
|
||||||
@@ -574,7 +574,7 @@ type MaybeApplyPatchVerifiedResult =
|
|||||||
| { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
|
| { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
|
||||||
| { type: MaybeApplyPatchVerified.NotApplyPatch }
|
| { 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* (
|
export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatchVerified")(function* (
|
||||||
argv: string[],
|
argv: string[],
|
||||||
cwd: string,
|
cwd: string,
|
||||||
@@ -596,7 +596,7 @@ export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatc
|
|||||||
|
|
||||||
switch (result.type) {
|
switch (result.type) {
|
||||||
case MaybeApplyPatch.Body: {
|
case MaybeApplyPatch.Body: {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const args = result.args
|
const args = result.args
|
||||||
const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
|
const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
|
||||||
const changes = new Map<string, ApplyPatchFileChange>()
|
const changes = new Map<string, ApplyPatchFileChange>()
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Plugin } from "../plugin"
|
import { Plugin } from "../plugin"
|
||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { File } from "../file"
|
|
||||||
import { Snapshot } from "../snapshot"
|
import { Snapshot } from "../snapshot"
|
||||||
import * as Project from "./project"
|
import * as Project from "./project"
|
||||||
import * as Vcs from "./vcs"
|
import * as Vcs from "./vcs"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { FileWatcher } from "@/file/watcher"
|
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
@@ -23,8 +21,6 @@ export const layer = Layer.effect(
|
|||||||
// InstanceStore imports only the lightweight tag from bootstrap-service.ts,
|
// InstanceStore imports only the lightweight tag from bootstrap-service.ts,
|
||||||
// so it can depend on bootstrap without importing this implementation graph.
|
// so it can depend on bootstrap without importing this implementation graph.
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const file = yield* File.Service
|
|
||||||
const fileWatcher = yield* FileWatcher.Service
|
|
||||||
const format = yield* Format.Service
|
const format = yield* Format.Service
|
||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
const plugin = yield* Plugin.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
|
// Each service self-manages its own slow work via Effect.forkScoped against
|
||||||
// its per-instance state scope. We just await materialization here.
|
// its per-instance state scope. We just await materialization here.
|
||||||
yield* Effect.forEach(
|
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 }))),
|
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
|
||||||
{ concurrency: "unbounded", discard: true },
|
{ concurrency: "unbounded", discard: true },
|
||||||
).pipe(Effect.withSpan("InstanceBootstrap.init"))
|
).pipe(Effect.withSpan("InstanceBootstrap.init"))
|
||||||
@@ -57,8 +53,6 @@ export const layer = Layer.effect(
|
|||||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||||
Layer.provide([
|
Layer.provide([
|
||||||
Config.defaultLayer,
|
Config.defaultLayer,
|
||||||
File.defaultLayer,
|
|
||||||
FileWatcher.defaultLayer,
|
|
||||||
Format.defaultLayer,
|
Format.defaultLayer,
|
||||||
LSP.defaultLayer,
|
LSP.defaultLayer,
|
||||||
Plugin.defaultLayer,
|
Plugin.defaultLayer,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LocalContext } from "@/util/local-context"
|
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"
|
import type * as Project from "./project"
|
||||||
|
|
||||||
export interface InstanceContext {
|
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.
|
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
|
||||||
*/
|
*/
|
||||||
export function containsPath(filepath: string, ctx: InstanceContext): boolean {
|
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.
|
// Non-git projects set worktree to "/" which would match ANY absolute path.
|
||||||
// Skip worktree check in this case to preserve external_directory permissions.
|
// Skip worktree check in this case to preserve external_directory permissions.
|
||||||
if (ctx.worktree === "/") return false
|
if (ctx.worktree === "/") return false
|
||||||
return AppFileSystem.contains(ctx.worktree, filepath)
|
return FSUtil.contains(ctx.worktree, filepath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
|||||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
import { InstanceRef } from "@/effect/instance-ref"
|
import { InstanceRef } from "@/effect/instance-ref"
|
||||||
import { disposeInstance as runDisposers } from "@/effect/instance-registry"
|
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 { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
|
||||||
import { type InstanceContext } from "./instance-context"
|
import { type InstanceContext } from "./instance-context"
|
||||||
import { InstanceBootstrap } from "./bootstrap-service"
|
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 load = (input: LoadInput): Effect.Effect<InstanceContext> => {
|
||||||
const directory = AppFileSystem.resolve(input.directory)
|
const directory = FSUtil.resolve(input.directory)
|
||||||
return Effect.uninterruptibleMask((restore) =>
|
return Effect.uninterruptibleMask((restore) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const existing = cache.get(directory)
|
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 reload = (input: LoadInput): Effect.Effect<InstanceContext> => {
|
||||||
const directory = AppFileSystem.resolve(input.directory)
|
const directory = FSUtil.resolve(input.directory)
|
||||||
return Effect.uninterruptibleMask((restore) =>
|
return Effect.uninterruptibleMask((restore) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const previous = cache.get(directory)
|
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 disposeDirectory = Effect.fn("InstanceStore.disposeDirectory")(function* (input: string) {
|
||||||
const directory = AppFileSystem.resolve(input)
|
const directory = FSUtil.resolve(input)
|
||||||
const entry = cache.get(directory)
|
const entry = cache.get(directory)
|
||||||
if (!entry) return
|
if (!entry) return
|
||||||
const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit)
|
const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit)
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
|||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { which } from "../util/which"
|
import { which } from "@opencode-ai/core/util/which"
|
||||||
import { Command } from "@/command"
|
import { Command } from "@/command"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect"
|
import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect"
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
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 { AppProcess } from "@opencode-ai/core/process"
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
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(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const proc = yield* AppProcess.Service
|
const proc = yield* AppProcess.Service
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
const projectV2 = yield* ProjectV2.Service
|
const projectV2 = yield* ProjectV2.Service
|
||||||
@@ -326,7 +326,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
|
const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
|
||||||
const base64 = Buffer.from(buffer).toString("base64")
|
const base64 = Buffer.from(buffer).toString("base64")
|
||||||
const mime = AppFileSystem.mimeType(shortest)
|
const mime = FSUtil.mimeType(shortest)
|
||||||
const url = `data:${mime};base64,${base64}`
|
const url = `data:${mime};base64,${base64}`
|
||||||
yield* update({ projectID: input.id, icon: { url } }).pipe(
|
yield* update({ projectID: input.id, icon: { url } }).pipe(
|
||||||
Effect.catchTag("Project.NotFoundError", () => Effect.void),
|
Effect.catchTag("Project.NotFoundError", () => Effect.void),
|
||||||
@@ -468,7 +468,7 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(ProjectV2.defaultLayer),
|
Layer.provide(ProjectV2.defaultLayer),
|
||||||
Layer.provide(AppProcess.defaultLayer),
|
Layer.provide(AppProcess.defaultLayer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Database.defaultLayer),
|
Layer.provide(Database.defaultLayer),
|
||||||
Layer.provide(RuntimeFlags.defaultLayer),
|
Layer.provide(RuntimeFlags.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
|
import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
|
||||||
import { formatPatch, structuredPatch } from "diff"
|
import { formatPatch, structuredPatch } from "diff"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { FileWatcher } from "@/file/watcher"
|
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
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 })
|
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||||
|
|
||||||
const unsubscribe = yield* events.listen((event) => {
|
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
|
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
|
if (!data.file.endsWith("HEAD")) return Effect.void
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const next = yield* get()
|
const next = yield* get()
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { Effect, Layer, Context, Schema, Types } from "effect"
|
|||||||
import { EffectBridge } from "@/effect/bridge"
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { EffectPromise } from "@/effect/promise"
|
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 { isRecord } from "@/util/record"
|
||||||
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
|
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
|
||||||
import { ProviderTransform } from "./transform"
|
import { ProviderTransform } from "./transform"
|
||||||
@@ -1183,7 +1183,7 @@ function modelSuggestions(provider: Info | undefined, modelID: ProviderV2.ModelI
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.Service
|
||||||
const env = yield* Env.Service
|
const env = yield* Env.Service
|
||||||
@@ -1861,7 +1861,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
export const defaultLayer = Layer.suspend(() =>
|
export const defaultLayer = Layer.suspend(() =>
|
||||||
layer.pipe(
|
layer.pipe(
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Env.defaultLayer),
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Context, Layer, Scope } from "effect"
|
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 { Global } from "@opencode-ai/core/global"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { ConfigReference } from "@/config/reference"
|
import { ConfigReference } from "@/config/reference"
|
||||||
@@ -83,11 +83,11 @@ function branchLabel(branch: string | undefined) {
|
|||||||
|
|
||||||
function normalizedTarget(target?: string) {
|
function normalizedTarget(target?: string) {
|
||||||
if (!target) return
|
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) {
|
function containsReferencePath(referencePath: string, target: string) {
|
||||||
return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target)
|
return FSUtil.contains(normalizedTarget(referencePath) ?? referencePath, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueGitReferences(references: Resolved[]) {
|
function uniqueGitReferences(references: Resolved[]) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
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 { Flock } from "@opencode-ai/core/util/flock"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import {
|
import {
|
||||||
@@ -168,7 +168,7 @@ export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(functi
|
|||||||
const ensureWithServices = Effect.fn("RepositoryCache.ensureWithServices")(function* (
|
const ensureWithServices = Effect.fn("RepositoryCache.ensureWithServices")(function* (
|
||||||
input: EnsureInput,
|
input: EnsureInput,
|
||||||
services: {
|
services: {
|
||||||
fs: AppFileSystem.Interface
|
fs: FSUtil.Interface
|
||||||
git: Git.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,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const git = yield* Git.Service
|
const git = yield* Git.Service
|
||||||
|
|
||||||
return Service.of({
|
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(
|
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { File } from "@/file"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Ripgrep } from "@/file/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
|
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
@@ -37,6 +38,47 @@ export const FindSymbolQuery = Schema.Struct({
|
|||||||
query: Schema.String,
|
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 = {
|
export const FilePaths = {
|
||||||
findText: "/find",
|
findText: "/find",
|
||||||
findFile: "/find/file",
|
findFile: "/find/file",
|
||||||
@@ -82,7 +124,7 @@ export const FileApi = HttpApi.make("file")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("list", FilePaths.list, {
|
HttpApiEndpoint.get("list", FilePaths.list, {
|
||||||
query: FileQuery,
|
query: FileQuery,
|
||||||
success: described(Schema.Array(File.Node), "Files and directories"),
|
success: described(Schema.Array(LegacyEntry), "Files and directories"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "file.list",
|
identifier: "file.list",
|
||||||
@@ -92,7 +134,7 @@ export const FileApi = HttpApi.make("file")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("content", FilePaths.content, {
|
HttpApiEndpoint.get("content", FilePaths.content, {
|
||||||
query: FileQuery,
|
query: FileQuery,
|
||||||
success: described(File.Content, "File content"),
|
success: described(LegacyContent, "File content"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "file.read",
|
identifier: "file.read",
|
||||||
@@ -102,7 +144,7 @@ export const FileApi = HttpApi.make("file")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("status", FilePaths.status, {
|
HttpApiEndpoint.get("status", FilePaths.status, {
|
||||||
query: WorkspaceRoutingQuery,
|
query: WorkspaceRoutingQuery,
|
||||||
success: described(Schema.Array(File.Info), "File status"),
|
success: described(Schema.Array(LegacyStatus), "File status"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "file.status",
|
identifier: "file.status",
|
||||||
|
|||||||
@@ -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 { RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
@@ -21,7 +21,7 @@ export const FileSystemGroup = HttpApiGroup.make("v2.fs")
|
|||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("read", "/api/fs/read", {
|
HttpApiEndpoint.get("read", "/api/fs/read", {
|
||||||
query: ReadQuery,
|
query: ReadQuery,
|
||||||
success: LocationFileSystem.Content,
|
success: FileSystem.Content,
|
||||||
})
|
})
|
||||||
.annotateMerge(locationQueryOpenApi)
|
.annotateMerge(locationQueryOpenApi)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
@@ -35,7 +35,7 @@ export const FileSystemGroup = HttpApiGroup.make("v2.fs")
|
|||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("list", "/api/fs/list", {
|
HttpApiEndpoint.get("list", "/api/fs/list", {
|
||||||
query: ListQuery,
|
query: ListQuery,
|
||||||
success: Schema.Array(LocationFileSystem.Entry),
|
success: Schema.Array(FileSystem.Entry),
|
||||||
})
|
})
|
||||||
.annotateMerge(locationQueryOpenApi)
|
.annotateMerge(locationQueryOpenApi)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
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 { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
@@ -42,7 +42,7 @@ export class V2LocationMiddleware extends HttpApiMiddleware.Service<
|
|||||||
| PluginBoot.Service
|
| PluginBoot.Service
|
||||||
| PermissionV2.Service
|
| PermissionV2.Service
|
||||||
| ProjectReference.Service
|
| ProjectReference.Service
|
||||||
| LocationFileSystem.Service
|
| FileSystem.Service
|
||||||
}
|
}
|
||||||
>()("@opencode/ExperimentalHttpApiV2Location") {}
|
>()("@opencode/ExperimentalHttpApiV2Location") {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import * as InstanceState from "@/effect/instance-state"
|
import * as InstanceState from "@/effect/instance-state"
|
||||||
import { File } from "@/file"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Ripgrep } from "@/file/ripgrep"
|
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||||
import { Effect } from "effect"
|
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 { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { InstanceHttpApi } from "../api"
|
import { InstanceHttpApi } from "../api"
|
||||||
|
|
||||||
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
|
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const svc = yield* File.Service
|
|
||||||
const ripgrep = yield* Ripgrep.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 } }) {
|
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
|
||||||
return (yield* ripgrep
|
return (yield* ripgrep
|
||||||
@@ -19,12 +29,15 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
|||||||
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
|
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
|
||||||
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
|
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
|
||||||
}) {
|
}) {
|
||||||
return yield* svc.search({
|
return (yield* filesystem(
|
||||||
query: ctx.query.query,
|
FileSystem.Service.use((fs) =>
|
||||||
limit: ctx.query.limit ?? 10,
|
fs.find({
|
||||||
dirs: ctx.query.dirs !== "false",
|
query: ctx.query.query,
|
||||||
type: ctx.query.type,
|
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* () {
|
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 } }) {
|
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 } }) {
|
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* () {
|
const status = Effect.fn("FileHttpApi.status")(function* () {
|
||||||
return yield* svc.status()
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
return handlers
|
return handlers
|
||||||
@@ -51,4 +91,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
|||||||
.handle("content", content)
|
.handle("content", content)
|
||||||
.handle("status", status)
|
.handle("status", status)
|
||||||
}),
|
}),
|
||||||
)
|
).pipe(Layer.provide(LocationServiceMap.layer))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { InstanceHttpApi } from "../../api"
|
import { InstanceHttpApi } from "../../api"
|
||||||
@@ -6,7 +6,7 @@ import { InstanceHttpApi } from "../../api"
|
|||||||
export const fileSystemHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.fs", (handlers) =>
|
export const fileSystemHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.fs", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return handlers
|
return handlers
|
||||||
.handle("read", (ctx) => LocationFileSystem.Service.use((fs) => fs.read(ctx.query)))
|
.handle("read", (ctx) => FileSystem.Service.use((fs) => fs.read(ctx.query)))
|
||||||
.handle("list", (ctx) => LocationFileSystem.Service.use((fs) => fs.list(ctx.query)))
|
.handle("list", (ctx) => FileSystem.Service.use((fs) => fs.list(ctx.query)))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,16 +9,14 @@ import {
|
|||||||
HttpServerResponse,
|
HttpServerResponse,
|
||||||
} from "effect/unstable/http"
|
} from "effect/unstable/http"
|
||||||
import * as Socket from "effect/unstable/socket/Socket"
|
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 { Account } from "@/account/account"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Command } from "@/command"
|
import { Command } from "@/command"
|
||||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||||
import { File } from "@/file"
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
import { FileWatcher } from "@/file/watcher"
|
|
||||||
import { Ripgrep } from "@/file/ripgrep"
|
|
||||||
import { Format } from "@/format"
|
import { Format } from "@/format"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
@@ -166,7 +164,7 @@ const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effe
|
|||||||
|
|
||||||
const uiRoute = HttpRouter.use((router) =>
|
const uiRoute = HttpRouter.use((router) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const client = yield* HttpClient.HttpClient
|
const client = yield* HttpClient.HttpClient
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
yield* router.add("*", "/*", (request) =>
|
yield* router.add("*", "/*", (request) =>
|
||||||
@@ -198,8 +196,6 @@ export function createRoutes(
|
|||||||
Auth.defaultLayer,
|
Auth.defaultLayer,
|
||||||
Command.defaultLayer,
|
Command.defaultLayer,
|
||||||
Config.defaultLayer,
|
Config.defaultLayer,
|
||||||
File.defaultLayer,
|
|
||||||
FileWatcher.defaultLayer,
|
|
||||||
Format.defaultLayer,
|
Format.defaultLayer,
|
||||||
LSP.defaultLayer,
|
LSP.defaultLayer,
|
||||||
Installation.defaultLayer,
|
Installation.defaultLayer,
|
||||||
@@ -232,7 +228,7 @@ export function createRoutes(
|
|||||||
Vcs.defaultLayer,
|
Vcs.defaultLayer,
|
||||||
Workspace.defaultLayer,
|
Workspace.defaultLayer,
|
||||||
Worktree.appLayer,
|
Worktree.appLayer,
|
||||||
AppFileSystem.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
FetchHttpClient.layer,
|
FetchHttpClient.layer,
|
||||||
HttpServer.layerServices,
|
HttpServer.layerServices,
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -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 { Effect, Stream } from "effect"
|
||||||
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
@@ -53,7 +53,7 @@ function notFound() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function embeddedUIResponse(file: string, body: Uint8Array) {
|
function embeddedUIResponse(file: string, body: Uint8Array) {
|
||||||
const mime = AppFileSystem.mimeType(file)
|
const mime = FSUtil.mimeType(file)
|
||||||
const headers = new Headers({ "content-type": mime })
|
const headers = new Headers({ "content-type": mime })
|
||||||
if (mime.startsWith("text/html")) {
|
if (mime.startsWith("text/html")) {
|
||||||
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
|
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
|
||||||
@@ -63,7 +63,7 @@ function embeddedUIResponse(file: string, body: Uint8Array) {
|
|||||||
|
|
||||||
export function serveEmbeddedUIEffect(
|
export function serveEmbeddedUIEffect(
|
||||||
requestPath: string,
|
requestPath: string,
|
||||||
fs: AppFileSystem.Interface,
|
fs: FSUtil.Interface,
|
||||||
embeddedWebUI: Record<string, string>,
|
embeddedWebUI: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||||
@@ -77,7 +77,7 @@ export function serveEmbeddedUIEffect(
|
|||||||
|
|
||||||
export function serveUIEffect(
|
export function serveUIEffect(
|
||||||
request: HttpServerRequest.HttpServerRequest,
|
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* () {
|
return Effect.gen(function* () {
|
||||||
const embeddedWebUI = yield* Effect.promise(() => embeddedUI(services.disableEmbeddedWebUi))
|
const embeddedWebUI = yield* Effect.promise(() => embeddedUI(services.disableEmbeddedWebUi))
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Config } from "@/config/config"
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
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 { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import type { MessageV2 } from "./message-v2"
|
import type { MessageV2 } from "./message-v2"
|
||||||
@@ -31,14 +31,14 @@ function extract(messages: SessionLegacy.WithParts[]) {
|
|||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly clear: (messageID: MessageID) => Effect.Effect<void>
|
readonly clear: (messageID: MessageID) => Effect.Effect<void>
|
||||||
readonly systemPaths: () => Effect.Effect<Set<string>, AppFileSystem.Error>
|
readonly systemPaths: () => Effect.Effect<Set<string>, FSUtil.Error>
|
||||||
readonly system: () => Effect.Effect<string[], AppFileSystem.Error>
|
readonly system: () => Effect.Effect<string[], FSUtil.Error>
|
||||||
readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
|
readonly find: (dir: string) => Effect.Effect<string | undefined, FSUtil.Error>
|
||||||
readonly resolve: (
|
readonly resolve: (
|
||||||
messages: SessionLegacy.WithParts[],
|
messages: SessionLegacy.WithParts[],
|
||||||
filepath: string,
|
filepath: string,
|
||||||
messageID: MessageID,
|
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") {}
|
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<
|
export const layer: Layer.Layer<
|
||||||
Service,
|
Service,
|
||||||
never,
|
never,
|
||||||
AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service
|
FSUtil.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service
|
||||||
> = Layer.effect(
|
> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const cfg = yield* Config.Service
|
const cfg = yield* Config.Service
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||||
@@ -225,7 +225,7 @@ export const layer: Layer.Layer<
|
|||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Global.layer),
|
Layer.provide(Global.layer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(RuntimeFlags.defaultLayer),
|
Layer.provide(RuntimeFlags.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { SessionStatus } from "./status"
|
|||||||
import { LLM } from "./llm"
|
import { LLM } from "./llm"
|
||||||
import { Shell } from "@/shell/shell"
|
import { Shell } from "@/shell/shell"
|
||||||
import { ShellID } from "@/tool/shell/id"
|
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 { Truncate } from "@/tool/truncate"
|
||||||
import { Image } from "@/image/image"
|
import { Image } from "@/image/image"
|
||||||
import { decodeDataUrl } from "@/util/data-url"
|
import { decodeDataUrl } from "@/util/data-url"
|
||||||
@@ -112,7 +112,7 @@ export const layer = Layer.effect(
|
|||||||
const commands = yield* Command.Service
|
const commands = yield* Command.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
const fsys = yield* AppFileSystem.Service
|
const fsys = yield* FSUtil.Service
|
||||||
const mcp = yield* MCP.Service
|
const mcp = yield* MCP.Service
|
||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
@@ -178,7 +178,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const target = name.slice(slash + 1)
|
const target = name.slice(slash + 1)
|
||||||
const targetPath = path.resolve(reference.path, target)
|
const targetPath = path.resolve(reference.path, target)
|
||||||
if (!AppFileSystem.contains(reference.path, targetPath)) {
|
if (!FSUtil.contains(reference.path, targetPath)) {
|
||||||
parts.push(
|
parts.push(
|
||||||
referenceTextPart({
|
referenceTextPart({
|
||||||
reference,
|
reference,
|
||||||
@@ -777,7 +777,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const reference = yield* references.get(name.slice(0, slash))
|
const reference = yield* references.get(name.slice(0, slash))
|
||||||
if (!reference || reference.kind === "invalid") return
|
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("/")
|
const target = path.relative(reference.path, filepath).split(path.sep).join("/")
|
||||||
if (!target || target.startsWith("../") || target === "..") return
|
if (!target || target.startsWith("../") || target === "..") return
|
||||||
@@ -1343,7 +1343,7 @@ export const layer = Layer.effect(
|
|||||||
const isLastStep = step >= maxSteps
|
const isLastStep = step >= maxSteps
|
||||||
msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(
|
msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(
|
||||||
Effect.provideService(RuntimeFlags.Service, flags),
|
Effect.provideService(RuntimeFlags.Service, flags),
|
||||||
Effect.provideService(AppFileSystem.Service, fsys),
|
Effect.provideService(FSUtil.Service, fsys),
|
||||||
Effect.provideService(Session.Service, sessions),
|
Effect.provideService(Session.Service, sessions),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1656,7 +1656,7 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Provider.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Instruction.defaultLayer),
|
Layer.provide(Instruction.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(SessionRevert.defaultLayer),
|
Layer.provide(SessionRevert.defaultLayer),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path"
|
|||||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Agent } from "@/agent/agent"
|
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 { InstanceState } from "@/effect/instance-state"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { PartID } from "./schema"
|
import { PartID } from "./schema"
|
||||||
@@ -18,7 +18,7 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
|
|||||||
session: Session.Info
|
session: Session.Info
|
||||||
}) {
|
}) {
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
const fsys = yield* AppFileSystem.Service
|
const fsys = yield* FSUtil.Service
|
||||||
const sessions = yield* Session.Service
|
const sessions = yield* Session.Service
|
||||||
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
|
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
|
||||||
if (!userMessage) return input.messages
|
if (!userMessage) return input.messages
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { which } from "@/util/which"
|
import { which } from "@opencode-ai/core/util/which"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { spawn, type ChildProcess } from "child_process"
|
import { spawn, type ChildProcess } from "child_process"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NodePath } from "@effect/platform-node"
|
|||||||
import { Effect, Layer, Path, Schema, Context } from "effect"
|
import { Effect, Layer, Path, Schema, Context } from "effect"
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
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 { Global } from "@opencode-ai/core/global"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
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 class Service extends Context.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Path.Path | HttpClient.HttpClient> =
|
export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | HttpClient.HttpClient> = Layer.effect(
|
||||||
Layer.effect(
|
Service,
|
||||||
Service,
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
const log = Log.create({ service: "skill-discovery" })
|
||||||
const log = Log.create({ service: "skill-discovery" })
|
const fs = yield* FSUtil.Service
|
||||||
const fs = yield* AppFileSystem.Service
|
const path = yield* Path.Path
|
||||||
const path = yield* Path.Path
|
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
const cache = path.join(Global.Path.cache, "skills")
|
||||||
const cache = path.join(Global.Path.cache, "skills")
|
|
||||||
|
|
||||||
const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
|
const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
|
||||||
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
|
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
|
||||||
|
|
||||||
return yield* HttpClientRequest.get(url).pipe(
|
return yield* HttpClientRequest.get(url).pipe(
|
||||||
http.execute,
|
http.execute,
|
||||||
Effect.flatMap((res) => res.arrayBuffer),
|
Effect.flatMap((res) => res.arrayBuffer),
|
||||||
Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
|
Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
|
||||||
Effect.as(true),
|
Effect.as(true),
|
||||||
Effect.catch((err) =>
|
Effect.catch((err) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
log.error("failed to download", { url, err })
|
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 false
|
return false
|
||||||
}
|
}),
|
||||||
return true
|
),
|
||||||
})
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const dirs = yield* Effect.forEach(
|
const pull = Effect.fn("Discovery.pull")(function* (url: string) {
|
||||||
list,
|
const base = url.endsWith("/") ? url : `${url}/`
|
||||||
(skill) =>
|
const index = new URL("index.json", base).href
|
||||||
Effect.gen(function* () {
|
const host = base.slice(0, -1)
|
||||||
const root = path.join(cache, skill.name)
|
|
||||||
|
|
||||||
yield* Effect.forEach(
|
log.info("fetching index", { url: index })
|
||||||
skill.files,
|
|
||||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
|
||||||
{
|
|
||||||
concurrency: fileConcurrency,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const md = path.join(root, "SKILL.md")
|
const data = yield* HttpClientRequest.get(index).pipe(
|
||||||
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
HttpClientRequest.acceptJson,
|
||||||
}),
|
http.execute,
|
||||||
{ concurrency: skillConcurrency },
|
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(
|
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(NodePath.layer),
|
Layer.provide(NodePath.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { EventV2Bridge } from "@/event-v2-bridge"
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Permission } from "@/permission"
|
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 { Config } from "@/config/config"
|
||||||
import { ConfigMarkdown } from "@/config/markdown"
|
import { ConfigMarkdown } from "@/config/markdown"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
@@ -173,7 +173,7 @@ const scan = Effect.fnUntraced(function* (
|
|||||||
const discoverSkills = Effect.fnUntraced(function* (
|
const discoverSkills = Effect.fnUntraced(function* (
|
||||||
config: Config.Interface,
|
config: Config.Interface,
|
||||||
discovery: Discovery.Interface,
|
discovery: Discovery.Interface,
|
||||||
fsys: AppFileSystem.Interface,
|
fsys: FSUtil.Interface,
|
||||||
global: Global.Interface,
|
global: Global.Interface,
|
||||||
disableExternalSkills: boolean,
|
disableExternalSkills: boolean,
|
||||||
disableClaudeCodeSkills: boolean,
|
disableClaudeCodeSkills: boolean,
|
||||||
@@ -253,7 +253,7 @@ export const layer = Layer.effect(
|
|||||||
const discovery = yield* Discovery.Service
|
const discovery = yield* Discovery.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const events = yield* EventV2Bridge.Service
|
const events = yield* EventV2Bridge.Service
|
||||||
const fsys = yield* AppFileSystem.Service
|
const fsys = yield* FSUtil.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
const discovered = yield* InstanceState.make(
|
const discovered = yield* InstanceState.make(
|
||||||
@@ -322,7 +322,7 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(Discovery.defaultLayer),
|
Layer.provide(Discovery.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(EventV2Bridge.defaultLayer),
|
Layer.provide(EventV2Bridge.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Global.layer),
|
Layer.provide(Global.layer),
|
||||||
Layer.provide(RuntimeFlags.defaultLayer),
|
Layer.provide(RuntimeFlags.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,14 @@
|
|||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
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 { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
|
||||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
|
|
||||||
const log = Log.create({ service: "storage" })
|
const log = Log.create({ service: "storage" })
|
||||||
|
|
||||||
type Migration = (
|
type Migration = (dir: string, fs: FSUtil.Interface, git: Git.Interface) => Effect.Effect<void, FSUtil.Error>
|
||||||
dir: string,
|
|
||||||
fs: AppFileSystem.Interface,
|
|
||||||
git: Git.Interface,
|
|
||||||
) => Effect.Effect<void, AppFileSystem.Error>
|
|
||||||
|
|
||||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("NotFoundError", {
|
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("NotFoundError", {
|
||||||
message: Schema.String,
|
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({
|
const RootFile = Schema.Struct({
|
||||||
path: Schema.optional(
|
path: Schema.optional(
|
||||||
@@ -57,11 +53,11 @@ const decodeMessage = Schema.decodeUnknownOption(MessageFile)
|
|||||||
const decodeSummary = Schema.decodeUnknownOption(SummaryFile)
|
const decodeSummary = Schema.decodeUnknownOption(SummaryFile)
|
||||||
|
|
||||||
export interface Interface {
|
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 read: <T>(key: string[]) => Effect.Effect<T, Error>
|
||||||
readonly update: <T>(key: string[], fn: (draft: T) => void) => 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 write: <T>(key: string[], content: T) => Effect.Effect<void, FSUtil.Error>
|
||||||
readonly list: (prefix: string[]) => Effect.Effect<string[][], AppFileSystem.Error>
|
readonly list: (prefix: string[]) => Effect.Effect<string[][], FSUtil.Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Storage") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Storage") {}
|
||||||
@@ -85,7 +81,7 @@ function parseMigration(text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MIGRATIONS: Migration[] = [
|
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")
|
const project = path.resolve(dir, "../project")
|
||||||
if (!(yield* fs.isDir(project))) return
|
if (!(yield* fs.isDir(project))) return
|
||||||
const projectDirs = yield* fs.glob("*", {
|
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", {
|
for (const item of yield* fs.glob("session/*/*.json", {
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
@@ -219,7 +215,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const git = yield* Git.Service
|
const git = yield* Git.Service
|
||||||
const locks = yield* RcMap.make({
|
const locks = yield* RcMap.make({
|
||||||
lookup: () => TxReentrantLock.make(),
|
lookup: () => TxReentrantLock.make(),
|
||||||
@@ -251,7 +247,7 @@ export const layer = Layer.effect(
|
|||||||
const fail = (target: string): Effect.Effect<never, NotFoundError> =>
|
const fail = (target: string): Effect.Effect<never, NotFoundError> =>
|
||||||
Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` }))
|
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)))
|
body.pipe(Effect.catchIf(missing, () => fail(target)))
|
||||||
|
|
||||||
const writeJson = Effect.fnUntraced(function* (target: string, content: unknown) {
|
const writeJson = Effect.fnUntraced(function* (target: string, content: unknown) {
|
||||||
@@ -261,7 +257,7 @@ export const layer = Layer.effect(
|
|||||||
const withResolved = <A, E>(
|
const withResolved = <A, E>(
|
||||||
key: string[],
|
key: string[],
|
||||||
fn: (target: string, rw: TxReentrantLock.TxReentrantLock) => Effect.Effect<A, E>,
|
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.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const target = file((yield* state).dir, key)
|
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"
|
export * as Storage from "./storage"
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ import * as path from "path"
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
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 { InstanceState } from "@/effect/instance-state"
|
||||||
import { Patch } from "../patch"
|
import { Patch } from "../patch"
|
||||||
import { createTwoFilesPatch, diffLines } from "diff"
|
import { createTwoFilesPatch, diffLines } from "diff"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
import { trimDiff } from "./edit"
|
import { trimDiff } from "./edit"
|
||||||
import { LSP } from "@/lsp/lsp"
|
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 DESCRIPTION from "./apply_patch.txt"
|
||||||
import { File } from "../file"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import * as Bom from "@/util/bom"
|
import * as Bom from "@/util/bom"
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export const ApplyPatchTool = Tool.define(
|
|||||||
"apply_patch",
|
"apply_patch",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
const afs = yield* AppFileSystem.Service
|
const afs = yield* FSUtil.Service
|
||||||
const format = yield* Format.Service
|
const format = yield* Format.Service
|
||||||
const events = yield* EventV2Bridge.Service
|
const events = yield* EventV2Bridge.Service
|
||||||
|
|
||||||
@@ -253,13 +253,13 @@ export const ApplyPatchTool = Tool.define(
|
|||||||
if (yield* format.file(edited)) {
|
if (yield* format.file(edited)) {
|
||||||
yield* Bom.syncFile(afs, edited, change.bom)
|
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
|
// Publish file change events
|
||||||
for (const update of updates) {
|
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
|
// Notify LSP of file changes and collect diagnostics
|
||||||
@@ -286,7 +286,7 @@ export const ApplyPatchTool = Tool.define(
|
|||||||
for (const change of fileChanges) {
|
for (const change of fileChanges) {
|
||||||
if (change.type === "delete") continue
|
if (change.type === "delete") continue
|
||||||
const target = change.movePath ?? change.filePath
|
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
|
if (!block) continue
|
||||||
const rel = path.relative(instance.worktree, target).replaceAll("\\", "/")
|
const rel = path.relative(instance.worktree, target).replaceAll("\\", "/")
|
||||||
output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}`
|
output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}`
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import * as Tool from "./tool"
|
|||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { createTwoFilesPatch, diffLines } from "diff"
|
import { createTwoFilesPatch, diffLines } from "diff"
|
||||||
import DESCRIPTION from "./edit.txt"
|
import DESCRIPTION from "./edit.txt"
|
||||||
import { File } from "../file"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { FileWatcher } from "../file/watcher"
|
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
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"
|
import * as Bom from "@/util/bom"
|
||||||
|
|
||||||
function normalizeLineEndings(text: string): string {
|
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>()
|
const locks = new Map<string, Semaphore.Semaphore>()
|
||||||
|
|
||||||
function lock(filePath: string) {
|
function lock(filePath: string) {
|
||||||
const resolvedFilePath = AppFileSystem.resolve(filePath)
|
const resolvedFilePath = FSUtil.resolve(filePath)
|
||||||
const hit = locks.get(resolvedFilePath)
|
const hit = locks.get(resolvedFilePath)
|
||||||
if (hit) return hit
|
if (hit) return hit
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export const EditTool = Tool.define(
|
|||||||
"edit",
|
"edit",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
const afs = yield* AppFileSystem.Service
|
const afs = yield* FSUtil.Service
|
||||||
const format = yield* Format.Service
|
const format = yield* Format.Service
|
||||||
const events = yield* EventV2Bridge.Service
|
const events = yield* EventV2Bridge.Service
|
||||||
|
|
||||||
@@ -108,8 +108,8 @@ export const EditTool = Tool.define(
|
|||||||
if (yield* format.file(filePath)) {
|
if (yield* format.file(filePath)) {
|
||||||
contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
|
contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
|
||||||
}
|
}
|
||||||
yield* events.publish(File.Event.Edited, { file: filePath })
|
yield* events.publish(FileSystem.Event.Edited, { file: filePath })
|
||||||
yield* events.publish(FileWatcher.Event.Updated, {
|
yield* events.publish(Watcher.Event.Updated, {
|
||||||
file: filePath,
|
file: filePath,
|
||||||
event: existed ? "change" : "add",
|
event: existed ? "change" : "add",
|
||||||
})
|
})
|
||||||
@@ -152,8 +152,8 @@ export const EditTool = Tool.define(
|
|||||||
if (yield* format.file(filePath)) {
|
if (yield* format.file(filePath)) {
|
||||||
contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
|
contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
|
||||||
}
|
}
|
||||||
yield* events.publish(File.Event.Edited, { file: filePath })
|
yield* events.publish(FileSystem.Event.Edited, { file: filePath })
|
||||||
yield* events.publish(FileWatcher.Event.Updated, {
|
yield* events.publish(Watcher.Event.Updated, {
|
||||||
file: filePath,
|
file: filePath,
|
||||||
event: "change",
|
event: "change",
|
||||||
})
|
})
|
||||||
@@ -192,7 +192,7 @@ export const EditTool = Tool.define(
|
|||||||
let output = "Edit applied successfully."
|
let output = "Edit applied successfully."
|
||||||
yield* lsp.touchFile(filePath, "document")
|
yield* lsp.touchFile(filePath, "document")
|
||||||
const diagnostics = yield* lsp.diagnostics()
|
const diagnostics = yield* lsp.diagnostics()
|
||||||
const normalizedFilePath = AppFileSystem.normalizePath(filePath)
|
const normalizedFilePath = FSUtil.normalizePath(filePath)
|
||||||
const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])
|
const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])
|
||||||
if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`
|
if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as EffectLogger from "@opencode-ai/core/effect/logger"
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import type * as Tool from "./tool"
|
import type * as Tool from "./tool"
|
||||||
import { containsPath } from "../project/instance-context"
|
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"
|
type Kind = "file" | "directory"
|
||||||
|
|
||||||
@@ -23,14 +23,14 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
|
|||||||
if (options?.bypass) return
|
if (options?.bypass) return
|
||||||
|
|
||||||
const ins = yield* InstanceState.context
|
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
|
if (containsPath(full, ins)) return
|
||||||
|
|
||||||
const kind = options?.kind ?? "file"
|
const kind = options?.kind ?? "file"
|
||||||
const dir = kind === "directory" ? full : path.dirname(full)
|
const dir = kind === "directory" ? full : path.dirname(full)
|
||||||
const glob =
|
const glob =
|
||||||
process.platform === "win32"
|
process.platform === "win32"
|
||||||
? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
|
? FSUtil.normalizePathPattern(path.join(dir, "*"))
|
||||||
: path.join(dir, "*").replaceAll("\\", "/")
|
: path.join(dir, "*").replaceAll("\\", "/")
|
||||||
|
|
||||||
yield* ctx.ask({
|
yield* ctx.ask({
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import path from "path"
|
|||||||
import { Effect, Option, Schema } from "effect"
|
import { Effect, Option, Schema } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
import DESCRIPTION from "./glob.txt"
|
import DESCRIPTION from "./glob.txt"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
@@ -20,7 +20,7 @@ export const GlobTool = Tool.define(
|
|||||||
"glob",
|
"glob",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const rg = yield* Ripgrep.Service
|
const rg = yield* Ripgrep.Service
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import path from "path"
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { Effect, Option } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
import DESCRIPTION from "./grep.txt"
|
import DESCRIPTION from "./grep.txt"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
@@ -24,7 +24,7 @@ export const Parameters = Schema.Struct({
|
|||||||
export const GrepTool = Tool.define(
|
export const GrepTool = Tool.define(
|
||||||
"grep",
|
"grep",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const rg = yield* Ripgrep.Service
|
const rg = yield* Ripgrep.Service
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ export const GrepTool = Tool.define(
|
|||||||
kind: requestedInfo?.type === "Directory" ? "directory" : "file",
|
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 info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
||||||
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, 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
|
if (result.items.length === 0) return empty
|
||||||
|
|
||||||
const rows = result.items.map((item) => ({
|
const rows = result.items.map((item) => ({
|
||||||
path: AppFileSystem.resolve(
|
path: FSUtil.resolve(path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text)),
|
||||||
path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text),
|
|
||||||
),
|
|
||||||
line: item.line_number,
|
line: item.line_number,
|
||||||
text: item.lines.text,
|
text: item.lines.text,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import DESCRIPTION from "./lsp.txt"
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
|
|
||||||
const operations = [
|
const operations = [
|
||||||
"goToDefinition",
|
"goToDefinition",
|
||||||
@@ -38,7 +38,7 @@ export const LspTool = Tool.define(
|
|||||||
"lsp",
|
"lsp",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
return {
|
return {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: Parameters,
|
parameters: Parameters,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Effect, Option, Schema, Scope, Stream } from "effect"
|
|||||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import * as Tool from "./tool"
|
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 { LSP } from "@/lsp/lsp"
|
||||||
import DESCRIPTION from "./read.txt"
|
import DESCRIPTION from "./read.txt"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
@@ -39,7 +39,7 @@ export const Parameters = Schema.Struct({
|
|||||||
export const ReadTool = Tool.define(
|
export const ReadTool = Tool.define(
|
||||||
"read",
|
"read",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const instruction = yield* Instruction.Service
|
const instruction = yield* Instruction.Service
|
||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
@@ -208,7 +208,7 @@ export const ReadTool = Tool.define(
|
|||||||
filepath = path.resolve(instance.directory, filepath)
|
filepath = path.resolve(instance.directory, filepath)
|
||||||
}
|
}
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
filepath = AppFileSystem.normalizePath(filepath)
|
filepath = FSUtil.normalizePath(filepath)
|
||||||
}
|
}
|
||||||
yield* reference.ensure(filepath)
|
yield* reference.ensure(filepath)
|
||||||
const title = path.relative(instance.worktree, 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 loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)
|
||||||
const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES)
|
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)
|
const isImage = SUPPORTED_IMAGE_MIMES.has(mime)
|
||||||
|
|
||||||
if (isImage || isPdfAttachment(mime)) {
|
if (isImage || isPdfAttachment(mime)) {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { Effect, Layer, Context } from "effect"
|
|||||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
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 { Format } from "../format"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { EffectBridge } from "@/effect/bridge"
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
@@ -42,7 +42,7 @@ import { Question } from "../question"
|
|||||||
import { Todo } from "../session/todo"
|
import { Todo } from "../session/todo"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { Instruction } from "../session/instruction"
|
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 { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
@@ -96,7 +96,7 @@ export const layer: Layer.Layer<
|
|||||||
| Reference.Service
|
| Reference.Service
|
||||||
| LSP.Service
|
| LSP.Service
|
||||||
| Instruction.Service
|
| Instruction.Service
|
||||||
| AppFileSystem.Service
|
| FSUtil.Service
|
||||||
| EventV2Bridge.Service
|
| EventV2Bridge.Service
|
||||||
| HttpClient.HttpClient
|
| HttpClient.HttpClient
|
||||||
| ChildProcessSpawner
|
| ChildProcessSpawner
|
||||||
@@ -380,7 +380,7 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
Layer.provide(Reference.defaultLayer),
|
Layer.provide(Reference.defaultLayer),
|
||||||
Layer.provide(LSP.defaultLayer),
|
Layer.provide(LSP.defaultLayer),
|
||||||
Layer.provide(Instruction.defaultLayer),
|
Layer.provide(Instruction.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(EventV2Bridge.defaultLayer),
|
Layer.provide(EventV2Bridge.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(Format.defaultLayer),
|
Layer.provide(Format.defaultLayer),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { InstanceState } from "@/effect/instance-state"
|
|||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { Language, type Node } from "web-tree-sitter"
|
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 { fileURLToPath } from "url"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
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) {
|
const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan) {
|
||||||
if (scan.dirs.size > 0) {
|
if (scan.dirs.size > 0) {
|
||||||
const globs = Array.from(scan.dirs).map((dir) => {
|
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, "*")
|
return path.join(dir, "*")
|
||||||
})
|
})
|
||||||
yield* ctx.ask({
|
yield* ctx.ask({
|
||||||
@@ -336,7 +336,7 @@ export const ShellTool = Tool.define(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const spawner = yield* ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const trunc = yield* Truncate.Service
|
const trunc = yield* Truncate.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
@@ -348,16 +348,16 @@ export const ShellTool = Tool.define(
|
|||||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||||
const file = lines[0]?.trim()
|
const file = lines[0]?.trim()
|
||||||
if (!file) return
|
if (!file) return
|
||||||
return AppFileSystem.normalizePath(file)
|
return FSUtil.normalizePath(file)
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) {
|
const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) {
|
||||||
if (process.platform === "win32") {
|
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)
|
const file = yield* cygpath(shell, text)
|
||||||
if (file) return file
|
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)
|
return path.resolve(root, text)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path"
|
|||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
import DESCRIPTION from "./skill.txt"
|
import DESCRIPTION from "./skill.txt"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NodePath } from "@effect/platform-node"
|
|||||||
import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect"
|
import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import type { Agent } from "../agent/agent"
|
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 { evaluate } from "@/permission/evaluate"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Identifier } from "../id/id"
|
import { Identifier } from "../id/id"
|
||||||
@@ -50,7 +50,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Tr
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
|
|
||||||
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
||||||
const cutoff = Identifier.timestamp(
|
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"
|
export * as Truncate from "./truncate"
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { LSP } from "@/lsp/lsp"
|
|||||||
import { createTwoFilesPatch } from "diff"
|
import { createTwoFilesPatch } from "diff"
|
||||||
import DESCRIPTION from "./write.txt"
|
import DESCRIPTION from "./write.txt"
|
||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
import { File } from "../file"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { FileWatcher } from "../file/watcher"
|
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||||
import { Format } from "../format"
|
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 { InstanceState } from "@/effect/instance-state"
|
||||||
import { trimDiff } from "./edit"
|
import { trimDiff } from "./edit"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
@@ -28,7 +28,7 @@ export const WriteTool = Tool.define(
|
|||||||
"write",
|
"write",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* FSUtil.Service
|
||||||
const events = yield* EventV2Bridge.Service
|
const events = yield* EventV2Bridge.Service
|
||||||
const format = yield* Format.Service
|
const format = yield* Format.Service
|
||||||
|
|
||||||
@@ -65,8 +65,8 @@ export const WriteTool = Tool.define(
|
|||||||
if (yield* format.file(filepath)) {
|
if (yield* format.file(filepath)) {
|
||||||
yield* Bom.syncFile(fs, filepath, desiredBom)
|
yield* Bom.syncFile(fs, filepath, desiredBom)
|
||||||
}
|
}
|
||||||
yield* events.publish(File.Event.Edited, { file: filepath })
|
yield* events.publish(FileSystem.Event.Edited, { file: filepath })
|
||||||
yield* events.publish(FileWatcher.Event.Updated, {
|
yield* events.publish(Watcher.Event.Updated, {
|
||||||
file: filepath,
|
file: filepath,
|
||||||
event: exists ? "change" : "add",
|
event: exists ? "change" : "add",
|
||||||
})
|
})
|
||||||
@@ -74,7 +74,7 @@ export const WriteTool = Tool.define(
|
|||||||
let output = "Wrote file successfully."
|
let output = "Wrote file successfully."
|
||||||
yield* lsp.touchFile(filepath, "document")
|
yield* lsp.touchFile(filepath, "document")
|
||||||
const diagnostics = yield* lsp.diagnostics()
|
const diagnostics = yield* lsp.diagnostics()
|
||||||
const normalizedFilepath = AppFileSystem.normalizePath(filepath)
|
const normalizedFilepath = FSUtil.normalizePath(filepath)
|
||||||
let projectDiagnosticsCount = 0
|
let projectDiagnosticsCount = 0
|
||||||
for (const [file, issues] of Object.entries(diagnostics)) {
|
for (const [file, issues] of Object.entries(diagnostics)) {
|
||||||
const current = file === normalizedFilepath
|
const current = file === normalizedFilepath
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Effect } from "effect"
|
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_CODE = 0xfeff
|
||||||
const BOM = String.fromCharCode(BOM_CODE)
|
const BOM = String.fromCharCode(BOM_CODE)
|
||||||
@@ -15,15 +15,11 @@ export function join(text: string, bom: boolean) {
|
|||||||
return BOM + stripped
|
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)))
|
return split(new TextDecoder("utf-8", { ignoreBOM: true }).decode(yield* fs.readFile(filePath)))
|
||||||
})
|
})
|
||||||
|
|
||||||
export const syncFile = Effect.fn("Bom.syncFile")(function* (
|
export const syncFile = Effect.fn("Bom.syncFile")(function* (fs: FSUtil.Interface, filePath: string, bom: boolean) {
|
||||||
fs: AppFileSystem.Interface,
|
|
||||||
filePath: string,
|
|
||||||
bom: boolean,
|
|
||||||
) {
|
|
||||||
const current = yield* readFile(fs, filePath)
|
const current = yield* readFile(fs, filePath)
|
||||||
if (current.bom === bom) return current.text
|
if (current.bom === bom) return current.text
|
||||||
yield* fs.writeWithDirs(filePath, join(current.text, bom))
|
yield* fs.writeWithDirs(filePath, join(current.text, bom))
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user