Skip to content

Commit 6f34004

Browse files
perf(web): precompute command palette search indexes (#7)
Precompute normalized search terms and haystacks for project and thread command palette entries so each search keystroke reuses indexed data instead of repeatedly normalizing large item lists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a0b5d39 commit 6f34004

2 files changed

Lines changed: 109 additions & 24 deletions

File tree

apps/web/src/components/CommandPalette.logic.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { describe, expect, it, vi } from "vitest";
22
import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
3-
import type { Thread } from "../types";
3+
import type { Project, Thread } from "../types";
44
import {
5+
buildCommandPaletteSearchIndex,
6+
buildProjectActionItems,
57
buildThreadActionItems,
68
filterCommandPaletteGroups,
79
type CommandPaletteGroup,
@@ -10,6 +12,19 @@ import {
1012
const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
1113
const PROJECT_ID = ProjectId.make("project-1");
1214

15+
function makeProject(overrides: Partial<Project> = {}): Project {
16+
return {
17+
id: PROJECT_ID,
18+
environmentId: LOCAL_ENVIRONMENT_ID,
19+
name: "Project",
20+
cwd: "/Users/example/project",
21+
repositoryIdentity: null,
22+
defaultModelSelection: null,
23+
scripts: [],
24+
...overrides,
25+
};
26+
}
27+
1328
function makeThread(overrides: Partial<Thread> = {}): Thread {
1429
return {
1530
id: ThreadId.make("thread-1"),
@@ -37,6 +52,48 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
3752
};
3853
}
3954

55+
describe("buildCommandPaletteSearchIndex", () => {
56+
it("normalizes terms once for filtering and ranking", () => {
57+
expect(buildCommandPaletteSearchIndex([" Fix Navbar ", "", "Feature/Branch"])).toEqual({
58+
normalizedTerms: ["fix navbar", "feature/branch"],
59+
haystack: "fix navbar feature/branch",
60+
});
61+
});
62+
});
63+
64+
describe("buildProjectActionItems", () => {
65+
it("precomputes search indexes for project results", () => {
66+
const items = buildProjectActionItems({
67+
projects: [
68+
makeProject({
69+
name: "Web App",
70+
cwd: "/Users/example/large project",
71+
}),
72+
],
73+
valuePrefix: "project",
74+
icon: () => null,
75+
runProject: async (_project) => undefined,
76+
});
77+
78+
expect(items[0]?.searchIndex).toEqual({
79+
normalizedTerms: ["web app", "/users/example/large project"],
80+
haystack: "web app /users/example/large project",
81+
});
82+
83+
const groups = filterCommandPaletteGroups({
84+
activeGroups: [],
85+
query: "large project",
86+
isInSubmenu: false,
87+
projectSearchItems: items,
88+
threadSearchItems: [],
89+
});
90+
91+
expect(groups[0]?.items.map((item) => item.value)).toEqual([
92+
"project:environment-local:project-1",
93+
]);
94+
});
95+
});
96+
4097
describe("buildThreadActionItems", () => {
4198
it("orders threads by most recent activity and formats timestamps from updatedAt", () => {
4299
vi.useFakeTimers();

apps/web/src/components/CommandPalette.logic.ts

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface CommandPaletteItem {
1313
readonly kind: "action" | "submenu";
1414
readonly value: string;
1515
readonly searchTerms: ReadonlyArray<string>;
16+
readonly searchIndex?: CommandPaletteSearchIndex;
1617
readonly title: ReactNode;
1718
readonly description?: string;
1819
readonly timestamp?: string;
@@ -24,6 +25,11 @@ export interface CommandPaletteItem {
2425
readonly shortcutCommand?: KeybindingCommand;
2526
}
2627

28+
export interface CommandPaletteSearchIndex {
29+
readonly normalizedTerms: ReadonlyArray<string>;
30+
readonly haystack: string;
31+
}
32+
2733
export interface CommandPaletteActionItem extends CommandPaletteItem {
2834
readonly kind: "action";
2935
readonly keepOpen?: boolean;
@@ -87,23 +93,39 @@ export function normalizeSearchText(value: string): string {
8793
return value.trim().toLowerCase().replace(/\s+/g, " ");
8894
}
8995

96+
export function buildCommandPaletteSearchIndex(
97+
searchTerms: ReadonlyArray<string>,
98+
): CommandPaletteSearchIndex {
99+
return {
100+
normalizedTerms: searchTerms
101+
.filter((term) => term.length > 0)
102+
.map((term) => normalizeSearchText(term)),
103+
haystack: normalizeSearchText(searchTerms.join(" ")),
104+
};
105+
}
106+
90107
export function buildProjectActionItems(input: {
91108
projects: ReadonlyArray<Project>;
92109
valuePrefix: string;
93110
icon: (project: Project) => ReactNode;
94111
runProject: (project: Project) => Promise<void>;
95112
}): CommandPaletteActionItem[] {
96-
return input.projects.map((project) => ({
97-
kind: "action",
98-
value: `${input.valuePrefix}:${project.environmentId}:${project.id}`,
99-
searchTerms: [project.name, project.cwd],
100-
title: project.name,
101-
description: project.cwd,
102-
icon: input.icon(project),
103-
run: async () => {
104-
await input.runProject(project);
105-
},
106-
}));
113+
return input.projects.map((project) => {
114+
const searchTerms = [project.name, project.cwd];
115+
116+
return {
117+
kind: "action",
118+
value: `${input.valuePrefix}:${project.environmentId}:${project.id}`,
119+
searchTerms,
120+
searchIndex: buildCommandPaletteSearchIndex(searchTerms),
121+
title: project.name,
122+
description: project.cwd,
123+
icon: input.icon(project),
124+
run: async () => {
125+
await input.runProject(project);
126+
},
127+
};
128+
});
107129
}
108130

109131
export type BuildThreadActionItemsThread = Pick<
@@ -150,12 +172,14 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
150172

151173
const leadingContent = input.renderLeadingContent?.(thread);
152174
const trailingContent = input.renderTrailingContent?.(thread);
175+
const searchTerms = [thread.title, projectTitle ?? ``, thread.branch ?? ``];
153176

154177
return Object.assign(
155178
{
156179
kind: "action" as const,
157180
value: `thread:${thread.id}`,
158-
searchTerms: [thread.title, projectTitle ?? ``, thread.branch ?? ``],
181+
searchTerms,
182+
searchIndex: buildCommandPaletteSearchIndex(searchTerms),
159183
title: thread.title,
160184
description: descriptionParts.join(` · `),
161185
timestamp: formatRelativeTimeLabel(
@@ -174,8 +198,7 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
174198
});
175199
}
176200

177-
function rankSearchFieldMatch(field: string, normalizedQuery: string): number {
178-
const normalizedField = normalizeSearchText(field);
201+
function rankNormalizedSearchFieldMatch(normalizedField: string, normalizedQuery: string): number {
179202
if (normalizedField.length === 0 || !normalizedField.includes(normalizedQuery)) {
180203
return Number.NEGATIVE_INFINITY;
181204
}
@@ -188,19 +211,24 @@ function rankSearchFieldMatch(field: string, normalizedQuery: string): number {
188211
return 1;
189212
}
190213

191-
function rankCommandPaletteItemMatch(
214+
function getCommandPaletteSearchIndex(
192215
item: CommandPaletteActionItem | CommandPaletteSubmenuItem,
216+
): CommandPaletteSearchIndex {
217+
return item.searchIndex ?? buildCommandPaletteSearchIndex(item.searchTerms);
218+
}
219+
220+
function rankCommandPaletteSearchIndexMatch(
221+
index: CommandPaletteSearchIndex,
193222
normalizedQuery: string,
194223
): number {
195-
const terms = item.searchTerms.filter((term) => term.length > 0);
196-
if (terms.length === 0) {
224+
if (index.normalizedTerms.length === 0) {
197225
return 0;
198226
}
199227

200-
for (const [index, field] of terms.entries()) {
201-
const fieldRank = rankSearchFieldMatch(field, normalizedQuery);
228+
for (const [termIndex, normalizedField] of index.normalizedTerms.entries()) {
229+
const fieldRank = rankNormalizedSearchFieldMatch(normalizedField, normalizedQuery);
202230
if (fieldRank !== Number.NEGATIVE_INFINITY) {
203-
return 1_000 - index * 100 + fieldRank;
231+
return 1_000 - termIndex * 100 + fieldRank;
204232
}
205233
}
206234

@@ -253,15 +281,15 @@ export function filterCommandPaletteGroups(input: {
253281
return searchableGroups.flatMap((group) => {
254282
const items = group.items
255283
.map((item, index) => {
256-
const haystack = normalizeSearchText(item.searchTerms.join(" "));
257-
if (!haystack.includes(normalizedQuery)) {
284+
const searchIndex = getCommandPaletteSearchIndex(item);
285+
if (!searchIndex.haystack.includes(normalizedQuery)) {
258286
return null;
259287
}
260288

261289
return {
262290
item,
263291
index,
264-
rank: rankCommandPaletteItemMatch(item, normalizedQuery),
292+
rank: rankCommandPaletteSearchIndexMatch(searchIndex, normalizedQuery),
265293
};
266294
})
267295
.filter(

0 commit comments

Comments
 (0)