fix failing tests from state management changes

This commit is contained in:
Smartsheet-JB-Brown 2025-04-18 11:08:21 -07:00
parent 968e19047b
commit a122dc7465
92 changed files with 1464 additions and 1467 deletions

View file

@ -249,7 +249,7 @@ classDiagram
- Coordinates repository operations
- Provides filtering and sorting
4. **packageManagerMessageHandler**
4. **marketplaceMessageHandler**
- Routes messages between UI and backend
- Processes commands from the UI
- Returns data and status updates

View file

@ -159,7 +159,7 @@ class PackageManagerManager {
}
/**
* Get package manager items from sources
* Get marketplace items from sources
*/
public async getPackageManagerItems(
sources: PackageManagerSource[],
@ -251,14 +251,14 @@ The PackageManagerSourceValidation component handles validation of marketplace s
```typescript
export class PackageManagerSourceValidation {
/**
* Validates a package manager source URL
* Validates a marketplace source URL
*/
public static validateSourceUrl(url: string): ValidationError[] {
// Implementation details
}
/**
* Validates a package manager source name
* Validates a marketplace source name
*/
public static validateSourceName(name?: string): ValidationError[] {
// Implementation details

View file

@ -51,7 +51,7 @@ This interface represents a complete repository:
```typescript
/**
* Represents an individual package manager item
* Represents an individual marketplace item
*/
export interface PackageManagerItem {
name: string
@ -113,7 +113,7 @@ Enhanced match tracking:
```typescript
/**
* Error type for package manager source validation
* Error type for marketplace source validation
*/
export interface ValidationError {
field: string
@ -154,7 +154,7 @@ Manages UI state:
- **isFetching**: Loading state indicator
- **activeTab**: Current view tab
- **refreshingUrls**: Sources being refreshed
- **sources**: Package manager sources
- **sources**: Marketplace sources
- **filters**: Active filters
- **sortConfig**: Sort configuration

View file

@ -23,7 +23,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
<Tab>
<TabHeader>
<div className="flex justify-between items-center">
<h3>Package Manager</h3>
<h3>Marketplace</h3>
<div className="flex gap-2">
<Button
variant={state.activeTab === "browse" ? "default" : "secondary"}

View file

@ -420,7 +420,7 @@ Integration tests verify that different components work together correctly.
### Backend Integration Tests
```typescript
describe("Package Manager Integration", () => {
describe("Marketplace Integration", () => {
let manager: PackageManagerManager
let metadataScanner: MetadataScanner
let templateItems: PackageManagerItem[]
@ -428,7 +428,7 @@ describe("Package Manager Integration", () => {
beforeAll(async () => {
// Load real data from template
metadataScanner = new MetadataScanner()
const templatePath = path.resolve(__dirname, "../../../../package-manager-template")
const templatePath = path.resolve(__dirname, "../../../../marketplace-template")
templateItems = await metadataScanner.scanDirectory(templatePath, "https://example.com")
})
@ -507,7 +507,7 @@ describe("Package Manager Integration", () => {
### Frontend Integration Tests
```typescript
describe("Package Manager UI Integration", () => {
describe("Marketplace UI Integration", () => {
const mockItems: PackageManagerItem[] = [
{
name: "Test Package",
@ -665,7 +665,7 @@ Real template data is used for integration tests:
beforeAll(async () => {
// Load real data from template
metadataScanner = new MetadataScanner()
const templatePath = path.resolve(__dirname, "../../../../package-manager-template")
const templatePath = path.resolve(__dirname, "../../../../marketplace-template")
templateItems = await metadataScanner.scanDirectory(templatePath, "https://example.com")
})
```
@ -911,7 +911,7 @@ The Marketplace tests are organized by functionality rather than by file structu
### Consolidated Test Files
```
src/services/package-manager/__tests__/
src/services/marketplace/__tests__/
├── PackageManager.consolidated.test.ts # Combined tests
├── searchUtils.test.ts # Search utility tests
└── PackageSubcomponents.test.ts # Subcomponent tests
@ -922,7 +922,7 @@ src/services/package-manager/__tests__/
Tests are organized into logical groups:
```typescript
describe("Package Manager", () => {
describe("Marketplace", () => {
// Shared setup
describe("Direct Filtering", () => {
@ -964,7 +964,7 @@ module.exports = {
lines: 85,
statements: 85,
},
"src/services/package-manager/*.ts": {
"src/services/marketplace/*.ts": {
branches: 90,
functions: 90,
lines: 90,
@ -1003,7 +1003,7 @@ describe("containsSearchTerm", () => {
```typescript
// Optimized integration tests
describe("Package Manager Integration", () => {
describe("Marketplace Integration", () => {
// Load template data once for all tests
beforeAll(async () => {
templateItems = await metadataScanner.scanDirectory(templatePath)
@ -1098,7 +1098,7 @@ describe("Complex integration test", () => {
it("should handle complex search", async () => {
// Enable debug logging for this test
const originalDebug = process.env.DEBUG
process.env.DEBUG = "package-manager:*"
process.env.DEBUG = "marketplace:*"
// Test logic...
@ -1167,4 +1167,4 @@ describe("Package filtering", () => {
---
**Previous**: [UI Component Design](./05-ui-components.md) | **Next**: [Extending the Package Manager](./07-extending.md)
**Previous**: [UI Component Design](./05-ui-components.md) | **Next**: [Extending the Marketplace](./07-extending.md)

View file

@ -184,7 +184,7 @@ Register your template with the Marketplace:
// In your extension code
const registerTemplates = (context: vscode.ExtensionContext) => {
const templatePath = path.join(context.extensionPath, "templates", "your-template")
packageManager.registerTemplate(templatePath)
marketplace.registerTemplate(templatePath)
}
```
@ -336,7 +336,7 @@ const ViewModeSelector: React.FC<{
4. **Integrate with the Main UI**:
```tsx
<div className="package-manager-container">
<div className="marketplace-container">
<div className="toolbar">
<ViewModeSelector viewMode={viewMode} setViewMode={setViewMode} />
{/* Other toolbar items */}
@ -476,7 +476,7 @@ const PackageManagerView: React.FC<{
const PackageCard = componentOverrides.PackageCard || PackageManagerItemCard
return (
<div className="package-manager">
<div className="marketplace">
{items.map((item) => (
<PackageCard
key={item.name}
@ -584,8 +584,8 @@ class CustomSourceProvider implements SourceProvider {
```typescript
// In your extension code
const registerSourceProviders = (packageManager: PackageManagerManager) => {
packageManager.registerSourceProvider(new CustomSourceProvider())
const registerSourceProviders = (marketplace: PackageManagerManager) => {
marketplace.registerSourceProvider(new CustomSourceProvider())
}
```
@ -637,7 +637,7 @@ To add support for custom messages:
const extendMessageHandler = () => {
const originalHandler = handlePackageManagerMessages
return async (message: any, packageManager: PackageManagerManager) => {
return async (message: any, marketplace: PackageManagerManager) => {
// Handle custom messages
if (message.type === "yourCustomMessage") {
// Your custom message handling
@ -650,7 +650,7 @@ const extendMessageHandler = () => {
}
// Fall back to the original handler
return originalHandler(message, packageManager)
return originalHandler(message, marketplace)
}
}
```
@ -661,8 +661,8 @@ const extendMessageHandler = () => {
// In your extension code
const customMessageHandler = extendMessageHandler()
context.subscriptions.push(
vscode.commands.registerCommand("packageManager.handleMessage", (message) => {
return customMessageHandler(message, packageManager)
vscode.commands.registerCommand("marketplace.handleMessage", (message) => {
return customMessageHandler(message, marketplace)
}),
)
```
@ -728,8 +728,8 @@ class ApiSourceProvider implements SourceProvider {
```typescript
// In your extension code
const registerApiProvider = (packageManager: PackageManagerManager) => {
packageManager.registerSourceProvider(new ApiSourceProvider("https://your-api.example.com"))
const registerApiProvider = (marketplace: PackageManagerManager) => {
marketplace.registerSourceProvider(new ApiSourceProvider("https://your-api.example.com"))
}
```
@ -829,7 +829,7 @@ class LocalDevProvider {
}
```
2. **Integrate with the Package Manager**:
2. **Integrate with the Marketplace**:
```typescript
// In your extension code
@ -845,15 +845,15 @@ const registerLocalDevTools = (context: vscode.ExtensionContext) => {
// Register commands
context.subscriptions.push(
vscode.commands.registerCommand("packageManager.createLocal", async (template, name) => {
vscode.commands.registerCommand("marketplace.createLocal", async (template, name) => {
return localDevProvider.createLocalPackage(template, name)
}),
vscode.commands.registerCommand("packageManager.buildLocal", async (packagePath) => {
vscode.commands.registerCommand("marketplace.buildLocal", async (packagePath) => {
return localDevProvider.buildLocalPackage(packagePath)
}),
vscode.commands.registerCommand("packageManager.testLocal", async (packagePath) => {
vscode.commands.registerCommand("marketplace.testLocal", async (packagePath) => {
return localDevProvider.testLocalPackage(packagePath)
}),
)

View file

@ -26,7 +26,7 @@
"src/schemas/ipc.ts",
"src/extension.ts",
"scripts/**",
"package-manager-template/**",
"marketplace-template/**",
"src/utils/git.ts"
],
"workspaces": {

View file

@ -1,6 +1,6 @@
# Package Manager Template
# Marketplace Template
This template provides a basic structure for creating a package manager source repository. The structure follows the required format for Roo Code's package manager.
This template provides a basic structure for creating a marketplace source repository. The structure follows the required format for Roo Code's marketplace.
## Structure
@ -33,7 +33,7 @@ version: "1.0.0"
## Usage
1. Copy this template to create your own package manager repository
1. Copy this template to create your own marketplace repository
2. Update the metadata.en.yml with your repository information
3. Add your MCP servers, roles, or other components
4. Each component must have its own metadata.en.yml file with the required fields

View file

@ -1,5 +1,5 @@
name: "Example MCP Server"
description: "An example MCP server for testing package manager functionality"
description: "An example MCP server for testing marketplace functionality"
type: "mcp server"
version: "1.0.0"
author: "JB Brown"

View file

@ -0,0 +1,5 @@
name: "Marketplace Template"
description: "A template repository for creating marketplace sources"
version: "1.0.0"
author: "JB Brown"
authorUrl: "https://www.linkedin.com/in/jbbrown1/"

View file

@ -1,5 +0,0 @@
name: "Package Manager Template"
description: "A template repository for creating package manager sources"
version: "1.0.0"
author: "JB Brown"
authorUrl: "https://www.linkedin.com/in/jbbrown1/"

View file

@ -96,7 +96,7 @@
"icon": "$(notebook)"
},
{
"command": "roo-cline.packageManagerButtonClicked",
"command": "roo-cline.marketplaceButtonClicked",
"title": "Marketplace",
"icon": "$(extensions)"
},
@ -261,7 +261,7 @@
"when": "view == roo-cline.SidebarProvider"
},
{
"command": "roo-cline.packageManagerButtonClicked",
"command": "roo-cline.marketplaceButtonClicked",
"group": "navigation@4",
"when": "view == roo-cline.SidebarProvider"
},
@ -303,7 +303,7 @@
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
},
{
"command": "roo-cline.packageManagerButtonClicked",
"command": "roo-cline.marketplaceButtonClicked",
"group": "navigation@4",
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
},

View file

@ -95,10 +95,10 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
"roo-cline.helpButtonClicked": () => {
vscode.env.openExternal(vscode.Uri.parse("https://docs.roocode.com"))
},
"roo-cline.packageManagerButtonClicked": () => {
"roo-cline.marketplaceButtonClicked": () => {
const visibleProvider = getVisibleProviderOrLog(outputChannel)
if (!visibleProvider) return
visibleProvider.postMessageToWebview({ type: "action", action: "packageManagerButtonClicked" })
visibleProvider.postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" })
},
"roo-cline.showHumanRelayDialog": (params: { requestId: string; promptText: string }) => {
const panel = getPanel()

View file

@ -4,7 +4,7 @@ import fs from "fs/promises"
import EventEmitter from "events"
import { Anthropic } from "@anthropic-ai/sdk"
import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../services/package-manager/constants"
import { DEFAULT_MARKETPLACE_SOURCE } from "../../services/marketplace/constants"
import delay from "delay"
import axios from "axios"
import pWaitFor from "p-wait-for"
@ -38,7 +38,7 @@ import { getTheme } from "../../integrations/theme/getTheme"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { McpHub } from "../../services/mcp/McpHub"
import { McpServerManager } from "../../services/mcp/McpServerManager"
import { PackageManagerManager } from "../../services/package-manager"
import { MarketplaceManager } from "../../services/marketplace"
import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService"
import { fileExistsAtPath } from "../../utils/fs"
import { setSoundEnabled } from "../../utils/sound"
@ -77,7 +77,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
return this._workspaceTracker
}
protected mcpHub?: McpHub // Change from private to protected
private packageManagerManager?: PackageManagerManager
private marketplaceManager?: MarketplaceManager
public isViewLaunched = false
public settingsImportedAt?: number
@ -764,7 +764,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
*/
private setWebviewMessageListener(webview: vscode.Webview) {
const onReceiveMessage = async (message: WebviewMessage) =>
webviewMessageHandler(this, message, this.packageManagerManager)
webviewMessageHandler(this, message, this.marketplaceManager)
webview.onDidReceiveMessage(onReceiveMessage, null, this.disposables)
}
@ -1221,7 +1221,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
showRooIgnoredFiles,
language,
maxReadFileLine,
packageManagerSources,
marketplaceSources,
} = await this.getState()
const telemetryKey = process.env.POSTHOG_API_KEY
@ -1229,13 +1229,12 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get<string[]>("allowedCommands") || []
const cwd = this.cwd
// Get package manager items from the manager
const packageManagerItems = this.packageManagerManager?.getCurrentItems() || []
const marketplaceItems = this.marketplaceManager?.getCurrentItems() || []
return {
version: this.context.extension?.packageJSON?.version ?? "",
packageManagerItems,
packageManagerSources: packageManagerSources ?? [],
marketplaceItems,
marketplaceSources: marketplaceSources ?? [],
apiConfiguration,
customInstructions,
alwaysAllowReadOnly: alwaysAllowReadOnly ?? false,
@ -1393,7 +1392,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
telemetrySetting: stateValues.telemetrySetting || "unset",
showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? true,
maxReadFileLine: stateValues.maxReadFileLine ?? 500,
packageManagerSources: stateValues.packageManagerSources ?? [DEFAULT_PACKAGE_MANAGER_SOURCE],
marketplaceSources: stateValues.marketplaceSources ?? [DEFAULT_MARKETPLACE_SOURCE],
}
}
@ -1489,11 +1488,11 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
}
/**
* Set the package manager manager instance
* @param packageManagerManager The package manager manager instance
* Set the marketplace manager instance
* @param marketplaceManager The marketplace manager instance
*/
public setPackageManagerManager(packageManagerManager: PackageManagerManager) {
this.packageManagerManager = packageManagerManager
public setMarketplaceManager(marketplaceManager: MarketplaceManager) {
this.marketplaceManager = marketplaceManager
}
/**

View file

@ -3,23 +3,23 @@ import { ClineProvider } from "./ClineProvider"
import { WebviewMessage } from "../../shared/WebviewMessage"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
import {
PackageManagerManager,
MarketplaceManager,
ComponentType,
PackageManagerItem,
PackageManagerSource,
MarketplaceItem,
MarketplaceSource,
validateSources,
ValidationError,
} from "../../services/package-manager"
import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../services/package-manager/constants"
} from "../../services/marketplace"
import { DEFAULT_MARKETPLACE_SOURCE } from "../../services/marketplace/constants"
import { GlobalState } from "../../schemas"
/**
* Handle package manager-related messages from the webview
* Handle marketplace-related messages from the webview
*/
export async function handlePackageManagerMessages(
export async function handleMarketplaceMessages(
provider: ClineProvider,
message: WebviewMessage,
packageManagerManager: PackageManagerManager,
marketplaceManager: MarketplaceManager,
): Promise<boolean> {
// Utility function for updating global state
const updateGlobalState = async <K extends keyof GlobalState>(key: K, value: GlobalState[K]) =>
@ -27,37 +27,36 @@ export async function handlePackageManagerMessages(
switch (message.type) {
case "webviewDidLaunch": {
// For webviewDidLaunch, we don't do anything - package manager items will be loaded by explicit fetchPackageManagerItems
// For webviewDidLaunch, we don't do anything - marketplace items will be loaded by explicit fetchMarketplaceItems
return true
}
case "fetchPackageManagerItems": {
case "fetchMarketplaceItems": {
// Prevent multiple simultaneous fetches
if (packageManagerManager.isFetching) {
if (marketplaceManager.isFetching) {
await provider.postMessageToWebview({
type: "state",
text: "Fetch already in progress",
})
packageManagerManager.isFetching = false
marketplaceManager.isFetching = false
return true
}
// Check if we need to force refresh using type assertion
const forceRefresh = (message as any).forceRefresh === true
try {
packageManagerManager.isFetching = true
marketplaceManager.isFetching = true
// Wrap the entire initialization in a try-catch block
try {
// Initialize default sources if none exist
let sources =
((await provider.contextProxy.getValue("packageManagerSources")) as PackageManagerSource[]) ||
[]
((await provider.contextProxy.getValue("marketplaceSources")) as MarketplaceSource[]) || []
if (!sources || sources.length === 0) {
sources = [DEFAULT_PACKAGE_MANAGER_SOURCE]
sources = [DEFAULT_MARKETPLACE_SOURCE]
// Save the default sources
await provider.contextProxy.setValue("packageManagerSources", sources)
await provider.contextProxy.setValue("marketplaceSources", sources)
}
// Add timing information
@ -74,23 +73,23 @@ export async function handlePackageManagerMessages(
return true
}
const result = await packageManagerManager.getPackageManagerItems(enabledSources)
const result = await marketplaceManager.getMarketplaceItems(enabledSources)
// If there are errors but also items, show warning
if (result.errors && result.items.length > 0) {
vscode.window.showWarningMessage(
`Some package manager sources failed to load:\n${result.errors.join("\n")}`,
`Some marketplace sources failed to load:\n${result.errors.join("\n")}`,
)
}
// If there are errors and no items, show error
else if (result.errors && result.items.length === 0) {
const errorMessage = `Failed to load package manager sources:\n${result.errors.join("\n")}`
const errorMessage = `Failed to load marketplace sources:\n${result.errors.join("\n")}`
vscode.window.showErrorMessage(errorMessage)
await provider.postMessageToWebview({
type: "state",
text: errorMessage,
})
packageManagerManager.isFetching = false
marketplaceManager.isFetching = false
}
const endTime = Date.now()
@ -101,8 +100,8 @@ export async function handlePackageManagerMessages(
// Send state to webview
await provider.postStateToWebview()
} catch (initError) {
const errorMessage = `Package manager initialization failed: ${initError instanceof Error ? initError.message : String(initError)}`
console.error("Error in package manager initialization:", initError)
const errorMessage = `Marketplace initialization failed: ${initError instanceof Error ? initError.message : String(initError)}`
console.error("Error in marketplace initialization:", initError)
vscode.window.showErrorMessage(errorMessage)
await provider.postMessageToWebview({
type: "state",
@ -110,31 +109,31 @@ export async function handlePackageManagerMessages(
})
// The state will already be updated with empty items by PackageManagerManager
await provider.postStateToWebview()
packageManagerManager.isFetching = false
marketplaceManager.isFetching = false
}
} catch (error) {
const errorMessage = `Failed to fetch package manager items: ${error instanceof Error ? error.message : String(error)}`
console.error("Failed to fetch package manager items:", error)
const errorMessage = `Failed to fetch marketplace items: ${error instanceof Error ? error.message : String(error)}`
console.error("Failed to fetch marketplace items:", error)
vscode.window.showErrorMessage(errorMessage)
await provider.postMessageToWebview({
type: "state",
text: errorMessage,
})
packageManagerManager.isFetching = false
marketplaceManager.isFetching = false
}
return true
}
case "packageManagerSources": {
case "marketplaceSources": {
if (message.sources) {
// Enforce maximum of 10 sources
const MAX_SOURCES = 10
let updatedSources: PackageManagerSource[]
let updatedSources: MarketplaceSource[]
if (message.sources.length > MAX_SOURCES) {
// Truncate to maximum allowed and show warning
updatedSources = message.sources.slice(0, MAX_SOURCES)
vscode.window.showWarningMessage(
`Maximum of ${MAX_SOURCES} package manager sources allowed. Additional sources have been removed.`,
`Maximum of ${MAX_SOURCES} marketplace sources allowed. Additional sources have been removed.`,
)
} else {
updatedSources = message.sources
@ -162,19 +161,19 @@ export async function handlePackageManagerMessages(
updatedSources = updatedSources.filter((_, index) => !invalidIndices.has(index))
// Show validation errors
const errorMessage = `Package manager sources validation failed:\n${validationErrors.map((e: ValidationError) => e.message).join("\n")}`
const errorMessage = `Marketplace sources validation failed:\n${validationErrors.map((e: ValidationError) => e.message).join("\n")}`
console.error(errorMessage)
vscode.window.showErrorMessage(errorMessage)
}
// Update the global state with the validated sources
await updateGlobalState("packageManagerSources", updatedSources)
await updateGlobalState("marketplaceSources", updatedSources)
// Clean up cache directories for repositories that are no longer in the sources list
try {
await packageManagerManager.cleanupCacheDirectories(updatedSources)
await marketplaceManager.cleanupCacheDirectories(updatedSources)
} catch (error) {
console.error("Package Manager: Error during cache cleanup:", error)
console.error("Marketplace: Error during cache cleanup:", error)
}
// Update the webview with the new state
@ -188,43 +187,42 @@ export async function handlePackageManagerMessages(
vscode.env.openExternal(vscode.Uri.parse(message.url))
} catch (error) {
console.error(
`Package Manager: Failed to open URL: ${error instanceof Error ? error.message : String(error)}`,
`Marketplace: Failed to open URL: ${error instanceof Error ? error.message : String(error)}`,
)
vscode.window.showErrorMessage(
`Failed to open URL: ${error instanceof Error ? error.message : String(error)}`,
)
}
} else {
console.error("Package Manager: openExternal called without a URL")
console.error("Marketplace: openExternal called without a URL")
}
return true
}
case "filterPackageManagerItems": {
case "filterMarketplaceItems": {
if (message.filters) {
try {
// Update filtered items and post state
packageManagerManager.updateWithFilteredItems({
marketplaceManager.updateWithFilteredItems({
type: message.filters.type as ComponentType | undefined,
search: message.filters.search,
tags: message.filters.tags,
})
await provider.postStateToWebview()
} catch (error) {
console.error("Package Manager: Error filtering items:", error)
vscode.window.showErrorMessage("Failed to filter package manager items")
console.error("Marketplace: Error filtering items:", error)
vscode.window.showErrorMessage("Failed to filter marketplace items")
}
}
return true
}
case "refreshPackageManagerSource": {
case "refreshMarketplaceSource": {
if (message.url) {
try {
// Get the current sources
const sources =
((await provider.contextProxy.getValue("packageManagerSources")) as PackageManagerSource[]) ||
[]
((await provider.contextProxy.getValue("marketplaceSources")) as MarketplaceSource[]) || []
// Find the source with the matching URL
const source = sources.find((s) => s.url === message.url)
@ -232,17 +230,14 @@ export async function handlePackageManagerMessages(
if (source) {
try {
// Refresh the repository with the source name
const refreshResult = await packageManagerManager.refreshRepository(
message.url,
source.name,
)
const refreshResult = await marketplaceManager.refreshRepository(message.url, source.name)
if (refreshResult.error) {
vscode.window.showErrorMessage(
`Failed to refresh source: ${source.name || message.url} - ${refreshResult.error}`,
)
} else {
vscode.window.showInformationMessage(
`Successfully refreshed package manager source: ${source.name || message.url}`,
`Successfully refreshed marketplace source: ${source.name || message.url}`,
)
}
await provider.postStateToWebview()
@ -254,12 +249,12 @@ export async function handlePackageManagerMessages(
})
}
} else {
console.error(`Package Manager: Source URL not found: ${message.url}`)
console.error(`Marketplace: Source URL not found: ${message.url}`)
vscode.window.showErrorMessage(`Source URL not found: ${message.url}`)
}
} catch (error) {
console.error(
`Package Manager: Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`,
`Marketplace: Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`,
)
vscode.window.showErrorMessage(
`Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`,

View file

@ -42,16 +42,16 @@ import { getDiffStrategy } from "../diff/DiffStrategy"
import { SYSTEM_PROMPT } from "../prompts/system"
import { buildApiHandler } from "../../api"
import { GlobalState } from "../../schemas"
import { PackageManagerManager } from "../../services/package-manager"
import { handlePackageManagerMessages } from "./packageManagerMessageHandler"
import { MarketplaceManager } from "../../services/marketplace"
import { handleMarketplaceMessages } from "./marketplaceMessageHandler"
// Track if package manager data has been loaded
let packageManagerDataLoaded = false
// Track if marketplace data has been loaded
let marketplaceDataLoaded = false
export const webviewMessageHandler = async (
provider: ClineProvider,
message: WebviewMessage,
packageManagerManager?: PackageManagerManager,
marketplaceManager?: MarketplaceManager,
) => {
// Utility functions provided for concise get/update of global state via contextProxy API.
const getGlobalState = <K extends keyof GlobalState>(key: K) => provider.contextProxy.getValue(key)
@ -64,10 +64,10 @@ export const webviewMessageHandler = async (
const customModes = await provider.customModesManager.getCustomModes()
await updateGlobalState("customModes", customModes)
// Don't handle package manager messages in webviewDidLaunch
// They will be handled by the fetchPackageManagerItems case
// Don't handle marketplace messages in webviewDidLaunch
// They will be handled by the fetchMarketplaceItems case
console.log(
`DEBUG: webviewDidLaunch - skipping package manager handling, will be triggered by explicit fetchPackageManagerItems`,
`DEBUG: webviewDidLaunch - skipping marketplace handling, will be triggered by explicit fetchMarketplaceItems`,
)
console.log(`DEBUG: About to call postStateToWebview`)
@ -261,22 +261,15 @@ export const webviewMessageHandler = async (
provider.isViewLaunched = true
break
case "fetchPackageManagerItems":
if (packageManagerManager) {
console.log(`DEBUG: Handling explicit fetchPackageManagerItems message`)
case "fetchMarketplaceItems":
if (marketplaceManager) {
try {
// Use non-null assertion to tell TypeScript that packageManagerManager is definitely not undefined here
console.log(`DEBUG: Before calling handlePackageManagerMessages for fetchPackageManagerItems`)
const result = await handlePackageManagerMessages(provider, message, packageManagerManager!)
console.log(
`DEBUG: After calling handlePackageManagerMessages for fetchPackageManagerItems, result: ${result}`,
)
console.log(`DEBUG: Package manager message handled successfully: ${message.type}`)
const result = await handleMarketplaceMessages(provider, message, marketplaceManager!)
} catch (error) {
console.error(`DEBUG: Error handling package manager message: ${error}`)
console.error(`DEBUG: Error handling marketplace message: ${error}`)
}
} else {
console.log(`DEBUG: packageManagerManager is undefined, skipping package manager message handling`)
console.log(`DEBUG: marketplaceManager is undefined, skipping marketplace message handling`)
}
break
case "newTask":
@ -1406,21 +1399,19 @@ export const webviewMessageHandler = async (
}
}
// Handle package manager related messages
// Handle package manager messages
if (
packageManagerManager &&
(message.type === "packageManagerSources" ||
marketplaceManager &&
(message.type === "marketplaceSources" ||
message.type === "openExternal" ||
message.type === "refreshPackageManagerSource" ||
message.type === "filterPackageManagerItems")
message.type === "refreshMarketplaceSource" ||
message.type === "filterMarketplaceItems")
) {
try {
console.log(`DEBUG: Routing ${message.type} message to packageManagerMessageHandler`)
const result = await handlePackageManagerMessages(provider, message, packageManagerManager)
console.log(`DEBUG: Package manager message handled successfully: ${message.type}, result: ${result}`)
console.log(`DEBUG: Routing ${message.type} message to marketplaceMessageHandler`)
const result = await handleMarketplaceMessages(provider, message, marketplaceManager)
console.log(`DEBUG: Marketplace message handled successfully: ${message.type}, result: ${result}`)
} catch (error) {
console.error(`DEBUG: Error handling package manager message: ${error}`)
console.error(`DEBUG: Error handling marketplace message: ${error}`)
}
}
}

View file

@ -352,7 +352,7 @@ type GlobalSettings = {
}
| undefined
enhancementApiConfigId?: string | undefined
packageManagerSources?:
marketplaceSources?:
| {
url: string
name?: string | undefined

View file

@ -355,7 +355,7 @@ type GlobalSettings = {
}
| undefined
enhancementApiConfigId?: string | undefined
packageManagerSources?:
marketplaceSources?:
| {
url: string
name?: string | undefined

View file

@ -19,7 +19,7 @@ import { ClineProvider } from "./core/webview/ClineProvider"
import { CodeActionProvider } from "./core/CodeActionProvider"
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
import { McpServerManager } from "./services/mcp/McpServerManager"
import { PackageManagerManager } from "./services/package-manager"
import { MarketplaceManager } from "./services/marketplace"
import { telemetryService } from "./services/telemetry/TelemetryService"
import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry"
import { API } from "./exports/api"
@ -38,7 +38,7 @@ import { formatLanguage } from "./shared/language"
let outputChannel: vscode.OutputChannel
let extensionContext: vscode.ExtensionContext
let packageManagerManager: PackageManagerManager
let marketplaceManager: MarketplaceManager
// This method is called when your extension is activated.
// Your extension is activated the very first time the command is executed.
@ -70,9 +70,8 @@ export async function activate(context: vscode.ExtensionContext) {
const provider = new ClineProvider(context, outputChannel, "sidebar")
// Initialize package manager
packageManagerManager = new PackageManagerManager(context)
provider.setPackageManagerManager(packageManagerManager)
marketplaceManager = new MarketplaceManager(context)
provider.setMarketplaceManager(marketplaceManager)
telemetryService.setProvider(provider)
context.subscriptions.push(
@ -134,12 +133,11 @@ export async function activate(context: vscode.ExtensionContext) {
export async function deactivate() {
outputChannel.appendLine("Roo-Code extension deactivated")
// Clean up package manager
if (packageManagerManager) {
if (marketplaceManager) {
try {
await packageManagerManager.cleanup()
await marketplaceManager.cleanup()
} catch (error) {
console.error("Failed to clean up package manager:", error)
console.error("Failed to clean up marketplace:", error)
}
}

View file

@ -567,7 +567,7 @@ export const globalSettingsSchema = z.object({
customModePrompts: customModePromptsSchema.optional(),
customSupportPrompts: customSupportPromptsSchema.optional(),
enhancementApiConfigId: z.string().optional(),
packageManagerSources: z
marketplaceSources: z
.array(
z.object({
url: z.string(),
@ -652,7 +652,7 @@ const globalSettingsRecord: GlobalSettingsRecord = {
customSupportPrompts: undefined,
enhancementApiConfigId: undefined,
cachedChromeHostUrl: undefined,
packageManagerSources: undefined,
marketplaceSources: undefined,
}
export const GLOBAL_SETTINGS_KEYS = Object.keys(globalSettingsRecord) as Keys<GlobalSettings>[]

View file

@ -5,11 +5,11 @@ import * as yaml from "js-yaml"
import simpleGit, { SimpleGit } from "simple-git"
import { MetadataScanner } from "./MetadataScanner"
import { validateAnyMetadata } from "./schemas"
import { LocalizationOptions, PackageManagerItem, PackageManagerRepository, RepositoryMetadata } from "./types"
import { LocalizationOptions, MarketplaceItem, MarketplaceRepository, RepositoryMetadata } from "./types"
import { getUserLocale } from "./utils"
/**
* Handles fetching and caching package manager repositories
* Handles fetching and caching marketplace repositories
*/
export class GitFetcher {
private readonly cacheDir: string
@ -19,7 +19,7 @@ export class GitFetcher {
private activeGitInstances: Set<SimpleGit> = new Set()
constructor(context: vscode.ExtensionContext, localizationOptions?: LocalizationOptions) {
this.cacheDir = path.join(context.globalStorageUri.fsPath, "package-manager-cache")
this.cacheDir = path.join(context.globalStorageUri.fsPath, "marketplace-cache")
this.localizationOptions = localizationOptions || {
userLocale: getUserLocale(),
fallbackLocale: "en",
@ -85,11 +85,7 @@ export class GitFetcher {
* @param sourceName Optional source repository name
* @returns Repository data
*/
async fetchRepository(
repoUrl: string,
forceRefresh = false,
sourceName?: string,
): Promise<PackageManagerRepository> {
async fetchRepository(repoUrl: string, forceRefresh = false, sourceName?: string): Promise<MarketplaceRepository> {
// Ensure cache directory exists
await fs.mkdir(this.cacheDir, { recursive: true })
@ -109,7 +105,7 @@ export class GitFetcher {
// Parse repository metadata
const metadata = await this.parseRepositoryMetadata(repoDir)
// Parse package manager items
// Parse marketplace items
// Get current branch using existing git instance
const branch = (await this.git?.revparse(["--abbrev-ref", "HEAD"])) || "main"
@ -291,17 +287,17 @@ export class GitFetcher {
}
/**
* Parse package manager items
* Parse marketplace items
* @param repoDir Repository directory
* @param repoUrl Repository URL
* @param sourceName Source repository name
* @returns Array of package manager items
* @returns Array of marketplace items
*/
private async parsePackageManagerItems(
repoDir: string,
repoUrl: string,
sourceName: string,
): Promise<PackageManagerItem[]> {
): Promise<MarketplaceItem[]> {
return this.metadataScanner.scanDirectory(repoDir, repoUrl, sourceName)
}
}

View file

@ -3,24 +3,25 @@ import * as path from "path"
import * as fs from "fs/promises"
import { GitFetcher } from "./GitFetcher"
import {
PackageManagerItem,
PackageManagerRepository,
PackageManagerSource,
MarketplaceItem,
MarketplaceRepository,
MarketplaceSource,
ComponentType,
ComponentMetadata,
LocalizationOptions,
} from "./types"
import { validateSource, validateSources } from "../../shared/MarketplaceValidation"
import { getUserLocale } from "./utils"
/**
* Service for managing package manager data
* Service for managing marketplace data
*/
export class PackageManagerManager {
private currentItems: PackageManagerItem[] = []
export class MarketplaceManager {
private currentItems: MarketplaceItem[] = []
private static readonly CACHE_EXPIRY_MS = 3600000 // 1 hour
private gitFetcher: GitFetcher
private cache: Map<string, { data: PackageManagerRepository; timestamp: number }> = new Map()
private cache: Map<string, { data: MarketplaceRepository; timestamp: number }> = new Map()
public isFetching = false
// Concurrency control
@ -36,11 +37,6 @@ export class PackageManagerManager {
this.gitFetcher = new GitFetcher(context, localizationOptions)
}
/**
* Gets package manager items from all enabled sources
* @param sources The package manager sources
* @returns An array of PackageManagerItem objects
*/
/**
* Queue an operation to run when no metadata scan is active
*/
@ -68,10 +64,8 @@ export class PackageManagerManager {
}
}
async getPackageManagerItems(
sources: PackageManagerSource[],
): Promise<{ items: PackageManagerItem[]; errors?: string[] }> {
const items: PackageManagerItem[] = []
async getMarketplaceItems(sources: MarketplaceSource[]): Promise<{ items: MarketplaceItem[]; errors?: string[] }> {
const items: MarketplaceItem[] = []
const errors: string[] = []
// Filter enabled sources
@ -91,12 +85,18 @@ export class PackageManagerManager {
const repo = await this.getRepositoryData(source.url, false, source.name)
if (repo.items && repo.items.length > 0) {
items.push(...repo.items)
// Ensure each item is properly attributed to its source
const itemsWithSource = repo.items.map((item) => ({
...item,
sourceName: source.name || this.getRepoNameFromUrl(source.url),
sourceUrl: source.url,
}))
items.push(...itemsWithSource)
}
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`PackageManagerManager: Failed to fetch data from ${source.url}:`, error)
console.error(`MarketplaceManager: Failed to fetch data from ${source.url}:`, error)
errors.push(`Source ${source.url}: ${errorMessage}`)
} finally {
this.unlockSource(source.url)
@ -115,13 +115,6 @@ export class PackageManagerManager {
return result
}
/**
* Gets repository data from a URL, using cache if available
* @param url The repository URL
* @param forceRefresh Whether to bypass the cache and force a refresh
* @param sourceName The name of the source
* @returns A PackageManagerRepository object
*/
/**
* Check if a source operation is in progress
*/
@ -147,12 +140,12 @@ export class PackageManagerManager {
url: string,
forceRefresh: boolean = false,
sourceName?: string,
): Promise<PackageManagerRepository> {
): Promise<MarketplaceRepository> {
try {
// Check cache first (unless force refresh is requested)
const cached = this.cache.get(url)
if (!forceRefresh && cached && Date.now() - cached.timestamp < PackageManagerManager.CACHE_EXPIRY_MS) {
if (!forceRefresh && cached && Date.now() - cached.timestamp < MarketplaceManager.CACHE_EXPIRY_MS) {
return cached.data
}
@ -161,7 +154,7 @@ export class PackageManagerManager {
// Create a timeout promise
let timeoutId: NodeJS.Timeout | undefined
const timeoutPromise = new Promise<PackageManagerRepository>((_, reject) => {
const timeoutPromise = new Promise<MarketplaceRepository>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`Repository fetch timed out after 30 seconds: ${url}`))
}, 30000) // 30 second timeout
@ -181,7 +174,7 @@ export class PackageManagerManager {
}
}
} catch (error) {
console.error(`PackageManagerManager: Error fetching repository data for ${url}:`, error)
console.error(`MarketplaceManager: Error fetching repository data for ${url}:`, error)
// Return empty repository data instead of throwing
return {
@ -202,13 +195,13 @@ export class PackageManagerManager {
* @param sourceName Optional name of the source
* @returns The refreshed repository data
*/
async refreshRepository(url: string, sourceName?: string): Promise<PackageManagerRepository> {
async refreshRepository(url: string, sourceName?: string): Promise<MarketplaceRepository> {
try {
// Force a refresh by bypassing the cache
const data = await this.getRepositoryData(url, true, sourceName)
return data
} catch (error) {
console.error(`PackageManagerManager: Failed to refresh repository ${url}:`, error)
console.error(`MarketplaceManager: Failed to refresh repository ${url}:`, error)
return {
metadata: {
name: "Unknown Repository",
@ -231,12 +224,12 @@ export class PackageManagerManager {
/**
* Cleans up cache directories for repositories that are no longer in the configured sources
* @param currentSources The current list of package manager sources
* @param currentSources The current list of marketplace sources
*/
async cleanupCacheDirectories(currentSources: PackageManagerSource[]): Promise<void> {
async cleanupCacheDirectories(currentSources: MarketplaceSource[]): Promise<void> {
try {
// Get the cache directory path
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "package-manager-cache")
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "marketplace-cache")
// Check if cache directory exists
try {
@ -261,11 +254,11 @@ export class PackageManagerManager {
const dirPath = path.join(cacheDir, dirName)
await fs.rm(dirPath, { recursive: true, force: true })
} catch (error) {
console.error(`PackageManagerManager: Failed to delete directory ${dirName}:`, error)
console.error(`MarketplaceManager: Failed to delete directory ${dirName}:`, error)
}
}
} catch (error) {
console.error("PackageManagerManager: Error cleaning up cache directories:", error)
console.error("MarketplaceManager: Error cleaning up cache directories:", error)
}
}
@ -282,7 +275,7 @@ export class PackageManagerManager {
}
/**
* Filters package manager items based on criteria
* Filters marketplace items based on criteria
* @param items The items to filter
* @param filters The filter criteria
* @returns Filtered items
@ -293,7 +286,7 @@ export class PackageManagerManager {
private filterCache = new Map<
string,
{
items: PackageManagerItem[]
items: MarketplaceItem[]
timestamp: number
}
>()
@ -302,11 +295,11 @@ export class PackageManagerManager {
* Clear old entries from the filter cache
*/
private cleanupFilterCache(): void {
if (this.filterCache.size > PackageManagerManager.MAX_CACHE_SIZE) {
if (this.filterCache.size > MarketplaceManager.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)
.slice(0, MarketplaceManager.MAX_CACHE_SIZE)
this.filterCache.clear()
entries.forEach(([key, value]) => this.filterCache.set(key, value))
@ -317,9 +310,9 @@ export class PackageManagerManager {
* Filter items
*/
filterItems(
items: PackageManagerItem[],
items: MarketplaceItem[],
filters: { type?: ComponentType; search?: string; tags?: string[] },
): PackageManagerItem[] {
): MarketplaceItem[] {
// Create cache key from filters
const cacheKey = JSON.stringify(filters)
const cached = this.filterCache.get(cacheKey)
@ -331,9 +324,9 @@ export class PackageManagerManager {
this.cleanupFilterCache()
// Process items in batches to avoid memory spikes
const allFilteredItems: PackageManagerItem[] = []
for (let i = 0; i < items.length; i += PackageManagerManager.BATCH_SIZE) {
const batch = items.slice(i, Math.min(i + PackageManagerManager.BATCH_SIZE, items.length))
const allFilteredItems: MarketplaceItem[] = []
for (let i = 0; i < items.length; i += MarketplaceManager.BATCH_SIZE) {
const batch = items.slice(i, Math.min(i + MarketplaceManager.BATCH_SIZE, items.length))
const filteredBatch = this.processItemBatch(batch, filters)
allFilteredItems.push(...filteredBatch)
}
@ -347,16 +340,13 @@ export class PackageManagerManager {
return allFilteredItems
}
/**
* Process a batch of items
*/
/**
* Process a batch of items
*/
private processItemBatch(
batch: PackageManagerItem[],
batch: MarketplaceItem[],
filters: { type?: ComponentType; search?: string; tags?: string[] },
): PackageManagerItem[] {
): MarketplaceItem[] {
// Helper functions
const normalizeText = (text: string) => text.toLowerCase().replace(/\s+/g, " ").trim()
const searchTerm = filters.search ? normalizeText(filters.search) : ""
@ -463,22 +453,22 @@ export class PackageManagerManager {
return null
})
.filter((item): item is PackageManagerItem => item !== null)
.filter((item): item is MarketplaceItem => item !== null)
}
/**
* Sorts package manager items
* Sorts marketplace items
* @param items The items to sort
* @param sortBy The field to sort by
* @param sortOrder The sort order
* @returns Sorted items
*/
sortItems(
items: PackageManagerItem[],
sortBy: keyof Pick<PackageManagerItem, "name" | "author" | "lastUpdated">,
items: MarketplaceItem[],
sortBy: keyof Pick<MarketplaceItem, "name" | "author" | "lastUpdated">,
sortOrder: "asc" | "desc",
sortSubcomponents: boolean = false,
): PackageManagerItem[] {
): MarketplaceItem[] {
return [...items]
.map((item) => {
// Deep clone the item
@ -506,11 +496,12 @@ export class PackageManagerManager {
return sortOrder === "asc" ? comparison : -comparison
})
}
/**
* Gets the current package manager items
* Gets the current marketplace items
* @returns The current items
*/
getCurrentItems(): PackageManagerItem[] {
getCurrentItems(): MarketplaceItem[] {
return this.currentItems
}
@ -519,14 +510,14 @@ export class PackageManagerManager {
* @param filters The filter criteria
* @returns Filtered items
*/
updateWithFilteredItems(filters: { type?: ComponentType; search?: string; tags?: string[] }): PackageManagerItem[] {
updateWithFilteredItems(filters: { type?: ComponentType; search?: string; tags?: string[] }): MarketplaceItem[] {
const filteredItems = this.filterItems(this.currentItems, filters)
this.currentItems = filteredItems
return filteredItems
}
/**
* Cleans up resources used by the package manager
* Cleans up resources used by the marketplace
*/
async cleanup(): Promise<void> {
// Clean up cache directories for all sources
@ -542,9 +533,9 @@ export class PackageManagerManager {
*/
private getSortValue(
item:
| PackageManagerItem
| MarketplaceItem
| { type: ComponentType; path: string; metadata?: ComponentMetadata; lastUpdated?: string },
sortBy: keyof Pick<PackageManagerItem, "name" | "author" | "lastUpdated">,
sortBy: keyof Pick<MarketplaceItem, "name" | "author" | "lastUpdated">,
): string {
if ("metadata" in item && item.metadata) {
// Handle subcomponent
@ -560,7 +551,7 @@ export class PackageManagerManager {
}
} else {
// Handle parent item
const parentItem = item as PackageManagerItem
const parentItem = item as MarketplaceItem
switch (sortBy) {
case "name":
return parentItem.name

View file

@ -9,7 +9,7 @@ import {
ComponentType,
LocalizationOptions,
LocalizedMetadata,
PackageManagerItem,
MarketplaceItem,
PackageMetadata,
} from "./types"
import { getUserLocale } from "./utils"
@ -60,12 +60,12 @@ export class MetadataScanner {
repoUrl: string,
sourceName?: string,
depth: number = 0,
): AsyncGenerator<PackageManagerItem[]> {
): AsyncGenerator<MarketplaceItem[]> {
if (depth > MetadataScanner.MAX_DEPTH) {
return
}
const batch: PackageManagerItem[] = []
const batch: MarketplaceItem[] = []
const entries = await fs.readdir(rootDir, { withFileTypes: true })
for (const entry of entries) {
@ -129,13 +129,13 @@ export class MetadataScanner {
repoUrl: string,
sourceName?: string,
isRecursiveCall: boolean = false,
): Promise<PackageManagerItem[]> {
): Promise<MarketplaceItem[]> {
// Only set originalRootDir on the first call
if (!isRecursiveCall && !this.originalRootDir) {
this.originalRootDir = rootDir
}
const items: PackageManagerItem[] = []
const items: MarketplaceItem[] = []
const generator = this.scanDirectoryBatched(rootDir, repoUrl, sourceName)
for await (const batch of generator) {
@ -233,7 +233,7 @@ export class MetadataScanner {
repoUrl: string,
rootDir: string,
sourceName?: string,
): Promise<PackageManagerItem | null> {
): Promise<MarketplaceItem | null> {
// Skip if no type or invalid type
if (!metadata.type || !this.isValidComponentType(metadata.type)) {
return null
@ -307,7 +307,7 @@ export class MetadataScanner {
*/
private async scanPackageSubcomponents(
packageDir: string,
packageItem: PackageManagerItem,
packageItem: MarketplaceItem,
parentPath: string = "",
): Promise<void> {
try {

View file

@ -74,7 +74,7 @@ describe("GitFetcher", () => {
let gitFetcher: GitFetcher
const mockSimpleGit = simpleGit as jest.MockedFunction<typeof simpleGit>
const testRepoUrl = "https://github.com/test/repo"
const testRepoDir = path.join(mockContext.globalStorageUri.fsPath, "package-manager-cache", "repo")
const testRepoDir = path.join(mockContext.globalStorageUri.fsPath, "marketplace-cache", "repo")
beforeEach(() => {
jest.clearAllMocks()
@ -87,15 +87,15 @@ describe("GitFetcher", () => {
if (!options?.recursive || !options?.force) {
return Promise.reject(new Error("Invalid rm call: missing recursive or force options"))
}
// Allow any path under package-manager-cache directory
// Allow any path under marketplace-cache directory
const normalizedPath = path.normalize(pathToRemove)
const normalizedCachePath = path.normalize(
path.join(mockContext.globalStorageUri.fsPath, "package-manager-cache"),
path.join(mockContext.globalStorageUri.fsPath, "marketplace-cache"),
)
if (normalizedPath.startsWith(normalizedCachePath)) {
return Promise.resolve(undefined)
}
return Promise.reject(new Error(`Invalid rm call: path ${pathToRemove} not in package-manager-cache`))
return Promise.reject(new Error(`Invalid rm call: path ${pathToRemove} not in marketplace-cache`))
})
// Setup fs.stat mock for repository structure validation
@ -375,7 +375,7 @@ describe("GitFetcher", () => {
// Verify that simpleGit's clone was called with the correct arguments
const mockGit = mockSimpleGit()
expect(mockGit.clone).toHaveBeenCalledWith(url, expect.stringContaining("package-manager-cache"))
expect(mockGit.clone).toHaveBeenCalledWith(url, expect.stringContaining("marketplace-cache"))
})
it("should handle paths with special characters when cloning", async () => {
@ -389,7 +389,7 @@ describe("GitFetcher", () => {
// Verify that simpleGit's clone was called with the correct arguments
const mockGit = mockSimpleGit()
expect(mockGit.clone).toHaveBeenCalledWith(url, expect.stringContaining("package-manager-cache"))
expect(mockGit.clone).toHaveBeenCalledWith(url, expect.stringContaining("marketplace-cache"))
})
})
})

View file

@ -0,0 +1,8 @@
import { isValidGitRepositoryUrl } from "../../../shared/MarketplaceValidation"
describe("Git URL Validation", () => {
test("validates multi-segment domain SSH URL", () => {
const url = "git@git.lab.company.com:team-name/project-name.git"
expect(isValidGitRepositoryUrl(url)).toBe(true)
})
})

View file

@ -1,5 +1,5 @@
import { PackageManagerManager } from "../PackageManagerManager"
import { PackageManagerItem, PackageManagerSource, PackageManagerRepository, ComponentType } from "../types"
import { MarketplaceManager } from "../MarketplaceManager"
import { MarketplaceItem, MarketplaceSource, MarketplaceRepository, ComponentType } from "../types"
import { MetadataScanner } from "../MetadataScanner"
import { GitFetcher } from "../GitFetcher"
import * as path from "path"
@ -31,15 +31,15 @@ describe("PackageManagerManager", () => {
storageUri: { fsPath: "" },
} as unknown as vscode.ExtensionContext
let manager: PackageManagerManager
let manager: MarketplaceManager
beforeEach(() => {
// Create a new manager instance with the mock context for each test
manager = new PackageManagerManager(mockContext)
manager = new MarketplaceManager(mockContext)
})
it("should correctly filter items by search term", () => {
const items: PackageManagerItem[] = [
const items: MarketplaceItem[] = [
{
name: "Test Item 1",
description: "First test item",
@ -63,7 +63,7 @@ describe("PackageManagerManager", () => {
})
it("should correctly filter items by type", () => {
const items: PackageManagerItem[] = [
const items: MarketplaceItem[] = [
{
name: "Mode Item",
description: "A mode",
@ -87,7 +87,7 @@ describe("PackageManagerManager", () => {
})
it("should preserve original items when filtering", () => {
const items: PackageManagerItem[] = [
const items: MarketplaceItem[] = [
{
name: "Test Item 1",
description: "First test item",
@ -110,13 +110,13 @@ describe("PackageManagerManager", () => {
})
})
let manager: PackageManagerManager
let manager: MarketplaceManager
let metadataScanner: MetadataScanner
let realItems: PackageManagerItem[]
let realItems: MarketplaceItem[]
beforeAll(async () => {
// Load real data from the template
const templatePath = path.resolve(__dirname, "../../../../package-manager-template")
const templatePath = path.resolve(__dirname, "../../../../marketplace-template")
metadataScanner = new MetadataScanner()
realItems = await metadataScanner.scanDirectory(templatePath, "https://example.com")
})
@ -125,14 +125,14 @@ describe("PackageManagerManager", () => {
const context = {
globalStorageUri: { fsPath: path.resolve(__dirname, "../../../../mock/settings/path") },
} as vscode.ExtensionContext
manager = new PackageManagerManager(context)
manager = new MarketplaceManager(context)
})
describe("Type Filter Behavior", () => {
let typeFilterTestItems: PackageManagerItem[]
let typeFilterTestItems: MarketplaceItem[]
test("should include package with MCP server subcomponent when filtering by type 'mcp server'", () => {
const items: PackageManagerItem[] = [
const items: MarketplaceItem[] = [
{
name: "Data Platform Package",
description: "A package containing MCP servers",
@ -175,7 +175,7 @@ describe("PackageManagerManager", () => {
})
test("should include package when filtering by subcomponent type", () => {
const items: PackageManagerItem[] = [
const items: MarketplaceItem[] = [
{
name: "Data Platform Package",
description: "A package containing MCP servers",
@ -265,7 +265,7 @@ describe("PackageManagerManager", () => {
test("should not include package when filtering by type with no matching subcomponents", () => {
// Create a package with no matching subcomponents
const noMatchPackage: PackageManagerItem = {
const noMatchPackage: MarketplaceItem = {
name: "No Match Package",
description: "A package with no matching subcomponents",
type: "package",
@ -294,7 +294,7 @@ describe("PackageManagerManager", () => {
test("should handle package with no subcomponents", () => {
// Create a package with no subcomponents
const noSubcomponentsPackage: PackageManagerItem = {
const noSubcomponentsPackage: MarketplaceItem = {
name: "No Subcomponents Package",
description: "A package with no subcomponents",
type: "package",
@ -310,7 +310,7 @@ describe("PackageManagerManager", () => {
})
describe("Consistency with Search Term Behavior", () => {
let consistencyTestItems: PackageManagerItem[]
let consistencyTestItems: MarketplaceItem[]
beforeEach(() => {
// Create test items
@ -376,7 +376,7 @@ describe("PackageManagerManager", () => {
})
describe("sortItems with subcomponents", () => {
const testItems: PackageManagerItem[] = [
const testItems: MarketplaceItem[] = [
{
name: "B Package",
description: "Package B",
@ -463,7 +463,7 @@ describe("PackageManagerManager", () => {
url: "/test/c",
repoUrl: "https://example.com",
items: [],
} as PackageManagerItem,
} as MarketplaceItem,
]
const sorted = manager.sortItems(itemsWithEmpty, "name", "asc")
expect(sorted[2].name).toBe("C Package")
@ -473,7 +473,7 @@ describe("PackageManagerManager", () => {
describe("filterItems with real data", () => {
it("should return all subcomponents with match info", () => {
const testItems: PackageManagerItem[] = [
const testItems: MarketplaceItem[] = [
{
name: "Data Platform Package",
description: "A test platform",
@ -546,11 +546,11 @@ describe("PackageManagerManager", () => {
})
})
// This test was skipped because it depends on the actual content of the package-manager-template
// This test was skipped because it depends on the actual content of the marketplace-template
// which may change over time
it("should find data validator in package-manager-template", async () => {
it("should find data validator in marketplace-template", async () => {
// Load real data from the template
const templatePath = path.resolve(__dirname, "../../../../package-manager-template")
const templatePath = path.resolve(__dirname, "../../../../marketplace-template")
const scanner = new MetadataScanner()
const items = await scanner.scanDirectory(templatePath, "https://example.com")
@ -594,18 +594,68 @@ describe("PackageManagerManager", () => {
})
})
describe("Concurrency Control", () => {
let manager: PackageManagerManager
describe("Source Attribution", () => {
let manager: MarketplaceManager
beforeEach(() => {
const mockContext = {
globalStorageUri: { fsPath: "/test/path" },
} as vscode.ExtensionContext
manager = new PackageManagerManager(mockContext)
manager = new MarketplaceManager(mockContext)
})
it("should maintain source attribution for items", async () => {
const sources: MarketplaceSource[] = [
{ url: "https://github.com/test/repo1", name: "Source 1", enabled: true },
{ url: "https://github.com/test/repo2", name: "Source 2", enabled: true },
]
// Mock getRepositoryData to return different items for each source
jest.spyOn(manager as any, "getRepositoryData")
.mockImplementationOnce(() =>
Promise.resolve({
metadata: { name: "test", description: "test", version: "1.0.0" },
items: [
{
name: "Item 1",
type: "mode",
description: "Test item",
url: "test1",
repoUrl: "https://github.com/test/repo1",
},
],
url: sources[0].url,
}),
)
.mockImplementationOnce(() =>
Promise.resolve({
metadata: { name: "test", description: "test", version: "1.0.0" },
items: [],
url: sources[1].url,
}),
)
const result = await manager.getMarketplaceItems(sources)
// Verify items maintain their source attribution
expect(result.items).toHaveLength(1)
expect(result.items[0].sourceName).toBe("Source 1")
expect(result.items[0].sourceUrl).toBe("https://github.com/test/repo1")
})
})
describe("Concurrency Control", () => {
let manager: MarketplaceManager
beforeEach(() => {
const mockContext = {
globalStorageUri: { fsPath: "/test/path" },
} as vscode.ExtensionContext
manager = new MarketplaceManager(mockContext)
})
it("should not allow concurrent operations on the same source", async () => {
const source: PackageManagerSource = {
const source: MarketplaceSource = {
url: "https://github.com/test/repo",
enabled: true,
}
@ -616,12 +666,12 @@ describe("Concurrency Control", () => {
metadata: { name: "test", description: "test", version: "1.0.0" },
items: [],
url: source.url,
} as PackageManagerRepository),
} as MarketplaceRepository),
)
// Start two concurrent operations
const operation1 = manager.getPackageManagerItems([source])
const operation2 = manager.getPackageManagerItems([source])
const operation1 = manager.getMarketplaceItems([source])
const operation2 = manager.getMarketplaceItems([source])
// Wait for both to complete
const [result1, result2] = await Promise.all([operation1, operation2])
@ -635,11 +685,11 @@ describe("Concurrency Control", () => {
it("should not allow metadata scanning during git operations", async () => {
try {
const source1: PackageManagerSource = {
const source1: MarketplaceSource = {
url: "https://github.com/test/repo1",
enabled: true,
}
const source2: PackageManagerSource = {
const source2: MarketplaceSource = {
url: "https://github.com/test/repo2",
enabled: true,
}
@ -667,7 +717,7 @@ describe("Concurrency Control", () => {
})
// Process both sources
await manager.getPackageManagerItems([source1, source2])
await manager.getMarketplaceItems([source1, source2])
// Verify metadata scanning didn't occur during git operations
expect(metadataScanDuringGit).toBe(false)
@ -677,7 +727,7 @@ describe("Concurrency Control", () => {
})
it("should queue metadata scans and process them sequentially", async () => {
const sources: PackageManagerSource[] = [
const sources: MarketplaceSource[] = [
{ url: "https://github.com/test/repo1", enabled: true },
{ url: "https://github.com/test/repo2", enabled: true },
{ url: "https://github.com/test/repo3", enabled: true },
@ -721,7 +771,7 @@ describe("Concurrency Control", () => {
;(manager as any).gitFetcher = mockGitFetcher
// Process all sources
await manager.getPackageManagerItems(sources)
await manager.getMarketplaceItems(sources)
// Verify scans were called and only one was active at a time
expect(scanDirectorySpy).toHaveBeenCalledTimes(sources.length)

View file

@ -6,13 +6,12 @@ import {
validateSource,
validateSources,
ValidationError,
} from "../PackageManagerSourceValidation"
import { PackageManagerSource } from "../types"
} from "../../../shared/MarketplaceValidation"
import { MarketplaceSource } from "../types"
describe("PackageManagerSourceValidation", () => {
describe("MarketplaceSourceValidation", () => {
describe("isValidGitRepositoryUrl", () => {
const validUrls = [
// Public Git hosting services
"https://github.com/username/repo",
"https://gitlab.com/username/repo",
"https://bitbucket.org/username/repo",
@ -68,7 +67,7 @@ describe("PackageManagerSourceValidation", () => {
expect(errors).toHaveLength(1)
expect(errors[0]).toEqual({
field: "url",
message: "Invalid URL format",
message: "URL must be a valid Git repository URL (e.g., https://git.example.com/username/repo)",
})
})
@ -122,13 +121,13 @@ describe("PackageManagerSourceValidation", () => {
})
describe("validateSourceDuplicates", () => {
const existingSources: PackageManagerSource[] = [
const existingSources: MarketplaceSource[] = [
{ url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true },
{ url: "https://git.company.com/user2/repo2", name: "Source 2", enabled: true },
]
test("should accept unique sources", () => {
const newSource: PackageManagerSource = {
const newSource: MarketplaceSource = {
url: "https://git.company.com/user3/repo3",
name: "Source 3",
enabled: true,
@ -138,7 +137,7 @@ describe("PackageManagerSourceValidation", () => {
})
test("should reject duplicate URLs (case insensitive)", () => {
const newSource: PackageManagerSource = {
const newSource: MarketplaceSource = {
url: "HTTPS://GIT.COMPANY.COM/USER1/REPO1",
name: "Different Name",
enabled: true,
@ -150,7 +149,7 @@ describe("PackageManagerSourceValidation", () => {
})
test("should reject duplicate names (case insensitive)", () => {
const newSource: PackageManagerSource = {
const newSource: MarketplaceSource = {
url: "https://git.company.com/user3/repo3",
name: "SOURCE 1",
enabled: true,
@ -162,7 +161,7 @@ describe("PackageManagerSourceValidation", () => {
})
test("should detect duplicates within source list", () => {
const sourcesWithDuplicates: PackageManagerSource[] = [
const sourcesWithDuplicates: MarketplaceSource[] = [
{ url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true },
{ url: "https://git.company.com/user1/repo1", name: "Source 2", enabled: true }, // Duplicate URL
{ url: "https://git.company.com/user3/repo3", name: "Source 1", enabled: true }, // Duplicate name
@ -185,12 +184,12 @@ describe("PackageManagerSourceValidation", () => {
})
describe("validateSource", () => {
const existingSources: PackageManagerSource[] = [
const existingSources: MarketplaceSource[] = [
{ url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true },
]
test("should accept valid source", () => {
const source: PackageManagerSource = {
const source: MarketplaceSource = {
url: "https://git.company.com/user2/repo2",
name: "Source 2",
enabled: true,
@ -200,7 +199,7 @@ describe("PackageManagerSourceValidation", () => {
})
test("should accumulate multiple validation errors", () => {
const source: PackageManagerSource = {
const source: MarketplaceSource = {
url: "https://git.company.com/user1/repo1", // Duplicate URL
name: "This name is way too long to be valid\t", // Too long and has tab
enabled: true,
@ -212,7 +211,7 @@ describe("PackageManagerSourceValidation", () => {
describe("validateSources", () => {
test("should accept valid source list", () => {
const sources: PackageManagerSource[] = [
const sources: MarketplaceSource[] = [
{ url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true },
{ url: "https://git.company.com/user2/repo2", name: "Source 2", enabled: true },
]
@ -221,7 +220,7 @@ describe("PackageManagerSourceValidation", () => {
})
test("should detect multiple issues across sources", () => {
const sources: PackageManagerSource[] = [
const sources: MarketplaceSource[] = [
{ url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true },
{ url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true }, // Duplicate URL and name
{ url: "invalid-url", name: "This name is way too long\t", enabled: true }, // Invalid URL and name
@ -231,7 +230,7 @@ describe("PackageManagerSourceValidation", () => {
})
test("should include source index in error messages", () => {
const sources: PackageManagerSource[] = [{ url: "invalid-url", name: "Source 1", enabled: true }]
const sources: MarketplaceSource[] = [{ url: "invalid-url", name: "Source 1", enabled: true }]
const errors = validateSources(sources)
expect(errors[0].message).toContain("Source #1")
})

View file

@ -1,21 +1,21 @@
/**
* Constants for the package manager
* Constants for the marketplace
*/
/**
* Default package manager repository URL
* Default marketplace repository URL
*/
export const DEFAULT_PACKAGE_MANAGER_REPO_URL = "https://github.com/RooVetGit/Roo-Code-Marketplace"
/**
* Default package manager repository name
* Default marketplace repository name
*/
export const DEFAULT_PACKAGE_MANAGER_REPO_NAME = "Roo Code"
/**
* Default package manager source
* Default marketplace source
*/
export const DEFAULT_PACKAGE_MANAGER_SOURCE = {
export const DEFAULT_MARKETPLACE_SOURCE = {
url: DEFAULT_PACKAGE_MANAGER_REPO_URL,
name: DEFAULT_PACKAGE_MANAGER_REPO_NAME,
enabled: true,

View file

@ -0,0 +1,4 @@
export * from "./GitFetcher"
export * from "./MarketplaceManager"
export * from "./types"
export * from "../../shared/MarketplaceValidation"

View file

@ -65,9 +65,9 @@ export interface SubcomponentMetadata extends ComponentMetadata {
}
/**
* Represents an individual package manager item
* Represents an individual marketplace item
*/
export interface PackageManagerItem {
export interface MarketplaceItem {
name: string
description: string
type: ComponentType
@ -93,9 +93,9 @@ export interface PackageManagerItem {
}
/**
* Represents a Git repository source for package manager items
* Represents a Git repository source for marketplace items
*/
export interface PackageManagerSource {
export interface MarketplaceSource {
url: string
name?: string
enabled: boolean
@ -104,9 +104,9 @@ export interface PackageManagerSource {
/**
* Represents a repository with its metadata and items
*/
export interface PackageManagerRepository {
export interface MarketplaceRepository {
metadata: RepositoryMetadata
items: PackageManagerItem[]
items: MarketplaceItem[]
url: string
error?: string
defaultBranch?: string

View file

@ -1,4 +0,0 @@
export * from "./GitFetcher"
export * from "./PackageManagerManager"
export * from "./types"
export * from "./PackageManagerSourceValidation"

View file

@ -15,7 +15,7 @@ import {
import { McpServer } from "./mcp"
import { GitCommit } from "../utils/git"
import { Mode } from "./modes"
import { PackageManagerItem, PackageManagerSource } from "../services/package-manager/types"
import { MarketplaceItem, MarketplaceSource } from "../services/marketplace/types"
export type { ApiConfigMeta, ToolProgressStatus }
@ -78,7 +78,7 @@ export interface ExtensionMessage {
| "settingsButtonClicked"
| "historyButtonClicked"
| "promptsButtonClicked"
| "packageManagerButtonClicked"
| "marketplaceButtonClicked"
| "didBecomeVisible"
| "focusInput"
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
@ -115,7 +115,7 @@ export interface ExtensionMessage {
label?: string
}>
error?: string
items?: PackageManagerItem[]
items?: MarketplaceItem[]
url?: string // For repositoryRefreshComplete
}
@ -211,8 +211,8 @@ export type ExtensionState = Pick<
renderContext: "sidebar" | "editor"
settingsImportedAt?: number
packageManagerSources?: PackageManagerSource[]
packageManagerItems?: PackageManagerItem[]
marketplaceSources?: MarketplaceSource[]
marketplaceItems?: MarketplaceItem[]
}
export type { ClineMessage, ClineAsk, ClineSay }

View file

@ -1,21 +1,16 @@
/**
* Validation utilities for package manager sources
* Shared validation utilities for marketplace sources
*/
import { PackageManagerSource } from "./types"
import { MarketplaceSource } from "../services/marketplace/types"
/**
* Error type for package manager source validation
* Error type for marketplace source validation
*/
export interface ValidationError {
field: string
message: string
}
/**
* Validates a package manager source URL
* @param url The URL to validate
* @returns An array of validation errors, empty if valid
*/
/**
* Checks if a URL is a valid Git repository URL
* @param url The URL to validate
@ -60,17 +55,6 @@ export function validateSourceUrl(url: string): ValidationError[] {
return errors // Return early if URL is empty
}
// Check if URL is valid format
try {
new URL(url)
} catch (e) {
errors.push({
field: "url",
message: "Invalid URL format",
})
return errors // Return early if URL is not valid
}
// Check for non-visible characters (except spaces)
const nonVisibleCharRegex = /[^\S ]/
if (nonVisibleCharRegex.test(url)) {
@ -91,11 +75,6 @@ export function validateSourceUrl(url: string): ValidationError[] {
return errors
}
/**
* Validates a package manager source name
* @param name The name to validate
* @returns An array of validation errors, empty if valid
*/
export function validateSourceName(name?: string): ValidationError[] {
const errors: ValidationError[] = []
@ -124,12 +103,6 @@ export function validateSourceName(name?: string): ValidationError[] {
return errors
}
/**
* Validates a list of package manager sources for duplicates
* @param sources The list of sources to validate
* @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>()
@ -143,14 +116,13 @@ function normalizeString(str: string): string {
}
export function validateSourceDuplicates(
sources: PackageManagerSource[],
newSource?: PackageManagerSource,
sources: MarketplaceSource[],
newSource?: MarketplaceSource,
): ValidationError[] {
const errors: ValidationError[] = []
const urlMap = new Map<string, number>()
const nameMap = new Map<string, number>()
// Process existing sources
// Process existing sources
const seen = new Set<string>()
@ -203,43 +175,11 @@ export function validateSourceDuplicates(
}
}
// Check new source against existing sources if provided
if (newSource) {
if (newSource.url) {
const normalizedNewUrl = normalizeString(newSource.url)
const existingUrlIndex = urlMap.get(normalizedNewUrl)
if (existingUrlIndex !== undefined) {
errors.push({
field: "url",
message: `URL is a duplicate of Source #${existingUrlIndex + 1}`,
})
}
}
if (newSource.name) {
const normalizedNewName = normalizeString(newSource.name)
const existingNameIndex = nameMap.get(normalizedNewName)
if (existingNameIndex !== undefined) {
errors.push({
field: "name",
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]
@ -262,26 +202,14 @@ export function validateSourceDuplicates(
}
}
}
// Remove temporary entries
urlMap.delete(normalizedNewUrl)
if (normalizedNewName) {
nameMap.delete(normalizedNewName)
}
}
return errors
}
/**
* Validates a package manager source
* @param source The source to validate
* @param existingSources Existing sources to check for duplicates
* @returns An array of validation errors, empty if valid
*/
export function validateSource(
source: PackageManagerSource,
existingSources: PackageManagerSource[] = [],
source: MarketplaceSource,
existingSources: MarketplaceSource[] = [],
): ValidationError[] {
// Combine all validation errors
return [
@ -291,12 +219,7 @@ export function validateSource(
]
}
/**
* Validates a list of package manager sources
* @param sources The sources to validate
* @returns An array of validation errors, empty if valid
*/
export function validateSources(sources: PackageManagerSource[]): ValidationError[] {
export function validateSources(sources: MarketplaceSource[]): 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

View file

@ -1,7 +1,7 @@
import { z } from "zod"
import { ApiConfiguration, ApiProvider } from "./api"
import { Mode, PromptComponent, ModeConfig } from "./modes"
import { PackageManagerSource } from "../services/package-manager/types"
import { MarketplaceSource } from "../services/marketplace/types"
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
@ -126,11 +126,11 @@ export interface WebviewMessage {
| "maxReadFileLine"
| "searchFiles"
| "toggleApiConfigPin"
| "packageManagerSources"
| "fetchPackageManagerItems"
| "filterPackageManagerItems"
| "packageManagerButtonClicked"
| "refreshPackageManagerSource"
| "marketplaceSources"
| "fetchMarketplaceItems"
| "filterMarketplaceItems"
| "marketplaceButtonClicked"
| "refreshMarketplaceSource"
| "repositoryRefreshComplete"
| "openExternal"
text?: string
@ -158,7 +158,7 @@ export interface WebviewMessage {
source?: "global" | "project"
requestId?: string
ids?: string[]
sources?: PackageManagerSource[]
sources?: MarketplaceSource[]
filters?: { type?: string; search?: string; tags?: string[] }
url?: string // For openExternal
}

View file

@ -0,0 +1,34 @@
import { isValidGitRepositoryUrl } from "../MarketplaceValidation"
describe("Git URL Validation", () => {
const validUrls = [
"https://github.com/user/repo",
"https://gitlab.com/group/repo",
"https://git.internal.company.com/team/repo",
"git@github.com:user/repo.git",
"git@git.internal.company.com:team/repo",
"git://gitlab.com/group/repo.git",
"git://git.internal.company.com/team-name/repo-name",
"https://github.com/org-name/repo-name",
"git@gitlab.com:group-name/project-name.git",
]
const invalidUrls = [
"not-a-url",
"http://single/repo",
"https://github.com/no-repo",
"git@github.com/wrong-format",
"git://invalid@domain:repo",
"git@domain:no-slash",
"git@domain:/invalid-start",
"git@domain:group//repo",
]
test.each(validUrls)("should accept valid git URL: %s", (url) => {
expect(isValidGitRepositoryUrl(url)).toBe(true)
})
test.each(invalidUrls)("should reject invalid git URL: %s", (url) => {
expect(isValidGitRepositoryUrl(url)).toBe(false)
})
})

View file

@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ExtensionMessage } from "../../src/shared/ExtensionMessage"
import TranslationProvider from "./i18n/TranslationContext"
import { PackageManagerViewStateManager } from "./components/package-manager/PackageManagerViewStateManager"
import { MarketplaceViewStateManager } from "./components/marketplace/MarketplaceViewStateManager"
import { vscode } from "./utils/vscode"
import { telemetryClient } from "./utils/TelemetryClient"
@ -14,11 +14,11 @@ import HistoryView from "./components/history/HistoryView"
import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView"
import WelcomeView from "./components/welcome/WelcomeView"
import McpView from "./components/mcp/McpView"
import PackageManagerView from "./components/package-manager/PackageManagerView"
import PackageManagerView from "./components/marketplace/MarketplaceView"
import PromptsView from "./components/prompts/PromptsView"
import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog"
type Tab = "settings" | "history" | "mcp" | "prompts" | "chat" | "packageManager"
type Tab = "settings" | "history" | "mcp" | "prompts" | "chat" | "marketplace"
const tabsByMessageAction: Partial<Record<NonNullable<ExtensionMessage["action"]>, Tab>> = {
chatButtonClicked: "chat",
@ -26,7 +26,7 @@ const tabsByMessageAction: Partial<Record<NonNullable<ExtensionMessage["action"]
promptsButtonClicked: "prompts",
mcpButtonClicked: "mcp",
historyButtonClicked: "history",
packageManagerButtonClicked: "packageManager",
marketplaceButtonClicked: "marketplace",
}
const App = () => {
@ -34,7 +34,7 @@ const App = () => {
useExtensionState()
// Create a persistent state manager
const packageManagerStateManager = useMemo(() => new PackageManagerViewStateManager(), [])
const marketplaceStateManager = useMemo(() => new MarketplaceViewStateManager(), [])
const [showAnnouncement, setShowAnnouncement] = useState(false)
const [tab, setTab] = useState<Tab>("chat")
@ -124,8 +124,8 @@ const App = () => {
{tab === "settings" && (
<SettingsView ref={settingsRef} onDone={() => setTab("chat")} targetSection={currentSection} />
)}
{tab === "packageManager" && (
<PackageManagerView stateManager={packageManagerStateManager} onDone={() => switchTab("chat")} />
{tab === "marketplace" && (
<PackageManagerView stateManager={marketplaceStateManager} onDone={() => switchTab("chat")} />
)}
<ChatView
ref={chatViewRef}

View file

@ -2,27 +2,28 @@ 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"
import { PackageManagerSource } from "../../../../src/services/package-manager/types"
import { PackageManagerViewStateManager } from "./PackageManagerViewStateManager"
import { MarketplaceSource } from "../../../../src/services/marketplace/types"
import { validateSource } from "../../../../src/shared/MarketplaceValidation"
import { MarketplaceViewStateManager } from "./MarketplaceViewStateManager"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "cmdk"
import { PackageManagerItemCard } from "./components/PackageManagerItemCard"
import { MarketplaceItemCard } from "./components/MarketplaceItemCard"
import { useStateManager } from "./useStateManager"
import { useAppTranslation } from "@/i18n/TranslationContext"
interface PackageManagerViewProps {
interface MarketplaceViewProps {
onDone?: () => void
stateManager: PackageManagerViewStateManager
stateManager: MarketplaceViewStateManager
}
const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateManager }) => {
const MarketplaceView: React.FC<MarketplaceViewProps> = ({ onDone, stateManager }) => {
const { t } = useAppTranslation()
const [state, manager] = useStateManager(stateManager)
const [tagSearch, setTagSearch] = useState("")
const [isTagInputActive, setIsTagInputActive] = useState(false)
// Fetch items only on first mount or when no items exist
// Fetch items on first mount or when returning to empty state
useEffect(() => {
if (state.allItems.length === 0 && !state.isFetching) {
if (!state.allItems.length && !state.isFetching) {
manager.transition({ type: "FETCH_ITEMS" })
}
}, [manager, state.allItems.length, state.isFetching])
@ -44,7 +45,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
<Tab>
<TabHeader className="flex justify-between items-center sticky top-0 z-10 bg-vscode-editor-background border-b border-vscode-panel-border">
<div className="flex items-center">
<h3 className="text-vscode-foreground m-0">{t("package-manager:title")}</h3>
<h3 className="text-vscode-foreground m-0">{t("marketplace:title")}</h3>
</div>
<div className="flex gap-2">
<Button
@ -54,7 +55,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
"bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground",
)}
onClick={() => manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "browse" } })}>
{t("package-manager:tabs.browse")}
{t("marketplace:tabs.browse")}
</Button>
<Button
variant={state.activeTab === "sources" ? "default" : "secondary"}
@ -63,7 +64,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
"bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground",
)}
onClick={() => manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "sources" } })}>
{t("package-manager:tabs.sources")}
{t("marketplace:tabs.sources")}
</Button>
</div>
</TabHeader>
@ -74,7 +75,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
<div className="mb-4">
<input
type="text"
placeholder={t("package-manager:filters.search.placeholder")}
placeholder={t("marketplace:filters.search.placeholder")}
value={state.filters.search}
onChange={(e) =>
manager.transition({
@ -88,7 +89,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
<div className="flex flex-wrap justify-between gap-2">
<div className="whitespace-nowrap">
<label htmlFor="type-filter" className="mr-2">
{t("package-manager:filters.type.label")}
{t("marketplace:filters.type.label")}
</label>
<select
id="type-filter"
@ -100,18 +101,18 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
})
}
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded">
<option value="">{t("package-manager:filters.type.all")}</option>
<option value="mode">{t("package-manager:filters.type.mode")}</option>
<option value="">{t("marketplace:filters.type.all")}</option>
<option value="mode">{t("marketplace:filters.type.mode")}</option>
<option value="mcp server">
{t("package-manager:filters.type.mcp server")}
{t("marketplace:filters.type.mcp server")}
</option>
<option value="prompt">{t("package-manager:filters.type.prompt")}</option>
<option value="package">{t("package-manager:filters.type.package")}</option>
<option value="prompt">{t("marketplace:filters.type.prompt")}</option>
<option value="package">{t("marketplace:filters.type.package")}</option>
</select>
</div>
<div className="whitespace-nowrap">
<label className="mr-2">{t("package-manager:filters.sort.label")}</label>
<label className="mr-2">{t("marketplace:filters.sort.label")}</label>
<select
value={state.sortConfig.by}
onChange={(e) =>
@ -121,9 +122,9 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
})
}
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded mr-2">
<option value="name">{t("package-manager:filters.sort.name")}</option>
<option value="name">{t("marketplace:filters.sort.name")}</option>
<option value="lastUpdated">
{t("package-manager:filters.sort.lastUpdated")}
{t("marketplace:filters.sort.lastUpdated")}
</option>
</select>
<button
@ -147,11 +148,9 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
<div>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center">
<label className="mr-2">
{t("package-manager:filters.tags.label")}
</label>
<label className="mr-2">{t("marketplace:filters.tags.label")}</label>
<span className="text-xs text-vscode-descriptionForeground">
{t("package-manager:filters.tags.available", {
{t("marketplace:filters.tags.available", {
count: allTags.length,
})}
</span>
@ -165,7 +164,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
})
}
className="p-1 bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground rounded text-xs">
{t("package-manager:filters.tags.clear", {
{t("marketplace:filters.tags.clear", {
count: state.filters.tags.length,
})}
</button>
@ -173,7 +172,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
</div>
<Command className="rounded-lg border border-vscode-dropdown-border">
<CommandInput
placeholder={t("package-manager:filters.tags.placeholder")}
placeholder={t("marketplace:filters.tags.placeholder")}
value={tagSearch}
onValueChange={setTagSearch}
onFocus={() => setIsTagInputActive(true)}
@ -187,7 +186,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
{(isTagInputActive || tagSearch) && (
<CommandList className="max-h-[200px] overflow-y-auto bg-vscode-dropdown-background">
<CommandEmpty className="p-2 text-sm text-vscode-descriptionForeground">
{t("package-manager:filters.tags.noResults")}
{t("marketplace:filters.tags.noResults")}
</CommandEmpty>
<CommandGroup>
{filteredTags.map((tag: string) => (
@ -237,10 +236,10 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
</Command>
<div className="text-xs text-vscode-descriptionForeground mt-1">
{state.filters.tags.length > 0
? t("package-manager:filters.tags.selected", {
? t("marketplace:filters.tags.selected", {
count: state.filters.tags.length,
})
: t("package-manager:filters.tags.clickToFilter")}
: t("marketplace:filters.tags.clickToFilter")}
</div>
</div>
)}
@ -251,16 +250,12 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
// Use items directly from backend
const items = state.displayItems || []
const isEmpty = items.length === 0
const isLoading = state.isFetching
// Show loading state if fetching and not filtering
// Only show loading state if we're fetching and not filtering
if (
isLoading &&
!(state.filters.type || state.filters.search || state.filters.tags.length > 0)
) {
// Only show loading state if we're fetching and have no items to display
if (state.isFetching && isEmpty) {
return (
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground">
<p>{t("package-manager:items.refresh.refreshing")}</p>
<p>{t("marketplace:items.refresh.refreshing")}</p>
</div>
)
}
@ -269,7 +264,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
if (isEmpty) {
return (
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground">
<p>{t("package-manager:items.empty.noItems")}</p>
<p>{t("marketplace:items.empty.noItems")}</p>
</div>
)
}
@ -278,11 +273,11 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
return (
<div>
<p className="text-vscode-descriptionForeground mb-4">
{t("package-manager:items.count", { count: items.length })}
{t("marketplace:items.count", { count: items.length })}
</p>
<div className="grid grid-cols-1 gap-4 pb-4">
{items.map((item) => (
<PackageManagerItemCard
<MarketplaceItemCard
key={`${item.repoUrl}-${item.name}`}
item={item}
filters={state.filters}
@ -301,7 +296,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
})()}
</>
) : (
<PackageManagerSourcesConfig
<MarketplaceSourcesConfig
sources={state.sources}
refreshingUrls={state.refreshingUrls}
onRefreshSource={(url) => manager.transition({ type: "REFRESH_SOURCE", payload: { url } })}
@ -315,14 +310,14 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
)
}
interface PackageManagerSourcesConfigProps {
sources: PackageManagerSource[]
export interface MarketplaceSourcesConfigProps {
sources: MarketplaceSource[]
refreshingUrls: string[]
onRefreshSource: (url: string) => void
onSourcesChange: (sources: PackageManagerSource[]) => void
onSourcesChange: (sources: MarketplaceSource[]) => void
}
const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> = ({
export const MarketplaceSourcesConfig: React.FC<MarketplaceSourcesConfigProps> = ({
sources,
refreshingUrls,
onRefreshSource,
@ -334,71 +329,46 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
const [error, setError] = useState("")
const handleAddSource = () => {
if (!newSourceUrl) {
setError(t("package-manager:sources.errors.emptyUrl"))
return
}
try {
new URL(newSourceUrl)
} catch (e) {
setError(t("package-manager:sources.errors.invalidUrl"))
return
}
const nonVisibleCharRegex = /[^\S ]/
if (nonVisibleCharRegex.test(newSourceUrl)) {
setError(t("package-manager:sources.errors.nonVisibleChars"))
return
}
if (!isValidGitRepositoryUrl(newSourceUrl)) {
setError(t("package-manager:sources.errors.invalidGitUrl"))
return
}
const normalizedNewUrl = newSourceUrl.toLowerCase().replace(/\s+/g, "")
if (sources.some((source) => source.url.toLowerCase().replace(/\s+/g, "") === normalizedNewUrl)) {
setError(t("package-manager:sources.errors.duplicateUrl"))
return
}
if (newSourceName) {
if (newSourceName.length > 20) {
setError(t("package-manager:sources.errors.nameTooLong"))
return
}
if (nonVisibleCharRegex.test(newSourceName)) {
setError(t("package-manager:sources.errors.nonVisibleCharsName"))
return
}
const normalizedNewName = newSourceName.toLowerCase().replace(/\s+/g, "")
if (
sources.some(
(source) => source.name && source.name.toLowerCase().replace(/\s+/g, "") === normalizedNewName,
)
) {
setError(t("package-manager:sources.errors.duplicateName"))
return
}
}
// Check max sources limit first
const MAX_SOURCES = 10
if (sources.length >= MAX_SOURCES) {
setError(t("package-manager:sources.errors.maxSources", { max: MAX_SOURCES }))
setError(t("marketplace:sources.errors.maxSources", { max: MAX_SOURCES }))
return
}
const newSource: PackageManagerSource = {
// Create source object for validation
const sourceToValidate: MarketplaceSource = {
url: newSourceUrl,
name: newSourceName || undefined,
enabled: true,
}
onSourcesChange([...sources, newSource])
// Validate using shared validation
const validationErrors = validateSource(sourceToValidate, sources)
if (validationErrors.length > 0) {
// Map validation errors to UI error messages
const errorMessages: Record<string, string> = {
"url:empty": "marketplace:sources.errors.emptyUrl",
"url:nonvisible": "marketplace:sources.errors.nonVisibleChars",
"url:invalid": "marketplace:sources.errors.invalidGitUrl",
"url:duplicate": "marketplace:sources.errors.duplicateUrl",
"name:length": "marketplace:sources.errors.nameTooLong",
"name:nonvisible": "marketplace:sources.errors.nonVisibleCharsName",
"name:duplicate": "marketplace:sources.errors.duplicateName",
}
const error = validationErrors[0]
const errorKey = `${error.field}:${error.message.toLowerCase().split(" ")[0]}`
setError(t(errorMessages[errorKey] || "marketplace:sources.errors.invalidGitUrl"))
return
}
// Add the validated source
onSourcesChange([...sources, sourceToValidate])
onSourcesChange([...sources, sourceToValidate])
// Reset form state
setNewSourceUrl("")
setNewSourceName("")
setError("")
@ -422,15 +392,15 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
return (
<div>
<h4 className="text-vscode-foreground mb-2">{t("package-manager:sources.title")}</h4>
<p className="text-vscode-descriptionForeground mb-4">{t("package-manager:sources.description")}</p>
<h4 className="text-vscode-foreground mb-2">{t("marketplace:sources.title")}</h4>
<p className="text-vscode-descriptionForeground mb-4">{t("marketplace:sources.description")}</p>
<div className="mb-6">
<h5 className="text-vscode-foreground mb-2">{t("package-manager:sources.add.title")}</h5>
<h5 className="text-vscode-foreground mb-2">{t("marketplace:sources.add.title")}</h5>
<div className="flex flex-col gap-2 mb-2">
<input
type="text"
placeholder={t("package-manager:sources.add.urlPlaceholder")}
placeholder={t("marketplace:sources.add.urlPlaceholder")}
value={newSourceUrl}
onChange={(e) => {
setNewSourceUrl(e.target.value)
@ -439,11 +409,11 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
className="p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded"
/>
<p className="text-xs text-vscode-descriptionForeground mt-1 mb-2">
{t("package-manager:sources.add.urlFormats")}
{t("marketplace:sources.add.urlFormats")}
</p>
<input
type="text"
placeholder={t("package-manager:sources.add.namePlaceholder")}
placeholder={t("marketplace:sources.add.namePlaceholder")}
value={newSourceName}
onChange={(e) => {
setNewSourceName(e.target.value.slice(0, 20))
@ -456,17 +426,17 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
{error && <p className="text-red-500 mb-2">{error}</p>}
<Button onClick={handleAddSource}>
<span className="codicon codicon-add mr-2"></span>
{t("package-manager:sources.add.button")}
{t("marketplace:sources.add.button")}
</Button>
</div>
<h5 className="text-vscode-foreground mb-2">
{t("package-manager:sources.current.title")}{" "}
{t("marketplace:sources.current.title")}{" "}
<span className="text-vscode-descriptionForeground text-sm">
{t("package-manager:sources.current.count", { current: sources.length, max: 10 })}
{t("marketplace:sources.current.count", { current: sources.length, max: 10 })}
</span>
</h5>
{sources.length === 0 ? (
<p className="text-vscode-descriptionForeground">{t("package-manager:sources.current.empty")}</p>
<p className="text-vscode-descriptionForeground">{t("marketplace:sources.current.empty")}</p>
) : (
<div className="grid grid-cols-1 gap-2">
{sources.map((source, index) => (
@ -496,7 +466,7 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
variant="ghost"
size="icon"
onClick={() => onRefreshSource(source.url)}
title={t("package-manager:sources.current.refresh")}
title={t("marketplace:sources.current.refresh")}
className="text-vscode-foreground"
disabled={refreshingUrls.includes(source.url)}>
<span
@ -506,7 +476,7 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
variant="ghost"
size="icon"
onClick={() => handleRemoveSource(index)}
title={t("package-manager:sources.current.remove")}
title={t("marketplace:sources.current.remove")}
className="text-red-500">
<span className="codicon codicon-trash"></span>
</Button>
@ -519,16 +489,4 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
)
}
const isValidGitRepositoryUrl = (url: string): boolean => {
const trimmedUrl = url.trim()
const httpsPattern =
/^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org|dev\.azure\.com)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\/.+)*(\.git)?$/
const sshPattern = /^git@(github\.com|gitlab\.com|bitbucket\.org):([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(\.git)?$/
const gitProtocolPattern =
/^git:\/\/(github\.com|gitlab\.com|bitbucket\.org)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\.git)?$/
return httpsPattern.test(trimmedUrl) || sshPattern.test(trimmedUrl) || gitProtocolPattern.test(trimmedUrl)
}
export default PackageManagerView
export default MarketplaceView

View file

@ -0,0 +1,586 @@
import { MarketplaceItem, MarketplaceSource, MatchInfo } from "../../../../src/services/marketplace/types"
import { vscode } from "../../utils/vscode"
import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
import { DEFAULT_MARKETPLACE_SOURCE } from "../../../../src/services/marketplace/constants"
export interface ViewState {
allItems: MarketplaceItem[]
displayItems?: MarketplaceItem[] // Items currently being displayed (filtered or all)
isFetching: boolean
activeTab: "browse" | "sources"
refreshingUrls: string[]
sources: MarketplaceSource[]
filters: {
type: string
search: string
tags: string[]
}
sortConfig: {
by: "name" | "author" | "lastUpdated"
order: "asc" | "desc"
}
}
type TransitionPayloads = {
FETCH_ITEMS: undefined
FETCH_COMPLETE: { items: MarketplaceItem[] }
FETCH_ERROR: undefined
SET_ACTIVE_TAB: { tab: ViewState["activeTab"] }
UPDATE_FILTERS: { filters: Partial<ViewState["filters"]> }
UPDATE_SORT: { sortConfig: Partial<ViewState["sortConfig"]> }
REFRESH_SOURCE: { url: string }
REFRESH_SOURCE_COMPLETE: { url: string }
UPDATE_SOURCES: { sources: MarketplaceSource[] }
}
export interface ViewStateTransition {
type: keyof TransitionPayloads
payload?: TransitionPayloads[keyof TransitionPayloads]
}
export type StateChangeHandler = (state: ViewState) => void
export class MarketplaceViewStateManager {
private state: ViewState = this.loadInitialState()
private loadInitialState(): ViewState {
// Try to restore state from sessionStorage if available
if (typeof sessionStorage !== "undefined") {
const savedState = sessionStorage.getItem("marketplaceState")
if (savedState) {
try {
return JSON.parse(savedState)
} catch {
return this.getDefaultState()
}
}
}
return this.getDefaultState()
}
private getDefaultState(): ViewState {
return {
allItems: [],
displayItems: [] as MarketplaceItem[],
isFetching: false,
activeTab: "browse",
refreshingUrls: [],
sources: [DEFAULT_MARKETPLACE_SOURCE],
filters: {
type: "",
search: "",
tags: [],
},
sortConfig: {
by: "name",
order: "asc",
},
}
}
private fetchTimeoutId?: NodeJS.Timeout
private readonly FETCH_TIMEOUT = 30000 // 30 seconds
private stateChangeHandlers: Set<StateChangeHandler> = new Set()
private sourcesModified = false // Track if sources have been modified
// Empty constructor is required for test initialization
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
constructor() {
// Initialize is now handled by the loadInitialState call in the property initialization
}
public initialize(): void {
// Set initial state
this.state = this.getDefaultState()
// Send initial sources to extension
vscode.postMessage({
type: "marketplaceSources",
sources: [DEFAULT_MARKETPLACE_SOURCE],
} as WebviewMessage)
}
public onStateChange(handler: StateChangeHandler): () => void {
this.stateChangeHandlers.add(handler)
return () => this.stateChangeHandlers.delete(handler)
}
public cleanup(): void {
// Clear any pending timeouts
if (this.fetchTimeoutId) {
clearTimeout(this.fetchTimeoutId)
this.fetchTimeoutId = undefined
}
// Reset fetching state
if (this.state.isFetching) {
this.state.isFetching = false
this.notifyStateChange()
}
// Clear handlers but preserve state
this.stateChangeHandlers.clear()
}
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.length ? [...this.state.allItems] : [],
displayItems,
refreshingUrls,
sources: this.state.sources.length ? [...this.state.sources] : [DEFAULT_MARKETPLACE_SOURCE],
filters: {
...this.state.filters,
tags,
},
}
}
private notifyStateChange(): void {
const newState = this.getState() // Use getState to ensure proper copying
this.stateChangeHandlers.forEach((handler) => {
handler(newState)
})
// Save state to sessionStorage if available
if (typeof sessionStorage !== "undefined") {
try {
sessionStorage.setItem("marketplaceState", JSON.stringify(this.state))
} catch (error) {
console.warn("Failed to save marketplace state:", error)
}
}
}
public async transition(transition: ViewStateTransition): Promise<void> {
switch (transition.type) {
case "FETCH_ITEMS": {
// Don't start a new fetch if one is in progress
if (this.state.isFetching) {
return
}
// Clear any existing timeout
this.clearFetchTimeout()
// Send fetch request
vscode.postMessage({
type: "fetchMarketplaceItems",
bool: true,
} as WebviewMessage)
// Store current items before updating state
const currentItems = [...(this.state.allItems || [])]
// Update state after sending request
this.state = {
...this.state,
isFetching: true,
allItems: currentItems,
displayItems: currentItems,
}
this.notifyStateChange()
// Set timeout to reset state if fetch takes too long
this.fetchTimeoutId = setTimeout(() => {
this.clearFetchTimeout()
// On timeout, preserve items if we have them
if (currentItems.length > 0) {
this.state = {
...this.state,
isFetching: false,
allItems: currentItems,
displayItems: currentItems,
}
} else {
this.state = {
...this.getDefaultState(),
sources: [...this.state.sources],
activeTab: this.state.activeTab,
}
}
this.notifyStateChange()
}, this.FETCH_TIMEOUT)
break
}
case "FETCH_COMPLETE": {
const { items } = transition.payload as TransitionPayloads["FETCH_COMPLETE"]
// Clear any existing timeout
this.clearFetchTimeout()
// Always update allItems as source of truth
const sortedItems = this.sortItems([...items])
this.state = {
...this.state,
allItems: sortedItems,
displayItems: this.isFilterActive() ? this.filterItems(sortedItems) : sortedItems,
isFetching: false,
}
// Notify state change
this.notifyStateChange()
break
}
case "FETCH_ERROR": {
this.clearFetchTimeout()
// Preserve current filters and sources
const { filters, sources, activeTab } = this.state
// Reset state but preserve filters and sources
this.state = {
...this.getDefaultState(),
filters,
sources,
activeTab,
isFetching: false,
}
this.notifyStateChange()
break
}
case "SET_ACTIVE_TAB": {
const { tab } = transition.payload as TransitionPayloads["SET_ACTIVE_TAB"]
// Update tab state
this.state = {
...this.state,
activeTab: tab,
allItems: this.state.allItems || [],
displayItems: this.state.displayItems || [],
}
// If switching to browse tab with no items or modified sources, trigger fetch
if (tab === "browse" && (this.state.allItems.length === 0 || this.sourcesModified)) {
this.state.isFetching = true
this.sourcesModified = false
vscode.postMessage({
type: "fetchMarketplaceItems",
bool: true,
} as WebviewMessage)
}
// Update display items if needed
else if (tab === "browse" && this.state.allItems.length > 0) {
this.state.displayItems = this.isFilterActive()
? this.filterItems(this.state.allItems)
: [...this.state.allItems]
}
this.notifyStateChange()
break
}
case "UPDATE_FILTERS": {
const { filters = {} } = (transition.payload as TransitionPayloads["UPDATE_FILTERS"]) || {}
// Create new filters object preserving existing values for undefined fields
const updatedFilters = {
type: filters.type !== undefined ? filters.type : this.state.filters.type,
search: filters.search !== undefined ? filters.search : this.state.filters.search,
tags: filters.tags !== undefined ? filters.tags : this.state.filters.tags,
}
// Update state
this.state = {
...this.state,
filters: updatedFilters,
}
// Send filter message
vscode.postMessage({
type: "filterMarketplaceItems",
filters: updatedFilters,
} as WebviewMessage)
this.notifyStateChange()
break
}
case "UPDATE_SORT": {
const { sortConfig } = transition.payload as TransitionPayloads["UPDATE_SORT"]
// Create new state with updated sort config
this.state = {
...this.state,
sortConfig: {
...this.state.sortConfig,
...sortConfig,
},
}
// Apply sorting to both allItems and displayItems
// Sort items immutably
// Create new sorted arrays
const sortedAllItems = this.sortItems([...this.state.allItems])
const sortedDisplayItems = this.state.displayItems?.length
? this.sortItems([...this.state.displayItems])
: this.state.displayItems
this.state = {
...this.state,
allItems: sortedAllItems,
displayItems: sortedDisplayItems,
}
this.notifyStateChange()
break
}
case "REFRESH_SOURCE": {
const { url } = transition.payload as TransitionPayloads["REFRESH_SOURCE"]
if (!this.state.refreshingUrls.includes(url)) {
this.state = {
...this.state,
refreshingUrls: [...this.state.refreshingUrls, url],
}
this.notifyStateChange()
vscode.postMessage({
type: "refreshMarketplaceSource",
url,
} as WebviewMessage)
}
break
}
case "REFRESH_SOURCE_COMPLETE": {
const { url } = transition.payload as TransitionPayloads["REFRESH_SOURCE_COMPLETE"]
this.state = {
...this.state,
refreshingUrls: this.state.refreshingUrls.filter((existingUrl) => existingUrl !== url),
}
this.notifyStateChange()
break
}
case "UPDATE_SOURCES": {
const { sources } = transition.payload as TransitionPayloads["UPDATE_SOURCES"]
// If all sources are removed, add the default source
const updatedSources = sources.length === 0 ? [DEFAULT_MARKETPLACE_SOURCE] : [...sources]
// Mark sources as modified
this.sourcesModified = true
this.state = {
...this.state,
sources: updatedSources,
isFetching: false, // Reset fetching state
}
this.notifyStateChange()
// Send sources update to extension
vscode.postMessage({
type: "marketplaceSources",
sources: updatedSources,
} as WebviewMessage)
// If we're on the browse tab, trigger a fetch
if (this.state.activeTab === "browse") {
this.state.isFetching = true
this.notifyStateChange()
vscode.postMessage({
type: "fetchMarketplaceItems",
bool: true,
} as WebviewMessage)
}
break
}
}
}
private clearFetchTimeout(): void {
// Clear fetch timeout
if (this.fetchTimeoutId) {
clearTimeout(this.fetchTimeoutId)
this.fetchTimeoutId = undefined
}
}
public isFilterActive(): boolean {
return !!(this.state.filters.type || this.state.filters.search || this.state.filters.tags.length > 0)
}
public filterItems(items: MarketplaceItem[]): MarketplaceItem[] {
const { type, search, tags } = this.state.filters
return items
.map((item) => {
// Create a copy of the item to modify
const itemCopy = { ...item }
// Check specific match conditions for the main item
const typeMatch = !type || item.type === type
const nameMatch = search ? item.name.toLowerCase().includes(search.toLowerCase()) : false
const descriptionMatch = search
? (item.description || "").toLowerCase().includes(search.toLowerCase())
: false
const tagMatch = tags.length > 0 ? item.tags?.some((tag) => tags.includes(tag)) : false
// Determine if the main item matches all filters
const mainItemMatches =
typeMatch && (!search || nameMatch || descriptionMatch) && (!tags.length || tagMatch)
// For packages, check and mark matching subcomponents
if (item.type === "package" && item.items?.length) {
itemCopy.items = item.items.map((subItem) => {
// Check specific match conditions for subitem
const subTypeMatch = !type || subItem.type === type
const subNameMatch =
search && subItem.metadata
? subItem.metadata.name.toLowerCase().includes(search.toLowerCase())
: false
const subDescriptionMatch =
search && subItem.metadata
? subItem.metadata.description.toLowerCase().includes(search.toLowerCase())
: false
const subTagMatch =
tags.length > 0 ? Boolean(subItem.metadata?.tags?.some((tag) => tags.includes(tag))) : false
const subItemMatches =
subTypeMatch &&
(!search || subNameMatch || subDescriptionMatch) &&
(!tags.length || subTagMatch)
// Ensure all match properties are booleans
const matchInfo: MatchInfo = {
matched: Boolean(subItemMatches),
matchReason: subItemMatches
? {
typeMatch: Boolean(subTypeMatch),
nameMatch: Boolean(subNameMatch),
descriptionMatch: Boolean(subDescriptionMatch),
tagMatch: Boolean(subTagMatch),
}
: undefined,
}
return {
...subItem,
matchInfo,
}
})
}
const hasMatchingSubcomponents = itemCopy.items?.some((subItem) => subItem.matchInfo?.matched)
// Set match info on the main item
itemCopy.matchInfo = {
matched: mainItemMatches || Boolean(hasMatchingSubcomponents),
matchReason: {
typeMatch,
nameMatch,
descriptionMatch,
tagMatch,
hasMatchingSubcomponents: Boolean(hasMatchingSubcomponents),
},
}
// Return the item if it matches or has matching subcomponents
if (itemCopy.matchInfo.matched) {
return itemCopy
}
return null
})
.filter((item): item is MarketplaceItem => item !== null)
}
private sortItems(items: MarketplaceItem[]): MarketplaceItem[] {
const { by, order } = this.state.sortConfig
const itemsCopy = [...items]
return itemsCopy.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] || ""
return order === "asc" ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue)
})
}
public async handleMessage(message: any): Promise<void> {
// Handle empty or invalid message
if (!message || !message.type || message.type === "invalidType") {
const { sources } = this.state
this.state = {
...this.getDefaultState(),
sources: [...sources],
}
this.notifyStateChange()
return
}
// Handle state updates
if (message.type === "state") {
// Handle empty state
if (!message.state) {
const { sources } = this.state
this.state = {
...this.getDefaultState(),
sources: [...sources],
}
this.notifyStateChange()
return
}
// Update sources if present
if (message.state.sources || message.state.marketplaceSources) {
const sources = message.state.marketplaceSources || message.state.sources
this.state = {
...this.state,
sources: sources?.length > 0 ? [...sources] : [DEFAULT_MARKETPLACE_SOURCE],
}
this.notifyStateChange()
}
// Handle state updates for marketplace items
if (message.state.marketplaceItems !== undefined) {
const newItems = message.state.marketplaceItems
const currentItems = this.state.allItems || []
const hasNewItems = newItems.length > 0
const hasCurrentItems = currentItems.length > 0
const isOnBrowseTab = this.state.activeTab === "browse"
// Determine which items to use
const itemsToUse = hasNewItems ? newItems : isOnBrowseTab && hasCurrentItems ? currentItems : []
const sortedItems = this.sortItems([...itemsToUse])
const newDisplayItems = this.isFilterActive() ? this.filterItems(sortedItems) : sortedItems
// Update state in a single operation
this.state = {
...this.state,
isFetching: false,
allItems: sortedItems,
displayItems: newDisplayItems,
}
this.notifyStateChange()
}
}
// Handle repository refresh completion
if (message.type === "repositoryRefreshComplete" && message.url) {
void this.transition({
type: "REFRESH_SOURCE_COMPLETE",
payload: { url: message.url },
})
}
// Handle marketplace button clicks
if (message.type === "marketplaceButtonClicked") {
if (message.text) {
// Error case
void this.transition({ type: "FETCH_ERROR" })
} else {
// Refresh request
void this.transition({ type: "FETCH_ITEMS" })
}
}
}
}

View file

@ -0,0 +1,53 @@
import React from "react"
import { render, fireEvent, screen } from "@testing-library/react"
import { MarketplaceSourcesConfig } from "../MarketplaceView"
// Mock the translation hook
jest.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key, // Return the key as-is for testing
}),
}))
describe("MarketplaceSourcesConfig", () => {
const mockOnSourcesChange = jest.fn()
const mockOnRefreshSource = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
})
test("should accept multi-part corporate git URLs", () => {
render(
<MarketplaceSourcesConfig
sources={[]}
refreshingUrls={[]}
onSourcesChange={mockOnSourcesChange}
onRefreshSource={mockOnRefreshSource}
/>,
)
// Get the URL input
const urlInput = screen.getByPlaceholderText("marketplace:sources.add.urlPlaceholder")
// Type a multi-part corporate git URL
const gitUrl = "git@git.lab.company.com:team-core/project-name.git"
fireEvent.change(urlInput, { target: { value: gitUrl } })
// Click the add button
const addButton = screen.getByText("marketplace:sources.add.button")
fireEvent.click(addButton)
// Verify the source was added without validation errors
expect(mockOnSourcesChange).toHaveBeenCalledWith([
expect.objectContaining({
url: gitUrl,
enabled: true,
}),
])
// Verify no error message is shown
const errorElement = screen.queryByText("marketplace:sources.errors.invalidUrl")
expect(errorElement).not.toBeInTheDocument()
})
})

View file

@ -1,13 +1,9 @@
import { PackageManagerViewStateManager } from "../PackageManagerViewStateManager"
import { MarketplaceViewStateManager } from "../MarketplaceViewStateManager"
import { vscode } from "../../../utils/vscode"
import {
ComponentType,
PackageManagerItem,
PackageManagerSource,
} from "../../../../../src/services/package-manager/types"
import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../../../../src/services/package-manager/constants"
import { ComponentType, MarketplaceItem, MarketplaceSource } from "../../../../../src/services/marketplace/types"
import { DEFAULT_MARKETPLACE_SOURCE } from "../../../../../src/services/marketplace/constants"
const createTestItem = (overrides = {}): PackageManagerItem => ({
const createTestItem = (overrides = {}): MarketplaceItem => ({
name: "test",
type: "mode" as ComponentType,
description: "Test mode",
@ -20,7 +16,7 @@ const createTestItem = (overrides = {}): PackageManagerItem => ({
...overrides,
})
const createTestSources = (): PackageManagerSource[] => [
const createTestSources = (): MarketplaceSource[] => [
{ url: "https://github.com/test/repo1", enabled: true },
{ url: "https://github.com/test/repo2", enabled: true },
{ url: "https://github.com/test/repo3", enabled: true },
@ -33,13 +29,13 @@ jest.mock("../../../utils/vscode", () => ({
},
}))
describe("PackageManagerViewStateManager", () => {
let manager: PackageManagerViewStateManager
describe("MarketplaceViewStateManager", () => {
let manager: MarketplaceViewStateManager
beforeEach(() => {
jest.clearAllMocks()
jest.useFakeTimers()
manager = new PackageManagerViewStateManager()
manager = new MarketplaceViewStateManager()
manager.initialize() // Send initial sources
})
@ -57,7 +53,7 @@ describe("PackageManagerViewStateManager", () => {
isFetching: false,
activeTab: "browse",
refreshingUrls: [],
sources: [DEFAULT_PACKAGE_MANAGER_SOURCE],
sources: [DEFAULT_MARKETPLACE_SOURCE],
filters: {
type: "",
search: "",
@ -73,13 +69,13 @@ describe("PackageManagerViewStateManager", () => {
it("should send initial sources when initialized", () => {
manager.initialize()
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "packageManagerSources",
sources: [DEFAULT_PACKAGE_MANAGER_SOURCE],
type: "marketplaceSources",
sources: [DEFAULT_MARKETPLACE_SOURCE],
})
})
it("should initialize with default source", () => {
const manager = new PackageManagerViewStateManager()
const manager = new MarketplaceViewStateManager()
// Initial state should include default source
const state = manager.getState()
@ -93,7 +89,7 @@ describe("PackageManagerViewStateManager", () => {
// Verify initial message was sent to update sources
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "packageManagerSources",
type: "marketplaceSources",
sources: [
{
url: "https://github.com/RooVetGit/Roo-Code-Marketplace",
@ -111,7 +107,7 @@ describe("PackageManagerViewStateManager", () => {
await manager.transition({ type: "FETCH_ITEMS" })
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "fetchPackageManagerItems",
type: "fetchMarketplaceItems",
bool: true,
})
@ -155,34 +151,123 @@ describe("PackageManagerViewStateManager", () => {
})
describe("Race Conditions", () => {
it("should handle rapid tab switching during initial load", async () => {
// Start initial load
await manager.transition({ type: "FETCH_ITEMS" })
it("should maintain items state when repeatedly switching tabs", async () => {
// Start with initial items
const initialItems = [createTestItem({ name: "Initial Item" })]
await manager.transition({
type: "FETCH_COMPLETE",
payload: { items: initialItems },
})
// Quickly switch to sources tab
// First switch to sources
await manager.transition({
type: "SET_ACTIVE_TAB",
payload: { tab: "sources" },
})
// Switch back to browse before load completes
// Switch back to browse
await manager.transition({
type: "SET_ACTIVE_TAB",
payload: { tab: "browse" },
})
// Complete the initial load
// Verify items are preserved after first switch
let state = manager.getState()
expect(state.displayItems).toEqual(initialItems)
expect(state.allItems).toEqual(initialItems)
// Simulate receiving empty response during fetch
await manager.handleMessage({
type: "state",
state: { packageManagerItems: [createTestItem()] },
state: { marketplaceItems: [] },
})
const state = manager.getState()
expect(state.activeTab).toBe("browse")
expect(state.allItems).toHaveLength(1)
// Verify items are still preserved
state = manager.getState()
expect(state.displayItems).toEqual(initialItems)
expect(state.allItems).toEqual(initialItems)
// Switch to sources again
await manager.transition({
type: "SET_ACTIVE_TAB",
payload: { tab: "sources" },
})
// Switch back to browse again
await manager.transition({
type: "SET_ACTIVE_TAB",
payload: { tab: "browse" },
})
// Verify items are still preserved after second switch
state = manager.getState()
expect(state.displayItems).toEqual(initialItems)
expect(state.allItems).toEqual(initialItems)
// Simulate another empty response
await manager.handleMessage({
type: "state",
state: { marketplaceItems: [] },
})
// Final verification that items are still preserved
state = manager.getState()
expect(state.displayItems).toEqual(initialItems)
expect(state.allItems).toEqual(initialItems)
})
it("should preserve items when receiving empty response", async () => {
// Start with initial items
const initialItems = [createTestItem({ name: "Initial Item" })]
await manager.transition({
type: "FETCH_COMPLETE",
payload: { items: initialItems },
})
// Verify initial state
let state = manager.getState()
expect(state.allItems).toEqual(initialItems)
expect(state.displayItems).toEqual(initialItems)
// Simulate receiving an empty response
await manager.handleMessage({
type: "state",
state: { marketplaceItems: [] },
})
// Verify items are preserved
state = manager.getState()
expect(state.allItems).toEqual(initialItems)
expect(state.displayItems).toEqual(initialItems)
expect(state.isFetching).toBe(false)
})
it("should preserve items when switching tabs", async () => {
// Start with initial items
const initialItems = [createTestItem({ name: "Initial Item" })]
await manager.transition({
type: "FETCH_COMPLETE",
payload: { items: initialItems },
})
// Switch to sources tab
await manager.transition({
type: "SET_ACTIVE_TAB",
payload: { tab: "sources" },
})
// Switch back to browse
await manager.transition({
type: "SET_ACTIVE_TAB",
payload: { tab: "browse" },
})
// Verify that items are preserved
const state = manager.getState()
expect(state.displayItems).toEqual(initialItems)
expect(state.allItems).toEqual(initialItems)
})
it("should handle rapid filtering during initial load", async () => {
// Start initial load
await manager.transition({ type: "FETCH_ITEMS" })
@ -196,7 +281,7 @@ describe("PackageManagerViewStateManager", () => {
// Complete the initial load
await manager.handleMessage({
type: "state",
state: { packageManagerItems: [createTestItem()] },
state: { marketplaceItems: [createTestItem()] },
})
// Fast-forward past debounce time
@ -208,7 +293,7 @@ describe("PackageManagerViewStateManager", () => {
expect(state.displayItems).toBeDefined()
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "filterPackageManagerItems",
type: "filterMarketplaceItems",
filters: expect.objectContaining({ type: "mode" }),
}),
)
@ -233,7 +318,7 @@ describe("PackageManagerViewStateManager", () => {
// Each filter update should be sent immediately
expect(vscode.postMessage).toHaveBeenCalledTimes(2)
expect(vscode.postMessage).toHaveBeenLastCalledWith({
type: "filterPackageManagerItems",
type: "filterMarketplaceItems",
filters: {
search: "test",
type: "mode",
@ -266,21 +351,27 @@ describe("PackageManagerViewStateManager", () => {
// Get all calls to postMessage
const calls = (vscode.postMessage as jest.Mock).mock.calls
const sourcesMessages = calls.filter((call) => call[0].type === "packageManagerSources")
const sourcesMessages = calls.filter((call) => call[0].type === "marketplaceSources")
const lastSourcesMessage = sourcesMessages[sourcesMessages.length - 1]
// Verify state has default source
const state = manager.getState()
expect(state.sources).toEqual([DEFAULT_PACKAGE_MANAGER_SOURCE])
expect(state.sources).toEqual([DEFAULT_MARKETPLACE_SOURCE])
// Verify the last sources message was sent with default source
expect(lastSourcesMessage[0]).toEqual({
type: "packageManagerSources",
sources: [DEFAULT_PACKAGE_MANAGER_SOURCE],
type: "marketplaceSources",
sources: [DEFAULT_MARKETPLACE_SOURCE],
})
})
it("should handle rapid source operations during fetch", async () => {
it("should handle rapid source operations during fetch when in browse tab", async () => {
// Switch to browse tab first
await manager.transition({
type: "SET_ACTIVE_TAB",
payload: { tab: "browse" },
})
// Start a fetch
await manager.transition({ type: "FETCH_ITEMS" })
@ -295,7 +386,7 @@ describe("PackageManagerViewStateManager", () => {
// Complete the fetch
await manager.handleMessage({
type: "state",
state: { packageManagerItems: [createTestItem()] },
state: { marketplaceItems: [createTestItem()] },
})
const state = manager.getState()
@ -303,38 +394,6 @@ describe("PackageManagerViewStateManager", () => {
expect(state.allItems).toHaveLength(1)
expect(state.isFetching).toBe(false)
})
it("should trigger fetch after adding a new source and switching to browse", async () => {
// Reset mock before test
;(vscode.postMessage as jest.Mock).mockClear()
// Add a new source
const newSource = { url: "https://github.com/test/repo1", enabled: true }
await manager.transition({
type: "UPDATE_SOURCES",
payload: { sources: [DEFAULT_PACKAGE_MANAGER_SOURCE, newSource] },
})
// Switch to browse tab
await manager.transition({
type: "SET_ACTIVE_TAB",
payload: { tab: "browse" },
})
// Run any pending timers
jest.runAllTimers()
// Verify that a fetch was triggered
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "fetchPackageManagerItems",
bool: true,
})
// Verify state
const state = manager.getState()
expect(state.isFetching).toBe(true)
expect(state.activeTab).toBe("browse")
})
})
describe("Error Handling", () => {
@ -399,7 +458,7 @@ describe("PackageManagerViewStateManager", () => {
// Should send all updates immediately
expect(vscode.postMessage).toHaveBeenCalledTimes(3)
expect(vscode.postMessage).toHaveBeenLastCalledWith({
type: "filterPackageManagerItems",
type: "filterMarketplaceItems",
filters: {
type: "",
search: "test3",
@ -437,7 +496,7 @@ describe("PackageManagerViewStateManager", () => {
// Should send filter message with empty filters immediately
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "filterPackageManagerItems",
type: "filterMarketplaceItems",
filters: {
type: "",
search: "",
@ -476,7 +535,7 @@ describe("PackageManagerViewStateManager", () => {
// Should maintain type filter when search is cleared
expect(vscode.postMessage).toHaveBeenLastCalledWith({
type: "filterPackageManagerItems",
type: "filterMarketplaceItems",
filters: {
type: "mode",
search: "",
@ -513,9 +572,9 @@ describe("PackageManagerViewStateManager", () => {
expect(state.refreshingUrls).not.toContain(url)
})
it("should handle package manager button click with error", () => {
it("should handle marketplace button click with error", () => {
manager.handleMessage({
type: "packageManagerButtonClicked",
type: "marketplaceButtonClicked",
text: "error",
})
@ -523,15 +582,15 @@ describe("PackageManagerViewStateManager", () => {
expect(state.isFetching).toBe(false)
})
it("should handle package manager button click for refresh", () => {
it("should handle marketplace button click for refresh", () => {
manager.handleMessage({
type: "packageManagerButtonClicked",
type: "marketplaceButtonClicked",
})
const state = manager.getState()
expect(state.isFetching).toBe(true)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "fetchPackageManagerItems",
type: "fetchMarketplaceItems",
bool: true,
})
})
@ -548,20 +607,55 @@ describe("PackageManagerViewStateManager", () => {
expect(state.activeTab).toBe("sources")
})
it("should trigger fetch when switching to browse tab with no items", async () => {
it("should trigger initial fetch when switching to browse with no items", async () => {
jest.clearAllMocks() // Clear mock to ignore initialize() call
// Start in sources tab
await manager.transition({
type: "SET_ACTIVE_TAB",
payload: { tab: "sources" },
})
// Switch to browse tab
await manager.transition({
type: "SET_ACTIVE_TAB",
payload: { tab: "browse" },
})
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "fetchPackageManagerItems",
type: "fetchMarketplaceItems",
bool: true,
})
})
it("should not trigger fetch when switching to browse tab with existing items", async () => {
it("should not trigger fetch when switching to browse with existing items", async () => {
jest.clearAllMocks() // Clear mock to ignore initialize() call
// Add some items first
await manager.transition({
type: "FETCH_COMPLETE",
payload: { items: [createTestItem()] },
})
// Switch to sources tab
await manager.transition({
type: "SET_ACTIVE_TAB",
payload: { tab: "sources" },
})
// Switch back to browse tab
await manager.transition({
type: "SET_ACTIVE_TAB",
payload: { tab: "browse" },
})
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "fetchMarketplaceItems",
bool: true,
})
})
it("should automatically fetch when sources are modified and viewing browse tab", async () => {
jest.clearAllMocks() // Clear mock to ignore initialize() call
// Add some items first
@ -576,36 +670,15 @@ describe("PackageManagerViewStateManager", () => {
payload: { tab: "browse" },
})
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "fetchPackageManagerItems",
bool: true,
})
})
it("should trigger fetch when switching to browse tab after source modification", async () => {
jest.clearAllMocks() // Clear mock to ignore initialize() call
// Add some items first
await manager.transition({
type: "FETCH_COMPLETE",
payload: { items: [createTestItem()] },
})
// Modify sources
await manager.transition({
type: "UPDATE_SOURCES",
payload: { sources: [{ url: "https://github.com/test/repo1", enabled: true }] },
})
// Switch to browse tab
await manager.transition({
type: "SET_ACTIVE_TAB",
payload: { tab: "browse" },
})
// Should trigger fetch due to source modification
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "fetchPackageManagerItems",
type: "fetchMarketplaceItems",
bool: true,
})
})
@ -617,7 +690,7 @@ describe("PackageManagerViewStateManager", () => {
})
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "fetchPackageManagerItems",
type: "fetchMarketplaceItems",
bool: true,
})
})
@ -679,13 +752,19 @@ describe("PackageManagerViewStateManager", () => {
jest.useRealTimers()
})
it("should trigger fetch for remaining source after source deletion", async () => {
it("should trigger fetch for remaining source after source deletion when in browse tab", async () => {
// Start with two sources
const sources = [
{ url: "https://github.com/test/repo1", enabled: true },
{ url: "https://github.com/test/repo2", enabled: true },
]
// Switch to browse tab
await manager.transition({
type: "SET_ACTIVE_TAB",
payload: { tab: "browse" },
})
await manager.transition({
type: "UPDATE_SOURCES",
payload: { sources },
@ -702,7 +781,7 @@ describe("PackageManagerViewStateManager", () => {
// Verify that a fetch was triggered for the remaining source
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "fetchPackageManagerItems",
type: "fetchMarketplaceItems",
bool: true,
})
@ -737,11 +816,11 @@ describe("PackageManagerViewStateManager", () => {
// Get all calls to postMessage
const calls = (vscode.postMessage as jest.Mock).mock.calls
const sourcesMessage = calls.find((call) => call[0].type === "packageManagerSources")
const sourcesMessage = calls.find((call) => call[0].type === "marketplaceSources")
// Verify that the sources message was sent with default source
expect(sourcesMessage[0]).toEqual({
type: "packageManagerSources",
type: "marketplaceSources",
sources: [
{
url: "https://github.com/RooVetGit/Roo-Code-Marketplace",
@ -766,7 +845,7 @@ describe("PackageManagerViewStateManager", () => {
const state = manager.getState()
expect(state.sources).toEqual(sources)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "packageManagerSources",
type: "marketplaceSources",
sources,
})
})
@ -782,7 +861,7 @@ describe("PackageManagerViewStateManager", () => {
const state = manager.getState()
expect(state.refreshingUrls).toContain(url)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "refreshPackageManagerSource",
type: "refreshMarketplaceSource",
url,
})
})
@ -833,7 +912,7 @@ describe("PackageManagerViewStateManager", () => {
manager.handleMessage({
type: "state",
state: {
packageManagerItems: [initialItems[0]], // Only Item 1
marketplaceItems: [initialItems[0]], // Only Item 1
},
})
@ -861,7 +940,7 @@ describe("PackageManagerViewStateManager", () => {
jest.advanceTimersByTime(300)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "filterPackageManagerItems",
type: "filterMarketplaceItems",
filters: {
type: "mode",
search: "test",
@ -973,7 +1052,7 @@ describe("PackageManagerViewStateManager", () => {
})
describe("Message Handling", () => {
it("should restore sources from packageManagerSources on webview launch", () => {
it("should restore sources from marketplaceSources on webview launch", () => {
const savedSources = [
{
url: "https://github.com/RooVetGit/Roo-Code-Marketplace",
@ -990,7 +1069,7 @@ describe("PackageManagerViewStateManager", () => {
// Simulate VS Code restart by sending initial state with saved sources
manager.handleMessage({
type: "state",
state: { packageManagerSources: savedSources },
state: { marketplaceSources: savedSources },
})
const state = manager.getState()
@ -1000,11 +1079,11 @@ describe("PackageManagerViewStateManager", () => {
it("should use default source when state message has no sources", () => {
manager.handleMessage({
type: "state",
state: { packageManagerItems: [] },
state: { marketplaceItems: [] },
})
const state = manager.getState()
expect(state.sources).toEqual([DEFAULT_PACKAGE_MANAGER_SOURCE])
expect(state.sources).toEqual([DEFAULT_MARKETPLACE_SOURCE])
})
it("should update sources when receiving state message", () => {
@ -1030,13 +1109,13 @@ describe("PackageManagerViewStateManager", () => {
expect(state.sources).toEqual(customSources)
})
it("should handle state message with package manager items", () => {
it("should handle state message with marketplace items", () => {
const testItems = [createTestItem()]
// We need to use any here since we're testing the raw message handling
manager.handleMessage({
type: "state",
state: { packageManagerItems: testItems },
state: { marketplaceItems: testItems },
} as any)
const state = manager.getState()
@ -1062,9 +1141,9 @@ describe("PackageManagerViewStateManager", () => {
expect(state.refreshingUrls).not.toContain(url)
})
it("should handle packageManagerButtonClicked message with error", () => {
it("should handle marketplaceButtonClicked message with error", () => {
manager.handleMessage({
type: "packageManagerButtonClicked",
type: "marketplaceButtonClicked",
text: "error",
})
@ -1072,9 +1151,9 @@ describe("PackageManagerViewStateManager", () => {
expect(state.isFetching).toBe(false)
})
it("should handle packageManagerButtonClicked message for refresh", () => {
it("should handle marketplaceButtonClicked message for refresh", () => {
manager.handleMessage({
type: "packageManagerButtonClicked",
type: "marketplaceButtonClicked",
})
const state = manager.getState()

View file

@ -1,22 +1,22 @@
import React, { useMemo, useCallback } from "react"
import { Button } from "@/components/ui/button"
import { PackageManagerItem } from "../../../../../src/services/package-manager/types"
import { MarketplaceItem } from "../../../../../src/services/marketplace/types"
import { vscode } from "@/utils/vscode"
import { groupItemsByType, GroupedItems } from "../utils/grouping"
import { ExpandableSection } from "./ExpandableSection"
import { TypeGroup } from "./TypeGroup"
import { ViewState } from "../PackageManagerViewStateManager"
import { ViewState } from "../MarketplaceViewStateManager"
import { useAppTranslation } from "@/i18n/TranslationContext"
interface PackageManagerItemCardProps {
item: PackageManagerItem
interface MarketplaceItemCardProps {
item: MarketplaceItem
filters: ViewState["filters"]
setFilters: (filters: Partial<ViewState["filters"]>) => void
activeTab: ViewState["activeTab"]
setActiveTab: (tab: ViewState["activeTab"]) => void
}
export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
export const MarketplaceItemCard: React.FC<MarketplaceItemCardProps> = ({
item,
filters,
setFilters,
@ -36,15 +36,15 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
const typeLabel = useMemo(() => {
switch (item.type) {
case "mode":
return t("package-manager:filters.type.mode")
return t("marketplace:filters.type.mode")
case "mcp server":
return t("package-manager:filters.type.mcp server")
return t("marketplace:filters.type.mcp server")
case "prompt":
return t("package-manager:filters.type.prompt")
return t("marketplace:filters.type.prompt")
case "package":
return t("package-manager:filters.type.package")
return t("marketplace:filters.type.package")
default:
return t("package-manager:filters.type.all")
return t("marketplace:filters.type.all")
}
}, [item.type, t])
@ -112,7 +112,7 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
url: item.authorUrl,
})
}}>
{t("package-manager:items.card.by", { author: item.author })}
{t("marketplace:items.card.by", { author: item.author })}
</button>
) : (
<button
@ -124,13 +124,13 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
url: item.authorUrl,
})
}}>
{t("package-manager:items.card.viewSource")}
{t("marketplace:items.card.viewSource")}
</button>
)}
</p>
) : item.author ? (
<p className="text-sm text-vscode-descriptionForeground">
{t("package-manager:items.card.by", { author: item.author })}
{t("marketplace:items.card.by", { author: item.author })}
</p>
) : null}
</div>
@ -165,8 +165,8 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
}}
title={
filters.tags.includes(tag)
? t("package-manager:filters.tags.clear", { count: tag })
: t("package-manager:filters.tags.clickToFilter")
? t("marketplace:filters.tags.clear", { count: tag })
: t("marketplace:filters.tags.clickToFilter")
}>
{tag}
</button>
@ -199,31 +199,33 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
aria-label={
item.sourceUrl && isValidUrl(item.sourceUrl)
? ""
: item.sourceName || t("package-manager:items.card.viewSource")
: item.sourceName || t("marketplace:items.card.viewSource")
}>
<span
className={`codicon codicon-link-external${!item.sourceUrl || !isValidUrl(item.sourceUrl) ? " mr-2" : ""}`}></span>
{(!item.sourceUrl || !isValidUrl(item.sourceUrl)) &&
(item.sourceName || t("package-manager:items.card.viewSource"))}
(item.sourceName || t("marketplace:items.card.viewSource"))}
</Button>
</div>
<div className="border-t border-vscode-panel-border mt-4">
<ExpandableSection
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.components", { count: matchCount }) : undefined
})()}
defaultExpanded={item.items?.some((subItem) => subItem.matchInfo?.matched) ?? false}>
<div className="space-y-4">
{groupedItems &&
Object.entries(groupedItems).map(([type, group]) => (
<TypeGroup key={type} type={type} items={group.items} />
))}
</div>
</ExpandableSection>
</div>
{item.type === "package" && (
<div className="border-t border-vscode-panel-border mt-4">
<ExpandableSection
title={t("marketplace:items.components", { count: item.items?.length ?? 0 })}
badge={(() => {
const matchCount = item.items?.filter((subItem) => subItem.matchInfo?.matched).length ?? 0
return matchCount > 0 ? t("marketplace:items.components", { count: matchCount }) : undefined
})()}
defaultExpanded={item.items?.some((subItem) => subItem.matchInfo?.matched) ?? false}>
<div className="space-y-4">
{groupedItems &&
Object.entries(groupedItems).map(([type, group]) => (
<TypeGroup key={type} type={type} items={group.items} />
))}
</div>
</ExpandableSection>
</div>
)}
</div>
)
}

View file

@ -22,15 +22,15 @@ export const TypeGroup: React.FC<TypeGroupProps> = ({ type, items, className })
const typeLabel = useMemo(() => {
switch (type) {
case "mode":
return t("package-manager:type-group.modes")
return t("marketplace:type-group.modes")
case "mcp server":
return t("package-manager:type-group.mcp-servers")
return t("marketplace:type-group.mcp-servers")
case "prompt":
return t("package-manager:type-group.prompts")
return t("marketplace:type-group.prompts")
case "package":
return t("package-manager:type-group.packages")
return t("marketplace:type-group.packages")
default:
return t("package-manager:type-group.generic-type", {
return t("marketplace:type-group.generic-type", {
type: type.charAt(0).toUpperCase() + type.slice(1),
})
}
@ -57,7 +57,7 @@ export const TypeGroup: React.FC<TypeGroupProps> = ({ type, items, className })
)}
{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")}
{t("marketplace:type-group.match")}
</span>
)}
</li>

View file

@ -1,7 +1,7 @@
import React from "react"
import { screen, fireEvent } from "@testing-library/react"
import { PackageManagerItemCard } from "../PackageManagerItemCard"
import { PackageManagerItem } from "../../../../../../src/services/package-manager/types"
import { MarketplaceItemCard } from "../MarketplaceItemCard"
import { MarketplaceItem } from "../../../../../../src/services/marketplace/types"
import { renderWithProviders } from "@/test/test-utils"
// Mock vscode API
@ -12,8 +12,8 @@ jest.mock("@/utils/vscode", () => ({
},
}))
describe("PackageManagerItemCard", () => {
const mockItem: PackageManagerItem = {
describe("MarketplaceItemCard", () => {
const mockItem: MarketplaceItem = {
name: "Test Package",
description: "A test package",
type: "package",
@ -60,7 +60,7 @@ describe("PackageManagerItemCard", () => {
})
it("should render basic item information", () => {
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
expect(screen.getByText("Test Package")).toBeInTheDocument()
expect(screen.getByText("A test package")).toBeInTheDocument()
@ -79,7 +79,7 @@ describe("PackageManagerItemCard", () => {
})
it("should render tags", () => {
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
expect(screen.getByText("test")).toBeInTheDocument()
expect(screen.getByText("mock")).toBeInTheDocument()
@ -87,7 +87,7 @@ describe("PackageManagerItemCard", () => {
it("should handle tag clicks", () => {
const setFilters = jest.fn()
renderWithProviders(<PackageManagerItemCard {...defaultProps} setFilters={setFilters} />)
renderWithProviders(<MarketplaceItemCard {...defaultProps} setFilters={setFilters} />)
fireEvent.click(screen.getByText("test"))
expect(setFilters).toHaveBeenCalledWith(
@ -98,7 +98,7 @@ describe("PackageManagerItemCard", () => {
})
it("should render version and date information", () => {
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
expect(screen.getByText("1.0.0")).toBeInTheDocument()
// Use a regex to match the date since it depends on the timezone
@ -113,7 +113,7 @@ describe("PackageManagerItemCard", () => {
defaultBranch: "main",
path: "some/path",
}
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithSourceUrl} />)
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={itemWithSourceUrl} />)
const button = screen.getByRole("button", { name: /^$/ }) // Button with no text, only icon
fireEvent.click(button)
@ -130,7 +130,7 @@ describe("PackageManagerItemCard", () => {
defaultBranch: "main",
path: "some/path",
}
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithGitPath} />)
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={itemWithGitPath} />)
const button = screen.getByRole("button", { name: /View/i })
fireEvent.click(button)
@ -145,7 +145,7 @@ describe("PackageManagerItemCard", () => {
...mockItem,
sourceUrl: "https://example.com/direct-link",
}
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithSourceUrl} />)
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={itemWithSourceUrl} />)
// Find the source button by its empty aria-label
const button = screen.getByRole("button", {
@ -156,7 +156,7 @@ describe("PackageManagerItemCard", () => {
})
it("should show text label when sourceUrl is not present", () => {
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
// Find the source button by its aria-label
const button = screen.getByRole("button", {
@ -170,28 +170,28 @@ describe("PackageManagerItemCard", () => {
describe("Details section", () => {
it("should render expandable details section with correct count when item has no components", () => {
const itemWithNoItems = { ...mockItem, items: [] }
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithNoItems} />)
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={itemWithNoItems} />)
// The component uses t("package-manager:items.components", { count: 0 })
// The component uses t("marketplace: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} />)
renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
// The component uses t("package-manager:items.components", { count: 2 })
// The component uses t("marketplace:items.components", { count: 2 })
expect(screen.getByText("2 components")).toBeInTheDocument()
})
it("should not render details section when item has no subcomponents", () => {
const itemWithoutItems = { ...mockItem, items: [] }
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithoutItems} />)
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={itemWithoutItems} />)
expect(screen.queryByText("Component Details")).not.toBeInTheDocument()
})
it("should show grouped items when expanded", () => {
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
fireEvent.click(screen.getByText("2 components"))
// These use the type-group translations
@ -207,12 +207,33 @@ describe("PackageManagerItemCard", () => {
})
it("should maintain proper order of items within groups", () => {
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
fireEvent.click(screen.getByText("2 components"))
const items = screen.getAllByRole("listitem")
expect(items[0]).toHaveTextContent("Test Server")
expect(items[1]).toHaveTextContent("Test Mode")
})
it("should show expandable section for package type", () => {
const packageItem = { ...mockItem, type: "package" as const }
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={packageItem} />)
expect(screen.getByText("2 components")).toBeInTheDocument()
})
it("should not show expandable section for mode type", () => {
const modeItem = { ...mockItem, type: "mode" as const }
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={modeItem} />)
expect(screen.queryByText("2 components")).not.toBeInTheDocument()
})
it("should not show expandable section for mcp server type", () => {
const mcpServerItem = { ...mockItem, type: "mcp server" as const }
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={mcpServerItem} />)
expect(screen.queryByText("2 components")).not.toBeInTheDocument()
})
})
})

View file

@ -0,0 +1,43 @@
import { useState, useEffect } from "react"
import { MarketplaceViewStateManager, ViewState } from "./MarketplaceViewStateManager"
export function useStateManager(existingManager?: MarketplaceViewStateManager) {
const [manager] = useState(() => existingManager || new MarketplaceViewStateManager())
const [state, setState] = useState(() => manager.getState())
useEffect(() => {
const handleStateChange = (newState: ViewState) => {
setState((prevState) => {
// Compare specific state properties that matter for rendering
const hasChanged =
prevState.isFetching !== newState.isFetching ||
prevState.activeTab !== newState.activeTab ||
prevState.allItems !== newState.allItems ||
prevState.displayItems !== newState.displayItems ||
prevState.filters !== newState.filters ||
prevState.sources !== newState.sources ||
prevState.refreshingUrls !== newState.refreshingUrls
return hasChanged ? newState : prevState
})
}
const handleMessage = (event: MessageEvent) => {
manager.handleMessage(event.data)
}
window.addEventListener("message", handleMessage)
const unsubscribe = manager.onStateChange(handleStateChange)
return () => {
window.removeEventListener("message", handleMessage)
unsubscribe()
// Don't cleanup the manager if it was provided externally
if (!existingManager) {
manager.cleanup()
}
}
}, [manager, existingManager])
return [state, manager] as const
}

View file

@ -1,5 +1,5 @@
import { groupItemsByType, formatItemText, getTotalItemCount, getUniqueTypes } from "../grouping"
import { PackageManagerItem } from "../../../../../../src/services/package-manager/types"
import { MarketplaceItem } from "../../../../../../src/services/marketplace/types"
describe("grouping utilities", () => {
const mockItems = [
@ -30,7 +30,7 @@ describe("grouping utilities", () => {
version: "1.1.0",
},
},
] as PackageManagerItem["items"]
] as MarketplaceItem["items"]
describe("groupItemsByType", () => {
it("should group items by type correctly", () => {
@ -55,7 +55,7 @@ describe("grouping utilities", () => {
type: "mcp server",
path: "test/path",
},
] as PackageManagerItem["items"]
] as MarketplaceItem["items"]
const result = groupItemsByType(itemsWithMissingData)
expect(result["mcp server"].items[0].name).toBe("Unnamed item")
@ -75,7 +75,7 @@ describe("grouping utilities", () => {
path: "test/path",
metadata: { name: "Test" },
},
] as PackageManagerItem["items"]
] as MarketplaceItem["items"]
const result = groupItemsByType(itemsWithoutType)
expect(Object.keys(result)).toHaveLength(0)

View file

@ -1,4 +1,4 @@
import { PackageManagerItem } from "../../../../../src/services/package-manager/types"
import { MarketplaceItem } from "../../../../../src/services/marketplace/types"
export interface GroupedItems {
[type: string]: {
@ -24,7 +24,7 @@ export interface GroupedItems {
// Cache for group objects to avoid recreating them
const groupCache = new Map<string, { type: string; items: any[] }>()
export function groupItemsByType(items: PackageManagerItem["items"] = []): GroupedItems {
export function groupItemsByType(items: MarketplaceItem["items"] = []): GroupedItems {
if (!items?.length) {
return {}
}

View file

@ -1,479 +0,0 @@
import { PackageManagerItem, PackageManagerSource } from "../../../../src/services/package-manager/types"
import { vscode } from "../../utils/vscode"
import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../../../src/services/package-manager/constants"
export interface ViewState {
allItems: PackageManagerItem[]
displayItems?: PackageManagerItem[] // Items currently being displayed (filtered or all)
isFetching: boolean
activeTab: "browse" | "sources"
refreshingUrls: string[]
sources: PackageManagerSource[]
filters: {
type: string
search: string
tags: string[]
}
sortConfig: {
by: "name" | "author" | "lastUpdated"
order: "asc" | "desc"
}
}
type TransitionPayloads = {
FETCH_ITEMS: undefined
FETCH_COMPLETE: { items: PackageManagerItem[] }
FETCH_ERROR: undefined
SET_ACTIVE_TAB: { tab: ViewState["activeTab"] }
UPDATE_FILTERS: { filters: Partial<ViewState["filters"]> }
UPDATE_SORT: { sortConfig: Partial<ViewState["sortConfig"]> }
REFRESH_SOURCE: { url: string }
REFRESH_SOURCE_COMPLETE: { url: string }
UPDATE_SOURCES: { sources: PackageManagerSource[] }
}
export interface ViewStateTransition {
type: keyof TransitionPayloads
payload?: TransitionPayloads[keyof TransitionPayloads]
}
export type StateChangeHandler = (state: ViewState) => void
export class PackageManagerViewStateManager {
private state: ViewState = this.loadInitialState()
private loadInitialState(): ViewState {
// Try to restore state from sessionStorage
const savedState = sessionStorage.getItem("packageManagerState")
if (savedState) {
try {
return JSON.parse(savedState)
} catch {
return this.getDefaultState()
}
}
return this.getDefaultState()
}
private getDefaultState(): ViewState {
return {
allItems: [],
displayItems: [] as PackageManagerItem[],
isFetching: false,
activeTab: "browse",
refreshingUrls: [],
sources: [DEFAULT_PACKAGE_MANAGER_SOURCE],
filters: {
type: "",
search: "",
tags: [],
},
sortConfig: {
by: "name",
order: "asc",
},
}
}
private fetchTimeoutId?: NodeJS.Timeout
private readonly FETCH_TIMEOUT = 30000 // 30 seconds
private stateChangeHandlers: Set<StateChangeHandler> = new Set()
private sourcesModified = false // Track if sources have been modified
public initialize(): void {
// Send initial sources to extension
vscode.postMessage({
type: "packageManagerSources",
sources: [DEFAULT_PACKAGE_MANAGER_SOURCE],
} as WebviewMessage)
}
public onStateChange(handler: StateChangeHandler): () => void {
this.stateChangeHandlers.add(handler)
return () => this.stateChangeHandlers.delete(handler)
}
public cleanup(): void {
// Clear any pending timeouts
if (this.fetchTimeoutId) {
clearTimeout(this.fetchTimeoutId)
this.fetchTimeoutId = undefined
}
// Reset fetching state
if (this.state.isFetching) {
this.state.isFetching = false
this.notifyStateChange()
}
// Clear handlers but preserve state
this.stateChangeHandlers.clear()
}
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.length ? [...this.state.allItems] : [],
displayItems,
refreshingUrls,
sources: this.state.sources.length ? [...this.state.sources] : [DEFAULT_PACKAGE_MANAGER_SOURCE],
filters: {
...this.state.filters,
tags,
},
}
}
private notifyStateChange(): void {
const newState = this.getState() // Use getState to ensure proper copying
this.stateChangeHandlers.forEach((handler) => {
handler(newState)
})
// Save state to sessionStorage
try {
sessionStorage.setItem("packageManagerState", JSON.stringify(this.state))
} catch (error) {
console.warn("Failed to save package manager state:", error)
}
}
public async transition(transition: ViewStateTransition): Promise<void> {
switch (transition.type) {
case "FETCH_ITEMS": {
if (this.state.isFetching) {
return
}
// Clear any existing timeout before starting new fetch
this.clearFetchTimeout()
// Update state directly
this.state.isFetching = true
this.notifyStateChange()
// Set timeout for fetch operation
this.fetchTimeoutId = setTimeout(() => {
void this.transition({ type: "FETCH_ERROR" })
}, this.FETCH_TIMEOUT)
// Request items from extension
vscode.postMessage({
type: "fetchPackageManagerItems",
bool: true,
} as WebviewMessage)
break
}
case "FETCH_COMPLETE": {
const { items } = transition.payload as TransitionPayloads["FETCH_COMPLETE"]
// Clear any existing timeout
this.clearFetchTimeout()
// Create a new state object with sorted items
// 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
}
// Notify state change
this.notifyStateChange()
break
}
case "FETCH_ERROR": {
this.clearFetchTimeout()
// Update state directly
this.state.isFetching = false
this.notifyStateChange()
break
}
case "SET_ACTIVE_TAB": {
const { tab } = transition.payload as TransitionPayloads["SET_ACTIVE_TAB"]
// Update state directly
this.state.activeTab = tab
// Add default source when switching to sources tab if no sources exist
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)
}
this.notifyStateChange()
// Handle browse tab switch
if (tab === "browse") {
// Clear any existing timeouts
this.clearFetchTimeout()
// Reset fetching state when switching tabs
if (this.state.isFetching) {
this.state.isFetching = false
this.notifyStateChange()
}
// Restore previous display items if they exist
if (this.state.allItems.length > 0) {
if (this.isFilterActive()) {
// Re-apply filters to ensure display items are current
this.state.displayItems = this.filterItems(this.state.allItems)
} else {
// Use all items if no filters are active
this.state.displayItems = this.state.allItems
}
this.notifyStateChange()
} else if (this.sourcesModified) {
// Fetch new items only if sources were modified or we have no items
this.sourcesModified = false
void this.transition({ type: "FETCH_ITEMS" })
}
}
break
}
case "UPDATE_FILTERS": {
const { filters = {} } = (transition.payload as TransitionPayloads["UPDATE_FILTERS"]) || {}
// Create new filters object with explicit checks for undefined and proper defaults
const updatedFilters = {
type: "type" in filters ? filters.type || "" : this.state.filters.type,
search: "search" in filters ? filters.search || "" : this.state.filters.search,
tags: "tags" in filters ? filters.tags || [] : this.state.filters.tags,
}
// Update state with new filters
this.state = {
...this.state,
filters: updatedFilters,
}
// If all filters are cleared, restore all items
if (
!updatedFilters.type &&
!updatedFilters.search &&
(!updatedFilters.tags || updatedFilters.tags.length === 0)
) {
this.state.displayItems = [...this.state.allItems]
this.notifyStateChange()
} else {
// Otherwise, apply the filters
this.notifyStateChange()
vscode.postMessage({
type: "filterPackageManagerItems",
filters: updatedFilters,
} as WebviewMessage)
}
break
}
case "UPDATE_SORT": {
const { sortConfig } = transition.payload as TransitionPayloads["UPDATE_SORT"]
// Create new state with updated sort config
this.state = {
...this.state,
sortConfig: {
...this.state.sortConfig,
...sortConfig,
},
}
// Apply sorting to both allItems and displayItems
// Sort items immutably
// 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
}
case "REFRESH_SOURCE": {
const { url } = transition.payload as TransitionPayloads["REFRESH_SOURCE"]
if (!this.state.refreshingUrls.includes(url)) {
this.state = {
...this.state,
refreshingUrls: [...this.state.refreshingUrls, url],
}
this.notifyStateChange()
vscode.postMessage({
type: "refreshPackageManagerSource",
url,
} as WebviewMessage)
}
break
}
case "REFRESH_SOURCE_COMPLETE": {
const { url } = transition.payload as TransitionPayloads["REFRESH_SOURCE_COMPLETE"]
this.state = {
...this.state,
refreshingUrls: this.state.refreshingUrls.filter((existingUrl) => existingUrl !== url),
}
this.notifyStateChange()
break
}
case "UPDATE_SOURCES": {
const { sources } = transition.payload as TransitionPayloads["UPDATE_SOURCES"]
// If all sources are removed, add the default source
const updatedSources = sources.length === 0 ? [DEFAULT_PACKAGE_MANAGER_SOURCE] : [...sources]
this.state = {
...this.state,
sources: updatedSources,
isFetching: false, // Reset fetching state first
}
this.sourcesModified = true // Set the flag when sources are modified
this.notifyStateChange()
// Send sources update to extension
vscode.postMessage({
type: "packageManagerSources",
sources: updatedSources,
} as WebviewMessage)
// Only start fetching if we have sources
if (updatedSources.length > 0) {
// Set fetching state and notify
this.state = {
...this.state,
isFetching: true,
}
this.notifyStateChange()
// Send fetch request
vscode.postMessage({
type: "fetchPackageManagerItems",
bool: true,
} as WebviewMessage)
}
break
}
}
}
private clearFetchTimeout(): void {
// Clear fetch timeout
if (this.fetchTimeoutId) {
clearTimeout(this.fetchTimeoutId)
this.fetchTimeoutId = undefined
}
}
public isFilterActive(): boolean {
return !!(this.state.filters.type || this.state.filters.search || this.state.filters.tags.length > 0)
}
public filterItems(items: PackageManagerItem[]): PackageManagerItem[] {
const { type, search, tags } = this.state.filters
return items.filter((item) => {
// Check if the item itself matches all filters
const mainItemMatches =
(!type || item.type === type) &&
(!search ||
item.name.toLowerCase().includes(search.toLowerCase()) ||
(item.description || "").toLowerCase().includes(search.toLowerCase()) ||
(item.author || "").toLowerCase().includes(search.toLowerCase())) &&
(!tags.length || item.tags?.some((tag) => tags.includes(tag)))
if (mainItemMatches) return true
// For packages, check if any subcomponent matches all filters
if (item.type === "package" && item.items?.length) {
return item.items.some(
(subItem) =>
(!type || subItem.type === type) &&
(!search ||
(subItem.metadata &&
(subItem.metadata.name.toLowerCase().includes(search.toLowerCase()) ||
subItem.metadata.description.toLowerCase().includes(search.toLowerCase())))) &&
(!tags.length || subItem.metadata?.tags?.some((tag) => tags.includes(tag))),
)
}
return false
})
}
private sortItems(items: PackageManagerItem[]): PackageManagerItem[] {
const { by, order } = this.state.sortConfig
// 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] || ""
return order === "asc" ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue)
})
return items
}
public async handleMessage(message: any): Promise<void> {
// Handle state updates from extension
if (message.type === "state") {
// Update sources from either sources or packageManagerSources in state
if (message.state?.sources || message.state?.packageManagerSources) {
const sources = message.state.packageManagerSources || message.state.sources
this.state = {
...this.state,
sources: sources?.length > 0 ? [...sources] : [DEFAULT_PACKAGE_MANAGER_SOURCE],
}
this.notifyStateChange()
}
if (message.state?.packageManagerItems) {
// Clear fetching state before updating items
this.state.isFetching = false
void this.transition({
type: "FETCH_COMPLETE",
payload: { items: message.state.packageManagerItems },
})
}
}
// Handle repository refresh completion
if (message.type === "repositoryRefreshComplete" && message.url) {
void this.transition({
type: "REFRESH_SOURCE_COMPLETE",
payload: { url: message.url },
})
}
// Handle package manager button clicks
if (message.type === "packageManagerButtonClicked") {
if (message.text) {
// Error case
void this.transition({ type: "FETCH_ERROR" })
} else {
// Refresh request
void this.transition({ type: "FETCH_ITEMS" })
}
}
}
}

View file

@ -1,213 +0,0 @@
import { render, screen, fireEvent, act } from "@testing-library/react"
import PackageManagerView from "../PackageManagerView"
import { ComponentMetadata, PackageManagerItem } from "../../../../../src/services/package-manager/types"
import { TranslationProvider } from "@/i18n/TranslationContext"
// Mock vscode API for external communication
const mockPostMessage = jest.fn()
jest.mock("../../../utils/vscode", () => ({
vscode: {
postMessage: (msg: any) => mockPostMessage(msg),
getState: () => undefined,
setState: (state: any) => state,
},
}))
// Mock ExtensionStateContext
jest.mock("../../../context/ExtensionStateContext", () => ({
useExtensionState: () => ({
packageManagerSources: [{ url: "test-url", enabled: true }],
setPackageManagerSources: jest.fn(),
language: "en",
experiments: {
search_and_replace: false,
insert_content: false,
powerSteering: false,
},
}),
}))
const mockMetadata: ComponentMetadata = {
name: "Test Server",
description: "A test server",
type: "mcp server",
version: "1.0.0",
}
describe("PackageManagerView", () => {
beforeAll(() => {
jest.setTimeout(5000) // 5 second timeout for all tests
})
const mockItems: PackageManagerItem[] = [
{
name: "Test Package",
description: "A test package",
type: "package",
repoUrl: "https://github.com/org/repo",
url: "test-url",
defaultBranch: "main",
tags: ["test", "mock"],
items: [
{
type: "mcp server",
path: "test/path",
metadata: mockMetadata,
},
],
},
{
name: "Another Package",
description: "Another test package",
type: "package",
repoUrl: "test-url-2",
url: "test-url-2",
tags: ["test", "another"],
},
]
beforeEach(() => {
jest.useFakeTimers()
mockPostMessage.mockClear()
// Mock window event listener to handle messages
const listeners = new Map()
window.addEventListener = jest.fn((event, handler) => {
if (event === "message") {
listeners.set("message", handler)
} else {
listeners.set(event, handler)
}
})
window.removeEventListener = jest.fn()
window.dispatchEvent = jest.fn((event: Event) => {
const messageEvent = event as MessageEvent
const handler = listeners.get(messageEvent.type)
if (handler) {
handler(messageEvent)
}
return true
})
})
const renderWithTranslation = (ui: React.ReactElement) => {
return render(<TranslationProvider>{ui}</TranslationProvider>)
}
it("should automatically fetch items on mount", async () => {
renderWithTranslation(<PackageManagerView />)
// Should immediately trigger a fetch
expect(mockPostMessage).toHaveBeenCalledWith({
type: "fetchPackageManagerItems",
bool: true,
})
// Should show loading state
expect(
screen.getByText((content, element) => {
// Match either the translated text or the raw key
return content === "Refreshing..." || content === "items.refresh.refreshing"
}),
).toBeInTheDocument()
// Simulate receiving items
await act(async () => {
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "state",
state: {
packageManagerItems: mockItems,
isFetching: false,
activeTab: "browse",
refreshingUrls: [],
sources: [],
filters: { type: "", search: "", tags: [] },
sortConfig: { by: "name", order: "asc" },
},
},
}),
)
})
// Should show items
expect(
screen.getByText((content) => {
// Match either the translated text or the raw key
return content === "2 items found" || content === "items.count"
}),
).toBeInTheDocument()
expect(screen.getByText("Test Package")).toBeInTheDocument()
expect(screen.getByText("Another Package")).toBeInTheDocument()
})
it("should update display items when receiving filtered results from backend", async () => {
renderWithTranslation(<PackageManagerView />)
// Load initial items
await act(async () => {
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "state",
state: {
packageManagerItems: [
{
name: "MCP Server 1",
type: "mcp server",
repoUrl: "test-url-1",
url: "test-url-1",
},
{
name: "Mode 1",
type: "mode",
repoUrl: "test-url-2",
url: "test-url-2",
},
{
name: "MCP Server 2",
type: "mcp server",
repoUrl: "test-url-3",
url: "test-url-3",
},
],
isFetching: false,
activeTab: "browse",
refreshingUrls: [],
sources: [],
filters: { type: "", search: "", tags: [] },
sortConfig: { by: "name", order: "asc" },
},
},
}),
)
})
// Verify initial items are shown
expect(
screen.getByText((content) => {
// Match either the translated text or the raw key
return content === "3 items found" || content === "items.count"
}),
).toBeInTheDocument()
expect(screen.getByText("MCP Server 1")).toBeInTheDocument()
expect(screen.getByText("Mode 1")).toBeInTheDocument()
expect(screen.getByText("MCP Server 2")).toBeInTheDocument()
// Select MCP Server from type filter
const typeFilter = screen.getByLabelText((content) => {
return content === "Filter by type:" || content === "filters.type.label"
})
await act(async () => {
fireEvent.change(typeFilter, { target: { value: "mcp server" } })
})
// Verify initial fetch and filter requests were sent
expect(mockPostMessage).toHaveBeenCalledTimes(2)
expect(mockPostMessage).toHaveBeenLastCalledWith({
type: "filterPackageManagerItems",
filters: { type: "mcp server", search: "", tags: [] },
})
})
})

View file

@ -1,37 +0,0 @@
import { useState, useEffect } from "react"
import { PackageManagerViewStateManager, ViewState } from "./PackageManagerViewStateManager"
export function useStateManager(existingManager?: PackageManagerViewStateManager) {
const [manager] = useState(() => existingManager || new PackageManagerViewStateManager())
const [state, setState] = useState(() => manager.getState())
useEffect(() => {
const handleStateChange = (newState: ViewState) => {
setState((prevState) => {
// Only update if something actually changed
if (JSON.stringify(prevState) === JSON.stringify(newState)) {
return prevState
}
return newState
})
}
const handleMessage = (event: MessageEvent) => {
manager.handleMessage(event.data)
}
window.addEventListener("message", handleMessage)
const unsubscribe = manager.onStateChange(handleStateChange)
return () => {
window.removeEventListener("message", handleMessage)
unsubscribe()
// Don't cleanup the manager if it was provided externally
if (!existingManager) {
manager.cleanup()
}
}
}, [manager, existingManager])
return [state, manager] as const
}

View file

@ -11,8 +11,8 @@ import { Mode, CustomModePrompts, defaultModeSlug, defaultPrompts, ModeConfig }
import { CustomSupportPrompts } from "../../../src/shared/support-prompt"
import { experimentDefault, ExperimentId } from "../../../src/shared/experiments"
import { TelemetrySetting } from "../../../src/shared/TelemetrySetting"
import { PackageManagerSource } from "../../../src/services/package-manager/types"
import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../../src/services/package-manager/constants"
import { MarketplaceSource } from "../../../src/services/marketplace/types"
import { DEFAULT_MARKETPLACE_SOURCE } from "../../../src/services/marketplace/constants"
export interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean
@ -88,7 +88,7 @@ export interface ExtensionStateContextType extends ExtensionState {
pinnedApiConfigs?: Record<string, boolean>
setPinnedApiConfigs: (value: Record<string, boolean>) => void
togglePinnedApiConfig: (configName: string) => void
setPackageManagerSources: (value: PackageManagerSource[]) => void
setMarketplaceSources: (value: MarketplaceSource[]) => void
}
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@ -161,7 +161,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior).
renderContext: "sidebar",
maxReadFileLine: 500, // Default max read file line limit
packageManagerSources: [DEFAULT_PACKAGE_MANAGER_SOURCE],
marketplaceSources: [DEFAULT_MARKETPLACE_SOURCE],
pinnedApiConfigs: {}, // Empty object for pinned API configs
terminalZshOhMy: false, // Default Oh My Zsh integration setting
terminalZshP10k: false, // Default Powerlevel10k integration setting
@ -188,8 +188,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const newState = message.state!
console.log("DEBUG: ExtensionStateContext received state message:", {
hasApiConfig: !!newState.apiConfiguration,
hasPackageManagerItems: !!newState.packageManagerItems,
packageManagerItemsCount: newState.packageManagerItems?.length || 0,
hasMarketplaceItems: !!newState.marketplaceItems,
marketplaceItemsCount: newState.marketplaceItems?.length || 0,
})
setState((prevState) => mergeExtensionState(prevState, newState))
@ -349,7 +349,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
return { ...prevState, pinnedApiConfigs: newPinned }
}),
setPackageManagerSources: (value) => setState((prevState) => ({ ...prevState, packageManagerSources: value })),
setMarketplaceSources: (value) => setState((prevState) => ({ ...prevState, marketplaceSources: value })),
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

View file

@ -37,8 +37,8 @@ i18next.use(initReactI18next).init({
interpolation: {
escapeValue: false, // React already escapes by default
},
defaultNS: "package-manager",
ns: ["package-manager"],
defaultNS: "marketplace",
ns: ["marketplace"],
})
export function loadTranslations() {

View file

@ -29,7 +29,7 @@ export const setupI18nForTests = () => {
chat: {
test: "Test",
},
"package-manager": {
marketplace: {
items: {
card: {
by: "by {{author}}",

View file

@ -19,15 +19,15 @@ i18next.use(initReactI18next).init({
},
resources: {
en: {
"package-manager": {
title: "Package Manager",
marketplace: {
title: "Marketplace",
tabs: {
browse: "Browse",
sources: "Sources",
},
filters: {
search: {
placeholder: "Search package manager items...",
placeholder: "Search marketplace items...",
},
type: {
label: "Filter by type:",
@ -55,7 +55,7 @@ i18next.use(initReactI18next).init({
},
items: {
empty: {
noItems: "No package manager items found",
noItems: "No marketplace items found",
withFilters: "Try adjusting your filters",
noSources: "Try adding a source in the Sources tab",
},
@ -88,8 +88,8 @@ i18next.use(initReactI18next).init({
// Minimal mock state
const mockExtensionState = {
language: "en",
packageManagerSources: [{ url: "test-url", enabled: true }],
setPackageManagerSources: jest.fn(),
marketplaceSources: [{ url: "test-url", enabled: true }],
setMarketplaceSources: jest.fn(),
experiments: {
search_and_replace: false,
insert_content: false,