Pārlūkot izejas kodu

fix(core): refresh fallback file search (#42348)

Kit Langton 4 dienas atpakaļ
vecāks
revīzija
20929b3081

+ 37 - 11
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 { makeLocationNode } from "@opencode-ai/util/effect/app-node"
 import path from "path"
 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 { Fff } from "#fff"
 import fuzzysort from "fuzzysort"
 import fuzzysort from "fuzzysort"
 import { FileSystem } from "../filesystem.js"
 import { FileSystem } from "../filesystem.js"
@@ -22,38 +22,64 @@ export type Options = typeof Options.Type
 
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
 export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
 
 
+const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
+
 export const ripgrepLayer = Layer.effect(
 export const ripgrepLayer = Layer.effect(
   Service,
   Service,
   Effect.gen(function* () {
   Effect.gen(function* () {
     const location = yield* Location.Service
     const location = yield* Location.Service
     const ripgrep = yield* Ripgrep.Service
     const ripgrep = yield* Ripgrep.Service
     const scope = yield* Scope.Scope
     const scope = yield* Scope.Scope
-    const files: string[] = []
-    const directories = new Set<string>()
+    const clock = yield* Clock.Clock
     const home = Protected.isHome(location.directory)
     const home = Protected.isHome(location.directory)
-    yield* ripgrep
-      .find({
+    let index = { files: [] as string[], directories: new Set<string>() }
+    let initialized = false
+    let settledAt = Number.NEGATIVE_INFINITY
+    let refreshing = false
+    const scan = Effect.gen(function* () {
+      const next = { files: [] as string[], directories: new Set<string>() }
+      if (!initialized) index = next
+      yield* ripgrep.find({
         cwd: location.directory,
         cwd: location.directory,
         pattern: "*",
         pattern: "*",
         limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
         limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
         exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
         exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
         onEntry: (entry) =>
         onEntry: (entry) =>
           Effect.sync(() => {
           Effect.sync(() => {
-            files.push(entry.path)
+            next.files.push(entry.path)
             const parts = entry.path.split("/")
             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({
     return Service.of({
       find: (input) =>
       find: (input) =>
         Effect.gen(function* () {
         Effect.gen(function* () {
+          yield* refresh
           const items =
           const items =
             input.type === "file"
             input.type === "file"
-              ? files
+              ? index.files
               : input.type === "directory"
               : 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) => {
           return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
             const relative = item.target
             const relative = item.target
             const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
             const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)

+ 66 - 1
packages/core/test/filesystem/search.test.ts

@@ -1,7 +1,8 @@
 import { describe, expect, test } from "bun:test"
 import { describe, expect, test } from "bun:test"
 import os from "os"
 import os from "os"
 import path from "path"
 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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { FileSystem } from "@opencode-ai/core/filesystem"
 import { FileSystem } from "@opencode-ai/core/filesystem"
 import { Protected } from "@opencode-ai/core/filesystem/protected"
 import { Protected } from "@opencode-ai/core/filesystem/protected"
@@ -56,4 +57,68 @@ describe("FileSystemSearch", () => {
       }).pipe(Effect.provide(layer), Effect.scoped),
       }).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<void>())
+    const started = Effect.runSync(Deferred.make<void>())
+    const release = Effect.runSync(Deferred.make<void>())
+    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),
+    )
+  })
 })
 })