From ee784bf8ab3135aba590776d701535b0c9f2e611 Mon Sep 17 00:00:00 2001 From: mask Date: Fri, 6 Mar 2026 21:51:51 -0600 Subject: [PATCH] Add fuzzy workspace entry search --- apps/server/src/workspaceEntries.test.ts | 26 +++ apps/server/src/workspaceEntries.ts | 191 +++++++++++++++++++---- 2 files changed, 186 insertions(+), 31 deletions(-) diff --git a/apps/server/src/workspaceEntries.test.ts b/apps/server/src/workspaceEntries.test.ts index ca8435336f..d867ad910d 100644 --- a/apps/server/src/workspaceEntries.test.ts +++ b/apps/server/src/workspaceEntries.test.ts @@ -70,6 +70,32 @@ describe("searchWorkspaceEntries", () => { assert.isTrue(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))); }); + it("supports fuzzy subsequence queries for composer path search", async () => { + const cwd = makeTempDir("t3code-workspace-fuzzy-query-"); + writeFile(cwd, "src/components/Composer.tsx"); + writeFile(cwd, "src/components/composePrompt.ts"); + writeFile(cwd, "docs/composition.md"); + + const result = await searchWorkspaceEntries({ cwd, query: "cmp", limit: 10 }); + const paths = result.entries.map((entry) => entry.path); + + assert.isAbove(result.entries.length, 0); + assert.include(paths, "src/components"); + assert.include(paths, "src/components/Composer.tsx"); + }); + + it("tracks truncation without sorting every fuzzy match", async () => { + const cwd = makeTempDir("t3code-workspace-fuzzy-limit-"); + writeFile(cwd, "src/components/Composer.tsx"); + writeFile(cwd, "src/components/composePrompt.ts"); + writeFile(cwd, "docs/composition.md"); + + const result = await searchWorkspaceEntries({ cwd, query: "cmp", limit: 1 }); + + assert.lengthOf(result.entries, 1); + assert.isTrue(result.truncated); + }); + it("excludes gitignored paths for git repositories", async () => { const cwd = makeTempDir("t3code-workspace-gitignore-"); runGit(cwd, ["init"]); diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts index 2c215bb6db..ddb7b58181 100644 --- a/apps/server/src/workspaceEntries.ts +++ b/apps/server/src/workspaceEntries.ts @@ -28,10 +28,20 @@ const IGNORED_DIRECTORY_NAMES = new Set([ interface WorkspaceIndex { scannedAt: number; - entries: ProjectEntry[]; + entries: SearchableWorkspaceEntry[]; truncated: boolean; } +interface SearchableWorkspaceEntry extends ProjectEntry { + normalizedPath: string; + normalizedName: string; +} + +interface RankedWorkspaceEntry { + entry: SearchableWorkspaceEntry; + score: number; +} + const workspaceIndexCache = new Map(); const inFlightWorkspaceIndexBuilds = new Map>(); @@ -55,6 +65,15 @@ function basenameOf(input: string): string { return input.slice(separatorIndex + 1); } +function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry { + const normalizedPath = entry.path.toLowerCase(); + return { + ...entry, + normalizedPath, + normalizedName: basenameOf(normalizedPath), + }; +} + function normalizeQuery(input: string): string { return input .trim() @@ -62,20 +81,120 @@ function normalizeQuery(input: string): string { .toLowerCase(); } -function scoreEntry(entry: ProjectEntry, query: string): number { +function scoreSubsequenceMatch(value: string, query: string): number | null { + if (!query) return 0; + + let queryIndex = 0; + let firstMatchIndex = -1; + let previousMatchIndex = -1; + let gapPenalty = 0; + + for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) { + if (value[valueIndex] !== query[queryIndex]) { + continue; + } + + if (firstMatchIndex === -1) { + firstMatchIndex = valueIndex; + } + if (previousMatchIndex !== -1) { + gapPenalty += valueIndex - previousMatchIndex - 1; + } + + previousMatchIndex = valueIndex; + queryIndex += 1; + if (queryIndex === query.length) { + const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length; + const lengthPenalty = Math.min(64, value.length - query.length); + return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty; + } + } + + return null; +} + +function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null { if (!query) { return entry.kind === "directory" ? 0 : 1; } - const normalizedPath = entry.path.toLowerCase(); - const normalizedName = basenameOf(normalizedPath); + const { normalizedPath, normalizedName } = entry; if (normalizedName === query) return 0; if (normalizedPath === query) return 1; if (normalizedName.startsWith(query)) return 2; if (normalizedPath.startsWith(query)) return 3; if (normalizedPath.includes(`/${query}`)) return 4; - return 5; + if (normalizedName.includes(query)) return 5; + if (normalizedPath.includes(query)) return 6; + + const nameFuzzyScore = scoreSubsequenceMatch(normalizedName, query); + if (nameFuzzyScore !== null) { + return 100 + nameFuzzyScore; + } + + const pathFuzzyScore = scoreSubsequenceMatch(normalizedPath, query); + if (pathFuzzyScore !== null) { + return 200 + pathFuzzyScore; + } + + return null; +} + +function compareRankedWorkspaceEntries( + left: RankedWorkspaceEntry, + right: RankedWorkspaceEntry, +): number { + const scoreDelta = left.score - right.score; + if (scoreDelta !== 0) return scoreDelta; + return left.entry.path.localeCompare(right.entry.path); +} + +function findInsertionIndex( + rankedEntries: RankedWorkspaceEntry[], + candidate: RankedWorkspaceEntry, +): number { + let low = 0; + let high = rankedEntries.length; + + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + const current = rankedEntries[middle]; + if (!current) { + break; + } + + if (compareRankedWorkspaceEntries(candidate, current) < 0) { + high = middle; + } else { + low = middle + 1; + } + } + + return low; +} + +function insertRankedEntry( + rankedEntries: RankedWorkspaceEntry[], + candidate: RankedWorkspaceEntry, + limit: number, +): void { + if (limit <= 0) { + return; + } + + const insertionIndex = findInsertionIndex(rankedEntries, candidate); + if (rankedEntries.length < limit) { + rankedEntries.splice(insertionIndex, 0, candidate); + return; + } + + if (insertionIndex >= limit) { + return; + } + + rankedEntries.splice(insertionIndex, 0, candidate); + rankedEntries.pop(); } function isPathInIgnoredDirectory(relativePath: string): boolean { @@ -253,20 +372,26 @@ async function buildWorkspaceIndexFromGit(cwd: string): Promise left.localeCompare(right)) - .map((directoryPath) => ({ - path: directoryPath, - kind: "directory", - parentPath: parentPathOf(directoryPath), - })); - const fileEntries: ProjectEntry[] = [...new Set(filePaths)] + .map( + (directoryPath): ProjectEntry => ({ + path: directoryPath, + kind: "directory", + parentPath: parentPathOf(directoryPath), + }), + ) + .map(toSearchableWorkspaceEntry); + const fileEntries = [...new Set(filePaths)] .toSorted((left, right) => left.localeCompare(right)) - .map((filePath) => ({ - path: filePath, - kind: "file", - parentPath: parentPathOf(filePath), - })); + .map( + (filePath): ProjectEntry => ({ + path: filePath, + kind: "file", + parentPath: parentPathOf(filePath), + }), + ) + .map(toSearchableWorkspaceEntry); const entries = [...directoryEntries, ...fileEntries]; return { @@ -284,7 +409,7 @@ async function buildWorkspaceIndex(cwd: string): Promise { const shouldFilterWithGitIgnore = await isInsideGitWorkTree(cwd); let pendingDirectories: string[] = [""]; - const entries: ProjectEntry[] = []; + const entries: SearchableWorkspaceEntry[] = []; let truncated = false; while (pendingDirectories.length > 0 && !truncated) { @@ -351,11 +476,11 @@ async function buildWorkspaceIndex(cwd: string): Promise { continue; } - const entry: ProjectEntry = { + const entry = toSearchableWorkspaceEntry({ path: candidate.relativePath, kind: candidate.dirent.isDirectory() ? "directory" : "file", parentPath: parentPathOf(candidate.relativePath), - }; + }); entries.push(entry); if (candidate.dirent.isDirectory()) { @@ -414,18 +539,22 @@ export async function searchWorkspaceEntries( ): Promise { const index = await getWorkspaceIndex(input.cwd); const normalizedQuery = normalizeQuery(input.query); - const candidates = normalizedQuery - ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery)) - : index.entries; - - const ranked = candidates.toSorted((left, right) => { - const scoreDelta = scoreEntry(left, normalizedQuery) - scoreEntry(right, normalizedQuery); - if (scoreDelta !== 0) return scoreDelta; - return left.path.localeCompare(right.path); - }); + const limit = Math.max(0, Math.floor(input.limit)); + const rankedEntries: RankedWorkspaceEntry[] = []; + let matchedEntryCount = 0; + + for (const entry of index.entries) { + const score = scoreEntry(entry, normalizedQuery); + if (score === null) { + continue; + } + + matchedEntryCount += 1; + insertRankedEntry(rankedEntries, { entry, score }, limit); + } return { - entries: ranked.slice(0, input.limit), - truncated: index.truncated || ranked.length > input.limit, + entries: rankedEntries.map((candidate) => candidate.entry), + truncated: index.truncated || matchedEntryCount > limit, }; }