From 85e07cec384544c8d7583c4fe0f889fb4162a34f Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 22 Apr 2026 22:20:22 +0530 Subject: [PATCH] feat(search): track file reads for frecency --- packages/opencode/src/file/index.ts | 41 +++++++----- packages/opencode/src/tool/read.ts | 3 + packages/opencode/test/file/search.test.ts | 77 ++++++++++++++++++++++ packages/opencode/test/tool/read.test.ts | 2 + 4 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 packages/opencode/test/file/search.test.ts diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 97b991f86..a6919a48a 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -512,6 +512,7 @@ export const layer = Layer.effect( using _ = log.time("read", { file }) const ctx = yield* InstanceState.context const full = path.join(ctx.directory, file) + const trackOpen = searchSvc.open({ cwd: ctx.directory, file }).pipe(Effect.ignore) if (!Instance.containsPath(full, ctx)) { throw new Error("Access denied: path escapes project directory") @@ -519,21 +520,23 @@ export const layer = Layer.effect( if (isImageByExtension(file)) { const exists = yield* appFs.existsSafe(full) - if (exists) { - const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) - return { - type: "text" as const, - content: Buffer.from(bytes).toString("base64"), - mimeType: getImageMimeType(file), - encoding: "base64" as const, - } + if (!exists) return { type: "text" as const, content: "" } + yield* trackOpen + const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) + return { + type: "text" as const, + content: Buffer.from(bytes).toString("base64"), + mimeType: getImageMimeType(file), + encoding: "base64" as const, } - return { type: "text" as const, content: "" } } const knownText = isTextByExtension(file) || isTextByName(file) - if (isBinaryByExtension(file) && !knownText) return { type: "binary" as const, content: "" } + if (isBinaryByExtension(file) && !knownText) { + yield* trackOpen + return { type: "binary" as const, content: "" } + } const exists = yield* appFs.existsSafe(full) if (!exists) return { type: "text" as const, content: "" } @@ -544,6 +547,7 @@ export const layer = Layer.effect( if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType } if (encode) { + yield* trackOpen const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) return { type: "text" as const, @@ -564,6 +568,7 @@ export const layer = Layer.effect( diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file]) } if (diff.trim()) { + yield* trackOpen const original = yield* git.show(ctx.directory, "HEAD", file) const patch = structuredPatch(file, file, original, content, "old", "new", { context: Infinity, @@ -571,9 +576,11 @@ export const layer = Layer.effect( }) return { type: "text" as const, content, patch, diff: formatPatch(patch) } } + yield* trackOpen return { type: "text" as const, content } } + yield* trackOpen return { type: "text" as const, content } }) @@ -647,13 +654,6 @@ export const layer = Layer.effect( yield* ensure() const { cache } = yield* InstanceState.get(state) - const items = kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs] - - const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit - const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target) - const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted - - log.info("search", { query, kind, results: output.length }) const preferHidden = query.startsWith(".") || query.includes("/.") if (!query) { @@ -661,6 +661,13 @@ export const layer = Layer.effect( return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit) } + const items = kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs] + + const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit + const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target) + const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted + + log.info("search", { query, kind, results: output.length }) return output }) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index c9b304862..4fa1e26e3 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -10,6 +10,7 @@ import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" +import { Search } from "../file/search" import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media" const DEFAULT_READ_LIMIT = 2000 @@ -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 scope = yield* Scope.Scope const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) { @@ -75,6 +77,7 @@ export const ReadTool = Tool.define( }) const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) { + yield* search.open({ file: filepath }).pipe(Effect.ignore) yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope)) }) diff --git a/packages/opencode/test/file/search.test.ts b/packages/opencode/test/file/search.test.ts new file mode 100644 index 000000000..2bbb4dbfd --- /dev/null +++ b/packages/opencode/test/file/search.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Effect, Layer } from "effect" +import { Fff } from "#fff" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Search } from "../../src/file/search" +import { Global } from "../../src/global" +import { Instance } from "../../src/project/instance" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await Instance.disposeAll() +}) + +const it = testEffect(Layer.mergeAll(Search.defaultLayer, CrossSpawnSpawner.defaultLayer)) + +function db(dir: string) { + const id = Buffer.from(AppFileSystem.resolve(dir)).toString("base64url") + return { + frecency: path.join(Global.Path.cache, "fff", `${id}.frecency.mdb`), + history: path.join(Global.Path.cache, "fff", `${id}.history.mdb`), + } +} + +describe("file.search", () => { + it.live("uses fff for Bun-backed grep", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + yield* Effect.promise(() => Bun.write(path.join(dir, "src", "match.ts"), "const needle = 1\n")) + + const search = yield* Search.Service + const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 }) + + expect(result.engine).toBe("fff") + expect(result.items).toHaveLength(1) + expect(result.items[0]?.path.text).toBe("src/match.ts") + }), + ), + ) + + it.live("records query history when a searched file is opened", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + yield* Effect.promise(() => Bun.write(path.join(dir, "alpha-target-one.ts"), "export const one = 1\n")) + yield* Effect.promise(() => Bun.write(path.join(dir, "alpha-target-two.ts"), "export const two = 2\n")) + + const search = yield* Search.Service + const results = yield* search.file({ cwd: dir, query: "alpha target two", limit: 10 }) + + expect(results).toContain("alpha-target-two.ts") + + yield* search.open({ cwd: dir, file: "alpha-target-two.ts" }) + yield* Effect.promise(() => Instance.disposeAll()) + + const picker = Fff.create({ + basePath: dir, + frecencyDbPath: db(dir).frecency, + historyDbPath: db(dir).history, + aiMode: true, + }) + expect(picker.ok).toBe(true) + if (!picker.ok) return + + const history = picker.value.getHistoricalQuery(0) + picker.value.destroy() + + expect(history.ok).toBe(true) + if (!history.ok) return + expect(history.value).toBe("alpha target two") + }), + ), + ) +}) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 42817d15d..d659a90ca 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 "@opencode-ai/shared/filesystem" +import { Search } from "../../src/file/search" import { LSP } from "../../src/lsp" import { Permission } from "../../src/permission" import { Instance } from "../../src/project/instance" @@ -40,6 +41,7 @@ const it = testEffect( CrossSpawnSpawner.defaultLayer, Instruction.defaultLayer, LSP.defaultLayer, + Search.defaultLayer, Truncate.defaultLayer, ), )