feat(search): track file reads for frecency

This commit is contained in:
Shoubhit Dash
2026-04-22 22:20:22 +05:30
parent e6bc596114
commit 85e07cec38
4 changed files with 106 additions and 17 deletions
+24 -17
View File
@@ -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
})
+3
View File
@@ -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))
})
@@ -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")
}),
),
)
})
+2
View File
@@ -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,
),
)