fix: 修复 @ typeahead 文件搜索无结果的问题
execa 新版将 signal 选项重命名为 cancelSignal,导致 execFileNoThrowWithCwd 调用 git ls-files 时抛出 TypeError,文件索引始终为空。同时改进了 FileIndex 的模糊匹配算法,从多个词边界起始位置评分取最优,提升搜索排名质量。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8b63e54e94
commit
221fb6eb05
@ -253,15 +253,14 @@ async function getFilesUsingGit(
|
|||||||
logForDebugging(`[FileIndex] getFilesUsingGit called`)
|
logForDebugging(`[FileIndex] getFilesUsingGit called`)
|
||||||
|
|
||||||
// Check if we're in a git repo. findGitRoot is LRU-memoized per path.
|
// Check if we're in a git repo. findGitRoot is LRU-memoized per path.
|
||||||
const repoRoot = findGitRoot(getCwd())
|
const cwd = getCwd()
|
||||||
|
const repoRoot = findGitRoot(cwd)
|
||||||
if (!repoRoot) {
|
if (!repoRoot) {
|
||||||
logForDebugging(`[FileIndex] not a git repo, returning null`)
|
logForDebugging(`[FileIndex] not a git repo, returning null`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cwd = getCwd()
|
|
||||||
|
|
||||||
// Get tracked files (fast - reads from git index)
|
// Get tracked files (fast - reads from git index)
|
||||||
// Run from repoRoot so paths are relative to repo root, not CWD
|
// Run from repoRoot so paths are relative to repo root, not CWD
|
||||||
const lsFilesStart = Date.now()
|
const lsFilesStart = Date.now()
|
||||||
@ -634,7 +633,9 @@ function findMatchingFiles(
|
|||||||
*/
|
*/
|
||||||
const REFRESH_THROTTLE_MS = 5_000
|
const REFRESH_THROTTLE_MS = 5_000
|
||||||
export function startBackgroundCacheRefresh(): void {
|
export function startBackgroundCacheRefresh(): void {
|
||||||
if (fileListRefreshPromise) return
|
if (fileListRefreshPromise) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Throttle only when a cache exists — cold start must always populate.
|
// Throttle only when a cache exists — cold start must always populate.
|
||||||
// Refresh immediately when .git/index mtime changed (tracked files).
|
// Refresh immediately when .git/index mtime changed (tracked files).
|
||||||
|
|||||||
@ -211,47 +211,88 @@ export class FileIndex {
|
|||||||
|
|
||||||
const haystack = caseSensitive ? paths[i]! : lowerPaths[i]!
|
const haystack = caseSensitive ? paths[i]! : lowerPaths[i]!
|
||||||
|
|
||||||
// Fused indexOf scan: find positions (SIMD-accelerated in JSC/V8) AND
|
// Greedy-leftmost indexOf gives fast but suboptimal positions when the
|
||||||
// accumulate gap/consecutive terms inline. The greedy-earliest positions
|
// first needle char appears early (e.g. 's' in "src/") while the real
|
||||||
// found here are identical to what the charCodeAt scorer would find, so
|
// match lives deeper (e.g. "settings/"). We score from multiple start
|
||||||
// we score directly from them — no second scan.
|
// positions — the leftmost hit plus every word-boundary occurrence of
|
||||||
let pos = haystack.indexOf(needleChars[0]!)
|
// needle[0] — and keep the best. Typical paths have 2–4 boundary starts,
|
||||||
if (pos === -1) continue
|
// so the overhead is minimal.
|
||||||
posBuf[0] = pos
|
|
||||||
|
// Collect candidate start positions for needle[0]
|
||||||
|
const firstChar = needleChars[0]!
|
||||||
|
let startCount = 0
|
||||||
|
// startPositions is stack-allocated (reused array would add complexity
|
||||||
|
// for marginal gain; paths rarely have >8 boundary starts)
|
||||||
|
const startPositions: number[] = []
|
||||||
|
|
||||||
|
// Always try the leftmost occurrence
|
||||||
|
const firstPos = haystack.indexOf(firstChar)
|
||||||
|
if (firstPos === -1) continue
|
||||||
|
startPositions[startCount++] = firstPos
|
||||||
|
|
||||||
|
// Also try every word-boundary position where needle[0] occurs
|
||||||
|
for (let bp = firstPos + 1; bp < haystack.length; bp++) {
|
||||||
|
if (haystack.charCodeAt(bp) !== firstChar.charCodeAt(0)) continue
|
||||||
|
// Check if this position is at a word boundary
|
||||||
|
const prevCode = haystack.charCodeAt(bp - 1)
|
||||||
|
if (
|
||||||
|
prevCode === 47 || // /
|
||||||
|
prevCode === 92 || // \
|
||||||
|
prevCode === 45 || // -
|
||||||
|
prevCode === 95 || // _
|
||||||
|
prevCode === 46 || // .
|
||||||
|
prevCode === 32 // space
|
||||||
|
) {
|
||||||
|
startPositions[startCount++] = bp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalPath = paths[i]!
|
||||||
|
const hLen = pathLens[i]!
|
||||||
|
const lengthBonus = Math.max(0, 32 - (hLen >> 2))
|
||||||
|
let bestScore = -Infinity
|
||||||
|
|
||||||
|
for (let si = 0; si < startCount; si++) {
|
||||||
|
posBuf[0] = startPositions[si]!
|
||||||
let gapPenalty = 0
|
let gapPenalty = 0
|
||||||
let consecBonus = 0
|
let consecBonus = 0
|
||||||
let prev = pos
|
let prev = posBuf[0]!
|
||||||
|
let matched = true
|
||||||
for (let j = 1; j < nLen; j++) {
|
for (let j = 1; j < nLen; j++) {
|
||||||
pos = haystack.indexOf(needleChars[j]!, prev + 1)
|
const pos = haystack.indexOf(needleChars[j]!, prev + 1)
|
||||||
if (pos === -1) continue outer
|
if (pos === -1) { matched = false; break }
|
||||||
posBuf[j] = pos
|
posBuf[j] = pos
|
||||||
const gap = pos - prev - 1
|
const gap = pos - prev - 1
|
||||||
if (gap === 0) consecBonus += BONUS_CONSECUTIVE
|
if (gap === 0) consecBonus += BONUS_CONSECUTIVE
|
||||||
else gapPenalty += PENALTY_GAP_START + gap * PENALTY_GAP_EXTENSION
|
else gapPenalty += PENALTY_GAP_START + gap * PENALTY_GAP_EXTENSION
|
||||||
prev = pos
|
prev = pos
|
||||||
}
|
}
|
||||||
|
if (!matched) continue
|
||||||
|
|
||||||
// Gap-bound reject: if the best-case score (all boundary bonuses) minus
|
// Gap-bound reject for this start position
|
||||||
// known gap penalties can't beat threshold, skip the boundary pass.
|
|
||||||
if (
|
if (
|
||||||
topK.length === limit &&
|
topK.length === limit &&
|
||||||
scoreCeiling + consecBonus - gapPenalty <= threshold
|
scoreCeiling + consecBonus - gapPenalty + lengthBonus <= threshold
|
||||||
) {
|
) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boundary/camelCase scoring: check the char before each match position.
|
// Boundary/camelCase scoring
|
||||||
const path = paths[i]!
|
|
||||||
const hLen = pathLens[i]!
|
|
||||||
let score = nLen * SCORE_MATCH + consecBonus - gapPenalty
|
let score = nLen * SCORE_MATCH + consecBonus - gapPenalty
|
||||||
score += scoreBonusAt(path, posBuf[0]!, true)
|
score += scoreBonusAt(originalPath, posBuf[0]!, true)
|
||||||
for (let j = 1; j < nLen; j++) {
|
for (let j = 1; j < nLen; j++) {
|
||||||
score += scoreBonusAt(path, posBuf[j]!, false)
|
score += scoreBonusAt(originalPath, posBuf[j]!, false)
|
||||||
}
|
}
|
||||||
score += Math.max(0, 32 - (hLen >> 2))
|
score += lengthBonus
|
||||||
|
|
||||||
|
if (score > bestScore) bestScore = score
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestScore === -Infinity) continue
|
||||||
|
const score = bestScore
|
||||||
|
|
||||||
if (topK.length < limit) {
|
if (topK.length < limit) {
|
||||||
topK.push({ path, fuzzScore: score })
|
topK.push({ path: originalPath, fuzzScore: score })
|
||||||
if (topK.length === limit) {
|
if (topK.length === limit) {
|
||||||
topK.sort((a, b) => a.fuzzScore - b.fuzzScore)
|
topK.sort((a, b) => a.fuzzScore - b.fuzzScore)
|
||||||
threshold = topK[0]!.fuzzScore
|
threshold = topK[0]!.fuzzScore
|
||||||
@ -264,7 +305,7 @@ export class FileIndex {
|
|||||||
if (topK[mid]!.fuzzScore < score) lo = mid + 1
|
if (topK[mid]!.fuzzScore < score) lo = mid + 1
|
||||||
else hi = mid
|
else hi = mid
|
||||||
}
|
}
|
||||||
topK.splice(lo, 0, { path, fuzzScore: score })
|
topK.splice(lo, 0, { path: originalPath, fuzzScore: score })
|
||||||
topK.shift()
|
topK.shift()
|
||||||
threshold = topK[0]!.fuzzScore
|
threshold = topK[0]!.fuzzScore
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,7 +109,7 @@ export function execFileNoThrowWithCwd(
|
|||||||
// Use execa for cross-platform .bat/.cmd compatibility on Windows
|
// Use execa for cross-platform .bat/.cmd compatibility on Windows
|
||||||
execa(file, args, {
|
execa(file, args, {
|
||||||
maxBuffer,
|
maxBuffer,
|
||||||
signal: abortSignal,
|
cancelSignal: abortSignal,
|
||||||
timeout: finalTimeout,
|
timeout: finalTimeout,
|
||||||
cwd: finalCwd,
|
cwd: finalCwd,
|
||||||
env: finalEnv,
|
env: finalEnv,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user