Skip to content

Commit 3e340bf

Browse files
committed
feat: optimize workspace search with performance improvements
- Pre-compile regex patterns to avoid repeated compilation - Cache stringified metadata to reduce JSON serialization overhead - Extract input field processing into reusable helper method - Add input validation to prevent performance issues from long search terms - Add comprehensive error handling for edge cases and malformed data
1 parent bab6c21 commit 3e340bf

File tree

1 file changed

+139
-43
lines changed

1 file changed

+139
-43
lines changed

src/workspacesProvider.ts

Lines changed: 139 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export class WorkspaceProvider
4343
private fetching = false;
4444
private visible = false;
4545
private searchFilter = "";
46+
private metadataCache: Record<string, string> = {};
4647

4748
constructor(
4849
private readonly getWorkspacesQuery: WorkspaceQuery,
@@ -54,6 +55,10 @@ export class WorkspaceProvider
5455
}
5556

5657
setSearchFilter(filter: string) {
58+
// Validate search term length to prevent performance issues
59+
if (filter.length > 200) {
60+
filter = filter.substring(0, 200);
61+
}
5762
this.searchFilter = filter;
5863
this.refresh(undefined);
5964
}
@@ -73,6 +78,9 @@ export class WorkspaceProvider
7378
}
7479
this.fetching = true;
7580

81+
// Clear metadata cache when refreshing to ensure data consistency
82+
this.clearMetadataCache();
83+
7684
// It is possible we called fetchAndRefresh() manually (through the button
7785
// for example), in which case we might still have a pending refresh that
7886
// needs to be cleared.
@@ -325,76 +333,164 @@ export class WorkspaceProvider
325333
}
326334

327335
/**
328-
* Check if a workspace matches the given search term using smart search logic.
329-
* Prioritizes exact word matches over substring matches.
336+
* Extract and normalize searchable text fields from a workspace.
337+
* This helper method reduces code duplication between exact word and substring matching.
330338
*/
331-
private matchesSearchTerm(
332-
workspace: WorkspaceTreeItem,
333-
searchTerm: string,
334-
): boolean {
335-
const workspaceName = workspace.workspace.name.toLowerCase();
336-
const ownerName = workspace.workspace.owner_name.toLowerCase();
339+
private extractSearchableFields(workspace: WorkspaceTreeItem): {
340+
workspaceName: string;
341+
ownerName: string;
342+
templateName: string;
343+
status: string;
344+
agentNames: string[];
345+
agentMetadataText: string;
346+
} {
347+
// Handle null/undefined workspace data safely
348+
const workspaceName = (workspace.workspace.name || "").toLowerCase();
349+
const ownerName = (workspace.workspace.owner_name || "").toLowerCase();
337350
const templateName = (
338351
workspace.workspace.template_display_name ||
339352
workspace.workspace.template_name ||
340353
""
341354
).toLowerCase();
342-
const status = workspace.workspace.latest_build.status.toLowerCase();
355+
const status = (
356+
workspace.workspace.latest_build?.status || ""
357+
).toLowerCase();
343358

344-
// Check if any agent names match the search term
345-
const agents = extractAgents(workspace.workspace.latest_build.resources);
346-
const agentNames = agents.map((agent) => agent.name.toLowerCase());
347-
const hasMatchingAgent = agentNames.some((agentName) =>
348-
agentName.includes(searchTerm),
359+
// Extract agent names with null safety
360+
const agents = extractAgents(
361+
workspace.workspace.latest_build?.resources || [],
349362
);
363+
const agentNames = agents
364+
.map((agent) => (agent.name || "").toLowerCase())
365+
.filter((name) => name.length > 0);
350366

351-
// Check if any agent metadata contains the search term
352-
const hasMatchingMetadata = agents.some((agent) => {
353-
const watcher = this.agentWatchers[agent.id];
354-
if (watcher?.metadata) {
355-
return watcher.metadata.some((metadata) => {
356-
const metadataStr = JSON.stringify(metadata).toLowerCase();
357-
return metadataStr.includes(searchTerm);
358-
});
359-
}
360-
return false;
361-
});
367+
// Extract and cache agent metadata with error handling
368+
let agentMetadataText = "";
369+
const metadataCacheKey = agents.map((agent) => agent.id).join(",");
362370

363-
// Smart search: Try exact word match first, then fall back to substring
364-
const searchWords = searchTerm
365-
.split(/\s+/)
366-
.filter((word) => word.length > 0);
367-
const allText = [
371+
if (this.metadataCache[metadataCacheKey]) {
372+
agentMetadataText = this.metadataCache[metadataCacheKey];
373+
} else {
374+
const metadataStrings: string[] = [];
375+
agents.forEach((agent) => {
376+
const watcher = this.agentWatchers[agent.id];
377+
if (watcher?.metadata) {
378+
watcher.metadata.forEach((metadata) => {
379+
try {
380+
metadataStrings.push(JSON.stringify(metadata).toLowerCase());
381+
} catch (error) {
382+
// Handle JSON serialization errors gracefully
383+
this.storage.output.warn(
384+
`Failed to serialize metadata for agent ${agent.id}: ${error}`,
385+
);
386+
}
387+
});
388+
}
389+
});
390+
agentMetadataText = metadataStrings.join(" ");
391+
this.metadataCache[metadataCacheKey] = agentMetadataText;
392+
}
393+
394+
return {
368395
workspaceName,
369396
ownerName,
370397
templateName,
371398
status,
372-
...agentNames,
399+
agentNames,
400+
agentMetadataText,
401+
};
402+
}
403+
404+
/**
405+
* Check if a workspace matches the given search term using smart search logic.
406+
* Prioritizes exact word matches over substring matches.
407+
*/
408+
private matchesSearchTerm(
409+
workspace: WorkspaceTreeItem,
410+
searchTerm: string,
411+
): boolean {
412+
// Early return for empty search terms
413+
if (!searchTerm || searchTerm.trim().length === 0) {
414+
return true;
415+
}
416+
417+
// Extract all searchable fields once
418+
const fields = this.extractSearchableFields(workspace);
419+
420+
// Pre-compile regex patterns for exact word matching
421+
const searchWords = searchTerm
422+
.split(/\s+/)
423+
.filter((word) => word.length > 0);
424+
425+
const regexPatterns: RegExp[] = [];
426+
for (const word of searchWords) {
427+
try {
428+
// Escape special regex characters to prevent injection
429+
const escapedWord = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
430+
regexPatterns.push(new RegExp(`\\b${escapedWord}\\b`, "i"));
431+
} catch (error) {
432+
// Handle invalid regex patterns
433+
this.storage.output.warn(
434+
`Invalid regex pattern for search word "${word}": ${error}`,
435+
);
436+
// Fall back to simple substring matching for this word
437+
continue;
438+
}
439+
}
440+
441+
// Combine all text for exact word matching
442+
const allText = [
443+
fields.workspaceName,
444+
fields.ownerName,
445+
fields.templateName,
446+
fields.status,
447+
...fields.agentNames,
448+
fields.agentMetadataText,
373449
].join(" ");
374450

375451
// Check for exact word matches (higher priority)
376452
const hasExactWordMatch =
377-
searchWords.length > 0 &&
378-
searchWords.some((word) => {
379-
// Escape special regex characters to prevent injection
380-
const escapedWord = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
381-
const wordBoundaryRegex = new RegExp(`\\b${escapedWord}\\b`, "i");
382-
return wordBoundaryRegex.test(allText);
453+
regexPatterns.length > 0 &&
454+
regexPatterns.some((pattern) => {
455+
try {
456+
return pattern.test(allText);
457+
} catch (error) {
458+
// Handle regex test errors gracefully
459+
this.storage.output.warn(
460+
`Regex test failed for pattern ${pattern}: ${error}`,
461+
);
462+
return false;
463+
}
383464
});
384465

385466
// Check for substring matches (lower priority) - only if no exact word match
386467
const hasSubstringMatch =
387468
!hasExactWordMatch &&
388-
(workspaceName.includes(searchTerm) ||
389-
ownerName.includes(searchTerm) ||
390-
templateName.includes(searchTerm) ||
391-
status.includes(searchTerm) ||
392-
hasMatchingAgent ||
393-
hasMatchingMetadata);
469+
(fields.workspaceName.includes(searchTerm) ||
470+
fields.ownerName.includes(searchTerm) ||
471+
fields.templateName.includes(searchTerm) ||
472+
fields.status.includes(searchTerm) ||
473+
fields.agentNames.some((agentName) => agentName.includes(searchTerm)) ||
474+
fields.agentMetadataText.includes(searchTerm));
394475

395476
// Return true if either exact word match or substring match
396477
return hasExactWordMatch || hasSubstringMatch;
397478
}
479+
480+
/**
481+
* Clear the metadata cache when workspaces are refreshed to ensure data consistency.
482+
* Also clears cache if it grows too large to prevent memory issues.
483+
*/
484+
private clearMetadataCache(): void {
485+
// Clear cache if it grows too large (prevent memory issues)
486+
const cacheSize = Object.keys(this.metadataCache).length;
487+
if (cacheSize > 1000) {
488+
this.storage.output.info(
489+
`Clearing metadata cache due to size (${cacheSize} entries)`,
490+
);
491+
}
492+
this.metadataCache = {};
493+
}
398494
}
399495

400496
/**

0 commit comments

Comments
 (0)