mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-21 17:54:23 +01:00
overhaul file search and support @ mentioning directories
This commit is contained in:
1
bun.lock
1
bun.lock
@@ -148,6 +148,7 @@
|
|||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
|
"fuzzysort": "3.1.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
"hono": "catalog:",
|
"hono": "catalog:",
|
||||||
"hono-openapi": "1.0.7",
|
"hono-openapi": "1.0.7",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
|
"fuzzysort": "3.1.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
"hono": "catalog:",
|
"hono": "catalog:",
|
||||||
"hono-openapi": "1.0.7",
|
"hono-openapi": "1.0.7",
|
||||||
|
|||||||
@@ -2,6 +2,22 @@ import { File } from "../../../file"
|
|||||||
import { bootstrap } from "../../bootstrap"
|
import { bootstrap } from "../../bootstrap"
|
||||||
import { cmd } from "../cmd"
|
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({
|
const FileReadCommand = cmd({
|
||||||
command: "read <path>",
|
command: "read <path>",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
@@ -48,6 +64,11 @@ const FileListCommand = cmd({
|
|||||||
export const FileCommand = cmd({
|
export const FileCommand = cmd({
|
||||||
command: "file",
|
command: "file",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs.command(FileReadCommand).command(FileStatusCommand).command(FileListCommand).demandCommand(),
|
yargs
|
||||||
|
.command(FileReadCommand)
|
||||||
|
.command(FileStatusCommand)
|
||||||
|
.command(FileListCommand)
|
||||||
|
.command(FileSearchCommand)
|
||||||
|
.demandCommand(),
|
||||||
async handler() {},
|
async handler() {},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,12 +40,14 @@ const FilesCommand = cmd({
|
|||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
const files = await Ripgrep.files({
|
const files: string[] = []
|
||||||
|
for await (const file of Ripgrep.files({
|
||||||
cwd: Instance.directory,
|
cwd: Instance.directory,
|
||||||
query: args.query,
|
|
||||||
glob: args.glob ? [args.glob] : undefined,
|
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"))
|
console.log(files.join("\n"))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import fs from "fs"
|
|||||||
import ignore from "ignore"
|
import ignore from "ignore"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
|
import { Ripgrep } from "./ripgrep"
|
||||||
|
import fuzzysort from "fuzzysort"
|
||||||
|
|
||||||
export namespace File {
|
export namespace File {
|
||||||
const log = Log.create({ service: "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() {
|
export async function status() {
|
||||||
const project = Instance.project
|
const project = Instance.project
|
||||||
if (project.vcs !== "git") return []
|
if (project.vcs !== "git") return []
|
||||||
@@ -201,4 +240,12 @@ export namespace File {
|
|||||||
return a.name.localeCompare(b.name)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import z from "zod/v4"
|
|||||||
import { NamedError } from "../util/error"
|
import { NamedError } from "../util/error"
|
||||||
import { lazy } from "../util/lazy"
|
import { lazy } from "../util/lazy"
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { Fzf } from "./fzf"
|
|
||||||
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
|
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
|
||||||
|
|
||||||
export namespace Ripgrep {
|
export namespace Ripgrep {
|
||||||
@@ -203,24 +203,48 @@ export namespace Ripgrep {
|
|||||||
return filepath
|
return filepath
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function files(input: { cwd: string; query?: string; glob?: string[]; limit?: number }) {
|
export async function* files(input: { cwd: string; glob?: string[] }) {
|
||||||
const commands = [`${$.escape(await filepath())} --files --follow --hidden --glob='!.git/*'`]
|
const args = [await filepath(), "--files", "--follow", "--hidden", "--glob=!.git/*"]
|
||||||
|
|
||||||
if (input.glob) {
|
if (input.glob) {
|
||||||
for (const g of 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}`)
|
const proc = Bun.spawn(args, {
|
||||||
if (input.limit) commands.push(`head -n ${input.limit}`)
|
cwd: input.cwd,
|
||||||
const joined = commands.join(" | ")
|
stdout: "pipe",
|
||||||
const result = await $`${{ raw: joined }}`.cwd(input.cwd).nothrow().text()
|
stderr: "ignore",
|
||||||
return result.split("\n").filter(Boolean)
|
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 }) {
|
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 {
|
interface Node {
|
||||||
path: string[]
|
path: string[]
|
||||||
children: Node[]
|
children: Node[]
|
||||||
|
|||||||
@@ -956,12 +956,11 @@ export namespace Server {
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const query = c.req.valid("query").query
|
const query = c.req.valid("query").query
|
||||||
const result = await Ripgrep.files({
|
const results = await File.search({
|
||||||
cwd: Instance.directory,
|
|
||||||
query,
|
query,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
})
|
})
|
||||||
return c.json(result)
|
return c.json(results)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -581,9 +581,15 @@ export namespace SessionPrompt {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
case "file:":
|
case "file:":
|
||||||
|
log.info("file", { mime: part.mime })
|
||||||
// have to normalize, symbol search returns absolute paths
|
// have to normalize, symbol search returns absolute paths
|
||||||
// Decode the pathname since URL constructor doesn't automatically decode it
|
// 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") {
|
if (part.mime === "text/plain") {
|
||||||
let offset: number | undefined = undefined
|
let offset: number | undefined = undefined
|
||||||
@@ -620,7 +626,7 @@ export namespace SessionPrompt {
|
|||||||
limit = end - offset
|
limit = end - offset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const args = { filePath, offset, limit }
|
const args = { filePath: filepath, offset, limit }
|
||||||
const result = await ReadTool.init().then((t) =>
|
const result = await ReadTool.init().then((t) =>
|
||||||
t.execute(args, {
|
t.execute(args, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -658,7 +664,7 @@ export namespace SessionPrompt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (part.mime === "application/x-directory") {
|
if (part.mime === "application/x-directory") {
|
||||||
const args = { path: filePath }
|
const args = { path: filepath }
|
||||||
const result = await ListTool.init().then((t) =>
|
const result = await ListTool.init().then((t) =>
|
||||||
t.execute(args, {
|
t.execute(args, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -695,15 +701,15 @@ export namespace SessionPrompt {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = Bun.file(filePath)
|
const file = Bun.file(filepath)
|
||||||
FileTime.read(input.sessionID, filePath)
|
FileTime.read(input.sessionID, filepath)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
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,
|
synthetic: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const GlobTool = Tool.define("glob", {
|
|||||||
const limit = 100
|
const limit = 100
|
||||||
const files = []
|
const files = []
|
||||||
let truncated = false
|
let truncated = false
|
||||||
for (const file of await Ripgrep.files({
|
for await (const file of Ripgrep.files({
|
||||||
cwd: search,
|
cwd: search,
|
||||||
glob: [params.pattern],
|
glob: [params.pattern],
|
||||||
})) {
|
})) {
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ export const ListTool = Tool.define("list", {
|
|||||||
const searchPath = path.resolve(Instance.directory, params.path || ".")
|
const searchPath = path.resolve(Instance.directory, params.path || ".")
|
||||||
|
|
||||||
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
|
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
|
// Build directory structure
|
||||||
const dirs = new Set<string>()
|
const dirs = new Set<string>()
|
||||||
|
|||||||
Reference in New Issue
Block a user