fix: speed up fff file search (#31366)
This commit is contained in:
parent
1772e8ee6e
commit
b1a6c40ad0
7 changed files with 68 additions and 39 deletions
|
|
@ -407,6 +407,7 @@ export function DialogSelectFile(props: {
|
||||||
items={items}
|
items={items}
|
||||||
key={(item) => item.id}
|
key={(item) => item.id}
|
||||||
filterKeys={["title", "description", "category"]}
|
filterKeys={["title", "description", "category"]}
|
||||||
|
skipFilter={(item) => item.type === "file"}
|
||||||
groupBy={grouped() ? (item) => item.category : () => ""}
|
groupBy={grouped() ? (item) => item.category : () => ""}
|
||||||
onMove={handleMove}
|
onMove={handleMove}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
|
|
||||||
|
|
@ -658,6 +658,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
},
|
},
|
||||||
key: atKey,
|
key: atKey,
|
||||||
filterKeys: ["display"],
|
filterKeys: ["display"],
|
||||||
|
skipFilter: (item) => item.type === "file" && !item.recent,
|
||||||
groupBy: (item) => {
|
groupBy: (item) => {
|
||||||
if (item.type === "agent") return "agent"
|
if (item.type === "agent") return "agent"
|
||||||
if (item.recent) return "recent"
|
if (item.recent) return "recent"
|
||||||
|
|
|
||||||
|
|
@ -134,15 +134,12 @@ function item(hit: Fff.Hit): Item {
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectPaths<T>(
|
function collectPaths<T>(
|
||||||
out: { items: T[]; scores: Array<{ total: number }> },
|
items: T[],
|
||||||
toPath: (item: T) => string,
|
toPath: (item: T) => string,
|
||||||
opts?: { includeZeroScore?: boolean },
|
|
||||||
): string[] {
|
): string[] {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
out.items.flatMap((item, idx): string[] => {
|
items.flatMap((item): string[] => {
|
||||||
const score = out.scores[idx]
|
|
||||||
if (!score || (!opts?.includeZeroScore && score.total <= 0)) return []
|
|
||||||
const text = toPath(item)
|
const text = toPath(item)
|
||||||
if (!text) return []
|
if (!text) return []
|
||||||
return [text]
|
return [text]
|
||||||
|
|
@ -162,7 +159,7 @@ function searchFff(
|
||||||
if (!out.ok) return out
|
if (!out.ok) return out
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }),
|
value: collectPaths(out.value.items, (entry) => normalize(entry.relativePath)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (kind === "all") {
|
if (kind === "all") {
|
||||||
|
|
@ -170,14 +167,14 @@ function searchFff(
|
||||||
if (!out.ok) return out
|
if (!out.ok) return out
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
value: collectPaths(out.value, (entry) => normalize(entry.item.relativePath), { includeZeroScore: !query }),
|
value: collectPaths(out.value.items, (entry) => normalize(entry.item.relativePath)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const out = pick.fileSearch(query, opts)
|
const out = pick.fileSearch(query, opts)
|
||||||
if (!out.ok) return out
|
if (!out.ok) return out
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }),
|
value: collectPaths(out.value.items, (entry) => normalize(entry.relativePath)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,6 +238,13 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
|
||||||
// remove before process exit, so use ripgrep instead of native FFF there.
|
// remove before process exit, so use ripgrep instead of native FFF there.
|
||||||
if (process.env.OPENCODE_TEST_HOME) return undefined
|
if (process.env.OPENCODE_TEST_HOME) return undefined
|
||||||
|
|
||||||
|
const dir = FSUtil.resolve(cwd)
|
||||||
|
const existing = state.pick.get(dir)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const pending = state.wait.get(dir)
|
||||||
|
if (pending) return yield* Deferred.await(pending)
|
||||||
|
|
||||||
const available = yield* fffSync("check availability", () => Fff.available()).pipe(
|
const available = yield* fffSync("check availability", () => Fff.available()).pipe(
|
||||||
Effect.catch((error) => {
|
Effect.catch((error) => {
|
||||||
log.warn("fff availability check failed", { error })
|
log.warn("fff availability check failed", { error })
|
||||||
|
|
@ -249,13 +253,6 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
|
||||||
)
|
)
|
||||||
if (!available) return undefined
|
if (!available) return undefined
|
||||||
|
|
||||||
const dir = FSUtil.resolve(cwd)
|
|
||||||
const existing = state.pick.get(dir)
|
|
||||||
if (existing) return existing
|
|
||||||
|
|
||||||
const pending = state.wait.get(dir)
|
|
||||||
if (pending) return yield* Deferred.await(pending)
|
|
||||||
|
|
||||||
const gate = yield* Deferred.make<Picker, Error>()
|
const gate = yield* Deferred.make<Picker, Error>()
|
||||||
state.wait.set(dir, gate)
|
state.wait.set(dir, gate)
|
||||||
return yield* Effect.gen(function* () {
|
return yield* Effect.gen(function* () {
|
||||||
|
|
@ -353,13 +350,12 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
|
||||||
const query = input.query.trim()
|
const query = input.query.trim()
|
||||||
const kind = input.kind ?? "file"
|
const kind = input.kind ?? "file"
|
||||||
|
|
||||||
const pick = yield* picker(input.cwd)
|
const entry = yield* acquire(input.cwd).pipe(Effect.catch(() => Effect.succeed<Picker | undefined>(undefined)))
|
||||||
if (!pick) return undefined
|
if (!entry) return undefined
|
||||||
|
|
||||||
const dir = FSUtil.resolve(input.cwd)
|
const dir = FSUtil.resolve(input.cwd)
|
||||||
const limit = input.limit ?? 100
|
const limit = input.limit ?? 100
|
||||||
const fffResult = yield* fffSync(`${kind} search`, () =>
|
const fffResult = yield* fffSync(`${kind} search`, () =>
|
||||||
searchFff(pick, kind, query, {
|
searchFff(entry.pick, kind, query, {
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
currentFile: input.current, // supports both relative and absolute (relative preferred)
|
currentFile: input.current, // supports both relative and absolute (relative preferred)
|
||||||
pageSize: limit,
|
pageSize: limit,
|
||||||
|
|
|
||||||
|
|
@ -368,19 +368,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||||
const next = extractLineRange(value)
|
const next = extractLineRange(value)
|
||||||
const list = await input.findFiles(next.base)
|
const list = await input.findFiles(next.base)
|
||||||
return list
|
return list
|
||||||
.sort((a, b) => {
|
|
||||||
const dir = Number(b.endsWith("/")) - Number(a.endsWith("/"))
|
|
||||||
if (dir !== 0) {
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
const depth = a.split("/").length - b.split("/").length
|
|
||||||
if (depth !== 0) {
|
|
||||||
return depth
|
|
||||||
}
|
|
||||||
|
|
||||||
return a.localeCompare(b)
|
|
||||||
})
|
|
||||||
.map((item): Auto => {
|
.map((item): Auto => {
|
||||||
const url = pathToFileURL(path.resolve(input.directory, item))
|
const url = pathToFileURL(path.resolve(input.directory, item))
|
||||||
let filename = item
|
let filename = item
|
||||||
|
|
@ -472,8 +459,21 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||||
return mixed
|
return mixed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const next = removeLineRange(query())
|
||||||
|
if (mode() === "mention") {
|
||||||
|
return [
|
||||||
|
...fuzzysort
|
||||||
|
.go(next, agents(), { keys: ["value", "display", "description"] })
|
||||||
|
.map((item) => item.obj),
|
||||||
|
...files(),
|
||||||
|
...fuzzysort
|
||||||
|
.go(next, resources(), { keys: ["value", "display", "description"] })
|
||||||
|
.map((item) => item.obj),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
return fuzzysort
|
return fuzzysort
|
||||||
.go(removeLineRange(query()), mixed, {
|
.go(next, mixed, {
|
||||||
keys: [(item) => (item.kind === "mention" ? item.value : item.name).trimEnd(), "display", "description"],
|
keys: [(item) => (item.kind === "mention" ? item.value : item.name).trimEnd(), "display", "description"],
|
||||||
})
|
})
|
||||||
.map((item) => item.obj)
|
.map((item) => item.obj)
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@ import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
|
import { Log } from "@opencode-ai/core/util/log"
|
||||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { InstanceHttpApi } from "../api"
|
import { InstanceHttpApi } from "../api"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "server.file" })
|
||||||
|
|
||||||
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
|
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const ripgrep = yield* Ripgrep.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
|
|
@ -34,11 +37,23 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
||||||
const directory = (yield* InstanceState.context).directory
|
const directory = (yield* InstanceState.context).directory
|
||||||
const limit = ctx.query.limit ?? 10
|
const limit = ctx.query.limit ?? 10
|
||||||
const kind = ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : "all")
|
const kind = ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : "all")
|
||||||
|
const started = performance.now()
|
||||||
// Prefer fff (frecency + fuzzy ranking) and trust its ordering. Fall back
|
// Prefer fff (frecency + fuzzy ranking) and trust its ordering. Fall back
|
||||||
// to the ripgrep-backed FileSystem.find when fff is unavailable.
|
// to the ripgrep-backed FileSystem.find when fff is unavailable.
|
||||||
const fff = yield* search.file({ cwd: directory, query: ctx.query.query, limit, kind }).pipe(Effect.orDie)
|
const fff = yield* search.file({ cwd: directory, query: ctx.query.query, limit, kind }).pipe(Effect.orDie)
|
||||||
if (fff !== undefined) return fff
|
if (fff !== undefined) {
|
||||||
return (yield* filesystem(
|
log.info("find file", {
|
||||||
|
engine: "fff",
|
||||||
|
query: ctx.query.query,
|
||||||
|
kind,
|
||||||
|
directory,
|
||||||
|
limit,
|
||||||
|
results: fff.length,
|
||||||
|
duration: Math.round(performance.now() - started),
|
||||||
|
})
|
||||||
|
return fff
|
||||||
|
}
|
||||||
|
const fallback = (yield* filesystem(
|
||||||
FileSystem.Service.use((fs) =>
|
FileSystem.Service.use((fs) =>
|
||||||
fs.find({
|
fs.find({
|
||||||
query: ctx.query.query,
|
query: ctx.query.query,
|
||||||
|
|
@ -47,6 +62,16 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)).map((item) => item.path)
|
)).map((item) => item.path)
|
||||||
|
log.info("find file", {
|
||||||
|
engine: "ripgrep",
|
||||||
|
query: ctx.query.query,
|
||||||
|
kind,
|
||||||
|
directory,
|
||||||
|
limit,
|
||||||
|
results: fallback.length,
|
||||||
|
duration: Math.round(performance.now() - started),
|
||||||
|
})
|
||||||
|
return fallback
|
||||||
})
|
})
|
||||||
|
|
||||||
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
|
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||||
},
|
},
|
||||||
key: (item) => item.path,
|
key: (item) => item.path,
|
||||||
filterKeys: ["path"],
|
filterKeys: ["path"],
|
||||||
|
skipFilter: () => true,
|
||||||
onSelect: selectMention,
|
onSelect: selectMention,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export interface FilteredListProps<T> {
|
||||||
groupBy?: (x: T) => string
|
groupBy?: (x: T) => string
|
||||||
sortBy?: (a: T, b: T) => number
|
sortBy?: (a: T, b: T) => number
|
||||||
sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number
|
sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number
|
||||||
|
skipFilter?: (item: T) => boolean
|
||||||
onSelect?: (value: T | undefined, index: number) => void
|
onSelect?: (value: T | undefined, index: number) => void
|
||||||
noInitialSelection?: boolean
|
noInitialSelection?: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -35,10 +36,14 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
|
||||||
all,
|
all,
|
||||||
(x) => {
|
(x) => {
|
||||||
if (!needle) return x
|
if (!needle) return x
|
||||||
if (!props.filterKeys && Array.isArray(x) && x.every((e) => typeof e === "string")) {
|
const skipFilter = props.skipFilter
|
||||||
return fuzzysort.go(needle, x).map((x) => x.target) as T[]
|
const filterable = skipFilter ? x.filter((item) => !skipFilter(item)) : x
|
||||||
}
|
const skipped = skipFilter ? x.filter(skipFilter) : []
|
||||||
return fuzzysort.go(needle, x, { keys: props.filterKeys! }).map((x) => x.obj)
|
const filtered =
|
||||||
|
!props.filterKeys && Array.isArray(filterable) && filterable.every((e) => typeof e === "string")
|
||||||
|
? (fuzzysort.go(needle, filterable).map((x) => x.target) as T[])
|
||||||
|
: fuzzysort.go(needle, filterable, { keys: props.filterKeys! }).map((x) => x.obj)
|
||||||
|
return skipped.length ? [...filtered, ...skipped] : filtered
|
||||||
},
|
},
|
||||||
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
|
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
|
||||||
entries(),
|
entries(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue