| ... | ... | @@ -28,10 +28,20 @@ const IGNORED_DIRECTORY_NAMES = new Set([ |
| 28 | 28 | |
| 29 | 29 | interface WorkspaceIndex { |
| 30 | 30 | scannedAt: number; |
| 31 | | entries: ProjectEntry[]; |
| 31 | entries: SearchableWorkspaceEntry[]; |
| 32 | 32 | truncated: boolean; |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | interface SearchableWorkspaceEntry extends ProjectEntry { |
| 36 | normalizedPath: string; |
| 37 | normalizedName: string; |
| 38 | } |
| 39 | |
| 40 | interface RankedWorkspaceEntry { |
| 41 | entry: SearchableWorkspaceEntry; |
| 42 | score: number; |
| 43 | } |
| 44 | |
| 35 | 45 | const workspaceIndexCache = new Map<string, WorkspaceIndex>(); |
| 36 | 46 | const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>(); |
| 37 | 47 | |
| ... | ... | @@ -55,6 +65,15 @@ function basenameOf(input: string): string { |
| 55 | 65 | return input.slice(separatorIndex + 1); |
| 56 | 66 | } |
| 57 | 67 | |
| 68 | function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry { |
| 69 | const normalizedPath = entry.path.toLowerCase(); |
| 70 | return { |
| 71 | ...entry, |
| 72 | normalizedPath, |
| 73 | normalizedName: basenameOf(normalizedPath), |
| 74 | }; |
| 75 | } |
| 76 | |
| 58 | 77 | function normalizeQuery(input: string): string { |
| 59 | 78 | return input |
| 60 | 79 | .trim() |
| ... | ... | @@ -62,20 +81,120 @@ function normalizeQuery(input: string): string { |
| 62 | 81 | .toLowerCase(); |
| 63 | 82 | } |
| 64 | 83 | |
| 65 | | function scoreEntry(entry: ProjectEntry, query: string): number { |
| 84 | function scoreSubsequenceMatch(value: string, query: string): number | null { |
| 85 | if (!query) return 0; |
| 86 | |
| 87 | let queryIndex = 0; |
| 88 | let firstMatchIndex = -1; |
| 89 | let previousMatchIndex = -1; |
| 90 | let gapPenalty = 0; |
| 91 | |
| 92 | for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) { |
| 93 | if (value[valueIndex] !== query[queryIndex]) { |
| 94 | continue; |
| 95 | } |
| 96 | |
| 97 | if (firstMatchIndex === -1) { |
| 98 | firstMatchIndex = valueIndex; |
| 99 | } |
| 100 | if (previousMatchIndex !== -1) { |
| 101 | gapPenalty += valueIndex - previousMatchIndex - 1; |
| 102 | } |
| 103 | |
| 104 | previousMatchIndex = valueIndex; |
| 105 | queryIndex += 1; |
| 106 | if (queryIndex === query.length) { |
| 107 | const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length; |
| 108 | const lengthPenalty = Math.min(64, value.length - query.length); |
| 109 | return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty; |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | return null; |
| 114 | } |
| 115 | |
| 116 | function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null { |
| 66 | 117 | if (!query) { |
| 67 | 118 | return entry.kind === "directory" ? 0 : 1; |
| 68 | 119 | } |
| 69 | 120 | |
| 70 | | const normalizedPath = entry.path.toLowerCase(); |
| 71 | | const normalizedName = basenameOf(normalizedPath); |
| 121 | const { normalizedPath, normalizedName } = entry; |
| 72 | 122 | |
| 73 | 123 | if (normalizedName === query) return 0; |
| 74 | 124 | if (normalizedPath === query) return 1; |
| 75 | 125 | if (normalizedName.startsWith(query)) return 2; |
| 76 | 126 | if (normalizedPath.startsWith(query)) return 3; |
| 77 | 127 | if (normalizedPath.includes(`/${query}`)) return 4; |
| 78 | | return 5; |
| 128 | if (normalizedName.includes(query)) return 5; |
| 129 | if (normalizedPath.includes(query)) return 6; |
| 130 | |
| 131 | const nameFuzzyScore = scoreSubsequenceMatch(normalizedName, query); |
| 132 | if (nameFuzzyScore !== null) { |
| 133 | return 100 + nameFuzzyScore; |
| 134 | } |
| 135 | |
| 136 | const pathFuzzyScore = scoreSubsequenceMatch(normalizedPath, query); |
| 137 | if (pathFuzzyScore !== null) { |
| 138 | return 200 + pathFuzzyScore; |
| 139 | } |
| 140 | |
| 141 | return null; |
| 142 | } |
| 143 | |
| 144 | function compareRankedWorkspaceEntries( |
| 145 | left: RankedWorkspaceEntry, |
| 146 | right: RankedWorkspaceEntry, |
| 147 | ): number { |
| 148 | const scoreDelta = left.score - right.score; |
| 149 | if (scoreDelta !== 0) return scoreDelta; |
| 150 | return left.entry.path.localeCompare(right.entry.path); |
| 151 | } |
| 152 | |
| 153 | function findInsertionIndex( |
| 154 | rankedEntries: RankedWorkspaceEntry[], |
| 155 | candidate: RankedWorkspaceEntry, |
| 156 | ): number { |
| 157 | let low = 0; |
| 158 | let high = rankedEntries.length; |
| 159 | |
| 160 | while (low < high) { |
| 161 | const middle = low + Math.floor((high - low) / 2); |
| 162 | const current = rankedEntries[middle]; |
| 163 | if (!current) { |
| 164 | break; |
| 165 | } |
| 166 | |
| 167 | if (compareRankedWorkspaceEntries(candidate, current) < 0) { |
| 168 | high = middle; |
| 169 | } else { |
| 170 | low = middle + 1; |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | return low; |
| 175 | } |
| 176 | |
| 177 | function insertRankedEntry( |
| 178 | rankedEntries: RankedWorkspaceEntry[], |
| 179 | candidate: RankedWorkspaceEntry, |
| 180 | limit: number, |
| 181 | ): void { |
| 182 | if (limit <= 0) { |
| 183 | return; |
| 184 | } |
| 185 | |
| 186 | const insertionIndex = findInsertionIndex(rankedEntries, candidate); |
| 187 | if (rankedEntries.length < limit) { |
| 188 | rankedEntries.splice(insertionIndex, 0, candidate); |
| 189 | return; |
| 190 | } |
| 191 | |
| 192 | if (insertionIndex >= limit) { |
| 193 | return; |
| 194 | } |
| 195 | |
| 196 | rankedEntries.splice(insertionIndex, 0, candidate); |
| 197 | rankedEntries.pop(); |
| 79 | 198 | } |
| 80 | 199 | |
| 81 | 200 | function isPathInIgnoredDirectory(relativePath: string): boolean { |
| ... | ... | @@ -253,20 +372,26 @@ async function buildWorkspaceIndexFromGit(cwd: string): Promise<WorkspaceIndex | |
| 253 | 372 | } |
| 254 | 373 | } |
| 255 | 374 | |
| 256 | | const directoryEntries: ProjectEntry[] = [...directorySet] |
| 375 | const directoryEntries = [...directorySet] |
| 257 | 376 | .toSorted((left, right) => left.localeCompare(right)) |
| 258 | | .map((directoryPath) => ({ |
| 259 | | path: directoryPath, |
| 260 | | kind: "directory", |
| 261 | | parentPath: parentPathOf(directoryPath), |
| 262 | | })); |
| 263 | | const fileEntries: ProjectEntry[] = [...new Set(filePaths)] |
| 377 | .map( |
| 378 | (directoryPath): ProjectEntry => ({ |
| 379 | path: directoryPath, |
| 380 | kind: "directory", |
| 381 | parentPath: parentPathOf(directoryPath), |
| 382 | }), |
| 383 | ) |
| 384 | .map(toSearchableWorkspaceEntry); |
| 385 | const fileEntries = [...new Set(filePaths)] |
| 264 | 386 | .toSorted((left, right) => left.localeCompare(right)) |
| 265 | | .map((filePath) => ({ |
| 266 | | path: filePath, |
| 267 | | kind: "file", |
| 268 | | parentPath: parentPathOf(filePath), |
| 269 | | })); |
| 387 | .map( |
| 388 | (filePath): ProjectEntry => ({ |
| 389 | path: filePath, |
| 390 | kind: "file", |
| 391 | parentPath: parentPathOf(filePath), |
| 392 | }), |
| 393 | ) |
| 394 | .map(toSearchableWorkspaceEntry); |
| 270 | 395 | |
| 271 | 396 | const entries = [...directoryEntries, ...fileEntries]; |
| 272 | 397 | return { |
| ... | ... | @@ -284,7 +409,7 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { |
| 284 | 409 | const shouldFilterWithGitIgnore = await isInsideGitWorkTree(cwd); |
| 285 | 410 | |
| 286 | 411 | let pendingDirectories: string[] = [""]; |
| 287 | | const entries: ProjectEntry[] = []; |
| 412 | const entries: SearchableWorkspaceEntry[] = []; |
| 288 | 413 | let truncated = false; |
| 289 | 414 | |
| 290 | 415 | while (pendingDirectories.length > 0 && !truncated) { |
| ... | ... | @@ -351,11 +476,11 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { |
| 351 | 476 | continue; |
| 352 | 477 | } |
| 353 | 478 | |
| 354 | | const entry: ProjectEntry = { |
| 479 | const entry = toSearchableWorkspaceEntry({ |
| 355 | 480 | path: candidate.relativePath, |
| 356 | 481 | kind: candidate.dirent.isDirectory() ? "directory" : "file", |
| 357 | 482 | parentPath: parentPathOf(candidate.relativePath), |
| 358 | | }; |
| 483 | }); |
| 359 | 484 | entries.push(entry); |
| 360 | 485 | |
| 361 | 486 | if (candidate.dirent.isDirectory()) { |
| ... | ... | @@ -419,18 +544,22 @@ export async function searchWorkspaceEntries( |
| 419 | 544 | ): Promise<ProjectSearchEntriesResult> { |
| 420 | 545 | const index = await getWorkspaceIndex(input.cwd); |
| 421 | 546 | const normalizedQuery = normalizeQuery(input.query); |
| 422 | | const candidates = normalizedQuery |
| 423 | | ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery)) |
| 424 | | : index.entries; |
| 425 | | |
| 426 | | const ranked = candidates.toSorted((left, right) => { |
| 427 | | const scoreDelta = scoreEntry(left, normalizedQuery) - scoreEntry(right, normalizedQuery); |
| 428 | | if (scoreDelta !== 0) return scoreDelta; |
| 429 | | return left.path.localeCompare(right.path); |
| 430 | | }); |
| 547 | const limit = Math.max(0, Math.floor(input.limit)); |
| 548 | const rankedEntries: RankedWorkspaceEntry[] = []; |
| 549 | let matchedEntryCount = 0; |
| 550 | |
| 551 | for (const entry of index.entries) { |
| 552 | const score = scoreEntry(entry, normalizedQuery); |
| 553 | if (score === null) { |
| 554 | continue; |
| 555 | } |
| 556 | |
| 557 | matchedEntryCount += 1; |
| 558 | insertRankedEntry(rankedEntries, { entry, score }, limit); |
| 559 | } |
| 431 | 560 | |
| 432 | 561 | return { |
| 433 | | entries: ranked.slice(0, input.limit), |
| 434 | | truncated: index.truncated || ranked.length > input.limit, |
| 562 | entries: rankedEntries.map((candidate) => candidate.entry), |
| 563 | truncated: index.truncated || matchedEntryCount > limit, |
| 435 | 564 | }; |
| 436 | 565 | } |