mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
more memory pressure work
This commit is contained in:
parent
c84343dbbe
commit
c0f615bca7
16 changed files with 286 additions and 157 deletions
|
|
@ -16,6 +16,7 @@ export class GitFetcher {
|
|||
private metadataScanner: MetadataScanner
|
||||
private git?: SimpleGit
|
||||
private localizationOptions: LocalizationOptions
|
||||
private activeGitInstances: Set<SimpleGit> = new Set()
|
||||
|
||||
constructor(context: vscode.ExtensionContext, localizationOptions?: LocalizationOptions) {
|
||||
this.cacheDir = path.join(context.globalStorageUri.fsPath, "package-manager-cache")
|
||||
|
|
@ -26,14 +27,55 @@ export class GitFetcher {
|
|||
this.metadataScanner = new MetadataScanner(undefined, this.localizationOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
dispose(): void {
|
||||
// Clean up all git instances
|
||||
this.activeGitInstances.forEach((git) => {
|
||||
try {
|
||||
// Force cleanup of git instance
|
||||
;(git as any)._executor = null
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
this.activeGitInstances.clear()
|
||||
|
||||
// Clean up metadata scanner
|
||||
if (this.metadataScanner) {
|
||||
this.metadataScanner = null as any
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize git instance for a repository
|
||||
* @param repoDir Repository directory
|
||||
*/
|
||||
private initGit(repoDir: string): void {
|
||||
// Clean up old git instance if it exists
|
||||
if (this.git) {
|
||||
this.activeGitInstances.delete(this.git)
|
||||
try {
|
||||
// Force cleanup of git instance
|
||||
;(this.git as any)._executor = null
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
// Create new git instance
|
||||
this.git = simpleGit(repoDir)
|
||||
this.activeGitInstances.add(this.git)
|
||||
|
||||
// Update MetadataScanner with new git instance
|
||||
const oldScanner = this.metadataScanner
|
||||
this.metadataScanner = new MetadataScanner(this.git, this.localizationOptions)
|
||||
|
||||
// Clean up old scanner
|
||||
if (oldScanner) {
|
||||
oldScanner.dispose?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -68,9 +110,8 @@ export class GitFetcher {
|
|||
const metadata = await this.parseRepositoryMetadata(repoDir)
|
||||
|
||||
// Parse package manager items
|
||||
// Get current branch
|
||||
const git = simpleGit(repoDir)
|
||||
const branch = await git.revparse(["--abbrev-ref", "HEAD"])
|
||||
// Get current branch using existing git instance
|
||||
const branch = (await this.git?.revparse(["--abbrev-ref", "HEAD"])) || "main"
|
||||
|
||||
const items = await this.parsePackageManagerItems(repoDir, repoUrl, sourceName || metadata.name)
|
||||
|
||||
|
|
@ -196,9 +237,8 @@ export class GitFetcher {
|
|||
}
|
||||
}
|
||||
|
||||
// Get current branch
|
||||
const git = simpleGit(repoDir)
|
||||
const branch = await git.revparse(["--abbrev-ref", "HEAD"])
|
||||
// Get current branch using existing git instance
|
||||
const branch = (await this.git?.revparse(["--abbrev-ref", "HEAD"])) || "main"
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to clone/pull repository: ${error instanceof Error ? error.message : String(error)}`,
|
||||
|
|
|
|||
|
|
@ -18,12 +18,13 @@ import { getUserLocale } from "./utils"
|
|||
* Handles component discovery and metadata loading
|
||||
*/
|
||||
export class MetadataScanner {
|
||||
private readonly git?: SimpleGit
|
||||
private git?: SimpleGit
|
||||
private localizationOptions: LocalizationOptions
|
||||
private originalRootDir: string | null = null
|
||||
private static readonly MAX_DEPTH = 5 // Maximum directory depth
|
||||
private static readonly BATCH_SIZE = 50 // Number of items to process at once
|
||||
private static readonly CONCURRENT_SCANS = 3 // Number of concurrent directory scans
|
||||
private isDisposed = false
|
||||
|
||||
constructor(git?: SimpleGit, localizationOptions?: LocalizationOptions) {
|
||||
this.git = git
|
||||
|
|
@ -33,6 +34,24 @@ export class MetadataScanner {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.isDisposed) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up git instance reference
|
||||
this.git = undefined
|
||||
|
||||
// Clear any other references
|
||||
this.originalRootDir = null
|
||||
this.localizationOptions = null as any
|
||||
|
||||
this.isDisposed = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Generator function to yield items in batches
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -130,81 +130,146 @@ export function validateSourceName(name?: string): ValidationError[] {
|
|||
* @param newSource The new source to check against the list (optional)
|
||||
* @returns An array of validation errors, empty if valid
|
||||
*/
|
||||
// Cache for normalized strings to avoid repeated operations
|
||||
const normalizeCache = new Map<string, string>()
|
||||
|
||||
function normalizeString(str: string): string {
|
||||
const cached = normalizeCache.get(str)
|
||||
if (cached) return cached
|
||||
|
||||
const normalized = str.toLowerCase().replace(/\s+/g, "")
|
||||
normalizeCache.set(str, normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function validateSourceDuplicates(
|
||||
sources: PackageManagerSource[],
|
||||
newSource?: PackageManagerSource,
|
||||
): ValidationError[] {
|
||||
const errors: ValidationError[] = []
|
||||
const normalizedUrls: { url: string; index: number }[] = []
|
||||
const normalizedNames: { name: string; index: number }[] = []
|
||||
const urlMap = new Map<string, number>()
|
||||
const nameMap = new Map<string, number>()
|
||||
|
||||
// Process existing sources
|
||||
sources.forEach((source, index) => {
|
||||
// Normalize URL (case and whitespace insensitive)
|
||||
const normalizedUrl = source.url.toLowerCase().replace(/\s+/g, "")
|
||||
normalizedUrls.push({ url: normalizedUrl, index })
|
||||
// Process existing sources
|
||||
const seen = new Set<string>()
|
||||
|
||||
// Normalize name if it exists (case and whitespace insensitive)
|
||||
if (source.name) {
|
||||
const normalizedName = source.name.toLowerCase().replace(/\s+/g, "")
|
||||
normalizedNames.push({ name: normalizedName, index })
|
||||
// Check for duplicates within existing sources
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const source = sources[i]
|
||||
const normalizedUrl = normalizeString(source.url)
|
||||
const normalizedName = source.name ? normalizeString(source.name) : null
|
||||
|
||||
// Check for URL duplicates
|
||||
for (let j = i + 1; j < sources.length; j++) {
|
||||
const otherSource = sources[j]
|
||||
const otherUrl = normalizeString(otherSource.url)
|
||||
|
||||
if (normalizedUrl === otherUrl) {
|
||||
const key = `url:${i}:${j}`
|
||||
if (!seen.has(key)) {
|
||||
errors.push({
|
||||
field: "url",
|
||||
message: `Source #${i + 1} has a duplicate URL with Source #${j + 1}`,
|
||||
})
|
||||
errors.push({
|
||||
field: "url",
|
||||
message: `Source #${j + 1} has a duplicate URL with Source #${i + 1}`,
|
||||
})
|
||||
seen.add(key)
|
||||
seen.add(`url:${j}:${i}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for name duplicates if both have names
|
||||
if (normalizedName && otherSource.name) {
|
||||
const otherName = normalizeString(otherSource.name)
|
||||
if (normalizedName === otherName) {
|
||||
const key = `name:${i}:${j}`
|
||||
if (!seen.has(key)) {
|
||||
errors.push({
|
||||
field: "name",
|
||||
message: `Source #${i + 1} has a duplicate name with Source #${j + 1}`,
|
||||
})
|
||||
errors.push({
|
||||
field: "name",
|
||||
message: `Source #${j + 1} has a duplicate name with Source #${i + 1}`,
|
||||
})
|
||||
seen.add(key)
|
||||
seen.add(`name:${j}:${i}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Check for duplicates within the existing sources
|
||||
normalizedUrls.forEach((item, index) => {
|
||||
const duplicates = normalizedUrls.filter((other, otherIndex) => other.url === item.url && otherIndex !== index)
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
errors.push({
|
||||
field: "url",
|
||||
message: `Source #${item.index + 1} has a duplicate URL with Source #${duplicates[0].index + 1} (case and whitespace insensitive match)`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
normalizedNames.forEach((item, index) => {
|
||||
const duplicates = normalizedNames.filter(
|
||||
(other, otherIndex) => other.name === item.name && otherIndex !== index,
|
||||
)
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
errors.push({
|
||||
field: "name",
|
||||
message: `Source #${item.index + 1} has a duplicate name with Source #${duplicates[0].index + 1} (case and whitespace insensitive match)`,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Check new source against existing sources if provided
|
||||
if (newSource) {
|
||||
// Validate URL
|
||||
if (newSource.url) {
|
||||
const normalizedNewUrl = newSource.url.toLowerCase().replace(/\s+/g, "")
|
||||
const duplicateUrl = normalizedUrls.find((item) => item.url === normalizedNewUrl)
|
||||
|
||||
if (duplicateUrl) {
|
||||
const normalizedNewUrl = normalizeString(newSource.url)
|
||||
const existingUrlIndex = urlMap.get(normalizedNewUrl)
|
||||
if (existingUrlIndex !== undefined) {
|
||||
errors.push({
|
||||
field: "url",
|
||||
message: `URL is a duplicate of Source #${duplicateUrl.index + 1} (case and whitespace insensitive match)`,
|
||||
message: `URL is a duplicate of Source #${existingUrlIndex + 1}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Validate name
|
||||
if (newSource.name) {
|
||||
const normalizedNewName = newSource.name.toLowerCase().replace(/\s+/g, "")
|
||||
const duplicateName = normalizedNames.find((item) => item.name === normalizedNewName)
|
||||
|
||||
if (duplicateName) {
|
||||
const normalizedNewName = normalizeString(newSource.name)
|
||||
const existingNameIndex = nameMap.get(normalizedNewName)
|
||||
if (existingNameIndex !== undefined) {
|
||||
errors.push({
|
||||
field: "name",
|
||||
message: `Name is a duplicate of Source #${duplicateName.index + 1} (case and whitespace insensitive match)`,
|
||||
message: `Name is a duplicate of Source #${existingNameIndex + 1}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check new source against existing sources if provided
|
||||
if (newSource) {
|
||||
const normalizedNewUrl = normalizeString(newSource.url)
|
||||
const normalizedNewName = newSource.name ? normalizeString(newSource.name) : null
|
||||
|
||||
// Add new source to maps temporarily
|
||||
const newIndex = sources.length
|
||||
urlMap.set(normalizedNewUrl, newIndex)
|
||||
if (normalizedNewName) {
|
||||
nameMap.set(normalizedNewName, newIndex)
|
||||
}
|
||||
|
||||
// Check for duplicates with existing sources
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const source = sources[i]
|
||||
const sourceUrl = normalizeString(source.url)
|
||||
|
||||
if (sourceUrl === normalizedNewUrl) {
|
||||
errors.push({
|
||||
field: "url",
|
||||
message: `URL is a duplicate of Source #${i + 1}`,
|
||||
})
|
||||
}
|
||||
|
||||
if (source.name && normalizedNewName) {
|
||||
const sourceName = normalizeString(source.name)
|
||||
if (sourceName === normalizedNewName) {
|
||||
errors.push({
|
||||
field: "name",
|
||||
message: `Name is a duplicate of Source #${i + 1}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove temporary entries
|
||||
urlMap.delete(normalizedNewUrl)
|
||||
if (normalizedNewName) {
|
||||
nameMap.delete(normalizedNewName)
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
|
|
@ -232,24 +297,37 @@ export function validateSource(
|
|||
* @returns An array of validation errors, empty if valid
|
||||
*/
|
||||
export function validateSources(sources: PackageManagerSource[]): ValidationError[] {
|
||||
const errors: ValidationError[] = []
|
||||
// Pre-allocate maximum possible size for errors array
|
||||
const errors: ValidationError[] = new Array(sources.length * 2 + (sources.length * (sources.length - 1)) / 2)
|
||||
let errorIndex = 0
|
||||
|
||||
// Validate each source individually
|
||||
sources.forEach((source, index) => {
|
||||
const sourceErrors = [...validateSourceUrl(source.url), ...validateSourceName(source.name)]
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const source = sources[i]
|
||||
const urlErrors = validateSourceUrl(source.url)
|
||||
const nameErrors = validateSourceName(source.name)
|
||||
|
||||
// Add index to error messages
|
||||
sourceErrors.forEach((error) => {
|
||||
errors.push({
|
||||
for (const error of urlErrors) {
|
||||
errors[errorIndex++] = {
|
||||
field: error.field,
|
||||
message: `Source #${index + 1}: ${error.message}`,
|
||||
})
|
||||
})
|
||||
})
|
||||
message: `Source #${i + 1}: ${error.message}`,
|
||||
}
|
||||
}
|
||||
for (const error of nameErrors) {
|
||||
errors[errorIndex++] = {
|
||||
field: error.field,
|
||||
message: `Source #${i + 1}: ${error.message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicates across all sources
|
||||
const duplicateErrors = validateSourceDuplicates(sources)
|
||||
errors.push(...duplicateErrors)
|
||||
for (const error of duplicateErrors) {
|
||||
errors[errorIndex++] = error
|
||||
}
|
||||
|
||||
return errors
|
||||
// Trim array to actual size
|
||||
return errors.slice(0, errorIndex)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,15 +86,21 @@ export class PackageManagerViewStateManager {
|
|||
}
|
||||
|
||||
public getState(): ViewState {
|
||||
// Only create new arrays if they exist and have items
|
||||
const displayItems = this.state.displayItems?.length ? [...this.state.displayItems] : this.state.displayItems
|
||||
const refreshingUrls = this.state.refreshingUrls.length ? [...this.state.refreshingUrls] : []
|
||||
const tags = this.state.filters.tags.length ? [...this.state.filters.tags] : []
|
||||
|
||||
// Create minimal new state object
|
||||
return {
|
||||
...this.state,
|
||||
allItems: [...this.state.allItems],
|
||||
displayItems: this.state.displayItems ? [...this.state.displayItems] : undefined,
|
||||
refreshingUrls: [...this.state.refreshingUrls],
|
||||
sources: [...this.state.sources],
|
||||
allItems: this.state.allItems.length ? [...this.state.allItems] : [],
|
||||
displayItems,
|
||||
refreshingUrls,
|
||||
sources: this.state.sources.length ? [...this.state.sources] : [DEFAULT_PACKAGE_MANAGER_SOURCE],
|
||||
filters: {
|
||||
...this.state.filters,
|
||||
tags: [...this.state.filters.tags],
|
||||
tags,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -113,17 +119,11 @@ export class PackageManagerViewStateManager {
|
|||
return
|
||||
}
|
||||
|
||||
// Create a new state object to ensure React sees the change
|
||||
const newState = {
|
||||
...this.state,
|
||||
isFetching: true,
|
||||
}
|
||||
|
||||
// Clear any existing timeout before starting new fetch
|
||||
this.clearFetchTimeout()
|
||||
|
||||
// Update state and notify before starting fetch
|
||||
this.state = newState
|
||||
// Update state directly
|
||||
this.state.isFetching = true
|
||||
this.notifyStateChange()
|
||||
|
||||
// Set timeout for fetch operation
|
||||
|
|
@ -146,21 +146,20 @@ export class PackageManagerViewStateManager {
|
|||
this.clearFetchTimeout()
|
||||
|
||||
// Create a new state object with sorted items
|
||||
const sortedItems = this.sortItems([...items])
|
||||
const newState = {
|
||||
...this.state,
|
||||
isFetching: false,
|
||||
displayItems: sortedItems, // Use items directly from backend
|
||||
// Sort items in place to avoid creating unnecessary copies
|
||||
const sortedItems = this.sortItems(items)
|
||||
|
||||
// Minimize state updates
|
||||
if (this.isFilterActive()) {
|
||||
this.state.displayItems = sortedItems
|
||||
this.state.isFetching = false
|
||||
} else {
|
||||
this.state.allItems = sortedItems
|
||||
this.state.displayItems = sortedItems
|
||||
this.state.isFetching = false
|
||||
}
|
||||
|
||||
// Only update allItems if this isn't a filter response
|
||||
if (!this.isFilterActive()) {
|
||||
newState.allItems = sortedItems
|
||||
}
|
||||
|
||||
// Update state and notify
|
||||
this.state = newState
|
||||
|
||||
// Notify state change
|
||||
this.notifyStateChange()
|
||||
break
|
||||
}
|
||||
|
|
@ -168,12 +167,8 @@ export class PackageManagerViewStateManager {
|
|||
case "FETCH_ERROR": {
|
||||
this.clearFetchTimeout()
|
||||
|
||||
// Create a new state object to ensure React sees the change
|
||||
this.state = {
|
||||
...this.state,
|
||||
isFetching: false,
|
||||
}
|
||||
|
||||
// Update state directly
|
||||
this.state.isFetching = false
|
||||
this.notifyStateChange()
|
||||
break
|
||||
}
|
||||
|
|
@ -181,23 +176,18 @@ export class PackageManagerViewStateManager {
|
|||
case "SET_ACTIVE_TAB": {
|
||||
const { tab } = transition.payload as TransitionPayloads["SET_ACTIVE_TAB"]
|
||||
|
||||
// Create a new state object
|
||||
const newState = {
|
||||
...this.state,
|
||||
activeTab: tab,
|
||||
}
|
||||
// Update state directly
|
||||
this.state.activeTab = tab
|
||||
|
||||
// Add default source when switching to sources tab if no sources exist
|
||||
if (tab === "sources" && newState.sources.length === 0) {
|
||||
newState.sources = [DEFAULT_PACKAGE_MANAGER_SOURCE]
|
||||
if (tab === "sources" && this.state.sources.length === 0) {
|
||||
this.state.sources = [DEFAULT_PACKAGE_MANAGER_SOURCE]
|
||||
vscode.postMessage({
|
||||
type: "packageManagerSources",
|
||||
sources: [DEFAULT_PACKAGE_MANAGER_SOURCE],
|
||||
} as WebviewMessage)
|
||||
}
|
||||
|
||||
// Update state and notify
|
||||
this.state = newState
|
||||
this.notifyStateChange()
|
||||
|
||||
// Handle browse tab switch
|
||||
|
|
@ -254,13 +244,12 @@ export class PackageManagerViewStateManager {
|
|||
}
|
||||
// Apply sorting to both allItems and displayItems
|
||||
// Sort items immutably
|
||||
const sortedAllItems = this.sortItems(this.state.allItems)
|
||||
const sortedDisplayItems = this.state.displayItems ? this.sortItems(this.state.displayItems) : undefined
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
allItems: sortedAllItems,
|
||||
displayItems: sortedDisplayItems,
|
||||
// Sort arrays in place
|
||||
if (this.state.allItems.length) {
|
||||
this.sortItems(this.state.allItems)
|
||||
}
|
||||
if (this.state.displayItems?.length) {
|
||||
this.sortItems(this.state.displayItems)
|
||||
}
|
||||
this.notifyStateChange()
|
||||
break
|
||||
|
|
@ -377,19 +366,16 @@ export class PackageManagerViewStateManager {
|
|||
|
||||
private sortItems(items: PackageManagerItem[]): PackageManagerItem[] {
|
||||
const { by, order } = this.state.sortConfig
|
||||
return [...items].sort((a, b) => {
|
||||
let aValue = a[by] || ""
|
||||
let bValue = b[by] || ""
|
||||
|
||||
// Handle dates for lastUpdated
|
||||
if (by === "lastUpdated") {
|
||||
aValue = aValue || "1970-01-01T00:00:00Z"
|
||||
bValue = bValue || "1970-01-01T00:00:00Z"
|
||||
}
|
||||
// Sort array in place
|
||||
items.sort((a, b) => {
|
||||
const aValue = by === "lastUpdated" ? a[by] || "1970-01-01T00:00:00Z" : a[by] || ""
|
||||
const bValue = by === "lastUpdated" ? b[by] || "1970-01-01T00:00:00Z" : b[by] || ""
|
||||
|
||||
const comparison = aValue.localeCompare(bValue)
|
||||
return order === "asc" ? comparison : -comparison
|
||||
return order === "asc" ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue)
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
public async handleMessage(message: any): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -208,21 +208,22 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{groupedItems && (
|
||||
<div className="border-t border-vscode-panel-border mt-4">
|
||||
<ExpandableSection
|
||||
title={t("package-manager:items.card.externalComponents", { count: 0 })}
|
||||
title={t("package-manager:items.components", { count: item.items?.length ?? 0 })}
|
||||
badge={(() => {
|
||||
const matchCount = item.items?.filter((subItem) => subItem.matchInfo?.matched).length ?? 0
|
||||
return matchCount > 0 ? t("package-manager:items.count", { count: matchCount }) : undefined
|
||||
return matchCount > 0 ? t("package-manager:items.components", { count: matchCount }) : undefined
|
||||
})()}
|
||||
defaultExpanded={item.items?.some((subItem) => subItem.matchInfo?.matched) ?? false}>
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedItems).map(([type, group]) => (
|
||||
<TypeGroup key={type} type={type} items={group.items} />
|
||||
))}
|
||||
{groupedItems &&
|
||||
Object.entries(groupedItems).map(([type, group]) => (
|
||||
<TypeGroup key={type} type={type} items={group.items} />
|
||||
))}
|
||||
</div>
|
||||
</ExpandableSection>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,11 +168,19 @@ describe("PackageManagerItemCard", () => {
|
|||
})
|
||||
|
||||
describe("Details section", () => {
|
||||
it("should render expandable details section when item has subcomponents", () => {
|
||||
it("should render expandable details section with correct count when item has no components", () => {
|
||||
const itemWithNoItems = { ...mockItem, items: [] }
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithNoItems} />)
|
||||
|
||||
// The component uses t("package-manager:items.components", { count: 0 })
|
||||
expect(screen.getByText("0 components")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should render expandable details section with correct count when item has components", () => {
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
|
||||
|
||||
// The component uses t("package-manager:items.card.externalComponents", { count: 0 })
|
||||
expect(screen.getByText("Contains 0 external component")).toBeInTheDocument()
|
||||
// The component uses t("package-manager:items.components", { count: 2 })
|
||||
expect(screen.getByText("2 components")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should not render details section when item has no subcomponents", () => {
|
||||
|
|
@ -184,7 +192,7 @@ describe("PackageManagerItemCard", () => {
|
|||
|
||||
it("should show grouped items when expanded", () => {
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText("Contains 0 external component"))
|
||||
fireEvent.click(screen.getByText("2 components"))
|
||||
|
||||
// These use the type-group translations
|
||||
expect(screen.getByText((content, element) => element?.textContent === "MCP Servers")).toBeInTheDocument()
|
||||
|
|
@ -200,7 +208,7 @@ describe("PackageManagerItemCard", () => {
|
|||
|
||||
it("should maintain proper order of items within groups", () => {
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText("Contains 0 external component"))
|
||||
fireEvent.click(screen.getByText("2 components"))
|
||||
|
||||
const items = screen.getAllByRole("listitem")
|
||||
expect(items[0]).toHaveTextContent("Test Server")
|
||||
|
|
|
|||
|
|
@ -3,12 +3,17 @@ import { PackageManagerViewStateManager, ViewState } from "./PackageManagerViewS
|
|||
|
||||
export function useStateManager() {
|
||||
const [manager] = useState(() => new PackageManagerViewStateManager())
|
||||
|
||||
const [state, setState] = useState(() => manager.getState())
|
||||
|
||||
useEffect(() => {
|
||||
const handleStateChange = (newState: ViewState) => {
|
||||
setState(newState)
|
||||
setState((prevState) => {
|
||||
// Only update if something actually changed
|
||||
if (JSON.stringify(prevState) === JSON.stringify(newState)) {
|
||||
return prevState
|
||||
}
|
||||
return newState
|
||||
})
|
||||
}
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
|
|
@ -23,7 +28,7 @@ export function useStateManager() {
|
|||
unsubscribe()
|
||||
manager.cleanup()
|
||||
}
|
||||
}, [manager, state])
|
||||
}, [manager]) // Remove state from dependencies
|
||||
|
||||
return [state, manager] as const
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
"noSources": "Try adding a source in the Sources tab"
|
||||
},
|
||||
"count": "{{count}} items found",
|
||||
"components": "{{count}} components",
|
||||
"refresh": {
|
||||
"button": "Refresh",
|
||||
"refreshing": "Refreshing..."
|
||||
|
|
@ -46,8 +47,6 @@
|
|||
"card": {
|
||||
"by": "by {{author}}",
|
||||
"from": "from {{source}}",
|
||||
"externalComponents": "Contains {{count}} external component",
|
||||
"externalComponents_plural": "Contains {{count}} external components",
|
||||
"viewSource": "View",
|
||||
"viewOnSource": "View on {{source}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
"noSources": "ソースタブでソースを追加してみてください"
|
||||
},
|
||||
"count": "{{count}}個のアイテムが見つかりました",
|
||||
"components": "{{count}}個のコンポーネント",
|
||||
"refresh": {
|
||||
"button": "更新",
|
||||
"refreshing": "更新中..."
|
||||
|
|
@ -46,8 +47,6 @@
|
|||
"card": {
|
||||
"by": "作者: {{author}}",
|
||||
"from": "ソース: {{source}}",
|
||||
"externalComponents": "{{count}}個の外部コンポーネントを含む",
|
||||
"externalComponents_plural": "{{count}}個の外部コンポーネントを含む",
|
||||
"viewSource": "表示",
|
||||
"viewOnSource": "{{source}}で表示"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
"noSources": "소스 탭에서 소스를 추가해 보세요"
|
||||
},
|
||||
"count": "{{count}}개의 항목을 찾았습니다",
|
||||
"components": "{{count}}개의 컴포넌트",
|
||||
"refresh": {
|
||||
"button": "새로 고침",
|
||||
"refreshing": "새로 고치는 중..."
|
||||
|
|
@ -46,8 +47,6 @@
|
|||
"card": {
|
||||
"by": "작성자: {{author}}",
|
||||
"from": "출처: {{source}}",
|
||||
"externalComponents": "외부 컴포넌트 {{count}}개 포함",
|
||||
"externalComponents_plural": "외부 컴포넌트 {{count}}개 포함",
|
||||
"viewSource": "보기",
|
||||
"viewOnSource": "{{source}}에서 보기"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@
|
|||
"noSources": "Spróbuj dodać źródło w zakładce Źródła"
|
||||
},
|
||||
"count": "Znaleziono {{count}} elementów",
|
||||
"components": "{{count}} komponent",
|
||||
"components_plural": "{{count}} komponenty",
|
||||
"refresh": {
|
||||
"button": "Odśwież",
|
||||
"refreshing": "Odświeżanie..."
|
||||
|
|
@ -46,8 +48,6 @@
|
|||
"card": {
|
||||
"by": "autor: {{author}}",
|
||||
"from": "z: {{source}}",
|
||||
"externalComponents": "Zawiera {{count}} komponent zewnętrzny",
|
||||
"externalComponents_plural": "Zawiera {{count}} komponenty zewnętrzne",
|
||||
"viewSource": "Zobacz",
|
||||
"viewOnSource": "Zobacz na {{source}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
"noSources": "Kaynaklar sekmesinde bir kaynak eklemeyi deneyin"
|
||||
},
|
||||
"count": "{{count}} öğe bulundu",
|
||||
"components": "{{count}} bileşen",
|
||||
"refresh": {
|
||||
"button": "Yenile",
|
||||
"refreshing": "Yenileniyor..."
|
||||
|
|
@ -46,8 +47,6 @@
|
|||
"card": {
|
||||
"by": "yazar: {{author}}",
|
||||
"from": "kaynak: {{source}}",
|
||||
"externalComponents": "{{count}} harici bileşen içeriyor",
|
||||
"externalComponents_plural": "{{count}} harici bileşen içeriyor",
|
||||
"viewSource": "Görüntüle",
|
||||
"viewOnSource": "{{source}} üzerinde görüntüle"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
"noSources": "Thử thêm một nguồn trong tab Nguồn"
|
||||
},
|
||||
"count": "Tìm thấy {{count}} mục",
|
||||
"components": "{{count}} thành phần",
|
||||
"refresh": {
|
||||
"button": "Làm mới",
|
||||
"refreshing": "Đang làm mới..."
|
||||
|
|
@ -46,8 +47,6 @@
|
|||
"card": {
|
||||
"by": "bởi {{author}}",
|
||||
"from": "từ {{source}}",
|
||||
"externalComponents": "Chứa {{count}} thành phần bên ngoài",
|
||||
"externalComponents_plural": "Chứa {{count}} thành phần bên ngoài",
|
||||
"viewSource": "Xem",
|
||||
"viewOnSource": "Xem trên {{source}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
"noSources": "尝试在源标签页中添加源"
|
||||
},
|
||||
"count": "找到{{count}}个项目",
|
||||
"components": "{{count}}个组件",
|
||||
"refresh": {
|
||||
"button": "刷新",
|
||||
"refreshing": "正在刷新..."
|
||||
|
|
@ -46,8 +47,6 @@
|
|||
"card": {
|
||||
"by": "作者:{{author}}",
|
||||
"from": "来源:{{source}}",
|
||||
"externalComponents": "包含{{count}}个外部组件",
|
||||
"externalComponents_plural": "包含{{count}}个外部组件",
|
||||
"viewSource": "查看",
|
||||
"viewOnSource": "在{{source}}上查看"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
"noSources": "嘗試在來源分頁中新增來源"
|
||||
},
|
||||
"count": "找到{{count}}個項目",
|
||||
"components": "{{count}}個元件",
|
||||
"refresh": {
|
||||
"button": "重新整理",
|
||||
"refreshing": "正在重新整理..."
|
||||
|
|
@ -46,8 +47,6 @@
|
|||
"card": {
|
||||
"by": "作者:{{author}}",
|
||||
"from": "來源:{{source}}",
|
||||
"externalComponents": "包含{{count}}個外部元件",
|
||||
"externalComponents_plural": "包含{{count}}個外部元件",
|
||||
"viewSource": "檢視",
|
||||
"viewOnSource": "在{{source}}上檢視"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ i18next.use(initReactI18next).init({
|
|||
noSources: "Try adding a source in the Sources tab",
|
||||
},
|
||||
count: "{{count}} items found",
|
||||
components: "{{count}} components",
|
||||
refresh: {
|
||||
button: "Refresh",
|
||||
refreshing: "Refreshing...",
|
||||
|
|
@ -69,8 +70,6 @@ i18next.use(initReactI18next).init({
|
|||
from: "from {{source}}",
|
||||
viewSource: "View",
|
||||
viewOnSource: "View on {{source}}",
|
||||
externalComponents: "Contains {{count}} external component",
|
||||
externalComponents_plural: "Contains {{count}} external components",
|
||||
},
|
||||
},
|
||||
"type-group": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue