From 20929b3081c440cbcf8c8b9cb65ccebca2b7b353 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 13 Aug 2026 12:13:31 -0400 Subject: [PATCH] fix(core): refresh fallback file search (#42348) --- packages/core/src/filesystem/search.ts | 48 ++++++++++---- packages/core/test/filesystem/search.test.ts | 67 +++++++++++++++++++- 2 files changed, 103 insertions(+), 12 deletions(-) diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 21a34576a09..71d754b4f53 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search.js" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import path from "path" -import { Context, Effect, Layer, Schema, Scope } from "effect" +import { Clock, Context, Duration, Effect, Layer, Schema, Scope } from "effect" import { Fff } from "#fff" import fuzzysort from "fuzzysort" import { FileSystem } from "../filesystem.js" @@ -22,38 +22,64 @@ export type Options = typeof Options.Type export class Service extends Context.Service()("@opencode/FileSystem/Search") {} +const REFRESH_INTERVAL = Duration.toMillis("10 seconds") + export const ripgrepLayer = Layer.effect( Service, Effect.gen(function* () { const location = yield* Location.Service const ripgrep = yield* Ripgrep.Service const scope = yield* Scope.Scope - const files: string[] = [] - const directories = new Set() + const clock = yield* Clock.Clock const home = Protected.isHome(location.directory) - yield* ripgrep - .find({ + let index = { files: [] as string[], directories: new Set() } + let initialized = false + let settledAt = Number.NEGATIVE_INFINITY + let refreshing = false + const scan = Effect.gen(function* () { + const next = { files: [] as string[], directories: new Set() } + if (!initialized) index = next + yield* ripgrep.find({ cwd: location.directory, pattern: "*", limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000, exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined, onEntry: (entry) => Effect.sync(() => { - files.push(entry.path) + next.files.push(entry.path) const parts = entry.path.split("/") - parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep)) + parts.slice(0, -1).forEach((_, offset) => + next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep), + ) }), }) - .pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope)) + index = next + initialized = true + }).pipe( + Effect.orDie, + Effect.ensuring( + Effect.sync(() => { + settledAt = clock.currentTimeMillisUnsafe() + refreshing = false + }), + ), + ) + const refresh = Effect.sync(() => { + if (refreshing || clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return + refreshing = true + return scan + }).pipe(Effect.flatMap((effect) => (effect ? effect.pipe(Effect.forkIn(scope)) : Effect.void))) + yield* refresh return Service.of({ find: (input) => Effect.gen(function* () { + yield* refresh const items = input.type === "file" - ? files + ? index.files : input.type === "directory" - ? Array.from(directories) - : [...files, ...directories] + ? 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) diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index 501932fa462..caa75809bec 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test" import os from "os" import path from "path" -import { Effect, Layer } from "effect" +import { Deferred, Effect, Layer } from "effect" +import { TestClock } from "effect/testing" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { FileSystem } from "@opencode-ai/core/filesystem" import { Protected } from "@opencode-ai/core/filesystem/protected" @@ -56,4 +57,68 @@ describe("FileSystemSearch", () => { }).pipe(Effect.provide(layer), Effect.scoped), ) }) + + test("refreshes a stale ripgrep index atomically without blocking search", async () => { + let scans = 0 + const initial = Effect.runSync(Deferred.make()) + const started = Effect.runSync(Deferred.make()) + const release = Effect.runSync(Deferred.make()) + 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-atomic")) })), + ), + ], + [ + Ripgrep.node, + Layer.succeed( + Ripgrep.Service, + Ripgrep.Service.of({ + find: (input) => + Effect.gen(function* () { + scans++ + if (scans > 1) { + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) + } + const entry = FileSystem.Entry.make({ + path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"), + type: "file", + }) + if (input.onEntry) yield* input.onEntry(entry) + if (scans === 1) yield* Deferred.succeed(initial, undefined) + return [entry] + }), + glob: () => Effect.succeed([]), + grep: () => Effect.succeed([]), + }), + ), + ], + ]) + + await Effect.runPromise( + Effect.gen(function* () { + const search = yield* FileSystemSearch.Service + yield* Deferred.await(initial) + expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts")) + expect(scans).toBe(1) + + yield* TestClock.adjust("10 seconds") + yield* search.find({ query: "old", type: "file" }) + yield* Deferred.await(started) + + expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts")) + expect(scans).toBe(2) + yield* Deferred.succeed(release, undefined) + const refreshed = yield* Effect.gen(function* () { + yield* Effect.yieldNow + return yield* search.find({ query: "new", type: "file" }) + }).pipe(Effect.repeat({ until: (entries) => entries.length > 0 })) + expect(refreshed[0]?.path).toBe(RelativePath.make("src/new.ts")) + expect(scans).toBe(2) + }).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped), + ) + }) })