@@ -43,6 +43,7 @@ export class WorkspaceProvider
43
43
private fetching = false ;
44
44
private visible = false ;
45
45
private searchFilter = "" ;
46
+ private metadataCache : Record < string , string > = { } ;
46
47
47
48
constructor (
48
49
private readonly getWorkspacesQuery : WorkspaceQuery ,
@@ -54,6 +55,10 @@ export class WorkspaceProvider
54
55
}
55
56
56
57
setSearchFilter ( filter : string ) {
58
+ // Validate search term length to prevent performance issues
59
+ if ( filter . length > 200 ) {
60
+ filter = filter . substring ( 0 , 200 ) ;
61
+ }
57
62
this . searchFilter = filter ;
58
63
this . refresh ( undefined ) ;
59
64
}
@@ -73,6 +78,9 @@ export class WorkspaceProvider
73
78
}
74
79
this . fetching = true ;
75
80
81
+ // Clear metadata cache when refreshing to ensure data consistency
82
+ this . clearMetadataCache ( ) ;
83
+
76
84
// It is possible we called fetchAndRefresh() manually (through the button
77
85
// for example), in which case we might still have a pending refresh that
78
86
// needs to be cleared.
@@ -325,76 +333,164 @@ export class WorkspaceProvider
325
333
}
326
334
327
335
/**
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 .
330
338
*/
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 ( ) ;
337
350
const templateName = (
338
351
workspace . workspace . template_display_name ||
339
352
workspace . workspace . template_name ||
340
353
""
341
354
) . toLowerCase ( ) ;
342
- const status = workspace . workspace . latest_build . status . toLowerCase ( ) ;
355
+ const status = (
356
+ workspace . workspace . latest_build ?. status || ""
357
+ ) . toLowerCase ( ) ;
343
358
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 || [ ] ,
349
362
) ;
363
+ const agentNames = agents
364
+ . map ( ( agent ) => ( agent . name || "" ) . toLowerCase ( ) )
365
+ . filter ( ( name ) => name . length > 0 ) ;
350
366
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 ( "," ) ;
362
370
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 {
368
395
workspaceName,
369
396
ownerName,
370
397
templateName,
371
398
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 ,
373
449
] . join ( " " ) ;
374
450
375
451
// Check for exact word matches (higher priority)
376
452
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
+ }
383
464
} ) ;
384
465
385
466
// Check for substring matches (lower priority) - only if no exact word match
386
467
const hasSubstringMatch =
387
468
! 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 ) ) ;
394
475
395
476
// Return true if either exact word match or substring match
396
477
return hasExactWordMatch || hasSubstringMatch ;
397
478
}
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
+ }
398
494
}
399
495
400
496
/**
0 commit comments