diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 5e5465b8fd..3257fe8840 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -1,8 +1,7 @@ export * as FileSystem from "./filesystem" import path from "path" -import { pathToFileURL } from "url" -import { Context, Effect, Layer, Option, Schema } from "effect" +import { Context, Effect, Layer, Schema } from "effect" import { EventV2 } from "./event" import { FSUtil } from "./fs-util" import { Location } from "./location" @@ -59,7 +58,7 @@ export const Event = { } export interface Interface { - readonly read: (input: ReadInput) => Effect.Effect + readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> readonly list: (input?: ListInput) => Effect.Effect readonly find: (input: FindInput) => Effect.Effect readonly glob: (input: GlobInput) => Effect.Effect @@ -91,27 +90,9 @@ const baseLayer = Layer.effect( const target = yield* resolve(input.path) const info = yield* fs.stat(target.real).pipe(Effect.orDie) if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) - const bytes = yield* fs.readFile(target.real).pipe(Effect.orDie) - const mime = FSUtil.mimeType(target.real) - if (!bytes.includes(0)) { - const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe( - Effect.option, - ) - if (Option.isSome(content)) - return { - uri: pathToFileURL(target.real).href, - name: path.basename(target.real), - content: content.value, - encoding: "utf8" as const, - mime, - } - } return { - uri: pathToFileURL(target.real).href, - name: path.basename(target.real), - content: Buffer.from(bytes).toString("base64"), - encoding: "base64" as const, - mime, + content: yield* fs.readFile(target.real).pipe(Effect.orDie), + mime: FSUtil.mimeType(target.real), } }), list: Effect.fn("FileSystem.list")(function* (input = {}) { diff --git a/packages/core/test/location-filesystem.test.ts b/packages/core/test/location-filesystem.test.ts index c7c7d3ba8e..a3ac24a905 100644 --- a/packages/core/test/location-filesystem.test.ts +++ b/packages/core/test/location-filesystem.test.ts @@ -1,6 +1,5 @@ import fs from "fs/promises" import path from "path" -import { fileURLToPath } from "url" import { describe, expect } from "bun:test" import { Effect, Exit, Layer } from "effect" import { FileSystem } from "@opencode-ai/core/filesystem" @@ -40,9 +39,9 @@ describe("FileSystem", () => { const service = yield* FileSystem.Service const text = yield* service.read({ path: RelativePath.make("text.txt") }) const binary = yield* service.read({ path: RelativePath.make("data.bin") }) - expect(text).toMatchObject({ name: "text.txt", content: "hello", encoding: "utf8", mime: "text/plain" }) - expect(fileURLToPath(text.uri)).toBe(path.join(directory, "text.txt")) - expect(binary).toMatchObject({ name: "data.bin", content: "AAEC", encoding: "base64" }) + expect(new TextDecoder().decode(text.content)).toBe("hello") + expect(text.mime).toBe("text/plain") + expect(binary.content).toEqual(new Uint8Array([0, 1, 2])) }).pipe(provide(directory)), ), ) diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 21447ba468..312ad8b8eb 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -38,8 +38,11 @@ const FileReadCommand = effectCmd({ description: "File path to read", }), handler: Effect.fn("Cli.debug.file.read")(function* (args) { - const content = yield* filesystem(FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) }))) - process.stdout.write(JSON.stringify(content, null, 2) + EOL) + const file = yield* filesystem(FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) }))) + process.stdout.write( + JSON.stringify({ content: Buffer.from(file.content).toString("base64"), encoding: "base64", mime: file.mime }, null, 2) + + EOL, + ) }), }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index 556edcd5e3..bacc4b0457 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -5,7 +5,7 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" -import { Effect, Layer } from "effect" +import { Effect, Layer, Option } from "effect" import ignore from "ignore" import path from "path" import { HttpApiBuilder } from "effect/unstable/httpapi" @@ -101,11 +101,26 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl return yield* filesystem( FileSystem.Service.use((fs) => fs.read({ path: RelativePath.make(ctx.query.path) })), ).pipe( - Effect.map((item) => ({ - type: item.encoding === "utf8" ? ("text" as const) : ("binary" as const), - content: item.encoding === "utf8" ? item.content.trim() : item.content, - ...(item.encoding === "base64" ? { encoding: item.encoding, mimeType: item.mime } : {}), - })), + Effect.flatMap((item) => + Effect.gen(function* () { + const text = item.content.includes(0) + ? Option.none() + : yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(item.content)).pipe( + Effect.option, + ) + return { item, text } + }), + ), + Effect.map(({ item, text }) => + Option.isSome(text) + ? { type: "text" as const, content: text.value.trim() } + : { + type: "binary" as const, + content: Buffer.from(item.content).toString("base64"), + encoding: "base64" as const, + mimeType: item.mime, + }, + ), ) }) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index bd07f75446..b38c421792 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -709,10 +709,18 @@ const scenarios: Scenario[] = [ "status", ), http.protected - .get("/api/fs/read", "v2.fs.read") + .get("/api/fs/read/*", "v2.fs.read") .seeded((ctx) => ctx.file("hello.txt", "hello\n")) - .at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() })) - .json(200, locationData(object)), + .at((ctx) => ({ path: "/api/fs/read/hello.txt", headers: ctx.headers() })) + .status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(result.text === "hello\n", "v2 fs read should return the file body") + check(result.contentType.includes("text/plain"), "v2 fs read should return the file content type") + }), + "status", + ), http.protected.get("/api/fs/list", "v2.fs.list").json(200, locationData(array)), http.protected .get("/api/fs/find", "v2.fs.find") diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index 7b998001f7..1c3023faf0 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -97,7 +97,7 @@ describe("PublicApi OpenAPI v2 errors", () => { test("documents references separately from filesystem routes", () => { const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec - for (const path of ["/api/fs/read", "/api/fs/list"]) { + for (const path of ["/api/fs/read/*", "/api/fs/list"]) { expect(spec.paths[path]?.get?.parameters, path).not.toContainEqual(expect.objectContaining({ name: "reference" })) } expect(spec.paths["/api/reference"]?.get).toBeDefined() diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 6a2d9b3be5..895659a6f7 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -389,12 +389,9 @@ describe("HttpApi SDK", () => { workspaceID, onRequest: (value) => (request = value), }) - const file = yield* call(() => sdk.v2.fs.read({ path: "hello.txt" })) const found = yield* call(() => sdk.v2.fs.find({ query: "hello", type: "file" })) const url = new URL(request!.url) - expect(file.response.status).toBe(200) - expect(file.data).toMatchObject({ data: { content: "hello" } }) expect(found.response.status).toBe(200) expect(found.data).toMatchObject({ data: [{ path: "hello.txt", type: "file" }] }) expect(url.searchParams.get("directory")).toBe(directory) diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index 603decd2e7..c1956cffe0 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -1,8 +1,5 @@ export * from "./gen/types.gen.js" -export type { - FileSystemContent as LocationFileSystemContent, - FileSystemEntry as LocationFileSystemEntry, -} from "./gen/types.gen.js" +export type { FileSystemEntry as LocationFileSystemEntry } from "./gen/types.gen.js" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index ffd59f2ba1..7cc280025c 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -5964,31 +5964,20 @@ export class Fs extends HeyApiClient { /** * Read file * - * Read one file relative to the requested location. + * Serve one file relative to the requested location. */ public read( - parameters: { + parameters?: { location?: { directory?: string workspace?: string } - path: string }, options?: Options, ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "location" }, - { in: "query", key: "path" }, - ], - }, - ], - ) + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) return (options?.client ?? this.client).get({ - url: "/api/fs/read", + url: "/api/fs/read/*", ...options, ...params, }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0ba11decbc..80b9394138 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4186,14 +4186,6 @@ export type PermissionSavedInfo = { resource: string } -export type FileSystemContent = { - uri: string - name?: string - content: string - encoding: "utf8" | "base64" - mime: string -} - export type FileSystemEntry = { path: string type: "file" | "directory" @@ -10554,14 +10546,13 @@ export type V2SessionPermissionReplyResponse = export type V2FsReadData = { body?: never path?: never - query: { + query?: { location?: { directory?: string workspace?: string } - path: string } - url: "/api/fs/read" + url: "/api/fs/read/*" } export type V2FsReadErrors = { @@ -10581,10 +10572,7 @@ export type V2FsReadResponses = { /** * Success */ - 200: { - location: LocationInfo - data: FileSystemContent - } + 200: Blob | File } export type V2FsReadResponse = V2FsReadResponses[keyof V2FsReadResponses] diff --git a/packages/server/src/groups/fs.ts b/packages/server/src/groups/fs.ts index a0eeed4410..96ce404619 100644 --- a/packages/server/src/groups/fs.ts +++ b/packages/server/src/groups/fs.ts @@ -2,14 +2,9 @@ import { FileSystem } from "@opencode-ai/core/filesystem" import { Location } from "@opencode-ai/core/location" import { PositiveInt, RelativePath } from "@opencode-ai/core/schema" import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" -const ReadQuery = Schema.Struct({ - ...LocationQuery.fields, - path: RelativePath, -}) - const ListQuery = Schema.Struct({ ...LocationQuery.fields, path: RelativePath.pipe(Schema.optional), @@ -24,16 +19,16 @@ const FindQuery = Schema.Struct({ export const FileSystemGroup = HttpApiGroup.make("server.fs") .add( - HttpApiEndpoint.get("fs.read", "/api/fs/read", { - query: ReadQuery, - success: Location.response(FileSystem.Content), + HttpApiEndpoint.get("fs.read", "/api/fs/read/*", { + query: LocationQuery, + success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), }) .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ identifier: "v2.fs.read", summary: "Read file", - description: "Read one file relative to the requested location.", + description: "Serve one file relative to the requested location.", }), ), ) diff --git a/packages/server/src/handlers/fs.ts b/packages/server/src/handlers/fs.ts index dcb545ca8f..5b3b58c1dc 100644 --- a/packages/server/src/handlers/fs.ts +++ b/packages/server/src/handlers/fs.ts @@ -1,5 +1,7 @@ import { FileSystem } from "@opencode-ai/core/filesystem" +import { RelativePath } from "@opencode-ai/core/schema" import { Effect } from "effect" +import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" import { response } from "../groups/location" @@ -7,13 +9,13 @@ import { response } from "../groups/location" export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) => Effect.gen(function* () { return handlers - .handle("fs.read", (ctx) => - response( - Effect.gen(function* () { - const fs = yield* FileSystem.Service - return yield* fs.read(ctx.query) - }), - ), + .handleRaw("fs.read", (ctx) => + Effect.gen(function* () { + const file = yield* (yield* FileSystem.Service).read({ + path: RelativePath.make(decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13))), + }) + return HttpServerResponse.uint8Array(file.content, { contentType: file.mime }) + }), ) .handle("fs.list", (ctx) => response(