Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 52 additions & 57 deletions packages/opencode/src/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 type Info = z.infer<typeof Info>

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 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 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 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 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 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(
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/server/instance/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
},
},
},
Expand Down Expand Up @@ -159,7 +159,7 @@ export const FileRoutes = lazy(() =>
description: "File content",
content: {
"application/json": {
schema: resolver(File.Content),
schema: resolver(File.Content.zod),
},
},
},
Expand Down Expand Up @@ -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)),
},
},
},
Expand Down
99 changes: 99 additions & 0 deletions packages/opencode/src/server/instance/httpapi/file.ts
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 6 additions & 1 deletion packages/opencode/src/server/instance/httpapi/index.ts
Original file line number Diff line number Diff line change
@@ -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),
)
50 changes: 50 additions & 0 deletions packages/opencode/test/server/file-httpapi.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
Loading