mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 06:33:27 +00:00
fix(core): bound fuzzy search memory (#42741)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
This commit is contained in:
parent
d35c6f04ed
commit
cae205a3d9
2 changed files with 92 additions and 21 deletions
|
|
@ -23,6 +23,32 @@ 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")
|
const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
|
||||||
|
type Prepared = ReturnType<typeof fuzzysort.prepare>
|
||||||
|
|
||||||
|
function emptyIndex() {
|
||||||
|
return { files: new Map<string, Prepared>(), directories: new Map<string, Prepared>() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(index: ReturnType<typeof emptyIndex>, 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(
|
export const ripgrepLayer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
|
|
@ -32,12 +58,13 @@ export const ripgrepLayer = Layer.effect(
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const clock = yield* Clock.Clock
|
const clock = yield* Clock.Clock
|
||||||
const home = Protected.isHome(location.directory)
|
const home = Protected.isHome(location.directory)
|
||||||
let index = { files: [] as string[], directories: new Set<string>() }
|
let index = emptyIndex()
|
||||||
let initialized = false
|
let initialized = false
|
||||||
let settledAt = Number.NEGATIVE_INFINITY
|
let settledAt = Number.NEGATIVE_INFINITY
|
||||||
let refreshing = false
|
let refreshing = false
|
||||||
const scan = Effect.gen(function* () {
|
const scan = Effect.gen(function* () {
|
||||||
const next = { files: [] as string[], directories: new Set<string>() }
|
const next = emptyIndex()
|
||||||
|
const previous = index
|
||||||
if (!initialized) index = next
|
if (!initialized) index = next
|
||||||
yield* ripgrep.find({
|
yield* ripgrep.find({
|
||||||
cwd: location.directory,
|
cwd: location.directory,
|
||||||
|
|
@ -46,11 +73,13 @@ export const ripgrepLayer = Layer.effect(
|
||||||
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
|
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
|
||||||
onEntry: (entry) =>
|
onEntry: (entry) =>
|
||||||
Effect.sync(() => {
|
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("/")
|
const parts = entry.path.split("/")
|
||||||
parts
|
parts.slice(0, -1).forEach((_, offset) => {
|
||||||
.slice(0, -1)
|
const directory = parts.slice(0, offset + 1).join("/") + path.sep
|
||||||
.forEach((_, offset) => next.directories.add(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
|
index = next
|
||||||
|
|
@ -74,20 +103,7 @@ export const ripgrepLayer = Layer.effect(
|
||||||
find: (input) =>
|
find: (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* refresh
|
yield* refresh
|
||||||
const items =
|
return search(index, input)
|
||||||
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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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 os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Deferred, Effect, Layer } from "effect"
|
import { Deferred, Effect, Layer } from "effect"
|
||||||
|
|
@ -123,4 +124,58 @@ describe("FileSystemSearch", () => {
|
||||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
}).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<void>())
|
||||||
|
const second = Effect.runSync(Deferred.make<void>())
|
||||||
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue