diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 07086f37e1a..ed939ba2b2e 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -23,6 +23,32 @@ export type Options = typeof Options.Type export class Service extends Context.Service()("@opencode/FileSystem/Search") {} const REFRESH_INTERVAL = Duration.toMillis("10 seconds") +type Prepared = ReturnType + +function emptyIndex() { + return { files: new Map(), directories: new Map() } +} + +function search(index: ReturnType, input: FileSystem.FindInput) { + const items = + input.type === "file" + ? Array.from(index.files.values()) + : input.type === "directory" + ? Array.from(index.directories.values()) + : [...index.files.values(), ...index.directories.values()] + const result = fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }) + // Targets are owned by the current location index. The only global fuzzysort + // state left is its query cache, which must not retain every query forever. + fuzzysort.cleanup() + return result.map((item) => { + const relative = item.target + const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const) + return FileSystem.Entry.make({ + path: RelativePath.make(relative), + type, + }) + }) +} export const ripgrepLayer = Layer.effect( Service, @@ -32,12 +58,13 @@ export const ripgrepLayer = Layer.effect( const scope = yield* Scope.Scope const clock = yield* Clock.Clock const home = Protected.isHome(location.directory) - let index = { files: [] as string[], directories: new Set() } + let index = emptyIndex() let initialized = false let settledAt = Number.NEGATIVE_INFINITY let refreshing = false const scan = Effect.gen(function* () { - const next = { files: [] as string[], directories: new Set() } + const next = emptyIndex() + const previous = index if (!initialized) index = next yield* ripgrep.find({ cwd: location.directory, @@ -46,11 +73,13 @@ export const ripgrepLayer = Layer.effect( exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined, onEntry: (entry) => Effect.sync(() => { - next.files.push(entry.path) + next.files.set(entry.path, previous.files.get(entry.path) ?? fuzzysort.prepare(entry.path)) const parts = entry.path.split("/") - parts - .slice(0, -1) - .forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep)) + parts.slice(0, -1).forEach((_, offset) => { + const directory = parts.slice(0, offset + 1).join("/") + path.sep + if (!next.directories.has(directory)) + next.directories.set(directory, previous.directories.get(directory) ?? fuzzysort.prepare(directory)) + }) }), }) index = next @@ -74,20 +103,7 @@ export const ripgrepLayer = Layer.effect( find: (input) => Effect.gen(function* () { yield* refresh - const items = - input.type === "file" - ? index.files - : input.type === "directory" - ? Array.from(index.directories) - : [...index.files, ...index.directories] - return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => { - const relative = item.target - const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const) - return FileSystem.Entry.make({ - path: RelativePath.make(relative), - type, - }) - }) + return search(index, input) }), }) }), diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index 4ab07a386b2..ce6682a4afe 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, spyOn, test } from "bun:test" +import fuzzysort from "fuzzysort" import os from "os" import path from "path" import { Deferred, Effect, Layer } from "effect" @@ -123,4 +124,58 @@ describe("FileSystemSearch", () => { }).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped), ) }) + + test("reuses location-owned fuzzy targets across index refreshes", async () => { + let scans = 0 + const first = Effect.runSync(Deferred.make()) + const second = Effect.runSync(Deferred.make()) + const prepare = spyOn(fuzzysort, "prepare") + const cleanup = spyOn(fuzzysort, "cleanup") + const layer = AppNodeBuilder.build(FileSystemSearch.node, [ + [ + Location.node, + Layer.succeed( + Location.Service, + Location.Service.of( + location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }), + ), + ), + ], + [ + Ripgrep.node, + Layer.succeed( + Ripgrep.Service, + Ripgrep.Service.of({ + find: (input) => + Effect.gen(function* () { + scans++ + const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" }) + if (input.onEntry) yield* input.onEntry(entry) + yield* Deferred.succeed(scans === 1 ? first : second, undefined) + return [entry] + }), + glob: () => Effect.succeed([]), + grep: () => Effect.succeed([]), + }), + ), + ], + ]) + + await Effect.runPromise( + Effect.gen(function* () { + const search = yield* FileSystemSearch.Service + yield* Deferred.await(first) + yield* search.find({ query: "index", type: "file" }) + yield* TestClock.adjust("10 seconds") + yield* search.find({ query: "index", type: "file" }) + yield* Deferred.await(second) + yield* search.find({ query: "index", type: "file" }) + + expect(prepare).toHaveBeenCalledTimes(2) + expect(cleanup).toHaveBeenCalledTimes(3) + }).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped), + ) + prepare.mockRestore() + cleanup.mockRestore() + }) })