memory optimization and revert test command in package.json to main

This commit is contained in:
Smartsheet-JB-Brown 2025-04-17 20:21:16 -07:00
parent 93b96c3253
commit 422aed61b7
7 changed files with 318 additions and 267 deletions

View file

@ -401,9 +401,9 @@
"package": "npm-run-all -l -p build:webview build:esbuild check-types lint",
"pretest": "npm run compile",
"dev": "cd webview-ui && npm run dev",
"test": "npm run test:extension",
"test:extension": "node --max-old-space-size=8192 ./node_modules/.bin/jest --silent --runInBand --detectOpenHandles --testTimeout=10000",
"test:extension:debug-memory": "node --max-old-space-size=8192 --trace-gc --expose-gc --heap-prof ./node_modules/.bin/jest --runInBand --logHeapUsage --detectOpenHandles --testTimeout=10000",
"test": "node scripts/run-tests.js",
"test:extension": "jest -w=40%",
"test:extension:debug-memory": "node --max-old-space-size=4096 --trace-gc --expose-gc --heap-prof ./node_modules/.bin/jest --runInBand --logHeapUsage --detectOpenHandles --testTimeout=10000",
"test:webview": "cd webview-ui && npm run test",
"prepare": "husky",
"publish:marketplace": "vsce publish && ovsx publish",

View file

@ -203,23 +203,13 @@ export async function handlePackageManagerMessages(
case "filterPackageManagerItems": {
if (message.filters) {
try {
// Get current items from the manager
const items = packageManagerManager.getCurrentItems()
// Apply filters using the manager's filtering logic
const filteredItems = packageManagerManager.filterItems(items, {
// Update filtered items and post state
packageManagerManager.updateWithFilteredItems({
type: message.filters.type as ComponentType | undefined,
search: message.filters.search,
tags: message.filters.tags,
})
// Get current state and merge filtered items
const currentState = await provider.getStateToPostToWebview()
await provider.postMessageToWebview({
type: "state",
state: {
...currentState,
packageManagerItems: filteredItems,
},
})
await provider.postStateToWebview()
} catch (error) {
console.error("Package Manager: Error filtering items:", error)
vscode.window.showErrorMessage("Failed to filter package manager items")

View file

@ -287,10 +287,45 @@ export class PackageManagerManager {
* @param filters The filter criteria
* @returns Filtered items
*/
// Cache size limit to prevent memory issues
private static readonly MAX_CACHE_SIZE = 100
private filterCache = new Map<
string,
{
items: PackageManagerItem[]
timestamp: number
}
>()
/**
* Clear old entries from the filter cache
*/
private cleanupFilterCache(): void {
if (this.filterCache.size > PackageManagerManager.MAX_CACHE_SIZE) {
// Sort by timestamp and keep only the most recent entries
const entries = Array.from(this.filterCache.entries())
.sort(([, a], [, b]) => b.timestamp - a.timestamp)
.slice(0, PackageManagerManager.MAX_CACHE_SIZE)
this.filterCache.clear()
entries.forEach(([key, value]) => this.filterCache.set(key, value))
}
}
filterItems(
items: PackageManagerItem[],
filters: { type?: ComponentType; search?: string; tags?: string[] },
): PackageManagerItem[] {
// Create cache key from filters
const cacheKey = JSON.stringify(filters)
const cached = this.filterCache.get(cacheKey)
if (cached) {
return cached.items
}
// Clean up old cache entries
this.cleanupFilterCache()
// Helper function to normalize text for case/whitespace-insensitive comparison
const normalizeText = (text: string) => text.toLowerCase().replace(/\s+/g, " ").trim()
@ -303,144 +338,118 @@ export class PackageManagerManager {
return normalizeText(text).includes(normalizeText(searchTerm))
}
// Create a deep clone of all items
const clonedItems = items.map((originalItem) => JSON.parse(JSON.stringify(originalItem)) as PackageManagerItem)
// Filter items with shallow copies
const filteredItems = items
.map((item) => {
// Create shallow copy of item
const itemCopy = { ...item }
// Apply filters
const filteredItems = clonedItems.filter((item) => {
// Check parent item matches
const itemMatches = {
type: !filters.type || item.type === filters.type,
search: !searchTerm || containsSearchTerm(item.name) || containsSearchTerm(item.description),
tags: !filters.tags?.length || (item.tags && filters.tags.some((tag) => item.tags!.includes(tag))),
}
// Check parent item matches
const itemMatches = {
type: !filters.type || itemCopy.type === filters.type,
search:
!searchTerm || containsSearchTerm(itemCopy.name) || containsSearchTerm(itemCopy.description),
tags:
!filters.tags?.length ||
(itemCopy.tags && filters.tags.some((tag) => itemCopy.tags!.includes(tag))),
}
// Check subcomponent matches
const subcomponentMatches =
item.items?.some((subItem) => {
const subMatches = {
type: !filters.type || subItem.type === filters.type,
search:
!searchTerm ||
(subItem.metadata &&
(containsSearchTerm(subItem.metadata.name) ||
containsSearchTerm(subItem.metadata.description))),
tags:
!filters.tags?.length ||
(subItem.metadata?.tags &&
filters.tags.some((tag) => subItem.metadata!.tags!.includes(tag))),
}
// When filtering by type, require exact type match
// For other filters (search/tags), any match is sufficient
return (
subMatches.type &&
(!searchTerm || subMatches.search) &&
(!filters.tags?.length || subMatches.tags)
)
}) ?? false
// Include item if either:
// 1. Parent matches all active filters, or
// 2. Parent is a package and any subcomponent matches any active filter
const hasActiveFilters = filters.type || searchTerm || filters.tags?.length
if (!hasActiveFilters) return true
const parentMatchesAll = itemMatches.type && itemMatches.search && itemMatches.tags
const isPackageWithMatchingSubcomponent = item.type === "package" && subcomponentMatches
return parentMatchesAll || isPackageWithMatchingSubcomponent
})
// Add match info to filtered items
return filteredItems.map((item) => {
// Calculate parent item matches
const itemMatches = {
type: !filters.type || item.type === filters.type,
search: !searchTerm || containsSearchTerm(item.name) || containsSearchTerm(item.description),
tags: !filters.tags?.length || (item.tags && filters.tags.some((tag) => item.tags!.includes(tag))),
}
// Process subcomponents
let hasMatchingSubcomponents = false
if (item.items) {
item.items = item.items.map((subItem) => {
// Calculate individual filter matches for subcomponent
const subMatches = {
type: !filters.type || subItem.type === filters.type,
search:
!searchTerm ||
(subItem.metadata &&
(containsSearchTerm(subItem.metadata.name) ||
containsSearchTerm(subItem.metadata.description))),
tags:
!filters.tags?.length ||
(subItem.metadata?.tags &&
filters.tags.some((tag) => subItem.metadata!.tags!.includes(tag))),
}
// A subcomponent matches if it matches all active filters
const subMatched = subMatches.type && subMatches.search && subMatches.tags
if (subMatched) {
hasMatchingSubcomponents = true
// Build match reason for matched subcomponent
const matchReason: Record<string, boolean> = {
...(searchTerm && {
nameMatch: !!subItem.metadata && containsSearchTerm(subItem.metadata.name),
descriptionMatch:
!!subItem.metadata && containsSearchTerm(subItem.metadata.description),
}),
...(filters.type && { typeMatch: subMatches.type }),
...(filters.tags?.length && { tagMatch: !!subMatches.tags }),
// Process subcomponents and track if any match
let hasMatchingSubcomponents = false
if (itemCopy.items?.length) {
itemCopy.items = itemCopy.items.map((subItem) => {
const subMatches = {
type: !filters.type || subItem.type === filters.type,
search:
!searchTerm ||
(subItem.metadata &&
(containsSearchTerm(subItem.metadata.name) ||
containsSearchTerm(subItem.metadata.description))),
tags:
!filters.tags?.length ||
!!(
subItem.metadata?.tags &&
filters.tags.some((tag) => subItem.metadata!.tags!.includes(tag))
),
}
subItem.matchInfo = {
matched: true,
matchReason,
const subItemMatched =
subMatches.type &&
(!searchTerm || subMatches.search) &&
(!filters.tags?.length || subMatches.tags)
if (subItemMatched) {
hasMatchingSubcomponents = true
// Set matchInfo for matching subcomponent
// Build match reason based on active filters
const matchReason: Record<string, boolean> = {}
if (searchTerm) {
matchReason.nameMatch = containsSearchTerm(subItem.metadata?.name || "")
matchReason.descriptionMatch = containsSearchTerm(subItem.metadata?.description || "")
}
// Always include typeMatch when filtering by type
if (filters.type) {
matchReason.typeMatch = subMatches.type
}
subItem.matchInfo = {
matched: true,
matchReason,
}
} else {
subItem.matchInfo = { matched: false }
}
return subItem
})
}
const hasActiveFilters = filters.type || searchTerm || filters.tags?.length
if (!hasActiveFilters) return itemCopy
const parentMatchesAll = itemMatches.type && itemMatches.search && itemMatches.tags
const isPackageWithMatchingSubcomponent = itemCopy.type === "package" && hasMatchingSubcomponents
if (parentMatchesAll || isPackageWithMatchingSubcomponent) {
// Add match info without deep cloning
// Build parent match reason based on active filters
const matchReason: Record<string, boolean> = {}
if (searchTerm) {
matchReason.nameMatch = containsSearchTerm(itemCopy.name)
matchReason.descriptionMatch = containsSearchTerm(itemCopy.description)
} else {
subItem.matchInfo = {
matched: false,
}
matchReason.nameMatch = false
matchReason.descriptionMatch = false
}
return subItem
})
}
// Always include typeMatch when filtering by type
if (filters.type) {
matchReason.typeMatch = itemMatches.type
}
// Build match reason for parent item
const matchReason: Record<string, boolean> = {
nameMatch: searchTerm ? containsSearchTerm(item.name) : true,
descriptionMatch: searchTerm ? containsSearchTerm(item.description) : true,
}
if (hasMatchingSubcomponents) {
matchReason.hasMatchingSubcomponents = true
}
if (filters.type) {
matchReason.typeMatch = itemMatches.type
}
if (filters.tags?.length) {
matchReason.tagMatch = !!itemMatches.tags
}
if (hasMatchingSubcomponents) {
matchReason.hasMatchingSubcomponents = true
}
itemCopy.matchInfo = {
matched: true,
matchReason,
}
return itemCopy
}
return null
})
.filter((item): item is PackageManagerItem => item !== null)
// Parent item is matched if:
// 1. It matches all active filters directly, or
// 2. It's a package and has any matching subcomponents
const parentMatchesAll =
(!filters.type || itemMatches.type) &&
(!searchTerm || itemMatches.search) &&
(!filters.tags?.length || itemMatches.tags)
const isPackageWithMatchingSubcomponent = item.type === "package" && hasMatchingSubcomponents
item.matchInfo = {
matched: parentMatchesAll || isPackageWithMatchingSubcomponent,
matchReason,
}
return item
// Cache the results with timestamp
this.filterCache.set(cacheKey, {
items: filteredItems,
timestamp: Date.now(),
})
return filteredItems
}
/**
@ -491,6 +500,17 @@ export class PackageManagerManager {
return this.currentItems
}
/**
* Updates current items with filtered results
* @param filters The filter criteria
* @returns Filtered items
*/
updateWithFilteredItems(filters: { type?: ComponentType; search?: string; tags?: string[] }): PackageManagerItem[] {
const filteredItems = this.filterItems(this.currentItems, filters)
this.currentItems = filteredItems
return filteredItems
}
/**
* Cleans up resources used by the package manager
*/
@ -499,6 +519,8 @@ export class PackageManagerManager {
const sources = Array.from(this.cache.keys()).map((url) => ({ url, enabled: true }))
await this.cleanupCacheDirectories(sources)
this.clearCache()
// Clear filter cache
this.filterCache.clear()
}
/**

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from "react"
import { useState, useEffect, useMemo, useCallback } from "react"
import { Button } from "@/components/ui/button"
import { Tab, TabContent, TabHeader } from "../common/Tab"
import { cn } from "@/lib/utils"
@ -25,8 +25,18 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
manager.transition({ type: "FETCH_ITEMS" })
}, [manager])
// Compute all available tags
const allTags = Array.from(new Set(state.allItems.flatMap((item) => item.tags || []))).sort()
// Memoize all available tags
const allTags = useMemo(
() => Array.from(new Set(state.allItems.flatMap((item) => item.tags || []))).sort(),
[state.allItems],
)
// Memoize filtered tags
const filteredTags = useMemo(
() =>
tagSearch ? allTags.filter((tag: string) => tag.toLowerCase().includes(tagSearch.toLowerCase())) : allTags,
[allTags, tagSearch],
)
return (
<Tab>
@ -178,55 +188,47 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
{t("package-manager:filters.tags.noResults")}
</CommandEmpty>
<CommandGroup>
{allTags
.filter((tag) =>
tag.toLowerCase().includes(tagSearch.toLowerCase()),
)
.map((tag) => (
<CommandItem
key={tag}
onSelect={() => {
const isSelected =
state.filters.tags.includes(tag)
if (isSelected) {
manager.transition({
type: "UPDATE_FILTERS",
payload: {
filters: {
tags: state.filters.tags.filter(
(t) => t !== tag,
),
},
{filteredTags.map((tag: string) => (
<CommandItem
key={tag}
onSelect={() => {
const isSelected = state.filters.tags.includes(tag)
if (isSelected) {
manager.transition({
type: "UPDATE_FILTERS",
payload: {
filters: {
tags: state.filters.tags.filter(
(t) => t !== tag,
),
},
})
} else {
manager.transition({
type: "UPDATE_FILTERS",
payload: {
filters: {
tags: [
...state.filters.tags,
tag,
],
},
},
})
} else {
manager.transition({
type: "UPDATE_FILTERS",
payload: {
filters: {
tags: [...state.filters.tags, tag],
},
})
}
}}
className={`flex items-center gap-2 p-1 cursor-pointer text-sm hover:bg-vscode-button-secondaryBackground ${
state.filters.tags.includes(tag)
? "bg-vscode-button-background text-vscode-button-foreground"
: "text-vscode-dropdown-foreground"
}`}
onMouseDown={(e) => {
e.preventDefault()
}}>
<span
className={`codicon ${state.filters.tags.includes(tag) ? "codicon-check" : ""}`}
/>
{tag}
</CommandItem>
))}
},
})
}
}}
className={`flex items-center gap-2 p-1 cursor-pointer text-sm hover:bg-vscode-button-secondaryBackground ${
state.filters.tags.includes(tag)
? "bg-vscode-button-background text-vscode-button-foreground"
: "text-vscode-dropdown-foreground"
}`}
onMouseDown={(e) => {
e.preventDefault()
}}>
<span
className={`codicon ${state.filters.tags.includes(tag) ? "codicon-check" : ""}`}
/>
{tag}
</CommandItem>
))}
</CommandGroup>
</CommandList>
)}
@ -400,16 +402,21 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
setError("")
}
const handleToggleSource = (index: number) => {
const updatedSources = [...sources]
updatedSources[index].enabled = !updatedSources[index].enabled
onSourcesChange(updatedSources)
}
const handleToggleSource = useCallback(
(index: number) => {
onSourcesChange(
sources.map((source, i) => (i === index ? { ...source, enabled: !source.enabled } : source)),
)
},
[sources, onSourcesChange],
)
const handleRemoveSource = (index: number) => {
const updatedSources = sources.filter((_, i) => i !== index)
onSourcesChange(updatedSources)
}
const handleRemoveSource = useCallback(
(index: number) => {
onSourcesChange(sources.filter((_, i) => i !== index))
},
[sources, onSourcesChange],
)
return (
<div>

View file

@ -1,4 +1,4 @@
import React, { useMemo } from "react"
import React, { useMemo, useCallback } from "react"
import { Button } from "@/components/ui/button"
import { PackageManagerItem } from "../../../../../src/services/package-manager/types"
import { vscode } from "@/utils/vscode"
@ -33,8 +33,8 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
}
}
const getTypeLabel = (type: string) => {
switch (type) {
const typeLabel = useMemo(() => {
switch (item.type) {
case "mode":
return t("package-manager:filters.type.mode")
case "mcp server":
@ -46,10 +46,10 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
default:
return t("package-manager:filters.type.all")
}
}
}, [item.type, t])
const getTypeColor = (type: string) => {
switch (type) {
const typeColor = useMemo(() => {
switch (item.type) {
case "mode":
return "bg-blue-600"
case "mcp server":
@ -61,32 +61,31 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
default:
return "bg-gray-600"
}
}
}, [item.type])
const handleOpenUrl = () => {
// If sourceUrl is present and valid, use it directly without modifications
// Memoize URL calculation
const urlToOpen = useMemo(() => {
if (item.sourceUrl && isValidUrl(item.sourceUrl)) {
return vscode.postMessage({
type: "openExternal",
url: item.sourceUrl,
})
return item.sourceUrl
}
// Otherwise use repoUrl with git path information
let urlToOpen = item.repoUrl
let url = item.repoUrl
if (item.defaultBranch) {
urlToOpen = `${urlToOpen}/tree/${item.defaultBranch}`
url = `${url}/tree/${item.defaultBranch}`
if (item.path) {
const normalizedPath = item.path.replace(/\\/g, "/").replace(/^\/+/, "")
urlToOpen = `${urlToOpen}/${normalizedPath}`
url = `${url}/${normalizedPath}`
}
}
return url
}, [item.sourceUrl, item.repoUrl, item.defaultBranch, item.path])
const handleOpenUrl = useCallback(() => {
vscode.postMessage({
type: "openExternal",
url: urlToOpen,
})
}
}, [urlToOpen])
// Group items by type
const groupedItems = useMemo(() => {
@ -135,9 +134,7 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
</p>
) : null}
</div>
<span className={`px-2 py-1 text-xs text-white rounded-full ${getTypeColor(item.type)}`}>
{getTypeLabel(item.type)}
</span>
<span className={`px-2 py-1 text-xs text-white rounded-full ${typeColor}`}>{typeLabel}</span>
</div>
<p className="my-2 text-vscode-foreground">{item.description}</p>

View file

@ -1,4 +1,4 @@
import React from "react"
import React, { useMemo } from "react"
import { cn } from "@/lib/utils"
import { useAppTranslation } from "@/i18n/TranslationContext"
@ -19,7 +19,7 @@ interface TypeGroupProps {
export const TypeGroup: React.FC<TypeGroupProps> = ({ type, items, className }) => {
const { t } = useAppTranslation()
const getTypeLabel = (type: string) => {
const typeLabel = useMemo(() => {
switch (type) {
case "mode":
return t("package-manager:type-group.modes")
@ -34,38 +34,45 @@ export const TypeGroup: React.FC<TypeGroupProps> = ({ type, items, className })
type: type.charAt(0).toUpperCase() + type.slice(1),
})
}
}
}, [type, t])
const containerClassName = useMemo(() => cn("mb-4", className), [className])
// Memoize the list items
const listItems = useMemo(() => {
if (!items?.length) return null
return items.map((item, index) => {
const itemClassName = cn(
"text-sm pl-1",
item.matchInfo?.matched ? "text-vscode-foreground font-medium" : "text-vscode-foreground",
)
const nameClassName = cn("font-medium", item.matchInfo?.matched ? "text-vscode-textLink" : "")
return (
<li key={`${item.path || index}`} className={itemClassName} title={item.path}>
<span className={nameClassName}>{item.name}</span>
{item.description && (
<span className="text-vscode-descriptionForeground"> - {item.description}</span>
)}
{item.matchInfo?.matched && (
<span className="ml-2 text-xs bg-vscode-badge-background text-vscode-badge-foreground px-1 py-0.5 rounded">
{t("package-manager:type-group.match")}
</span>
)}
</li>
)
})
}, [items, t])
if (!items?.length) {
return null
}
return (
<div className={cn("mb-4", className)}>
<h4 className="text-sm font-medium text-vscode-foreground mb-2">{getTypeLabel(type)}</h4>
<ol className="list-decimal list-inside space-y-1">
{items.map((item, index) => (
<li
key={`${item.path || index}`}
className={cn(
"text-sm pl-1",
item.matchInfo?.matched ? "text-vscode-foreground font-medium" : "text-vscode-foreground",
)}
title={item.path}>
<span className={cn("font-medium", item.matchInfo?.matched ? "text-vscode-textLink" : "")}>
{item.name}
</span>
{item.description && (
<span className="text-vscode-descriptionForeground"> - {item.description}</span>
)}
{item.matchInfo?.matched && (
<span className="ml-2 text-xs bg-vscode-badge-background text-vscode-badge-foreground px-1 py-0.5 rounded">
{t("package-manager:type-group.match")}
</span>
)}
</li>
))}
</ol>
<div className={containerClassName}>
<h4 className="text-sm font-medium text-vscode-foreground mb-2">{typeLabel}</h4>
<ol className="list-decimal list-inside space-y-1">{listItems}</ol>
</div>
)
}

View file

@ -21,33 +21,45 @@ export interface GroupedItems {
* @param items Array of items to group
* @returns Object with items grouped by type
*/
// Cache for group objects to avoid recreating them
const groupCache = new Map<string, { type: string; items: any[] }>()
export function groupItemsByType(items: PackageManagerItem["items"] = []): GroupedItems {
if (!items?.length) {
return {}
}
return items.reduce((groups: GroupedItems, item) => {
if (!item.type) {
return groups
}
// Clear old items from groups but keep the group objects
groupCache.forEach((group) => (group.items.length = 0))
if (!groups[item.type]) {
groups[item.type] = {
const groups: GroupedItems = {}
for (const item of items) {
if (!item.type) continue
let group = groupCache.get(item.type)
if (!group) {
group = {
type: item.type,
items: [],
}
groupCache.set(item.type, group)
}
groups[item.type].items.push({
if (!groups[item.type]) {
groups[item.type] = group
}
group.items.push({
name: item.metadata?.name || "Unnamed item",
description: item.metadata?.description,
metadata: item.metadata,
path: item.path,
matchInfo: item.matchInfo,
})
}
return groups
}, {})
return groups
}
/**
@ -55,19 +67,26 @@ export function groupItemsByType(items: PackageManagerItem["items"] = []): Group
* @param item The item to format
* @returns Formatted string with name and description
*/
// Reuse string buffer for formatting
const formatBuffer = {
result: "",
maxLength: 100,
}
export function formatItemText(item: { name: string; description?: string }): string {
if (!item.description) {
return item.name
}
// Truncate description if it's too long
const maxDescriptionLength = 100
const description =
item.description.length > maxDescriptionLength
? `${item.description.substring(0, maxDescriptionLength)}...`
// Reuse the same string buffer
formatBuffer.result = item.name
formatBuffer.result += " - "
formatBuffer.result +=
item.description.length > formatBuffer.maxLength
? item.description.substring(0, formatBuffer.maxLength) + "..."
: item.description
return `${item.name} - ${description}`
return formatBuffer.result
}
/**
@ -75,8 +94,12 @@ export function formatItemText(item: { name: string; description?: string }): st
* @param groups Grouped items object
* @returns Total number of items
*/
// Cache array of group values
let groupValuesCache: Array<{ items: any[] }> = []
export function getTotalItemCount(groups: GroupedItems): number {
return Object.values(groups).reduce((total, group) => total + group.items.length, 0)
groupValuesCache = Object.values(groups)
return groupValuesCache.reduce((total, group) => total + group.items.length, 0)
}
/**
@ -84,6 +107,11 @@ export function getTotalItemCount(groups: GroupedItems): number {
* @param groups Grouped items object
* @returns Array of type strings
*/
// Cache array of types
let typesCache: string[] = []
export function getUniqueTypes(groups: GroupedItems): string[] {
return Object.keys(groups).sort()
typesCache = Object.keys(groups)
typesCache.sort()
return typesCache
}