@@ -287,10 +287,45 @@ export class PackageManagerManager {
287287 * @param filters The filter criteria
288288 * @returns Filtered items
289289 */
290+ // Cache size limit to prevent memory issues
291+ private static readonly MAX_CACHE_SIZE = 100
292+ private filterCache = new Map <
293+ string ,
294+ {
295+ items : PackageManagerItem [ ]
296+ timestamp : number
297+ }
298+ > ( )
299+
300+ /**
301+ * Clear old entries from the filter cache
302+ */
303+ private cleanupFilterCache ( ) : void {
304+ if ( this . filterCache . size > PackageManagerManager . MAX_CACHE_SIZE ) {
305+ // Sort by timestamp and keep only the most recent entries
306+ const entries = Array . from ( this . filterCache . entries ( ) )
307+ . sort ( ( [ , a ] , [ , b ] ) => b . timestamp - a . timestamp )
308+ . slice ( 0 , PackageManagerManager . MAX_CACHE_SIZE )
309+
310+ this . filterCache . clear ( )
311+ entries . forEach ( ( [ key , value ] ) => this . filterCache . set ( key , value ) )
312+ }
313+ }
314+
290315 filterItems (
291316 items : PackageManagerItem [ ] ,
292317 filters : { type ?: ComponentType ; search ?: string ; tags ?: string [ ] } ,
293318 ) : PackageManagerItem [ ] {
319+ // Create cache key from filters
320+ const cacheKey = JSON . stringify ( filters )
321+ const cached = this . filterCache . get ( cacheKey )
322+ if ( cached ) {
323+ return cached . items
324+ }
325+
326+ // Clean up old cache entries
327+ this . cleanupFilterCache ( )
328+
294329 // Helper function to normalize text for case/whitespace-insensitive comparison
295330 const normalizeText = ( text : string ) => text . toLowerCase ( ) . replace ( / \s + / g, " " ) . trim ( )
296331
@@ -303,144 +338,118 @@ export class PackageManagerManager {
303338 return normalizeText ( text ) . includes ( normalizeText ( searchTerm ) )
304339 }
305340
306- // Create a deep clone of all items
307- const clonedItems = items . map ( ( originalItem ) => JSON . parse ( JSON . stringify ( originalItem ) ) as PackageManagerItem )
341+ // Filter items with shallow copies
342+ const filteredItems = items
343+ . map ( ( item ) => {
344+ // Create shallow copy of item
345+ const itemCopy = { ...item }
346+
347+ // Check parent item matches
348+ const itemMatches = {
349+ type : ! filters . type || itemCopy . type === filters . type ,
350+ search :
351+ ! searchTerm || containsSearchTerm ( itemCopy . name ) || containsSearchTerm ( itemCopy . description ) ,
352+ tags :
353+ ! filters . tags ?. length ||
354+ ( itemCopy . tags && filters . tags . some ( ( tag ) => itemCopy . tags ! . includes ( tag ) ) ) ,
355+ }
308356
309- // Apply filters
310- const filteredItems = clonedItems . filter ( ( item ) => {
311- // Check parent item matches
312- const itemMatches = {
313- type : ! filters . type || item . type === filters . type ,
314- search : ! searchTerm || containsSearchTerm ( item . name ) || containsSearchTerm ( item . description ) ,
315- tags : ! filters . tags ?. length || ( item . tags && filters . tags . some ( ( tag ) => item . tags ! . includes ( tag ) ) ) ,
316- }
357+ // Process subcomponents and track if any match
358+ let hasMatchingSubcomponents = false
359+ if ( itemCopy . items ?. length ) {
360+ itemCopy . items = itemCopy . items . map ( ( subItem ) => {
361+ const subMatches = {
362+ type : ! filters . type || subItem . type === filters . type ,
363+ search :
364+ ! searchTerm ||
365+ ( subItem . metadata &&
366+ ( containsSearchTerm ( subItem . metadata . name ) ||
367+ containsSearchTerm ( subItem . metadata . description ) ) ) ,
368+ tags :
369+ ! filters . tags ?. length ||
370+ ! ! (
371+ subItem . metadata ?. tags &&
372+ filters . tags . some ( ( tag ) => subItem . metadata ! . tags ! . includes ( tag ) )
373+ ) ,
374+ }
317375
318- // Check subcomponent matches
319- const subcomponentMatches =
320- item . items ?. some ( ( subItem ) => {
321- const subMatches = {
322- type : ! filters . type || subItem . type === filters . type ,
323- search :
324- ! searchTerm ||
325- ( subItem . metadata &&
326- ( containsSearchTerm ( subItem . metadata . name ) ||
327- containsSearchTerm ( subItem . metadata . description ) ) ) ,
328- tags :
329- ! filters . tags ?. length ||
330- ( subItem . metadata ?. tags &&
331- filters . tags . some ( ( tag ) => subItem . metadata ! . tags ! . includes ( tag ) ) ) ,
332- }
376+ const subItemMatched =
377+ subMatches . type &&
378+ ( ! searchTerm || subMatches . search ) &&
379+ ( ! filters . tags ?. length || subMatches . tags )
380+
381+ if ( subItemMatched ) {
382+ hasMatchingSubcomponents = true
383+ // Set matchInfo for matching subcomponent
384+ // Build match reason based on active filters
385+ const matchReason : Record < string , boolean > = { }
386+
387+ if ( searchTerm ) {
388+ matchReason . nameMatch = containsSearchTerm ( subItem . metadata ?. name || "" )
389+ matchReason . descriptionMatch = containsSearchTerm ( subItem . metadata ?. description || "" )
390+ }
391+
392+ // Always include typeMatch when filtering by type
393+ if ( filters . type ) {
394+ matchReason . typeMatch = subMatches . type
395+ }
396+
397+ subItem . matchInfo = {
398+ matched : true ,
399+ matchReason,
400+ }
401+ } else {
402+ subItem . matchInfo = { matched : false }
403+ }
333404
334- // When filtering by type, require exact type match
335- // For other filters (search/tags), any match is sufficient
336- return (
337- subMatches . type &&
338- ( ! searchTerm || subMatches . search ) &&
339- ( ! filters . tags ?. length || subMatches . tags )
340- )
341- } ) ?? false
342-
343- // Include item if either:
344- // 1. Parent matches all active filters, or
345- // 2. Parent is a package and any subcomponent matches any active filter
346- const hasActiveFilters = filters . type || searchTerm || filters . tags ?. length
347- if ( ! hasActiveFilters ) return true
348-
349- const parentMatchesAll = itemMatches . type && itemMatches . search && itemMatches . tags
350- const isPackageWithMatchingSubcomponent = item . type === "package" && subcomponentMatches
351- return parentMatchesAll || isPackageWithMatchingSubcomponent
352- } )
405+ return subItem
406+ } )
407+ }
353408
354- // Add match info to filtered items
355- return filteredItems . map ( ( item ) => {
356- // Calculate parent item matches
357- const itemMatches = {
358- type : ! filters . type || item . type === filters . type ,
359- search : ! searchTerm || containsSearchTerm ( item . name ) || containsSearchTerm ( item . description ) ,
360- tags : ! filters . tags ?. length || ( item . tags && filters . tags . some ( ( tag ) => item . tags ! . includes ( tag ) ) ) ,
361- }
409+ const hasActiveFilters = filters . type || searchTerm || filters . tags ?. length
410+ if ( ! hasActiveFilters ) return itemCopy
362411
363- // Process subcomponents
364- let hasMatchingSubcomponents = false
365- if ( item . items ) {
366- item . items = item . items . map ( ( subItem ) => {
367- // Calculate individual filter matches for subcomponent
368- const subMatches = {
369- type : ! filters . type || subItem . type === filters . type ,
370- search :
371- ! searchTerm ||
372- ( subItem . metadata &&
373- ( containsSearchTerm ( subItem . metadata . name ) ||
374- containsSearchTerm ( subItem . metadata . description ) ) ) ,
375- tags :
376- ! filters . tags ?. length ||
377- ( subItem . metadata ?. tags &&
378- filters . tags . some ( ( tag ) => subItem . metadata ! . tags ! . includes ( tag ) ) ) ,
379- }
412+ const parentMatchesAll = itemMatches . type && itemMatches . search && itemMatches . tags
413+ const isPackageWithMatchingSubcomponent = itemCopy . type === "package" && hasMatchingSubcomponents
380414
381- // A subcomponent matches if it matches all active filters
382- const subMatched = subMatches . type && subMatches . search && subMatches . tags
383-
384- if ( subMatched ) {
385- hasMatchingSubcomponents = true
386- // Build match reason for matched subcomponent
387- const matchReason : Record < string , boolean > = {
388- ...( searchTerm && {
389- nameMatch : ! ! subItem . metadata && containsSearchTerm ( subItem . metadata . name ) ,
390- descriptionMatch :
391- ! ! subItem . metadata && containsSearchTerm ( subItem . metadata . description ) ,
392- } ) ,
393- ...( filters . type && { typeMatch : subMatches . type } ) ,
394- ...( filters . tags ?. length && { tagMatch : ! ! subMatches . tags } ) ,
395- }
415+ if ( parentMatchesAll || isPackageWithMatchingSubcomponent ) {
416+ // Add match info without deep cloning
417+ // Build parent match reason based on active filters
418+ const matchReason : Record < string , boolean > = { }
396419
397- subItem . matchInfo = {
398- matched : true ,
399- matchReason,
400- }
420+ if ( searchTerm ) {
421+ matchReason . nameMatch = containsSearchTerm ( itemCopy . name )
422+ matchReason . descriptionMatch = containsSearchTerm ( itemCopy . description )
401423 } else {
402- subItem . matchInfo = {
403- matched : false ,
404- }
424+ matchReason . nameMatch = false
425+ matchReason . descriptionMatch = false
405426 }
406427
407- return subItem
408- } )
409- }
410-
411- // Build match reason for parent item
412- const matchReason : Record < string , boolean > = {
413- nameMatch : searchTerm ? containsSearchTerm ( item . name ) : true ,
414- descriptionMatch : searchTerm ? containsSearchTerm ( item . description ) : true ,
415- }
416-
417- if ( filters . type ) {
418- matchReason . typeMatch = itemMatches . type
419- }
420- if ( filters . tags ?. length ) {
421- matchReason . tagMatch = ! ! itemMatches . tags
422- }
423- if ( hasMatchingSubcomponents ) {
424- matchReason . hasMatchingSubcomponents = true
425- }
426-
427- // Parent item is matched if:
428- // 1. It matches all active filters directly, or
429- // 2. It's a package and has any matching subcomponents
430- const parentMatchesAll =
431- ( ! filters . type || itemMatches . type ) &&
432- ( ! searchTerm || itemMatches . search ) &&
433- ( ! filters . tags ?. length || itemMatches . tags )
428+ // Always include typeMatch when filtering by type
429+ if ( filters . type ) {
430+ matchReason . typeMatch = itemMatches . type
431+ }
434432
435- const isPackageWithMatchingSubcomponent = item . type === "package" && hasMatchingSubcomponents
433+ if ( hasMatchingSubcomponents ) {
434+ matchReason . hasMatchingSubcomponents = true
435+ }
436436
437- item . matchInfo = {
438- matched : parentMatchesAll || isPackageWithMatchingSubcomponent ,
439- matchReason,
440- }
437+ itemCopy . matchInfo = {
438+ matched : true ,
439+ matchReason,
440+ }
441+ return itemCopy
442+ }
443+ return null
444+ } )
445+ . filter ( ( item ) : item is PackageManagerItem => item !== null )
441446
442- return item
447+ // Cache the results with timestamp
448+ this . filterCache . set ( cacheKey , {
449+ items : filteredItems ,
450+ timestamp : Date . now ( ) ,
443451 } )
452+ return filteredItems
444453 }
445454
446455 /**
@@ -491,6 +500,17 @@ export class PackageManagerManager {
491500 return this . currentItems
492501 }
493502
503+ /**
504+ * Updates current items with filtered results
505+ * @param filters The filter criteria
506+ * @returns Filtered items
507+ */
508+ updateWithFilteredItems ( filters : { type ?: ComponentType ; search ?: string ; tags ?: string [ ] } ) : PackageManagerItem [ ] {
509+ const filteredItems = this . filterItems ( this . currentItems , filters )
510+ this . currentItems = filteredItems
511+ return filteredItems
512+ }
513+
494514 /**
495515 * Cleans up resources used by the package manager
496516 */
@@ -499,6 +519,8 @@ export class PackageManagerManager {
499519 const sources = Array . from ( this . cache . keys ( ) ) . map ( ( url ) => ( { url, enabled : true } ) )
500520 await this . cleanupCacheDirectories ( sources )
501521 this . clearCache ( )
522+ // Clear filter cache
523+ this . filterCache . clear ( )
502524 }
503525
504526 /**
0 commit comments