fix(core): initialize fff lazily
This commit is contained in:
parent
04f9b15178
commit
7b49c490a5
2 changed files with 182 additions and 99 deletions
|
|
@ -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)
|
||||
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,
|
||||
}
|
||||
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) =>
|
||||
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((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)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue