diff --git a/bun.lock b/bun.lock index d1b83367..8e803cff 100644 --- a/bun.lock +++ b/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", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index d8861548..6dba841a 100644 --- a/packages/opencode/package.json +++ b/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", diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 898b7d39..7be30473 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/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 ", + 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 ", 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() {}, }) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index ec3f9cb8..884b291b 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/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")) }) }, diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 8e142cf2..20e1f604 100644 --- a/packages/opencode/src/file/index.ts +++ b/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() + 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 + } } diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 1cbf6b8a..d023f47c 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/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[] diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 544e58b3..7020a2aa 100644 --- a/packages/opencode/src/server/server.ts +++ b/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( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6ebae336..8e7cf57f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/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, }, { diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index dbbe8868..7553a5aa 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/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], })) { diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index 819e6fde..b80f668a 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/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()