Compare commits

...

4 Commits

Author SHA1 Message Date
Kit Langton
8baf829f97 refactor(file): use Schema.Literals 2026-04-14 21:07:50 -04:00
Kit Langton
e34bbb394d refactor(file): use effectful HttpApi group builder
Build the file HttpApi handlers with an effectful group callback so the file service is resolved once at layer construction time and the endpoint handlers close over the resulting methods.
2026-04-14 20:43:39 -04:00
Kit Langton
a1cb64954a test(file): dispose instances after httpapi test
Align the file HttpApi server test with the shared instance cleanup pattern used by the other experimental slices.
2026-04-14 20:43:38 -04:00
Kit Langton
5922182166 add experimental file HttpApi read slice
Move the shared file DTOs to Effect Schema, add a parallel experimental file HttpApi surface for list/content/status, and cover the new read-only slice with a server test.
2026-04-14 20:43:38 -04:00
5 changed files with 207 additions and 58 deletions

View File

@@ -3,7 +3,8 @@ import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Git } from "@/git"
import { Effect, Layer, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { Effect, Layer, Context, Schema } from "effect"
import * as Stream from "effect/Stream"
import { formatPatch, structuredPatch } from "diff"
import fuzzysort from "fuzzysort"
@@ -17,62 +18,56 @@ import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
export namespace File {
export const Info = z
.object({
path: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.meta({
ref: "File",
})
export class Info extends Schema.Class<Info>("File")({
path: Schema.String,
added: Schema.Number,
removed: Schema.Number,
status: Schema.Literals(["added", "deleted", "modified"]),
}) {
static readonly zod = zod(this)
}
export type Info = z.infer<typeof Info>
export class Node extends Schema.Class<Node>("FileNode")({
name: Schema.String,
path: Schema.String,
absolute: Schema.String,
type: Schema.Literals(["file", "directory"]),
ignored: Schema.Boolean,
}) {
static readonly zod = zod(this)
}
export const Node = z
.object({
name: z.string(),
path: z.string(),
absolute: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
})
.meta({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
export class Hunk extends Schema.Class<Hunk>("FileContentHunk")({
oldStart: Schema.Number,
oldLines: Schema.Number,
newStart: Schema.Number,
newLines: Schema.Number,
lines: Schema.Array(Schema.String),
}) {
static readonly zod = zod(this)
}
export const Content = z
.object({
type: z.enum(["text", "binary"]),
content: z.string(),
diff: z.string().optional(),
patch: z
.object({
oldFileName: z.string(),
newFileName: z.string(),
oldHeader: z.string().optional(),
newHeader: z.string().optional(),
hunks: z.array(
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
),
index: z.string().optional(),
})
.optional(),
encoding: z.literal("base64").optional(),
mimeType: z.string().optional(),
})
.meta({
ref: "FileContent",
})
export type Content = z.infer<typeof Content>
export class Patch extends Schema.Class<Patch>("FileContentPatch")({
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),
}) {
static readonly zod = zod(this)
}
export class Content extends Schema.Class<Content>("FileContent")({
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),
}) {
static readonly zod = zod(this)
}
export const Event = {
Edited: BusEvent.define(

View File

@@ -126,7 +126,7 @@ export const FileRoutes = lazy(() =>
description: "Files and directories",
content: {
"application/json": {
schema: resolver(File.Node.array()),
schema: resolver(z.array(File.Node.zod)),
},
},
},
@@ -159,7 +159,7 @@ export const FileRoutes = lazy(() =>
description: "File content",
content: {
"application/json": {
schema: resolver(File.Content),
schema: resolver(File.Content.zod),
},
},
},
@@ -192,7 +192,7 @@ export const FileRoutes = lazy(() =>
description: "File status",
content: {
"application/json": {
schema: resolver(File.Info.array()),
schema: resolver(z.array(File.Info.zod)),
},
},
},

View File

@@ -0,0 +1,99 @@
import { AppLayer } from "@/effect/app-runtime"
import { memoMap } from "@/effect/run-service"
import { File } from "@/file"
import { lazy } from "@/util/lazy"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import type { Handler } from "hono"
const root = "/experimental/httpapi/file"
const Api = HttpApi.make("file")
.add(
HttpApiGroup.make("file")
.add(
HttpApiEndpoint.get("list", root, {
query: { path: Schema.optional(Schema.String) },
success: Schema.Array(File.Node),
}).annotateMerge(
OpenApi.annotations({
identifier: "file.list",
summary: "List files",
description: "List files and directories in a specified path.",
}),
),
HttpApiEndpoint.get("content", `${root}/content`, {
query: { path: Schema.String },
success: File.Content,
}).annotateMerge(
OpenApi.annotations({
identifier: "file.read",
summary: "Read file",
description: "Read the content of a specified file.",
}),
),
HttpApiEndpoint.get("status", `${root}/status`, {
success: Schema.Array(File.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "file.status",
summary: "Get file status",
description: "Get the git status of all files in the project.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "file",
description: "Experimental HttpApi file routes.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
const FileLive = HttpApiBuilder.group(
Api,
"file",
Effect.fn("FileHttpApi.handlers")(function* (handlers) {
const svc = yield* File.Service
const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path?: string } }) {
return Schema.decodeUnknownSync(Schema.Array(File.Node))(yield* svc.list(ctx.query.path))
})
const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
return Schema.decodeUnknownSync(File.Content)(yield* svc.read(ctx.query.path))
})
const status = Effect.fn("FileHttpApi.status")(function* () {
return Schema.decodeUnknownSync(Schema.Array(File.Info))(yield* svc.status())
})
return handlers.handle("list", list).handle("content", content).handle("status", status)
}),
).pipe(Layer.provide(File.defaultLayer))
const web = lazy(() =>
HttpRouter.toWebHandler(
Layer.mergeAll(
AppLayer,
HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe(
Layer.provide(FileLive),
Layer.provide(HttpServer.layerServices),
),
),
{
disableLogger: true,
memoMap,
},
),
)
export const FileHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw)

View File

@@ -1,7 +1,12 @@
import { lazy } from "@/util/lazy"
import { Hono } from "hono"
import { FileHttpApiHandler } from "./file"
import { QuestionHttpApiHandler } from "./question"
export const HttpApiRoutes = lazy(() =>
new Hono().all("/question", QuestionHttpApiHandler).all("/question/*", QuestionHttpApiHandler),
new Hono()
.all("/question", QuestionHttpApiHandler)
.all("/question/*", QuestionHttpApiHandler)
.all("/file", FileHttpApiHandler)
.all("/file/*", FileHttpApiHandler),
)

View File

@@ -0,0 +1,50 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
afterEach(async () => {
await Instance.disposeAll()
})
describe("experimental file httpapi", () => {
test("lists files, reads content, reports status, and serves docs", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "note.txt"), "hello")
},
})
const app = Server.Default().app
const headers = {
"content-type": "application/json",
"x-opencode-directory": tmp.path,
}
const list = await app.request("/experimental/httpapi/file?path=.", { headers })
expect(list.status).toBe(200)
const items = await list.json()
expect(items.some((item: { name: string }) => item.name === "note.txt")).toBe(true)
const read = await app.request("/experimental/httpapi/file/content?path=note.txt", { headers })
expect(read.status).toBe(200)
const content = await read.json()
expect(content.type).toBe("text")
expect(content.content).toContain("hello")
const status = await app.request("/experimental/httpapi/file/status", { headers })
expect(status.status).toBe(200)
expect(Array.isArray(await status.json())).toBe(true)
const doc = await app.request("/experimental/httpapi/file/doc", { headers })
expect(doc.status).toBe(200)
const spec = await doc.json()
expect(spec.paths["/experimental/httpapi/file"]?.get?.operationId).toBe("file.list")
expect(spec.paths["/experimental/httpapi/file/content"]?.get?.operationId).toBe("file.read")
expect(spec.paths["/experimental/httpapi/file/status"]?.get?.operationId).toBe("file.status")
})
})