diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index d5e24a0cfa..3e66806328 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -1,10 +1,10 @@ import { EOL } from "os" import { Effect } from "effect" import { AppRuntime } from "@/effect/app-runtime" +import { Search } from "@/file/search" import { File } from "../../../file" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" -import { Ripgrep } from "@/file/ripgrep" const FileSearchCommand = cmd({ command: "search ", @@ -95,7 +95,7 @@ const FileTreeCommand = cmd({ default: process.cwd(), }), async handler(args) { - const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 }) + const files = await Search.tree({ cwd: args.dir, limit: 200 }) console.log(JSON.stringify(files, null, 2)) }, }) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index 8c994d6e52..15178c2da0 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -1,6 +1,6 @@ import { EOL } from "os" import { AppRuntime } from "../../../effect/app-runtime" -import { Ripgrep } from "../../../file/ripgrep" +import { Search } from "../../../file/search" import { Instance } from "../../../project/instance" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" @@ -21,7 +21,7 @@ const TreeCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - process.stdout.write((await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit })) + EOL) + process.stdout.write((await Search.tree({ cwd: Instance.directory, limit: args.limit })) + EOL) }) }, }) @@ -46,7 +46,7 @@ const FilesCommand = cmd({ async handler(args) { await bootstrap(process.cwd(), async () => { const files: string[] = [] - for await (const file of await Ripgrep.files({ + for await (const file of await Search.files({ cwd: Instance.directory, glob: args.glob ? [args.glob] : undefined, })) { @@ -79,7 +79,7 @@ const SearchCommand = cmd({ async handler(args) { await bootstrap(process.cwd(), async () => { const results = await AppRuntime.runPromise( - Ripgrep.Service.use((svc) => + Search.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern: args.pattern, diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 6730957f23..0facdd2ba2 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -14,7 +14,7 @@ import { Global } from "../global" import { Instance } from "../project/instance" import { Log } from "../util/log" import { Protected } from "./protected" -import { Ripgrep } from "./ripgrep" +import { Search } from "./search" export namespace File { export const Info = z @@ -344,7 +344,7 @@ export namespace File { Service, Effect.gen(function* () { const appFs = yield* AppFileSystem.Service - const rg = yield* Ripgrep.Service + const searchSvc = yield* Search.Service const git = yield* Git.Service const state = yield* InstanceState.make( @@ -384,7 +384,7 @@ export namespace File { next.dirs = Array.from(dirs).toSorted() } else { - const files = yield* rg.files({ cwd: Instance.directory }).pipe( + const files = yield* searchSvc.files({ cwd: Instance.directory }).pipe( Stream.runCollect, Effect.map((chunk) => [...chunk]), ) @@ -512,6 +512,8 @@ export namespace File { if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory") + yield* searchSvc.open({ cwd: Instance.directory, file }).pipe(Effect.ignore) + if (isImageByExtension(file)) { const exists = yield* appFs.existsSafe(full) if (exists) { @@ -617,14 +619,26 @@ export namespace File { dirs?: boolean type?: "file" | "directory" }) { - yield* ensure() - const { cache } = yield* InstanceState.get(state) - const query = input.query.trim() const limit = input.limit ?? 100 const kind = input.type ?? (input.dirs === false ? "file" : "all") log.info("search", { query, kind }) + if (query && kind === "file") { + const files = yield* searchSvc.file({ + cwd: Instance.directory, + query, + limit, + }) + if (files.length) { + log.info("search", { query, kind, results: files.length, mode: "fff" }) + return files + } + } + + yield* ensure() + const { cache } = yield* InstanceState.get(state) + const preferHidden = query.startsWith(".") || query.includes("/.") if (!query) { @@ -649,7 +663,7 @@ export namespace File { ) export const defaultLayer = layer.pipe( - Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Search.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer), ) diff --git a/packages/opencode/src/server/instance/file.ts b/packages/opencode/src/server/instance/file.ts index db5e227770..a1823cc874 100644 --- a/packages/opencode/src/server/instance/file.ts +++ b/packages/opencode/src/server/instance/file.ts @@ -4,7 +4,7 @@ import { Effect } from "effect" import z from "zod" import { AppRuntime } from "../../effect/app-runtime" import { File } from "../../file" -import { Ripgrep } from "../../file/ripgrep" +import { Search } from "../../file/search" import { LSP } from "../../lsp" import { Instance } from "../../project/instance" import { lazy } from "../../util/lazy" @@ -15,14 +15,14 @@ export const FileRoutes = lazy(() => "/find", describeRoute({ summary: "Find text", - description: "Search for text patterns across files in the project using ripgrep.", + description: "Search for text patterns across files in the project.", operationId: "find.text", responses: { 200: { description: "Matches", content: { "application/json": { - schema: resolver(Ripgrep.Match.shape.data.array()), + schema: resolver(Search.Match.array()), }, }, }, @@ -37,7 +37,7 @@ export const FileRoutes = lazy(() => async (c) => { const pattern = c.req.valid("query").pattern const result = await AppRuntime.runPromise( - Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })), + Search.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })), ) return c.json(result.items) }, diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index c1577bc7d6..37c476a5d8 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -1,10 +1,9 @@ import path from "path" import z from "zod" -import { Effect, Option } from "effect" -import * as Stream from "effect/Stream" +import { Effect } from "effect" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "../filesystem" -import { Ripgrep } from "../file/ripgrep" +import { Search } from "../file/search" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" import { Tool } from "./tool" @@ -12,8 +11,8 @@ import { Tool } from "./tool" export const GlobTool = Tool.define( "glob", Effect.gen(function* () { - const rg = yield* Ripgrep.Service const fs = yield* AppFileSystem.Service + const searchSvc = yield* Search.Service return { description: DESCRIPTION, @@ -48,36 +47,18 @@ export const GlobTool = Tool.define( yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" }) const limit = 100 - let truncated = false - const files = yield* rg.files({ cwd: search, glob: [params.pattern], signal: ctx.abort }).pipe( - Stream.mapEffect((file) => - Effect.gen(function* () { - const full = path.resolve(search, file) - const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined))) - const mtime = - info?.mtime.pipe( - Option.map((date) => date.getTime()), - Option.getOrElse(() => 0), - ) ?? 0 - return { path: full, mtime } - }), - ), - Stream.take(limit + 1), - Stream.runCollect, - Effect.map((chunk) => [...chunk]), - ) - - if (files.length > limit) { - truncated = true - files.length = limit - } - files.sort((a, b) => b.mtime - a.mtime) + const files = yield* searchSvc.glob({ + cwd: search, + pattern: params.pattern, + limit, + signal: ctx.abort, + }) const output = [] - if (files.length === 0) output.push("No files found") - if (files.length > 0) { - output.push(...files.map((file) => file.path)) - if (truncated) { + if (files.files.length === 0) output.push("No files found") + if (files.files.length > 0) { + output.push(...files.files) + if (files.truncated) { output.push("") output.push( `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`, @@ -88,8 +69,8 @@ export const GlobTool = Tool.define( return { title: path.relative(ins.worktree, search), metadata: { - count: files.length, - truncated, + count: files.files.length, + truncated: files.truncated, }, output: output.join("\n"), } diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 0d717ba372..cfd9dd8d9d 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -1,9 +1,9 @@ import path from "path" import z from "zod" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "../filesystem" -import { Ripgrep } from "../file/ripgrep" +import { Search } from "../file/search" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" import { Tool } from "./tool" @@ -14,7 +14,7 @@ export const GrepTool = Tool.define( "grep", Effect.gen(function* () { const fs = yield* AppFileSystem.Service - const rg = yield* Ripgrep.Service + const searchSvc = yield* Search.Service return { description: DESCRIPTION, @@ -58,7 +58,7 @@ export const GrepTool = Tool.define( kind: info?.type === "Directory" ? "directory" : "file", }) - const result = yield* rg.search({ + const result = yield* searchSvc.search({ cwd, pattern: params.pattern, glob: params.include ? [params.include] : undefined, @@ -74,37 +74,13 @@ export const GrepTool = Tool.define( line: item.line_number, text: item.lines.text, })) - const times = new Map( - (yield* Effect.forEach( - [...new Set(rows.map((row) => row.path))], - Effect.fnUntraced(function* (file) { - const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (!info || info.type === "Directory") return undefined - return [ - file, - info.mtime.pipe( - Option.map((time) => time.getTime()), - Option.getOrElse(() => 0), - ) ?? 0, - ] as const - }), - { concurrency: 16 }, - )).filter((entry): entry is readonly [string, number] => Boolean(entry)), - ) - const matches = rows.flatMap((row) => { - const mtime = times.get(row.path) - if (mtime === undefined) return [] - return [{ ...row, mtime }] - }) - - matches.sort((a, b) => b.mtime - a.mtime) const limit = 100 - const truncated = matches.length > limit - const final = truncated ? matches.slice(0, limit) : matches + const truncated = rows.length > limit + const final = truncated ? rows.slice(0, limit) : rows if (final.length === 0) return empty - const total = matches.length + const total = rows.length const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`] let current = "" @@ -130,6 +106,10 @@ export const GrepTool = Tool.define( output.push("") output.push("(Some paths were inaccessible and skipped)") } + if (result.regexFallbackError) { + output.push("") + output.push(`(Regex fallback: ${result.regexFallbackError})`) + } return { title: params.pattern, diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index f3b044cbc1..81d7ccb33a 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -3,7 +3,7 @@ import z from "zod" import { Effect } from "effect" import * as Stream from "effect/Stream" import { InstanceState } from "@/effect/instance-state" -import { Ripgrep } from "../file/ripgrep" +import { Search } from "../file/search" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./ls.txt" import { Tool } from "./tool" @@ -40,7 +40,7 @@ const LIMIT = 100 export const ListTool = Tool.define( "list", Effect.gen(function* () { - const rg = yield* Ripgrep.Service + const searchSvc = yield* Search.Service return { description: DESCRIPTION, @@ -67,7 +67,7 @@ export const ListTool = Tool.define( }) const glob = IGNORE_PATTERNS.map((item) => `!${item}*`).concat(params.ignore?.map((item) => `!${item}`) || []) - const files = yield* rg.files({ cwd: search, glob, signal: ctx.abort }).pipe( + const files = yield* searchSvc.files({ cwd: search, glob, signal: ctx.abort }).pipe( Stream.take(LIMIT + 1), Stream.runCollect, Effect.map((chunk) => [...chunk]), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 501a8c97ed..e197304774 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -8,6 +8,7 @@ import { Tool } from "./tool" import { AppFileSystem } from "../filesystem" import { LSP } from "../lsp" import { FileTime } from "../file/time" +import { Search } from "../file/search" import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" import { assertExternalDirectoryEffect } from "./external-directory" @@ -31,6 +32,7 @@ export const ReadTool = Tool.define( const fs = yield* AppFileSystem.Service const instruction = yield* Instruction.Service const lsp = yield* LSP.Service + const search = yield* Search.Service const time = yield* FileTime.Service const scope = yield* Scope.Scope @@ -76,6 +78,7 @@ export const ReadTool = Tool.define( }) const warm = Effect.fn("ReadTool.warm")(function* (filepath: string, sessionID: Tool.Context["sessionID"]) { + yield* search.open({ file: filepath }).pipe(Effect.ignore) yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope)) yield* time.read(sessionID, filepath) }) diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index d5f3787ed6..6c55232355 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -4,7 +4,7 @@ import z from "zod" import { Effect } from "effect" import * as Stream from "effect/Stream" import { EffectLogger } from "@/effect/logger" -import { Ripgrep } from "../file/ripgrep" +import { Search } from "../file/search" import { Skill } from "../skill" import { Tool } from "./tool" @@ -16,7 +16,7 @@ export const SkillTool = Tool.define( "skill", Effect.gen(function* () { const skill = yield* Skill.Service - const rg = yield* Ripgrep.Service + const searchSvc = yield* Search.Service return () => Effect.gen(function* () { @@ -62,7 +62,7 @@ export const SkillTool = Tool.define( const dir = path.dirname(info.location) const base = pathToFileURL(dir).href const limit = 10 - const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe( + const files = yield* searchSvc.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe( Stream.filter((file) => !file.includes("SKILL.md")), Stream.map((file) => path.resolve(dir, file)), Stream.take(limit), diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 98b1fde000..1130162de3 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -40,7 +40,7 @@ import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import { Log } from "../../src/util/log" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { Ripgrep } from "../../src/file/ripgrep" +import { Search } from "../../src/file/search" import { Format } from "../../src/format" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -187,7 +187,7 @@ function makeHttp() { Layer.provide(Skill.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Search.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), Layer.provideMerge(question), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 464182395a..c0c426c9fa 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -55,7 +55,7 @@ import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import { AppFileSystem } from "../../src/filesystem" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { Ripgrep } from "../../src/file/ripgrep" +import { Search } from "../../src/file/search" import { Format } from "../../src/format" Log.init({ print: false }) @@ -141,7 +141,7 @@ function makeHttp() { Layer.provide(Skill.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Search.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), Layer.provideMerge(question), diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 092885ed18..fe6e7898f6 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -4,7 +4,7 @@ import { Cause, Effect, Exit, Layer } from "effect" import { GlobTool } from "../../src/tool/glob" import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { Ripgrep } from "../../src/file/ripgrep" +import { Search } from "../../src/file/search" import { AppFileSystem } from "../../src/filesystem" import { Truncate } from "../../src/tool/truncate" import { Agent } from "../../src/agent/agent" @@ -15,7 +15,7 @@ const it = testEffect( Layer.mergeAll( CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, - Ripgrep.defaultLayer, + Search.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, ), diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 7cdf6a0aa1..bae8b7c601 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -7,7 +7,7 @@ import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Truncate } from "../../src/tool/truncate" import { Agent } from "../../src/agent/agent" -import { Ripgrep } from "../../src/file/ripgrep" +import { Search } from "../../src/file/search" import { AppFileSystem } from "../../src/filesystem" import { testEffect } from "../lib/effect" @@ -15,7 +15,7 @@ const it = testEffect( Layer.mergeAll( CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, - Ripgrep.defaultLayer, + Search.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, ), diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 2064193d5b..b52a984301 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -4,6 +4,7 @@ import path from "path" import { Agent } from "../../src/agent/agent" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { AppFileSystem } from "../../src/filesystem" +import { Search } from "../../src/file/search" import { FileTime } from "../../src/file/time" import { LSP } from "../../src/lsp" import { Permission } from "../../src/permission" @@ -42,6 +43,7 @@ const it = testEffect( FileTime.defaultLayer, Instruction.defaultLayer, LSP.defaultLayer, + Search.defaultLayer, Truncate.defaultLayer, ), )