فهرست منبع

feat(httpapi): bridge file search endpoints (#24356)

Kit Langton 3 ماه پیش
والد
کامیت
05661c60ff

+ 1 - 1
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       |

+ 87 - 2
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))

+ 3 - 0
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))

+ 20 - 0
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([])
+  })
 })