Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/server/src/workspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
191 changes: 160 additions & 31 deletions apps/server/src/workspaceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, WorkspaceIndex>();
const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>();

Expand All @@ -55,27 +65,136 @@ 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()
.replace(/^[@./]+/, "")
.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 {
Expand Down Expand Up @@ -253,20 +372,26 @@ async function buildWorkspaceIndexFromGit(cwd: string): Promise<WorkspaceIndex |
}
}

const directoryEntries: ProjectEntry[] = [...directorySet]
const directoryEntries = [...directorySet]
.toSorted((left, right) => 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 {
Expand All @@ -284,7 +409,7 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
const shouldFilterWithGitIgnore = await isInsideGitWorkTree(cwd);

let pendingDirectories: string[] = [""];
const entries: ProjectEntry[] = [];
const entries: SearchableWorkspaceEntry[] = [];
let truncated = false;

while (pendingDirectories.length > 0 && !truncated) {
Expand Down Expand Up @@ -351,11 +476,11 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
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()) {
Expand Down Expand Up @@ -419,18 +544,22 @@ export async function searchWorkspaceEntries(
): Promise<ProjectSearchEntriesResult> {
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,
};
}
Loading