From 7b49c490a59da1021062d332dada3ce0dcc446dd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 11 Jul 2026 14:35:00 -0400 Subject: [PATCH] fix(core): initialize fff lazily --- packages/core/src/filesystem/search.ts | 207 ++++++++++--------- packages/core/test/filesystem/search.test.ts | 74 ++++++- 2 files changed, 182 insertions(+), 99 deletions(-) diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index da711da8763a..96a4afdaa44f 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -123,109 +123,120 @@ export const fffLayer = Layer.effect( Service, Effect.gen(function* () { const location = yield* Location.Service - const result = yield* Effect.try({ - try: () => - Fff.create({ - basePath: location.directory, - aiMode: true, - }), - catch: (cause) => cause, - }).pipe( - Effect.catch((error) => Effect.logWarning("failed to initialize fff", { error }).pipe(Effect.as(undefined))), - ) - if (!result?.ok) { - if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error }) - return Service.of({ - find: () => Effect.succeed([]), - glob: () => Effect.succeed([]), - grep: () => Effect.succeed([]), - }) - } - yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore)) - return Service.of({ - glob: (input) => - Effect.sync(() => { - const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "") - const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, { - pageIndex: 0, - pageSize: input.limit, - }) - if (!found.ok) throw found.error - return found.value.items.map((item) => - FileSystem.Entry.make({ - path: RelativePath.make(item.relativePath.replaceAll("\\", "/")), - type: "file", + const scope = yield* Scope.Scope + const load = yield* Effect.cached( + Effect.gen(function* () { + const result = yield* Effect.try({ + try: () => + Fff.create({ + basePath: location.directory, + aiMode: true, }), - ) - }), - grep: (input) => - Effect.sync(() => { - const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "") - const found = result.value.grep( - [prefix ? `${prefix}/**` : undefined, input.include, input.pattern] - .filter((value) => value !== undefined) - .join(" "), - { mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 }, - ) - if (!found.ok) throw found.error - return found.value.items.map((match) => { - const bytes = Buffer.from(match.lineContent) - return FileSystem.Match.make({ - entry: FileSystem.Entry.make({ - path: RelativePath.make(match.relativePath.replaceAll("\\", "/")), - type: "file", - }), - line: match.lineNumber, - offset: match.byteOffset, - text: match.lineContent.length > 2_000 ? match.lineContent.slice(0, 2_000) + "..." : match.lineContent, - submatches: match.matchRanges.map(([start, end]) => ({ - text: bytes.subarray(start, end).toString("utf8"), - start, - end, - })), - }) + catch: (cause) => cause, + }).pipe( + Effect.catch((error) => Effect.logWarning("failed to initialize fff", { error }).pipe(Effect.as(undefined))), + ) + if (!result?.ok) { + if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error }) + return Service.of({ + find: () => Effect.succeed([]), + glob: () => Effect.succeed([]), + grep: () => Effect.succeed([]), }) - }), - find: (input) => - Effect.sync(() => { - const options = { pageIndex: 0, pageSize: input.limit ?? 50 } - const items = (() => { - if (input.type === "file") { - const found = result.value.fileSearch(input.query.trim(), options) + } + yield* Scope.addFinalizer(scope, Effect.sync(() => result.value.destroy()).pipe(Effect.ignore)) + return Service.of({ + glob: (input) => + Effect.sync(() => { + const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "") + const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, { + pageIndex: 0, + pageSize: input.limit, + }) if (!found.ok) throw found.error - return found.value.items.map((item, index) => ({ - path: item.relativePath, - type: "file" as const, - score: found.value.scores[index]?.total ?? 0, - })) - } - if (input.type === "directory") { - const found = result.value.directorySearch(input.query.trim(), options) + return found.value.items.map((item) => + FileSystem.Entry.make({ + path: RelativePath.make(item.relativePath.replaceAll("\\", "/")), + type: "file", + }), + ) + }), + grep: (input) => + Effect.sync(() => { + const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "") + const found = result.value.grep( + [prefix ? `${prefix}/**` : undefined, input.include, input.pattern] + .filter((value) => value !== undefined) + .join(" "), + { mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 }, + ) if (!found.ok) throw found.error - return found.value.items.map((item, index) => ({ - path: item.relativePath, - type: "directory" as const, - score: found.value.scores[index]?.total ?? 0, - })) - } - const found = result.value.mixedSearch(input.query.trim(), options) - if (!found.ok) throw found.error - return found.value.items.map((item, index) => ({ - path: item.item.relativePath, - type: item.type, - score: found.value.scores[index]?.total ?? 0, - })) - })() - return items - .sort((a, b) => b.score - a.score || a.path.length - b.path.length) - .map((item) => { - const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "") - return FileSystem.Entry.make({ - path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")), - type: item.type, + return found.value.items.map((match) => { + const bytes = Buffer.from(match.lineContent) + return FileSystem.Match.make({ + entry: FileSystem.Entry.make({ + path: RelativePath.make(match.relativePath.replaceAll("\\", "/")), + type: "file", + }), + line: match.lineNumber, + offset: match.byteOffset, + text: + match.lineContent.length > 2_000 ? match.lineContent.slice(0, 2_000) + "..." : match.lineContent, + submatches: match.matchRanges.map(([start, end]) => ({ + text: bytes.subarray(start, end).toString("utf8"), + start, + end, + })), + }) }) - }) - }), + }), + find: (input) => + Effect.sync(() => { + const options = { pageIndex: 0, pageSize: input.limit ?? 50 } + const items = (() => { + if (input.type === "file") { + const found = result.value.fileSearch(input.query.trim(), options) + if (!found.ok) throw found.error + return found.value.items.map((item, index) => ({ + path: item.relativePath, + type: "file" as const, + score: found.value.scores[index]?.total ?? 0, + })) + } + if (input.type === "directory") { + const found = result.value.directorySearch(input.query.trim(), options) + if (!found.ok) throw found.error + return found.value.items.map((item, index) => ({ + path: item.relativePath, + type: "directory" as const, + score: found.value.scores[index]?.total ?? 0, + })) + } + const found = result.value.mixedSearch(input.query.trim(), options) + if (!found.ok) throw found.error + return found.value.items.map((item, index) => ({ + path: item.item.relativePath, + type: item.type, + score: found.value.scores[index]?.total ?? 0, + })) + })() + return items + .sort((a, b) => b.score - a.score || a.path.length - b.path.length) + .map((item) => { + const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "") + return FileSystem.Entry.make({ + path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")), + type: item.type, + }) + }) + }), + }) + }), + ) + return Service.of({ + find: (input) => Effect.flatMap(load, (service) => service.find(input)), + glob: (input) => Effect.flatMap(load, (service) => service.glob(input)), + grep: (input) => Effect.flatMap(load, (service) => service.grep(input)), }) }), ) diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index 6c47c85e9635..fca1dfc5c31e 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -1,10 +1,15 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import { Effect } from "effect" +import { Context, Effect, Layer, Scope } from "effect" +import { FileFinder } from "@ff-labs/fff-bun" +import "@opencode-ai/core/filesystem" +import { Fff } from "@opencode-ai/core/filesystem/fff.bun" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Location } from "@opencode-ai/core/location" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" @@ -42,3 +47,70 @@ describe("Ripgrep", () => { ), ) }) + +describe("FileSystemSearch", () => { + it.live("initializes fff on the first search", () => + withTmp((directory) => + Effect.gen(function* () { + if (!Fff.available()) return + yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "hello\n")) + const { FileSystemSearch } = yield* Effect.promise(() => import("@opencode-ai/core/filesystem/search")) + const create = FileFinder.create + const calls = { create: 0, destroy: 0 } + yield* Effect.acquireUseRelease( + Effect.sync(() => { + FileFinder.create = (options) => { + calls.create++ + const result = create(options) + if (!result.ok) return result + const destroy = result.value.destroy.bind(result.value) + result.value.destroy = () => { + calls.destroy++ + destroy() + } + return result + } + }), + () => + Effect.gen(function* () { + yield* Effect.acquireUseRelease( + Scope.make(), + (scope) => + Effect.gen(function* () { + const context = yield* Layer.buildWithScope( + FileSystemSearch.fffLayer.pipe( + Layer.provide( + Layer.succeed( + Location.Service, + Location.Service.of(location(Location.Ref.make({ directory }))), + ), + ), + ), + scope, + ) + const service = Context.get(context, FileSystemSearch.Service) + expect(calls.create).toBe(0) + + yield* Effect.all( + [ + service.find({ query: "", type: "file", limit: 1 }), + service.find({ query: "", type: "file", limit: 1 }), + ], + { concurrency: "unbounded" }, + ) + expect(calls.create).toBe(1) + + yield* service.find({ query: "", type: "file", limit: 1 }) + expect(calls.create).toBe(1) + expect(calls.destroy).toBe(0) + }), + (scope, exit) => Scope.close(scope, exit), + ) + expect(calls.destroy).toBe(1) + }), + () => Effect.sync(() => (FileFinder.create = create)), + ) + }), + ), + ) +})