feat(search): track file reads for frecency
This commit is contained in:
parent
e6bc596114
commit
85e07cec38
4 changed files with 106 additions and 17 deletions
|
|
@ -512,6 +512,7 @@ export const layer = Layer.effect(
|
||||||
using _ = log.time("read", { file })
|
using _ = log.time("read", { file })
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
const full = path.join(ctx.directory, file)
|
const full = path.join(ctx.directory, file)
|
||||||
|
const trackOpen = searchSvc.open({ cwd: ctx.directory, file }).pipe(Effect.ignore)
|
||||||
|
|
||||||
if (!Instance.containsPath(full, ctx)) {
|
if (!Instance.containsPath(full, ctx)) {
|
||||||
throw new Error("Access denied: path escapes project directory")
|
throw new Error("Access denied: path escapes project directory")
|
||||||
|
|
@ -519,21 +520,23 @@ export const layer = Layer.effect(
|
||||||
|
|
||||||
if (isImageByExtension(file)) {
|
if (isImageByExtension(file)) {
|
||||||
const exists = yield* appFs.existsSafe(full)
|
const exists = yield* appFs.existsSafe(full)
|
||||||
if (exists) {
|
if (!exists) return { type: "text" as const, content: "" }
|
||||||
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
yield* trackOpen
|
||||||
return {
|
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||||
type: "text" as const,
|
return {
|
||||||
content: Buffer.from(bytes).toString("base64"),
|
type: "text" as const,
|
||||||
mimeType: getImageMimeType(file),
|
content: Buffer.from(bytes).toString("base64"),
|
||||||
encoding: "base64" as const,
|
mimeType: getImageMimeType(file),
|
||||||
}
|
encoding: "base64" as const,
|
||||||
}
|
}
|
||||||
return { type: "text" as const, content: "" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownText = isTextByExtension(file) || isTextByName(file)
|
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)
|
const exists = yield* appFs.existsSafe(full)
|
||||||
if (!exists) return { type: "text" as const, content: "" }
|
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 && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
|
||||||
|
|
||||||
if (encode) {
|
if (encode) {
|
||||||
|
yield* trackOpen
|
||||||
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||||
return {
|
return {
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
|
|
@ -564,6 +568,7 @@ export const layer = Layer.effect(
|
||||||
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
|
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
|
||||||
}
|
}
|
||||||
if (diff.trim()) {
|
if (diff.trim()) {
|
||||||
|
yield* trackOpen
|
||||||
const original = yield* git.show(ctx.directory, "HEAD", file)
|
const original = yield* git.show(ctx.directory, "HEAD", file)
|
||||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||||
context: Infinity,
|
context: Infinity,
|
||||||
|
|
@ -571,9 +576,11 @@ export const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
return { type: "text" as const, content, patch, diff: formatPatch(patch) }
|
return { type: "text" as const, content, patch, diff: formatPatch(patch) }
|
||||||
}
|
}
|
||||||
|
yield* trackOpen
|
||||||
return { type: "text" as const, content }
|
return { type: "text" as const, content }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
yield* trackOpen
|
||||||
return { type: "text" as const, content }
|
return { type: "text" as const, content }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -647,13 +654,6 @@ export const layer = Layer.effect(
|
||||||
yield* ensure()
|
yield* ensure()
|
||||||
const { cache } = yield* InstanceState.get(state)
|
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("/.")
|
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
|
|
@ -661,6 +661,13 @@ export const layer = Layer.effect(
|
||||||
return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit)
|
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
|
return output
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import DESCRIPTION from "./read.txt"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
import { Instruction } from "../session/instruction"
|
import { Instruction } from "../session/instruction"
|
||||||
|
import { Search } from "../file/search"
|
||||||
import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
||||||
|
|
||||||
const DEFAULT_READ_LIMIT = 2000
|
const DEFAULT_READ_LIMIT = 2000
|
||||||
|
|
@ -31,6 +32,7 @@ export const ReadTool = Tool.define(
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
const instruction = yield* Instruction.Service
|
const instruction = yield* Instruction.Service
|
||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
|
const search = yield* Search.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
|
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) {
|
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))
|
yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
77
packages/opencode/test/file/search.test.ts
Normal file
77
packages/opencode/test/file/search.test.ts
Normal file
|
|
@ -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 { Agent } from "../../src/agent/agent"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
import { Search } from "../../src/file/search"
|
||||||
import { LSP } from "../../src/lsp"
|
import { LSP } from "../../src/lsp"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
|
|
@ -40,6 +41,7 @@ const it = testEffect(
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
Instruction.defaultLayer,
|
Instruction.defaultLayer,
|
||||||
LSP.defaultLayer,
|
LSP.defaultLayer,
|
||||||
|
Search.defaultLayer,
|
||||||
Truncate.defaultLayer,
|
Truncate.defaultLayer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue