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
+49 -54
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(
@@ -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)),
},
},
},
@@ -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)
@@ -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),
)
@@ -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")
})
})