feat(search): track file reads for frecency
This commit is contained in:
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user