| ... | @@ -28,10 +28,20 @@ const IGNORED_DIRECTORY_NAMES = new Set([ | ... | @@ -28,10 +28,20 @@ const IGNORED_DIRECTORY_NAMES = new Set([ |
| 28 | | 28 | |
| 29 | interface WorkspaceIndex { | 29 | interface WorkspaceIndex { |
| 30 | scannedAt: number; | 30 | scannedAt: number; |
| 31 | entries: ProjectEntry[]; | 31 | entries: SearchableWorkspaceEntry[]; |
| 32 | truncated: boolean; | 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 | const workspaceIndexCache = new Map<string, WorkspaceIndex>(); | 45 | const workspaceIndexCache = new Map<string, WorkspaceIndex>(); |
| 36 | const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>(); | 46 | const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>(); |
| 37 | | 47 | |
| ... | @@ -55,6 +65,15 @@ function basenameOf(input: string): string { | ... | @@ -55,6 +65,15 @@ function basenameOf(input: string): string { |
| 55 | return input.slice(separatorIndex + 1); | 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 | function normalizeQuery(input: string): string { | 77 | function normalizeQuery(input: string): string { |
| 59 | return input | 78 | return input |
| 60 | .trim() | 79 | .trim() |
| ... | @@ -62,20 +81,120 @@ function normalizeQuery(input: string): string { | ... | @@ -62,20 +81,120 @@ function normalizeQuery(input: string): string { |
| 62 | .toLowerCase(); | 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 | if (!query) { | 117 | if (!query) { |
| 67 | return entry.kind === "directory" ? 0 : 1; | 118 | return entry.kind === "directory" ? 0 : 1; |
| 68 | } | 119 | } |
| 69 | | 120 | |
| 70 | const normalizedPath = entry.path.toLowerCase(); | 121 | const { normalizedPath, normalizedName } = entry; |
| 71 | const normalizedName = basenameOf(normalizedPath); | | |
| 72 | | 122 | |
| 73 | if (normalizedName === query) return 0; | 123 | if (normalizedName === query) return 0; |
| 74 | if (normalizedPath === query) return 1; | 124 | if (normalizedPath === query) return 1; |
| 75 | if (normalizedName.startsWith(query)) return 2; | 125 | if (normalizedName.startsWith(query)) return 2; |
| 76 | if (normalizedPath.startsWith(query)) return 3; | 126 | if (normalizedPath.startsWith(query)) return 3; |
| 77 | if (normalizedPath.includes(`/${query}`)) return 4; | 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 | function isPathInIgnoredDirectory(relativePath: string): boolean { | 200 | function isPathInIgnoredDirectory(relativePath: string): boolean { |
| ... | @@ -253,20 +372,26 @@ async function buildWorkspaceIndexFromGit(cwd: string): Promise<WorkspaceIndex | | ... | @@ -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 | .toSorted((left, right) => left.localeCompare(right)) | 376 | .toSorted((left, right) => left.localeCompare(right)) |
| 258 | .map((directoryPath) => ({ | 377 | .map( |
| 259 | path: directoryPath, | 378 | (directoryPath): ProjectEntry => ({ |
| 260 | kind: "directory", | 379 | path: directoryPath, |
| 261 | parentPath: parentPathOf(directoryPath), | 380 | kind: "directory", |
| 262 | })); | 381 | parentPath: parentPathOf(directoryPath), |
| 263 | const fileEntries: ProjectEntry[] = [...new Set(filePaths)] | 382 | }), |
| | 383 | ) |
| | 384 | .map(toSearchableWorkspaceEntry); |
| | 385 | const fileEntries = [...new Set(filePaths)] |
| 264 | .toSorted((left, right) => left.localeCompare(right)) | 386 | .toSorted((left, right) => left.localeCompare(right)) |
| 265 | .map((filePath) => ({ | 387 | .map( |
| 266 | path: filePath, | 388 | (filePath): ProjectEntry => ({ |
| 267 | kind: "file", | 389 | path: filePath, |
| 268 | parentPath: parentPathOf(filePath), | 390 | kind: "file", |
| 269 | })); | 391 | parentPath: parentPathOf(filePath), |
| | 392 | }), |
| | 393 | ) |
| | 394 | .map(toSearchableWorkspaceEntry); |
| 270 | | 395 | |
| 271 | const entries = [...directoryEntries, ...fileEntries]; | 396 | const entries = [...directoryEntries, ...fileEntries]; |
| 272 | return { | 397 | return { |
| ... | @@ -284,7 +409,7 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { | ... | @@ -284,7 +409,7 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { |
| 284 | const shouldFilterWithGitIgnore = await isInsideGitWorkTree(cwd); | 409 | const shouldFilterWithGitIgnore = await isInsideGitWorkTree(cwd); |
| 285 | | 410 | |
| 286 | let pendingDirectories: string[] = [""]; | 411 | let pendingDirectories: string[] = [""]; |
| 287 | const entries: ProjectEntry[] = []; | 412 | const entries: SearchableWorkspaceEntry[] = []; |
| 288 | let truncated = false; | 413 | let truncated = false; |
| 289 | | 414 | |
| 290 | while (pendingDirectories.length > 0 && !truncated) { | 415 | while (pendingDirectories.length > 0 && !truncated) { |
| ... | @@ -351,11 +476,11 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { | ... | @@ -351,11 +476,11 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { |
| 351 | continue; | 476 | continue; |
| 352 | } | 477 | } |
| 353 | | 478 | |
| 354 | const entry: ProjectEntry = { | 479 | const entry = toSearchableWorkspaceEntry({ |
| 355 | path: candidate.relativePath, | 480 | path: candidate.relativePath, |
| 356 | kind: candidate.dirent.isDirectory() ? "directory" : "file", | 481 | kind: candidate.dirent.isDirectory() ? "directory" : "file", |
| 357 | parentPath: parentPathOf(candidate.relativePath), | 482 | parentPath: parentPathOf(candidate.relativePath), |
| 358 | }; | 483 | }); |
| 359 | entries.push(entry); | 484 | entries.push(entry); |
| 360 | | 485 | |
| 361 | if (candidate.dirent.isDirectory()) { | 486 | if (candidate.dirent.isDirectory()) { |
| ... | @@ -419,18 +544,22 @@ export async function searchWorkspaceEntries( | ... | @@ -419,18 +544,22 @@ export async function searchWorkspaceEntries( |
| 419 | ): Promise<ProjectSearchEntriesResult> { | 544 | ): Promise<ProjectSearchEntriesResult> { |
| 420 | const index = await getWorkspaceIndex(input.cwd); | 545 | const index = await getWorkspaceIndex(input.cwd); |
| 421 | const normalizedQuery = normalizeQuery(input.query); | 546 | const normalizedQuery = normalizeQuery(input.query); |
| 422 | const candidates = normalizedQuery | 547 | const limit = Math.max(0, Math.floor(input.limit)); |
| 423 | ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery)) | 548 | const rankedEntries: RankedWorkspaceEntry[] = []; |
| 424 | : index.entries; | 549 | let matchedEntryCount = 0; |
| 425 | | 550 | |
| 426 | const ranked = candidates.toSorted((left, right) => { | 551 | for (const entry of index.entries) { |
| 427 | const scoreDelta = scoreEntry(left, normalizedQuery) - scoreEntry(right, normalizedQuery); | 552 | const score = scoreEntry(entry, normalizedQuery); |
| 428 | if (scoreDelta !== 0) return scoreDelta; | 553 | if (score === null) { |
| 429 | return left.path.localeCompare(right.path); | 554 | continue; |
| 430 | }); | 555 | } |
| | 556 | |
| | 557 | matchedEntryCount += 1; |
| | 558 | insertRankedEntry(rankedEntries, { entry, score }, limit); |
| | 559 | } |
| 431 | | 560 | |
| 432 | return { | 561 | return { |
| 433 | entries: ranked.slice(0, input.limit), | 562 | entries: rankedEntries.map((candidate) => candidate.entry), |
| 434 | truncated: index.truncated || ranked.length > input.limit, | 563 | truncated: index.truncated || matchedEntryCount > limit, |
| 435 | }; | 564 | }; |
| 436 | } | 565 | } |