ソースを参照

fix: 修复 @ typeahead 文件搜索无结果的问题

execa 新版将 signal 选项重命名为 cancelSignal,导致 execFileNoThrowWithCwd
调用 git ls-files 时抛出 TypeError,文件索引始终为空。同时改进了
FileIndex 的模糊匹配算法,从多个词边界起始位置评分取最优,提升搜索排名质量。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude-code-best 3 週間 前
コミット
221fb6eb05

+ 5 - 4
src/hooks/fileSuggestions.ts

@@ -253,15 +253,14 @@ async function getFilesUsingGit(
   logForDebugging(`[FileIndex] getFilesUsingGit called`)
 
   // 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) {
     logForDebugging(`[FileIndex] not a git repo, returning null`)
     return null
   }
 
   try {
-    const cwd = getCwd()
-
     // Get tracked files (fast - reads from git index)
     // Run from repoRoot so paths are relative to repo root, not CWD
     const lsFilesStart = Date.now()
@@ -634,7 +633,9 @@ function findMatchingFiles(
  */
 const REFRESH_THROTTLE_MS = 5_000
 export function startBackgroundCacheRefresh(): void {
-  if (fileListRefreshPromise) return
+  if (fileListRefreshPromise) {
+    return
+  }
 
   // Throttle only when a cache exists — cold start must always populate.
   // Refresh immediately when .git/index mtime changed (tracked files).

+ 77 - 36
src/native-ts/file-index/index.ts

@@ -211,47 +211,88 @@ export class FileIndex {
 
       const haystack = caseSensitive ? paths[i]! : lowerPaths[i]!
 
-      // Fused indexOf scan: find positions (SIMD-accelerated in JSC/V8) AND
-      // accumulate gap/consecutive terms inline. The greedy-earliest positions
-      // found here are identical to what the charCodeAt scorer would find, so
-      // we score directly from them — no second scan.
-      let pos = haystack.indexOf(needleChars[0]!)
-      if (pos === -1) continue
-      posBuf[0] = pos
-      let gapPenalty = 0
-      let consecBonus = 0
-      let prev = pos
-      for (let j = 1; j < nLen; j++) {
-        pos = haystack.indexOf(needleChars[j]!, prev + 1)
-        if (pos === -1) continue outer
-        posBuf[j] = pos
-        const gap = pos - prev - 1
-        if (gap === 0) consecBonus += BONUS_CONSECUTIVE
-        else gapPenalty += PENALTY_GAP_START + gap * PENALTY_GAP_EXTENSION
-        prev = pos
-      }
-
-      // Gap-bound reject: if the best-case score (all boundary bonuses) minus
-      // known gap penalties can't beat threshold, skip the boundary pass.
-      if (
-        topK.length === limit &&
-        scoreCeiling + consecBonus - gapPenalty <= threshold
-      ) {
-        continue
+      // Greedy-leftmost indexOf gives fast but suboptimal positions when the
+      // first needle char appears early (e.g. 's' in "src/") while the real
+      // match lives deeper (e.g. "settings/"). We score from multiple start
+      // positions — the leftmost hit plus every word-boundary occurrence of
+      // needle[0] — and keep the best. Typical paths have 2–4 boundary starts,
+      // so the overhead is minimal.
+
+      // 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
+        }
       }
 
-      // Boundary/camelCase scoring: check the char before each match position.
-      const path = paths[i]!
+      const originalPath = paths[i]!
       const hLen = pathLens[i]!
-      let score = nLen * SCORE_MATCH + consecBonus - gapPenalty
-      score += scoreBonusAt(path, posBuf[0]!, true)
-      for (let j = 1; j < nLen; j++) {
-        score += scoreBonusAt(path, posBuf[j]!, false)
+      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 consecBonus = 0
+        let prev = posBuf[0]!
+        let matched = true
+        for (let j = 1; j < nLen; j++) {
+          const pos = haystack.indexOf(needleChars[j]!, prev + 1)
+          if (pos === -1) { matched = false; break }
+          posBuf[j] = pos
+          const gap = pos - prev - 1
+          if (gap === 0) consecBonus += BONUS_CONSECUTIVE
+          else gapPenalty += PENALTY_GAP_START + gap * PENALTY_GAP_EXTENSION
+          prev = pos
+        }
+        if (!matched) continue
+
+        // Gap-bound reject for this start position
+        if (
+          topK.length === limit &&
+          scoreCeiling + consecBonus - gapPenalty + lengthBonus <= threshold
+        ) {
+          continue
+        }
+
+        // Boundary/camelCase scoring
+        let score = nLen * SCORE_MATCH + consecBonus - gapPenalty
+        score += scoreBonusAt(originalPath, posBuf[0]!, true)
+        for (let j = 1; j < nLen; j++) {
+          score += scoreBonusAt(originalPath, posBuf[j]!, false)
+        }
+        score += lengthBonus
+
+        if (score > bestScore) bestScore = score
       }
-      score += Math.max(0, 32 - (hLen >> 2))
+
+      if (bestScore === -Infinity) continue
+      const score = bestScore
 
       if (topK.length < limit) {
-        topK.push({ path, fuzzScore: score })
+        topK.push({ path: originalPath, fuzzScore: score })
         if (topK.length === limit) {
           topK.sort((a, b) => a.fuzzScore - b.fuzzScore)
           threshold = topK[0]!.fuzzScore
@@ -264,7 +305,7 @@ export class FileIndex {
           if (topK[mid]!.fuzzScore < score) lo = mid + 1
           else hi = mid
         }
-        topK.splice(lo, 0, { path, fuzzScore: score })
+        topK.splice(lo, 0, { path: originalPath, fuzzScore: score })
         topK.shift()
         threshold = topK[0]!.fuzzScore
       }

+ 1 - 1
src/utils/execFileNoThrow.ts

@@ -109,7 +109,7 @@ export function execFileNoThrowWithCwd(
     // Use execa for cross-platform .bat/.cmd compatibility on Windows
     execa(file, args, {
       maxBuffer,
-      signal: abortSignal,
+      cancelSignal: abortSignal,
       timeout: finalTimeout,
       cwd: finalCwd,
       env: finalEnv,