From 05661c60ffb1ce1bde844f3a6aa5b6cb5bc22412 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 25 Apr 2026 14:12:54 -0400 Subject: [PATCH] feat(httpapi): bridge file search endpoints (#24356) --- packages/opencode/specs/effect/http-api.md | 2 +- .../server/routes/instance/httpapi/file.ts | 89 ++++++++++++++++++- .../src/server/routes/instance/index.ts | 3 + .../opencode/test/server/httpapi-file.test.ts | 20 +++++ 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 33568e65e2..948389223b 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -137,7 +137,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho | `provider` | `bridged` | list, auth, OAuth authorize/callback | | `config` | `bridged` partial | reads only; mutation remains Hono | | `project` | `bridged` partial | reads only; git-init remains Hono | -| `file` | `bridged` partial | list/content/status only | +| `file` | `bridged` partial | find text/file/symbol, list/content/status | | `mcp` | `bridged` partial | status only | | `workspace` | `bridged` | list, get, enter | | top-level instance reads | `bridged` | path, vcs, command, agent, skill, lsp, formatter | diff --git a/packages/opencode/src/server/routes/instance/httpapi/file.ts b/packages/opencode/src/server/routes/instance/httpapi/file.ts index c55d0c2e71..e283bff194 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/file.ts @@ -1,4 +1,7 @@ import { File } from "@/file" +import { Ripgrep } from "@/file/ripgrep" +import * as InstanceState from "@/effect/instance-state" +import { LSP } from "@/lsp" import { Effect, Layer, Schema } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "./auth" @@ -7,7 +10,31 @@ const FileQuery = Schema.Struct({ path: Schema.String, }) +const FindTextQuery = Schema.Struct({ + pattern: Schema.String, +}) + +const FindFileQuery = Schema.Struct({ + query: Schema.String, + dirs: Schema.optional(Schema.Literals(["true", "false"])), + type: Schema.optional(Schema.Literals(["file", "directory"])), + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ), +}) + +const FindSymbolQuery = Schema.Struct({ + query: Schema.String, +}) + export const FilePaths = { + findText: "/find", + findFile: "/find/file", + findSymbol: "/find/symbol", list: "/file", content: "/file/content", status: "/file/status", @@ -17,6 +44,36 @@ export const FileApi = HttpApi.make("file") .add( HttpApiGroup.make("file") .add( + HttpApiEndpoint.get("findText", FilePaths.findText, { + query: FindTextQuery, + success: Schema.Array(Ripgrep.SearchMatch), + }).annotateMerge( + OpenApi.annotations({ + identifier: "find.text", + summary: "Find text", + description: "Search for text patterns across files in the project using ripgrep.", + }), + ), + HttpApiEndpoint.get("findFile", FilePaths.findFile, { + query: FindFileQuery, + success: Schema.Array(Schema.String), + }).annotateMerge( + OpenApi.annotations({ + identifier: "find.files", + summary: "Find files", + description: "Search for files or directories by name or pattern in the project directory.", + }), + ), + HttpApiEndpoint.get("findSymbol", FilePaths.findSymbol, { + query: FindSymbolQuery, + success: Schema.Array(LSP.Symbol), + }).annotateMerge( + OpenApi.annotations({ + identifier: "find.symbols", + summary: "Find symbols", + description: "Search for workspace symbols like functions, classes, and variables using LSP.", + }), + ), HttpApiEndpoint.get("list", FilePaths.list, { query: FileQuery, success: Schema.Array(File.Node), @@ -66,6 +123,28 @@ export const FileApi = HttpApi.make("file") export const fileHandlers = Layer.unwrap( Effect.gen(function* () { const svc = yield* File.Service + const ripgrep = yield* Ripgrep.Service + + const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) { + return (yield* ripgrep + .search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 }) + .pipe(Effect.orDie)).items + }) + + const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: { + query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number } + }) { + return yield* svc.search({ + query: ctx.query.query, + limit: ctx.query.limit ?? 10, + dirs: ctx.query.dirs !== "false", + type: ctx.query.type, + }) + }) + + const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () { + return [] + }) const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) { return yield* svc.list(ctx.query.path) @@ -80,7 +159,13 @@ export const fileHandlers = Layer.unwrap( }) return HttpApiBuilder.group(FileApi, "file", (handlers) => - handlers.handle("list", list).handle("content", content).handle("status", status), + handlers + .handle("findText", findText) + .handle("findFile", findFile) + .handle("findSymbol", findSymbol) + .handle("list", list) + .handle("content", content) + .handle("status", status), ) }), -).pipe(Layer.provide(File.defaultLayer)) +).pipe(Layer.provide(File.defaultLayer), Layer.provide(Ripgrep.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index fec0bb1ed1..488e435422 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -51,6 +51,9 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context)) app.get("/project", (c) => handler(c.req.raw, context)) app.get("/project/current", (c) => handler(c.req.raw, context)) + app.get(FilePaths.findText, (c) => handler(c.req.raw, context)) + app.get(FilePaths.findFile, (c) => handler(c.req.raw, context)) + app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context)) app.get(FilePaths.list, (c) => handler(c.req.raw, context)) app.get(FilePaths.content, (c) => handler(c.req.raw, context)) app.get(FilePaths.status, (c) => handler(c.req.raw, context)) diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index 5a9058cb69..302e0a349c 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -54,4 +54,24 @@ describe("file HttpApi", () => { expect(status.status).toBe(200) expect(await status.json()).toContainEqual({ path: "hello.txt", added: 1, removed: 0, status: "added" }) }) + + test("serves search endpoints", async () => { + await using tmp = await tmpdir({ git: true }) + await Bun.write(path.join(tmp.path, "hello.txt"), "needle") + + const [text, files, symbols] = await Promise.all([ + request(FilePaths.findText, tmp.path, { pattern: "needle" }), + request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }), + request(FilePaths.findSymbol, tmp.path, { query: "hello" }), + ]) + + expect(text.status).toBe(200) + expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 })) + + expect(files.status).toBe(200) + expect(await files.json()).toContain("hello.txt") + + expect(symbols.status).toBe(200) + expect(await symbols.json()).toEqual([]) + }) })