authorgravatar for maskdotdev@gmail.comKiyotaka <maskdotdev@gmail.com> 2026-03-12 15:00:08-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-12 13:00:08-07:00
log1e276573c2925827691bb9840c21e8e130834aad
tree2da5772a4dca1d9999d86e625a45decf26b82b3e
parentdb17ff332307e6c80d034e6c13932d4bc253bca3
signature Signed by PGP key B5690EEEBB952194

feat: add fuzzy workspace entry search (#256)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

2 files changed, 186 insertions(+), 31 deletions(-)

apps/server/src/workspaceEntries.test.ts+26
...@@ -70,6 +70,32 @@ describe("searchWorkspaceEntries", () => {...@@ -70,6 +70,32 @@ describe("searchWorkspaceEntries", () => {
70 assert.isTrue(result.entries.every((entry) => entry.path.toLowerCase().includes("compo")));70 assert.isTrue(result.entries.every((entry) => entry.path.toLowerCase().includes("compo")));
71 });71 });
7272
73 it("supports fuzzy subsequence queries for composer path search", async () => {
74 const cwd = makeTempDir("t3code-workspace-fuzzy-query-");
75 writeFile(cwd, "src/components/Composer.tsx");
76 writeFile(cwd, "src/components/composePrompt.ts");
77 writeFile(cwd, "docs/composition.md");
78
79 const result = await searchWorkspaceEntries({ cwd, query: "cmp", limit: 10 });
80 const paths = result.entries.map((entry) => entry.path);
81
82 assert.isAbove(result.entries.length, 0);
83 assert.include(paths, "src/components");
84 assert.include(paths, "src/components/Composer.tsx");
85 });
86
87 it("tracks truncation without sorting every fuzzy match", async () => {
88 const cwd = makeTempDir("t3code-workspace-fuzzy-limit-");
89 writeFile(cwd, "src/components/Composer.tsx");
90 writeFile(cwd, "src/components/composePrompt.ts");
91 writeFile(cwd, "docs/composition.md");
92
93 const result = await searchWorkspaceEntries({ cwd, query: "cmp", limit: 1 });
94
95 assert.lengthOf(result.entries, 1);
96 assert.isTrue(result.truncated);
97 });
98
73 it("excludes gitignored paths for git repositories", async () => {99 it("excludes gitignored paths for git repositories", async () => {
74 const cwd = makeTempDir("t3code-workspace-gitignore-");100 const cwd = makeTempDir("t3code-workspace-gitignore-");
75 runGit(cwd, ["init"]);101 runGit(cwd, ["init"]);
apps/server/src/workspaceEntries.ts+160-31
...@@ -28,10 +28,20 @@ const IGNORED_DIRECTORY_NAMES = new Set([...@@ -28,10 +28,20 @@ const IGNORED_DIRECTORY_NAMES = new Set([
2828
29interface WorkspaceIndex {29interface WorkspaceIndex {
30 scannedAt: number;30 scannedAt: number;
31 entries: ProjectEntry[];31 entries: SearchableWorkspaceEntry[];
32 truncated: boolean;32 truncated: boolean;
33}33}
3434
35interface SearchableWorkspaceEntry extends ProjectEntry {
36 normalizedPath: string;
37 normalizedName: string;
38}
39
40interface RankedWorkspaceEntry {
41 entry: SearchableWorkspaceEntry;
42 score: number;
43}
44
35const workspaceIndexCache = new Map<string, WorkspaceIndex>();45const workspaceIndexCache = new Map<string, WorkspaceIndex>();
36const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>();46const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>();
3747
...@@ -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}
5767
68function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry {
69 const normalizedPath = entry.path.toLowerCase();
70 return {
71 ...entry,
72 normalizedPath,
73 normalizedName: basenameOf(normalizedPath),
74 };
75}
76
58function normalizeQuery(input: string): string {77function normalizeQuery(input: string): string {
59 return input78 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}
6483
65function scoreEntry(entry: ProjectEntry, query: string): number {84function 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
116function 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 }
69120
70 const normalizedPath = entry.path.toLowerCase();121 const { normalizedPath, normalizedName } = entry;
71 const normalizedName = basenameOf(normalizedPath);
72122
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
144function 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
153function 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
177function 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}
80199
81function isPathInIgnoredDirectory(relativePath: string): boolean {200function 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 }
255374
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);
270395
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);
285410
286 let pendingDirectories: string[] = [""];411 let pendingDirectories: string[] = [""];
287 const entries: ProjectEntry[] = [];412 const entries: SearchableWorkspaceEntry[] = [];
288 let truncated = false;413 let truncated = false;
289414
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 }
353478
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);
360485
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 = normalizedQuery547 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;
425550
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 }
431560
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}