summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-25 14:12:54 -0400
committerGitHub <[email protected]>2026-04-25 14:12:54 -0400
commit05661c60ffb1ce1bde844f3a6aa5b6cb5bc22412 (patch)
tree750b2b1b6db0e7e888649ad587a8207378b892fe
parent625aca49de2325c29189c6ac6ec5a49efc7e9450 (diff)
downloadopencode-05661c60ffb1ce1bde844f3a6aa5b6cb5bc22412.tar.gz
opencode-05661c60ffb1ce1bde844f3a6aa5b6cb5bc22412.zip
feat(httpapi): bridge file search endpoints (#24356)
-rw-r--r--packages/opencode/specs/effect/http-api.md2
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/file.ts89
-rw-r--r--packages/opencode/src/server/routes/instance/index.ts3
-rw-r--r--packages/opencode/test/server/httpapi-file.test.ts20
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 33568e65e..948389223 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 c55d0c2e7..e283bff19 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 fec0bb1ed..488e43542 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 5a9058cb6..302e0a349 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([])
+ })
})