more memory pressure work

This commit is contained in:
Smartsheet-JB-Brown 2025-04-17 21:40:26 -07:00
parent c84343dbbe
commit c0f615bca7
16 changed files with 286 additions and 157 deletions

View file

@ -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)}`,

View file

@ -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
*/

View file

@ -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)
}

View file

@ -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> {

View file

@ -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>
)
}

View file

@ -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")

View file

@ -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
}

View file

@ -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}}"
}

View file

@ -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}}で表示"
}

View file

@ -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}}에서 보기"
}

View file

@ -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}}"
}

View file

@ -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"
}

View file

@ -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}}"
}

View file

@ -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}}上查看"
}

View file

@ -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}}上檢視"
}

View file

@ -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": {