Просмотр исходного кода

overhaul file search and support @ mentioning directories

Dax Raad 4 месяцев назад
Родитель
Сommit
6e19200fca

+ 1 - 0
bun.lock

@@ -148,6 +148,7 @@
         "chokidar": "4.0.3",
         "decimal.js": "10.5.0",
         "diff": "8.0.2",
+        "fuzzysort": "3.1.0",
         "gray-matter": "4.0.3",
         "hono": "catalog:",
         "hono-openapi": "1.0.7",

+ 1 - 0
packages/opencode/package.json

@@ -42,6 +42,7 @@
     "chokidar": "4.0.3",
     "decimal.js": "10.5.0",
     "diff": "8.0.2",
+    "fuzzysort": "3.1.0",
     "gray-matter": "4.0.3",
     "hono": "catalog:",
     "hono-openapi": "1.0.7",

+ 22 - 1
packages/opencode/src/cli/cmd/debug/file.ts

@@ -2,6 +2,22 @@ import { File } from "../../../file"
 import { bootstrap } from "../../bootstrap"
 import { cmd } from "../cmd"
 
+const FileSearchCommand = cmd({
+  command: "search <query>",
+  builder: (yargs) =>
+    yargs.positional("query", {
+      type: "string",
+      demandOption: true,
+      description: "Search query",
+    }),
+  async handler(args) {
+    await bootstrap(process.cwd(), async () => {
+      const results = await File.search({ query: args.query })
+      console.log(results.join("\n"))
+    })
+  },
+})
+
 const FileReadCommand = cmd({
   command: "read <path>",
   builder: (yargs) =>
@@ -48,6 +64,11 @@ const FileListCommand = cmd({
 export const FileCommand = cmd({
   command: "file",
   builder: (yargs) =>
-    yargs.command(FileReadCommand).command(FileStatusCommand).command(FileListCommand).demandCommand(),
+    yargs
+      .command(FileReadCommand)
+      .command(FileStatusCommand)
+      .command(FileListCommand)
+      .command(FileSearchCommand)
+      .demandCommand(),
   async handler() {},
 })

+ 6 - 4
packages/opencode/src/cli/cmd/debug/ripgrep.ts

@@ -40,12 +40,14 @@ const FilesCommand = cmd({
       }),
   async handler(args) {
     await bootstrap(process.cwd(), async () => {
-      const files = await Ripgrep.files({
+      const files: string[] = []
+      for await (const file of Ripgrep.files({
         cwd: Instance.directory,
-        query: args.query,
         glob: args.glob ? [args.glob] : undefined,
-        limit: args.limit,
-      })
+      })) {
+        files.push(file)
+        if (args.limit && files.length >= args.limit) break
+      }
       console.log(files.join("\n"))
     })
   },

+ 47 - 0
packages/opencode/src/file/index.ts

@@ -7,6 +7,8 @@ import fs from "fs"
 import ignore from "ignore"
 import { Log } from "../util/log"
 import { Instance } from "../project/instance"
+import { Ripgrep } from "./ripgrep"
+import fuzzysort from "fuzzysort"
 
 export namespace File {
   const log = Log.create({ service: "file" })
@@ -74,6 +76,43 @@ export namespace File {
     ),
   }
 
+  const state = Instance.state(async () => {
+    type Entry = { files: string[]; dirs: string[] }
+    let cache: Entry = { files: [], dirs: [] }
+    let fetching = false
+    const fn = async (result: Entry) => {
+      fetching = true
+      const set = new Set<string>()
+      for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
+        result.files.push(file)
+        let current = file
+        while (true) {
+          const dir = path.dirname(current)
+          if (dir === current) break
+          current = dir
+          if (set.has(dir)) continue
+          set.add(dir)
+          result.dirs.push(dir + "/")
+        }
+      }
+      cache = result
+      fetching = false
+    }
+    fn(cache)
+
+    return {
+      async files() {
+        if (!fetching) {
+          fn({
+            files: [],
+            dirs: [],
+          })
+        }
+        return cache
+      },
+    }
+  })
+
   export async function status() {
     const project = Instance.project
     if (project.vcs !== "git") return []
@@ -201,4 +240,12 @@ export namespace File {
       return a.name.localeCompare(b.name)
     })
   }
+
+  export async function search(input: { query: string; limit?: number }) {
+    const limit = input.limit ?? 100
+    const result = await state().then((x) => x.files())
+    const items = input.query ? [...result.files, ...result.dirs] : [...result.dirs]
+    const sorted = fuzzysort.go(input.query, items, { limit: limit }).map((r) => r.target)
+    return sorted
+  }
 }

+ 35 - 11
packages/opencode/src/file/ripgrep.ts

@@ -6,7 +6,7 @@ import z from "zod/v4"
 import { NamedError } from "../util/error"
 import { lazy } from "../util/lazy"
 import { $ } from "bun"
-import { Fzf } from "./fzf"
+
 import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
 
 export namespace Ripgrep {
@@ -203,24 +203,48 @@ export namespace Ripgrep {
     return filepath
   }
 
-  export async function files(input: { cwd: string; query?: string; glob?: string[]; limit?: number }) {
-    const commands = [`${$.escape(await filepath())} --files --follow --hidden --glob='!.git/*'`]
-
+  export async function* files(input: { cwd: string; glob?: string[] }) {
+    const args = [await filepath(), "--files", "--follow", "--hidden", "--glob=!.git/*"]
     if (input.glob) {
       for (const g of input.glob) {
-        commands[0] += ` --glob='${g}'`
+        args.push(`--glob=${g}`)
       }
     }
 
-    if (input.query) commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
-    if (input.limit) commands.push(`head -n ${input.limit}`)
-    const joined = commands.join(" | ")
-    const result = await $`${{ raw: joined }}`.cwd(input.cwd).nothrow().text()
-    return result.split("\n").filter(Boolean)
+    const proc = Bun.spawn(args, {
+      cwd: input.cwd,
+      stdout: "pipe",
+      stderr: "ignore",
+      maxBuffer: 1024 * 1024 * 20,
+    })
+
+    const reader = proc.stdout.getReader()
+    const decoder = new TextDecoder()
+    let buffer = ""
+
+    try {
+      while (true) {
+        const { done, value } = await reader.read()
+        if (done) break
+
+        buffer += decoder.decode(value, { stream: true })
+        const lines = buffer.split("\n")
+        buffer = lines.pop() || ""
+
+        for (const line of lines) {
+          if (line) yield line
+        }
+      }
+
+      if (buffer) yield buffer
+    } finally {
+      reader.releaseLock()
+      await proc.exited
+    }
   }
 
   export async function tree(input: { cwd: string; limit?: number }) {
-    const files = await Ripgrep.files({ cwd: input.cwd })
+    const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd }))
     interface Node {
       path: string[]
       children: Node[]

+ 2 - 3
packages/opencode/src/server/server.ts

@@ -956,12 +956,11 @@ export namespace Server {
         ),
         async (c) => {
           const query = c.req.valid("query").query
-          const result = await Ripgrep.files({
-            cwd: Instance.directory,
+          const results = await File.search({
             query,
             limit: 10,
           })
-          return c.json(result)
+          return c.json(results)
         },
       )
       .get(

+ 12 - 6
packages/opencode/src/session/prompt.ts

@@ -581,9 +581,15 @@ export namespace SessionPrompt {
               }
               break
             case "file:":
+              log.info("file", { mime: part.mime })
               // have to normalize, symbol search returns absolute paths
               // Decode the pathname since URL constructor doesn't automatically decode it
-              const filePath = decodeURIComponent(url.pathname)
+              const filepath = decodeURIComponent(url.pathname)
+              const stat = await Bun.file(filepath).stat()
+
+              if (stat.isDirectory()) {
+                part.mime = "application/x-directory"
+              }
 
               if (part.mime === "text/plain") {
                 let offset: number | undefined = undefined
@@ -620,7 +626,7 @@ export namespace SessionPrompt {
                     limit = end - offset
                   }
                 }
-                const args = { filePath, offset, limit }
+                const args = { filePath: filepath, offset, limit }
                 const result = await ReadTool.init().then((t) =>
                   t.execute(args, {
                     sessionID: input.sessionID,
@@ -658,7 +664,7 @@ export namespace SessionPrompt {
               }
 
               if (part.mime === "application/x-directory") {
-                const args = { path: filePath }
+                const args = { path: filepath }
                 const result = await ListTool.init().then((t) =>
                   t.execute(args, {
                     sessionID: input.sessionID,
@@ -695,15 +701,15 @@ export namespace SessionPrompt {
                 ]
               }
 
-              const file = Bun.file(filePath)
-              FileTime.read(input.sessionID, filePath)
+              const file = Bun.file(filepath)
+              FileTime.read(input.sessionID, filepath)
               return [
                 {
                   id: Identifier.ascending("part"),
                   messageID: info.id,
                   sessionID: input.sessionID,
                   type: "text",
-                  text: `Called the Read tool with the following input: {\"filePath\":\"${filePath}\"}`,
+                  text: `Called the Read tool with the following input: {\"filePath\":\"${filepath}\"}`,
                   synthetic: true,
                 },
                 {

+ 1 - 1
packages/opencode/src/tool/glob.ts

@@ -23,7 +23,7 @@ export const GlobTool = Tool.define("glob", {
     const limit = 100
     const files = []
     let truncated = false
-    for (const file of await Ripgrep.files({
+    for await (const file of Ripgrep.files({
       cwd: search,
       glob: [params.pattern],
     })) {

+ 5 - 1
packages/opencode/src/tool/ls.ts

@@ -44,7 +44,11 @@ export const ListTool = Tool.define("list", {
     const searchPath = path.resolve(Instance.directory, params.path || ".")
 
     const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
-    const files = await Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs, limit: LIMIT })
+    const files = []
+    for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs })) {
+      files.push(file)
+      if (files.length >= LIMIT) break
+    }
 
     // Build directory structure
     const dirs = new Set<string>()