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 - Coordinates repository operations
- Provides filtering and sorting - Provides filtering and sorting
4. **packageManagerMessageHandler** 4. **marketplaceMessageHandler**
- Routes messages between UI and backend - Routes messages between UI and backend
- Processes commands from the UI - Processes commands from the UI
- Returns data and status updates - 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( public async getPackageManagerItems(
sources: PackageManagerSource[], sources: PackageManagerSource[],
@ -251,14 +251,14 @@ The PackageManagerSourceValidation component handles validation of marketplace s
```typescript ```typescript
export class PackageManagerSourceValidation { export class PackageManagerSourceValidation {
/** /**
* Validates a package manager source URL * Validates a marketplace source URL
*/ */
public static validateSourceUrl(url: string): ValidationError[] { public static validateSourceUrl(url: string): ValidationError[] {
// Implementation details // Implementation details
} }
/** /**
* Validates a package manager source name * Validates a marketplace source name
*/ */
public static validateSourceName(name?: string): ValidationError[] { public static validateSourceName(name?: string): ValidationError[] {
// Implementation details // Implementation details

View file

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

View file

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

View file

@ -420,7 +420,7 @@ Integration tests verify that different components work together correctly.
### Backend Integration Tests ### Backend Integration Tests
```typescript ```typescript
describe("Package Manager Integration", () => { describe("Marketplace Integration", () => {
let manager: PackageManagerManager let manager: PackageManagerManager
let metadataScanner: MetadataScanner let metadataScanner: MetadataScanner
let templateItems: PackageManagerItem[] let templateItems: PackageManagerItem[]
@ -428,7 +428,7 @@ describe("Package Manager Integration", () => {
beforeAll(async () => { beforeAll(async () => {
// Load real data from template // Load real data from template
metadataScanner = new MetadataScanner() 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") templateItems = await metadataScanner.scanDirectory(templatePath, "https://example.com")
}) })
@ -507,7 +507,7 @@ describe("Package Manager Integration", () => {
### Frontend Integration Tests ### Frontend Integration Tests
```typescript ```typescript
describe("Package Manager UI Integration", () => { describe("Marketplace UI Integration", () => {
const mockItems: PackageManagerItem[] = [ const mockItems: PackageManagerItem[] = [
{ {
name: "Test Package", name: "Test Package",
@ -665,7 +665,7 @@ Real template data is used for integration tests:
beforeAll(async () => { beforeAll(async () => {
// Load real data from template // Load real data from template
metadataScanner = new MetadataScanner() 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") 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 ### Consolidated Test Files
``` ```
src/services/package-manager/__tests__/ src/services/marketplace/__tests__/
├── PackageManager.consolidated.test.ts # Combined tests ├── PackageManager.consolidated.test.ts # Combined tests
├── searchUtils.test.ts # Search utility tests ├── searchUtils.test.ts # Search utility tests
└── PackageSubcomponents.test.ts # Subcomponent tests └── PackageSubcomponents.test.ts # Subcomponent tests
@ -922,7 +922,7 @@ src/services/package-manager/__tests__/
Tests are organized into logical groups: Tests are organized into logical groups:
```typescript ```typescript
describe("Package Manager", () => { describe("Marketplace", () => {
// Shared setup // Shared setup
describe("Direct Filtering", () => { describe("Direct Filtering", () => {
@ -964,7 +964,7 @@ module.exports = {
lines: 85, lines: 85,
statements: 85, statements: 85,
}, },
"src/services/package-manager/*.ts": { "src/services/marketplace/*.ts": {
branches: 90, branches: 90,
functions: 90, functions: 90,
lines: 90, lines: 90,
@ -1003,7 +1003,7 @@ describe("containsSearchTerm", () => {
```typescript ```typescript
// Optimized integration tests // Optimized integration tests
describe("Package Manager Integration", () => { describe("Marketplace Integration", () => {
// Load template data once for all tests // Load template data once for all tests
beforeAll(async () => { beforeAll(async () => {
templateItems = await metadataScanner.scanDirectory(templatePath) templateItems = await metadataScanner.scanDirectory(templatePath)
@ -1098,7 +1098,7 @@ describe("Complex integration test", () => {
it("should handle complex search", async () => { it("should handle complex search", async () => {
// Enable debug logging for this test // Enable debug logging for this test
const originalDebug = process.env.DEBUG const originalDebug = process.env.DEBUG
process.env.DEBUG = "package-manager:*" process.env.DEBUG = "marketplace:*"
// Test logic... // 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 // In your extension code
const registerTemplates = (context: vscode.ExtensionContext) => { const registerTemplates = (context: vscode.ExtensionContext) => {
const templatePath = path.join(context.extensionPath, "templates", "your-template") 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**: 4. **Integrate with the Main UI**:
```tsx ```tsx
<div className="package-manager-container"> <div className="marketplace-container">
<div className="toolbar"> <div className="toolbar">
<ViewModeSelector viewMode={viewMode} setViewMode={setViewMode} /> <ViewModeSelector viewMode={viewMode} setViewMode={setViewMode} />
{/* Other toolbar items */} {/* Other toolbar items */}
@ -476,7 +476,7 @@ const PackageManagerView: React.FC<{
const PackageCard = componentOverrides.PackageCard || PackageManagerItemCard const PackageCard = componentOverrides.PackageCard || PackageManagerItemCard
return ( return (
<div className="package-manager"> <div className="marketplace">
{items.map((item) => ( {items.map((item) => (
<PackageCard <PackageCard
key={item.name} key={item.name}
@ -584,8 +584,8 @@ class CustomSourceProvider implements SourceProvider {
```typescript ```typescript
// In your extension code // In your extension code
const registerSourceProviders = (packageManager: PackageManagerManager) => { const registerSourceProviders = (marketplace: PackageManagerManager) => {
packageManager.registerSourceProvider(new CustomSourceProvider()) marketplace.registerSourceProvider(new CustomSourceProvider())
} }
``` ```
@ -637,7 +637,7 @@ To add support for custom messages:
const extendMessageHandler = () => { const extendMessageHandler = () => {
const originalHandler = handlePackageManagerMessages const originalHandler = handlePackageManagerMessages
return async (message: any, packageManager: PackageManagerManager) => { return async (message: any, marketplace: PackageManagerManager) => {
// Handle custom messages // Handle custom messages
if (message.type === "yourCustomMessage") { if (message.type === "yourCustomMessage") {
// Your custom message handling // Your custom message handling
@ -650,7 +650,7 @@ const extendMessageHandler = () => {
} }
// Fall back to the original handler // Fall back to the original handler
return originalHandler(message, packageManager) return originalHandler(message, marketplace)
} }
} }
``` ```
@ -661,8 +661,8 @@ const extendMessageHandler = () => {
// In your extension code // In your extension code
const customMessageHandler = extendMessageHandler() const customMessageHandler = extendMessageHandler()
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand("packageManager.handleMessage", (message) => { vscode.commands.registerCommand("marketplace.handleMessage", (message) => {
return customMessageHandler(message, packageManager) return customMessageHandler(message, marketplace)
}), }),
) )
``` ```
@ -728,8 +728,8 @@ class ApiSourceProvider implements SourceProvider {
```typescript ```typescript
// In your extension code // In your extension code
const registerApiProvider = (packageManager: PackageManagerManager) => { const registerApiProvider = (marketplace: PackageManagerManager) => {
packageManager.registerSourceProvider(new ApiSourceProvider("https://your-api.example.com")) 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 ```typescript
// In your extension code // In your extension code
@ -845,15 +845,15 @@ const registerLocalDevTools = (context: vscode.ExtensionContext) => {
// Register commands // Register commands
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand("packageManager.createLocal", async (template, name) => { vscode.commands.registerCommand("marketplace.createLocal", async (template, name) => {
return localDevProvider.createLocalPackage(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) return localDevProvider.buildLocalPackage(packagePath)
}), }),
vscode.commands.registerCommand("packageManager.testLocal", async (packagePath) => { vscode.commands.registerCommand("marketplace.testLocal", async (packagePath) => {
return localDevProvider.testLocalPackage(packagePath) return localDevProvider.testLocalPackage(packagePath)
}), }),
) )

View file

@ -26,7 +26,7 @@
"src/schemas/ipc.ts", "src/schemas/ipc.ts",
"src/extension.ts", "src/extension.ts",
"scripts/**", "scripts/**",
"package-manager-template/**", "marketplace-template/**",
"src/utils/git.ts" "src/utils/git.ts"
], ],
"workspaces": { "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 ## Structure
@ -33,7 +33,7 @@ version: "1.0.0"
## Usage ## 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 2. Update the metadata.en.yml with your repository information
3. Add your MCP servers, roles, or other components 3. Add your MCP servers, roles, or other components
4. Each component must have its own metadata.en.yml file with the required fields 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" 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" type: "mcp server"
version: "1.0.0" version: "1.0.0"
author: "JB Brown" 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)" "icon": "$(notebook)"
}, },
{ {
"command": "roo-cline.packageManagerButtonClicked", "command": "roo-cline.marketplaceButtonClicked",
"title": "Marketplace", "title": "Marketplace",
"icon": "$(extensions)" "icon": "$(extensions)"
}, },
@ -261,7 +261,7 @@
"when": "view == roo-cline.SidebarProvider" "when": "view == roo-cline.SidebarProvider"
}, },
{ {
"command": "roo-cline.packageManagerButtonClicked", "command": "roo-cline.marketplaceButtonClicked",
"group": "navigation@4", "group": "navigation@4",
"when": "view == roo-cline.SidebarProvider" "when": "view == roo-cline.SidebarProvider"
}, },
@ -303,7 +303,7 @@
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider" "when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
}, },
{ {
"command": "roo-cline.packageManagerButtonClicked", "command": "roo-cline.marketplaceButtonClicked",
"group": "navigation@4", "group": "navigation@4",
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider" "when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
}, },

View file

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

View file

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

View file

@ -3,23 +3,23 @@ import { ClineProvider } from "./ClineProvider"
import { WebviewMessage } from "../../shared/WebviewMessage" import { WebviewMessage } from "../../shared/WebviewMessage"
import { ExtensionMessage } from "../../shared/ExtensionMessage" import { ExtensionMessage } from "../../shared/ExtensionMessage"
import { import {
PackageManagerManager, MarketplaceManager,
ComponentType, ComponentType,
PackageManagerItem, MarketplaceItem,
PackageManagerSource, MarketplaceSource,
validateSources, validateSources,
ValidationError, ValidationError,
} from "../../services/package-manager" } from "../../services/marketplace"
import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../services/package-manager/constants" import { DEFAULT_MARKETPLACE_SOURCE } from "../../services/marketplace/constants"
import { GlobalState } from "../../schemas" 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, provider: ClineProvider,
message: WebviewMessage, message: WebviewMessage,
packageManagerManager: PackageManagerManager, marketplaceManager: MarketplaceManager,
): Promise<boolean> { ): Promise<boolean> {
// Utility function for updating global state // Utility function for updating global state
const updateGlobalState = async <K extends keyof GlobalState>(key: K, value: GlobalState[K]) => const updateGlobalState = async <K extends keyof GlobalState>(key: K, value: GlobalState[K]) =>
@ -27,37 +27,36 @@ export async function handlePackageManagerMessages(
switch (message.type) { switch (message.type) {
case "webviewDidLaunch": { 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 return true
} }
case "fetchPackageManagerItems": { case "fetchMarketplaceItems": {
// Prevent multiple simultaneous fetches // Prevent multiple simultaneous fetches
if (packageManagerManager.isFetching) { if (marketplaceManager.isFetching) {
await provider.postMessageToWebview({ await provider.postMessageToWebview({
type: "state", type: "state",
text: "Fetch already in progress", text: "Fetch already in progress",
}) })
packageManagerManager.isFetching = false marketplaceManager.isFetching = false
return true return true
} }
// Check if we need to force refresh using type assertion // Check if we need to force refresh using type assertion
const forceRefresh = (message as any).forceRefresh === true const forceRefresh = (message as any).forceRefresh === true
try { try {
packageManagerManager.isFetching = true marketplaceManager.isFetching = true
// Wrap the entire initialization in a try-catch block // Wrap the entire initialization in a try-catch block
try { try {
// Initialize default sources if none exist // Initialize default sources if none exist
let sources = let sources =
((await provider.contextProxy.getValue("packageManagerSources")) as PackageManagerSource[]) || ((await provider.contextProxy.getValue("marketplaceSources")) as MarketplaceSource[]) || []
[]
if (!sources || sources.length === 0) { if (!sources || sources.length === 0) {
sources = [DEFAULT_PACKAGE_MANAGER_SOURCE] sources = [DEFAULT_MARKETPLACE_SOURCE]
// Save the default sources // Save the default sources
await provider.contextProxy.setValue("packageManagerSources", sources) await provider.contextProxy.setValue("marketplaceSources", sources)
} }
// Add timing information // Add timing information
@ -74,23 +73,23 @@ export async function handlePackageManagerMessages(
return true return true
} }
const result = await packageManagerManager.getPackageManagerItems(enabledSources) const result = await marketplaceManager.getMarketplaceItems(enabledSources)
// If there are errors but also items, show warning // If there are errors but also items, show warning
if (result.errors && result.items.length > 0) { if (result.errors && result.items.length > 0) {
vscode.window.showWarningMessage( 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 // If there are errors and no items, show error
else if (result.errors && result.items.length === 0) { 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) vscode.window.showErrorMessage(errorMessage)
await provider.postMessageToWebview({ await provider.postMessageToWebview({
type: "state", type: "state",
text: errorMessage, text: errorMessage,
}) })
packageManagerManager.isFetching = false marketplaceManager.isFetching = false
} }
const endTime = Date.now() const endTime = Date.now()
@ -101,8 +100,8 @@ export async function handlePackageManagerMessages(
// Send state to webview // Send state to webview
await provider.postStateToWebview() await provider.postStateToWebview()
} catch (initError) { } catch (initError) {
const errorMessage = `Package manager initialization failed: ${initError instanceof Error ? initError.message : String(initError)}` const errorMessage = `Marketplace initialization failed: ${initError instanceof Error ? initError.message : String(initError)}`
console.error("Error in package manager initialization:", initError) console.error("Error in marketplace initialization:", initError)
vscode.window.showErrorMessage(errorMessage) vscode.window.showErrorMessage(errorMessage)
await provider.postMessageToWebview({ await provider.postMessageToWebview({
type: "state", type: "state",
@ -110,31 +109,31 @@ export async function handlePackageManagerMessages(
}) })
// The state will already be updated with empty items by PackageManagerManager // The state will already be updated with empty items by PackageManagerManager
await provider.postStateToWebview() await provider.postStateToWebview()
packageManagerManager.isFetching = false marketplaceManager.isFetching = false
} }
} catch (error) { } catch (error) {
const errorMessage = `Failed to fetch package manager items: ${error instanceof Error ? error.message : String(error)}` const errorMessage = `Failed to fetch marketplace items: ${error instanceof Error ? error.message : String(error)}`
console.error("Failed to fetch package manager items:", error) console.error("Failed to fetch marketplace items:", error)
vscode.window.showErrorMessage(errorMessage) vscode.window.showErrorMessage(errorMessage)
await provider.postMessageToWebview({ await provider.postMessageToWebview({
type: "state", type: "state",
text: errorMessage, text: errorMessage,
}) })
packageManagerManager.isFetching = false marketplaceManager.isFetching = false
} }
return true return true
} }
case "packageManagerSources": { case "marketplaceSources": {
if (message.sources) { if (message.sources) {
// Enforce maximum of 10 sources // Enforce maximum of 10 sources
const MAX_SOURCES = 10 const MAX_SOURCES = 10
let updatedSources: PackageManagerSource[] let updatedSources: MarketplaceSource[]
if (message.sources.length > MAX_SOURCES) { if (message.sources.length > MAX_SOURCES) {
// Truncate to maximum allowed and show warning // Truncate to maximum allowed and show warning
updatedSources = message.sources.slice(0, MAX_SOURCES) updatedSources = message.sources.slice(0, MAX_SOURCES)
vscode.window.showWarningMessage( 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 { } else {
updatedSources = message.sources updatedSources = message.sources
@ -162,19 +161,19 @@ export async function handlePackageManagerMessages(
updatedSources = updatedSources.filter((_, index) => !invalidIndices.has(index)) updatedSources = updatedSources.filter((_, index) => !invalidIndices.has(index))
// Show validation errors // 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) console.error(errorMessage)
vscode.window.showErrorMessage(errorMessage) vscode.window.showErrorMessage(errorMessage)
} }
// Update the global state with the validated sources // 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 // Clean up cache directories for repositories that are no longer in the sources list
try { try {
await packageManagerManager.cleanupCacheDirectories(updatedSources) await marketplaceManager.cleanupCacheDirectories(updatedSources)
} catch (error) { } 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 // Update the webview with the new state
@ -188,43 +187,42 @@ export async function handlePackageManagerMessages(
vscode.env.openExternal(vscode.Uri.parse(message.url)) vscode.env.openExternal(vscode.Uri.parse(message.url))
} catch (error) { } catch (error) {
console.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( vscode.window.showErrorMessage(
`Failed to open URL: ${error instanceof Error ? error.message : String(error)}`, `Failed to open URL: ${error instanceof Error ? error.message : String(error)}`,
) )
} }
} else { } else {
console.error("Package Manager: openExternal called without a URL") console.error("Marketplace: openExternal called without a URL")
} }
return true return true
} }
case "filterPackageManagerItems": { case "filterMarketplaceItems": {
if (message.filters) { if (message.filters) {
try { try {
// Update filtered items and post state // Update filtered items and post state
packageManagerManager.updateWithFilteredItems({ marketplaceManager.updateWithFilteredItems({
type: message.filters.type as ComponentType | undefined, type: message.filters.type as ComponentType | undefined,
search: message.filters.search, search: message.filters.search,
tags: message.filters.tags, tags: message.filters.tags,
}) })
await provider.postStateToWebview() await provider.postStateToWebview()
} catch (error) { } catch (error) {
console.error("Package Manager: Error filtering items:", error) console.error("Marketplace: Error filtering items:", error)
vscode.window.showErrorMessage("Failed to filter package manager items") vscode.window.showErrorMessage("Failed to filter marketplace items")
} }
} }
return true return true
} }
case "refreshPackageManagerSource": { case "refreshMarketplaceSource": {
if (message.url) { if (message.url) {
try { try {
// Get the current sources // Get the current sources
const sources = const sources =
((await provider.contextProxy.getValue("packageManagerSources")) as PackageManagerSource[]) || ((await provider.contextProxy.getValue("marketplaceSources")) as MarketplaceSource[]) || []
[]
// Find the source with the matching URL // Find the source with the matching URL
const source = sources.find((s) => s.url === message.url) const source = sources.find((s) => s.url === message.url)
@ -232,17 +230,14 @@ export async function handlePackageManagerMessages(
if (source) { if (source) {
try { try {
// Refresh the repository with the source name // Refresh the repository with the source name
const refreshResult = await packageManagerManager.refreshRepository( const refreshResult = await marketplaceManager.refreshRepository(message.url, source.name)
message.url,
source.name,
)
if (refreshResult.error) { if (refreshResult.error) {
vscode.window.showErrorMessage( vscode.window.showErrorMessage(
`Failed to refresh source: ${source.name || message.url} - ${refreshResult.error}`, `Failed to refresh source: ${source.name || message.url} - ${refreshResult.error}`,
) )
} else { } else {
vscode.window.showInformationMessage( vscode.window.showInformationMessage(
`Successfully refreshed package manager source: ${source.name || message.url}`, `Successfully refreshed marketplace source: ${source.name || message.url}`,
) )
} }
await provider.postStateToWebview() await provider.postStateToWebview()
@ -254,12 +249,12 @@ export async function handlePackageManagerMessages(
}) })
} }
} else { } 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}`) vscode.window.showErrorMessage(`Source URL not found: ${message.url}`)
} }
} catch (error) { } catch (error) {
console.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( vscode.window.showErrorMessage(
`Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`, `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 { SYSTEM_PROMPT } from "../prompts/system"
import { buildApiHandler } from "../../api" import { buildApiHandler } from "../../api"
import { GlobalState } from "../../schemas" import { GlobalState } from "../../schemas"
import { PackageManagerManager } from "../../services/package-manager" import { MarketplaceManager } from "../../services/marketplace"
import { handlePackageManagerMessages } from "./packageManagerMessageHandler" import { handleMarketplaceMessages } from "./marketplaceMessageHandler"
// Track if package manager data has been loaded // Track if marketplace data has been loaded
let packageManagerDataLoaded = false let marketplaceDataLoaded = false
export const webviewMessageHandler = async ( export const webviewMessageHandler = async (
provider: ClineProvider, provider: ClineProvider,
message: WebviewMessage, message: WebviewMessage,
packageManagerManager?: PackageManagerManager, marketplaceManager?: MarketplaceManager,
) => { ) => {
// Utility functions provided for concise get/update of global state via contextProxy API. // 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) 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() const customModes = await provider.customModesManager.getCustomModes()
await updateGlobalState("customModes", customModes) await updateGlobalState("customModes", customModes)
// Don't handle package manager messages in webviewDidLaunch // Don't handle marketplace messages in webviewDidLaunch
// They will be handled by the fetchPackageManagerItems case // They will be handled by the fetchMarketplaceItems case
console.log( 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`) console.log(`DEBUG: About to call postStateToWebview`)
@ -261,22 +261,15 @@ export const webviewMessageHandler = async (
provider.isViewLaunched = true provider.isViewLaunched = true
break break
case "fetchPackageManagerItems": case "fetchMarketplaceItems":
if (packageManagerManager) { if (marketplaceManager) {
console.log(`DEBUG: Handling explicit fetchPackageManagerItems message`)
try { try {
// Use non-null assertion to tell TypeScript that packageManagerManager is definitely not undefined here const result = await handleMarketplaceMessages(provider, message, marketplaceManager!)
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}`)
} catch (error) { } catch (error) {
console.error(`DEBUG: Error handling package manager message: ${error}`) console.error(`DEBUG: Error handling marketplace message: ${error}`)
} }
} else { } else {
console.log(`DEBUG: packageManagerManager is undefined, skipping package manager message handling`) console.log(`DEBUG: marketplaceManager is undefined, skipping marketplace message handling`)
} }
break break
case "newTask": case "newTask":
@ -1406,21 +1399,19 @@ export const webviewMessageHandler = async (
} }
} }
// Handle package manager related messages
// Handle package manager messages
if ( if (
packageManagerManager && marketplaceManager &&
(message.type === "packageManagerSources" || (message.type === "marketplaceSources" ||
message.type === "openExternal" || message.type === "openExternal" ||
message.type === "refreshPackageManagerSource" || message.type === "refreshMarketplaceSource" ||
message.type === "filterPackageManagerItems") message.type === "filterMarketplaceItems")
) { ) {
try { try {
console.log(`DEBUG: Routing ${message.type} message to packageManagerMessageHandler`) console.log(`DEBUG: Routing ${message.type} message to marketplaceMessageHandler`)
const result = await handlePackageManagerMessages(provider, message, packageManagerManager) const result = await handleMarketplaceMessages(provider, message, marketplaceManager)
console.log(`DEBUG: Package manager message handled successfully: ${message.type}, result: ${result}`) console.log(`DEBUG: Marketplace message handled successfully: ${message.type}, result: ${result}`)
} catch (error) { } 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 | undefined
enhancementApiConfigId?: string | undefined enhancementApiConfigId?: string | undefined
packageManagerSources?: marketplaceSources?:
| { | {
url: string url: string
name?: string | undefined name?: string | undefined

View file

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

View file

@ -19,7 +19,7 @@ import { ClineProvider } from "./core/webview/ClineProvider"
import { CodeActionProvider } from "./core/CodeActionProvider" import { CodeActionProvider } from "./core/CodeActionProvider"
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider" import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
import { McpServerManager } from "./services/mcp/McpServerManager" import { McpServerManager } from "./services/mcp/McpServerManager"
import { PackageManagerManager } from "./services/package-manager" import { MarketplaceManager } from "./services/marketplace"
import { telemetryService } from "./services/telemetry/TelemetryService" import { telemetryService } from "./services/telemetry/TelemetryService"
import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry" import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry"
import { API } from "./exports/api" import { API } from "./exports/api"
@ -38,7 +38,7 @@ import { formatLanguage } from "./shared/language"
let outputChannel: vscode.OutputChannel let outputChannel: vscode.OutputChannel
let extensionContext: vscode.ExtensionContext let extensionContext: vscode.ExtensionContext
let packageManagerManager: PackageManagerManager let marketplaceManager: MarketplaceManager
// This method is called when your extension is activated. // This method is called when your extension is activated.
// Your extension is activated the very first time the command is executed. // 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") const provider = new ClineProvider(context, outputChannel, "sidebar")
// Initialize package manager marketplaceManager = new MarketplaceManager(context)
packageManagerManager = new PackageManagerManager(context) provider.setMarketplaceManager(marketplaceManager)
provider.setPackageManagerManager(packageManagerManager)
telemetryService.setProvider(provider) telemetryService.setProvider(provider)
context.subscriptions.push( context.subscriptions.push(
@ -134,12 +133,11 @@ export async function activate(context: vscode.ExtensionContext) {
export async function deactivate() { export async function deactivate() {
outputChannel.appendLine("Roo-Code extension deactivated") outputChannel.appendLine("Roo-Code extension deactivated")
// Clean up package manager if (marketplaceManager) {
if (packageManagerManager) {
try { try {
await packageManagerManager.cleanup() await marketplaceManager.cleanup()
} catch (error) { } 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(), customModePrompts: customModePromptsSchema.optional(),
customSupportPrompts: customSupportPromptsSchema.optional(), customSupportPrompts: customSupportPromptsSchema.optional(),
enhancementApiConfigId: z.string().optional(), enhancementApiConfigId: z.string().optional(),
packageManagerSources: z marketplaceSources: z
.array( .array(
z.object({ z.object({
url: z.string(), url: z.string(),
@ -652,7 +652,7 @@ const globalSettingsRecord: GlobalSettingsRecord = {
customSupportPrompts: undefined, customSupportPrompts: undefined,
enhancementApiConfigId: undefined, enhancementApiConfigId: undefined,
cachedChromeHostUrl: undefined, cachedChromeHostUrl: undefined,
packageManagerSources: undefined, marketplaceSources: undefined,
} }
export const GLOBAL_SETTINGS_KEYS = Object.keys(globalSettingsRecord) as Keys<GlobalSettings>[] 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 simpleGit, { SimpleGit } from "simple-git"
import { MetadataScanner } from "./MetadataScanner" import { MetadataScanner } from "./MetadataScanner"
import { validateAnyMetadata } from "./schemas" import { validateAnyMetadata } from "./schemas"
import { LocalizationOptions, PackageManagerItem, PackageManagerRepository, RepositoryMetadata } from "./types" import { LocalizationOptions, MarketplaceItem, MarketplaceRepository, RepositoryMetadata } from "./types"
import { getUserLocale } from "./utils" import { getUserLocale } from "./utils"
/** /**
* Handles fetching and caching package manager repositories * Handles fetching and caching marketplace repositories
*/ */
export class GitFetcher { export class GitFetcher {
private readonly cacheDir: string private readonly cacheDir: string
@ -19,7 +19,7 @@ export class GitFetcher {
private activeGitInstances: Set<SimpleGit> = new Set() private activeGitInstances: Set<SimpleGit> = new Set()
constructor(context: vscode.ExtensionContext, localizationOptions?: LocalizationOptions) { 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 || { this.localizationOptions = localizationOptions || {
userLocale: getUserLocale(), userLocale: getUserLocale(),
fallbackLocale: "en", fallbackLocale: "en",
@ -85,11 +85,7 @@ export class GitFetcher {
* @param sourceName Optional source repository name * @param sourceName Optional source repository name
* @returns Repository data * @returns Repository data
*/ */
async fetchRepository( async fetchRepository(repoUrl: string, forceRefresh = false, sourceName?: string): Promise<MarketplaceRepository> {
repoUrl: string,
forceRefresh = false,
sourceName?: string,
): Promise<PackageManagerRepository> {
// Ensure cache directory exists // Ensure cache directory exists
await fs.mkdir(this.cacheDir, { recursive: true }) await fs.mkdir(this.cacheDir, { recursive: true })
@ -109,7 +105,7 @@ export class GitFetcher {
// Parse repository metadata // Parse repository metadata
const metadata = await this.parseRepositoryMetadata(repoDir) const metadata = await this.parseRepositoryMetadata(repoDir)
// Parse package manager items // Parse marketplace items
// Get current branch using existing git instance // Get current branch using existing git instance
const branch = (await this.git?.revparse(["--abbrev-ref", "HEAD"])) || "main" 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 repoDir Repository directory
* @param repoUrl Repository URL * @param repoUrl Repository URL
* @param sourceName Source repository name * @param sourceName Source repository name
* @returns Array of package manager items * @returns Array of marketplace items
*/ */
private async parsePackageManagerItems( private async parsePackageManagerItems(
repoDir: string, repoDir: string,
repoUrl: string, repoUrl: string,
sourceName: string, sourceName: string,
): Promise<PackageManagerItem[]> { ): Promise<MarketplaceItem[]> {
return this.metadataScanner.scanDirectory(repoDir, repoUrl, sourceName) 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 * as fs from "fs/promises"
import { GitFetcher } from "./GitFetcher" import { GitFetcher } from "./GitFetcher"
import { import {
PackageManagerItem, MarketplaceItem,
PackageManagerRepository, MarketplaceRepository,
PackageManagerSource, MarketplaceSource,
ComponentType, ComponentType,
ComponentMetadata, ComponentMetadata,
LocalizationOptions, LocalizationOptions,
} from "./types" } from "./types"
import { validateSource, validateSources } from "../../shared/MarketplaceValidation"
import { getUserLocale } from "./utils" import { getUserLocale } from "./utils"
/** /**
* Service for managing package manager data * Service for managing marketplace data
*/ */
export class PackageManagerManager { export class MarketplaceManager {
private currentItems: PackageManagerItem[] = [] private currentItems: MarketplaceItem[] = []
private static readonly CACHE_EXPIRY_MS = 3600000 // 1 hour private static readonly CACHE_EXPIRY_MS = 3600000 // 1 hour
private gitFetcher: GitFetcher 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 public isFetching = false
// Concurrency control // Concurrency control
@ -36,11 +37,6 @@ export class PackageManagerManager {
this.gitFetcher = new GitFetcher(context, localizationOptions) 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 * Queue an operation to run when no metadata scan is active
*/ */
@ -68,10 +64,8 @@ export class PackageManagerManager {
} }
} }
async getPackageManagerItems( async getMarketplaceItems(sources: MarketplaceSource[]): Promise<{ items: MarketplaceItem[]; errors?: string[] }> {
sources: PackageManagerSource[], const items: MarketplaceItem[] = []
): Promise<{ items: PackageManagerItem[]; errors?: string[] }> {
const items: PackageManagerItem[] = []
const errors: string[] = [] const errors: string[] = []
// Filter enabled sources // Filter enabled sources
@ -91,12 +85,18 @@ export class PackageManagerManager {
const repo = await this.getRepositoryData(source.url, false, source.name) const repo = await this.getRepositoryData(source.url, false, source.name)
if (repo.items && repo.items.length > 0) { 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) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(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}`) errors.push(`Source ${source.url}: ${errorMessage}`)
} finally { } finally {
this.unlockSource(source.url) this.unlockSource(source.url)
@ -115,13 +115,6 @@ export class PackageManagerManager {
return result 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 * Check if a source operation is in progress
*/ */
@ -147,12 +140,12 @@ export class PackageManagerManager {
url: string, url: string,
forceRefresh: boolean = false, forceRefresh: boolean = false,
sourceName?: string, sourceName?: string,
): Promise<PackageManagerRepository> { ): Promise<MarketplaceRepository> {
try { try {
// Check cache first (unless force refresh is requested) // Check cache first (unless force refresh is requested)
const cached = this.cache.get(url) 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 return cached.data
} }
@ -161,7 +154,7 @@ export class PackageManagerManager {
// Create a timeout promise // Create a timeout promise
let timeoutId: NodeJS.Timeout | undefined let timeoutId: NodeJS.Timeout | undefined
const timeoutPromise = new Promise<PackageManagerRepository>((_, reject) => { const timeoutPromise = new Promise<MarketplaceRepository>((_, reject) => {
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
reject(new Error(`Repository fetch timed out after 30 seconds: ${url}`)) reject(new Error(`Repository fetch timed out after 30 seconds: ${url}`))
}, 30000) // 30 second timeout }, 30000) // 30 second timeout
@ -181,7 +174,7 @@ export class PackageManagerManager {
} }
} }
} catch (error) { } 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 empty repository data instead of throwing
return { return {
@ -202,13 +195,13 @@ export class PackageManagerManager {
* @param sourceName Optional name of the source * @param sourceName Optional name of the source
* @returns The refreshed repository data * @returns The refreshed repository data
*/ */
async refreshRepository(url: string, sourceName?: string): Promise<PackageManagerRepository> { async refreshRepository(url: string, sourceName?: string): Promise<MarketplaceRepository> {
try { try {
// Force a refresh by bypassing the cache // Force a refresh by bypassing the cache
const data = await this.getRepositoryData(url, true, sourceName) const data = await this.getRepositoryData(url, true, sourceName)
return data return data
} catch (error) { } catch (error) {
console.error(`PackageManagerManager: Failed to refresh repository ${url}:`, error) console.error(`MarketplaceManager: Failed to refresh repository ${url}:`, error)
return { return {
metadata: { metadata: {
name: "Unknown Repository", name: "Unknown Repository",
@ -231,12 +224,12 @@ export class PackageManagerManager {
/** /**
* Cleans up cache directories for repositories that are no longer in the configured sources * 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 { try {
// Get the cache directory path // 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 // Check if cache directory exists
try { try {
@ -261,11 +254,11 @@ export class PackageManagerManager {
const dirPath = path.join(cacheDir, dirName) const dirPath = path.join(cacheDir, dirName)
await fs.rm(dirPath, { recursive: true, force: true }) await fs.rm(dirPath, { recursive: true, force: true })
} catch (error) { } catch (error) {
console.error(`PackageManagerManager: Failed to delete directory ${dirName}:`, error) console.error(`MarketplaceManager: Failed to delete directory ${dirName}:`, error)
} }
} }
} catch (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 items The items to filter
* @param filters The filter criteria * @param filters The filter criteria
* @returns Filtered items * @returns Filtered items
@ -293,7 +286,7 @@ export class PackageManagerManager {
private filterCache = new Map< private filterCache = new Map<
string, string,
{ {
items: PackageManagerItem[] items: MarketplaceItem[]
timestamp: number timestamp: number
} }
>() >()
@ -302,11 +295,11 @@ export class PackageManagerManager {
* Clear old entries from the filter cache * Clear old entries from the filter cache
*/ */
private cleanupFilterCache(): void { 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 // Sort by timestamp and keep only the most recent entries
const entries = Array.from(this.filterCache.entries()) const entries = Array.from(this.filterCache.entries())
.sort(([, a], [, b]) => b.timestamp - a.timestamp) .sort(([, a], [, b]) => b.timestamp - a.timestamp)
.slice(0, PackageManagerManager.MAX_CACHE_SIZE) .slice(0, MarketplaceManager.MAX_CACHE_SIZE)
this.filterCache.clear() this.filterCache.clear()
entries.forEach(([key, value]) => this.filterCache.set(key, value)) entries.forEach(([key, value]) => this.filterCache.set(key, value))
@ -317,9 +310,9 @@ export class PackageManagerManager {
* Filter items * Filter items
*/ */
filterItems( filterItems(
items: PackageManagerItem[], items: MarketplaceItem[],
filters: { type?: ComponentType; search?: string; tags?: string[] }, filters: { type?: ComponentType; search?: string; tags?: string[] },
): PackageManagerItem[] { ): MarketplaceItem[] {
// Create cache key from filters // Create cache key from filters
const cacheKey = JSON.stringify(filters) const cacheKey = JSON.stringify(filters)
const cached = this.filterCache.get(cacheKey) const cached = this.filterCache.get(cacheKey)
@ -331,9 +324,9 @@ export class PackageManagerManager {
this.cleanupFilterCache() this.cleanupFilterCache()
// Process items in batches to avoid memory spikes // Process items in batches to avoid memory spikes
const allFilteredItems: PackageManagerItem[] = [] const allFilteredItems: MarketplaceItem[] = []
for (let i = 0; i < items.length; i += PackageManagerManager.BATCH_SIZE) { for (let i = 0; i < items.length; i += MarketplaceManager.BATCH_SIZE) {
const batch = items.slice(i, Math.min(i + PackageManagerManager.BATCH_SIZE, items.length)) const batch = items.slice(i, Math.min(i + MarketplaceManager.BATCH_SIZE, items.length))
const filteredBatch = this.processItemBatch(batch, filters) const filteredBatch = this.processItemBatch(batch, filters)
allFilteredItems.push(...filteredBatch) allFilteredItems.push(...filteredBatch)
} }
@ -347,16 +340,13 @@ export class PackageManagerManager {
return allFilteredItems return allFilteredItems
} }
/**
* Process a batch of items
*/
/** /**
* Process a batch of items * Process a batch of items
*/ */
private processItemBatch( private processItemBatch(
batch: PackageManagerItem[], batch: MarketplaceItem[],
filters: { type?: ComponentType; search?: string; tags?: string[] }, filters: { type?: ComponentType; search?: string; tags?: string[] },
): PackageManagerItem[] { ): MarketplaceItem[] {
// Helper functions // Helper functions
const normalizeText = (text: string) => text.toLowerCase().replace(/\s+/g, " ").trim() const normalizeText = (text: string) => text.toLowerCase().replace(/\s+/g, " ").trim()
const searchTerm = filters.search ? normalizeText(filters.search) : "" const searchTerm = filters.search ? normalizeText(filters.search) : ""
@ -463,22 +453,22 @@ export class PackageManagerManager {
return null 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 items The items to sort
* @param sortBy The field to sort by * @param sortBy The field to sort by
* @param sortOrder The sort order * @param sortOrder The sort order
* @returns Sorted items * @returns Sorted items
*/ */
sortItems( sortItems(
items: PackageManagerItem[], items: MarketplaceItem[],
sortBy: keyof Pick<PackageManagerItem, "name" | "author" | "lastUpdated">, sortBy: keyof Pick<MarketplaceItem, "name" | "author" | "lastUpdated">,
sortOrder: "asc" | "desc", sortOrder: "asc" | "desc",
sortSubcomponents: boolean = false, sortSubcomponents: boolean = false,
): PackageManagerItem[] { ): MarketplaceItem[] {
return [...items] return [...items]
.map((item) => { .map((item) => {
// Deep clone the item // Deep clone the item
@ -506,11 +496,12 @@ export class PackageManagerManager {
return sortOrder === "asc" ? comparison : -comparison return sortOrder === "asc" ? comparison : -comparison
}) })
} }
/** /**
* Gets the current package manager items * Gets the current marketplace items
* @returns The current items * @returns The current items
*/ */
getCurrentItems(): PackageManagerItem[] { getCurrentItems(): MarketplaceItem[] {
return this.currentItems return this.currentItems
} }
@ -519,14 +510,14 @@ export class PackageManagerManager {
* @param filters The filter criteria * @param filters The filter criteria
* @returns Filtered items * @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) const filteredItems = this.filterItems(this.currentItems, filters)
this.currentItems = filteredItems this.currentItems = filteredItems
return filteredItems return filteredItems
} }
/** /**
* Cleans up resources used by the package manager * Cleans up resources used by the marketplace
*/ */
async cleanup(): Promise<void> { async cleanup(): Promise<void> {
// Clean up cache directories for all sources // Clean up cache directories for all sources
@ -542,9 +533,9 @@ export class PackageManagerManager {
*/ */
private getSortValue( private getSortValue(
item: item:
| PackageManagerItem | MarketplaceItem
| { type: ComponentType; path: string; metadata?: ComponentMetadata; lastUpdated?: string }, | { type: ComponentType; path: string; metadata?: ComponentMetadata; lastUpdated?: string },
sortBy: keyof Pick<PackageManagerItem, "name" | "author" | "lastUpdated">, sortBy: keyof Pick<MarketplaceItem, "name" | "author" | "lastUpdated">,
): string { ): string {
if ("metadata" in item && item.metadata) { if ("metadata" in item && item.metadata) {
// Handle subcomponent // Handle subcomponent
@ -560,7 +551,7 @@ export class PackageManagerManager {
} }
} else { } else {
// Handle parent item // Handle parent item
const parentItem = item as PackageManagerItem const parentItem = item as MarketplaceItem
switch (sortBy) { switch (sortBy) {
case "name": case "name":
return parentItem.name return parentItem.name

View file

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

View file

@ -74,7 +74,7 @@ describe("GitFetcher", () => {
let gitFetcher: GitFetcher let gitFetcher: GitFetcher
const mockSimpleGit = simpleGit as jest.MockedFunction<typeof simpleGit> const mockSimpleGit = simpleGit as jest.MockedFunction<typeof simpleGit>
const testRepoUrl = "https://github.com/test/repo" 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(() => { beforeEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
@ -87,15 +87,15 @@ describe("GitFetcher", () => {
if (!options?.recursive || !options?.force) { if (!options?.recursive || !options?.force) {
return Promise.reject(new Error("Invalid rm call: missing recursive or force options")) 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 normalizedPath = path.normalize(pathToRemove)
const normalizedCachePath = path.normalize( const normalizedCachePath = path.normalize(
path.join(mockContext.globalStorageUri.fsPath, "package-manager-cache"), path.join(mockContext.globalStorageUri.fsPath, "marketplace-cache"),
) )
if (normalizedPath.startsWith(normalizedCachePath)) { if (normalizedPath.startsWith(normalizedCachePath)) {
return Promise.resolve(undefined) 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 // 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 // Verify that simpleGit's clone was called with the correct arguments
const mockGit = mockSimpleGit() 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 () => { 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 // Verify that simpleGit's clone was called with the correct arguments
const mockGit = mockSimpleGit() 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 { MarketplaceManager } from "../MarketplaceManager"
import { PackageManagerItem, PackageManagerSource, PackageManagerRepository, ComponentType } from "../types" import { MarketplaceItem, MarketplaceSource, MarketplaceRepository, ComponentType } from "../types"
import { MetadataScanner } from "../MetadataScanner" import { MetadataScanner } from "../MetadataScanner"
import { GitFetcher } from "../GitFetcher" import { GitFetcher } from "../GitFetcher"
import * as path from "path" import * as path from "path"
@ -31,15 +31,15 @@ describe("PackageManagerManager", () => {
storageUri: { fsPath: "" }, storageUri: { fsPath: "" },
} as unknown as vscode.ExtensionContext } as unknown as vscode.ExtensionContext
let manager: PackageManagerManager let manager: MarketplaceManager
beforeEach(() => { beforeEach(() => {
// Create a new manager instance with the mock context for each test // 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", () => { it("should correctly filter items by search term", () => {
const items: PackageManagerItem[] = [ const items: MarketplaceItem[] = [
{ {
name: "Test Item 1", name: "Test Item 1",
description: "First test item", description: "First test item",
@ -63,7 +63,7 @@ describe("PackageManagerManager", () => {
}) })
it("should correctly filter items by type", () => { it("should correctly filter items by type", () => {
const items: PackageManagerItem[] = [ const items: MarketplaceItem[] = [
{ {
name: "Mode Item", name: "Mode Item",
description: "A mode", description: "A mode",
@ -87,7 +87,7 @@ describe("PackageManagerManager", () => {
}) })
it("should preserve original items when filtering", () => { it("should preserve original items when filtering", () => {
const items: PackageManagerItem[] = [ const items: MarketplaceItem[] = [
{ {
name: "Test Item 1", name: "Test Item 1",
description: "First test item", description: "First test item",
@ -110,13 +110,13 @@ describe("PackageManagerManager", () => {
}) })
}) })
let manager: PackageManagerManager let manager: MarketplaceManager
let metadataScanner: MetadataScanner let metadataScanner: MetadataScanner
let realItems: PackageManagerItem[] let realItems: MarketplaceItem[]
beforeAll(async () => { beforeAll(async () => {
// Load real data from the template // Load real data from the template
const templatePath = path.resolve(__dirname, "../../../../package-manager-template") const templatePath = path.resolve(__dirname, "../../../../marketplace-template")
metadataScanner = new MetadataScanner() metadataScanner = new MetadataScanner()
realItems = await metadataScanner.scanDirectory(templatePath, "https://example.com") realItems = await metadataScanner.scanDirectory(templatePath, "https://example.com")
}) })
@ -125,14 +125,14 @@ describe("PackageManagerManager", () => {
const context = { const context = {
globalStorageUri: { fsPath: path.resolve(__dirname, "../../../../mock/settings/path") }, globalStorageUri: { fsPath: path.resolve(__dirname, "../../../../mock/settings/path") },
} as vscode.ExtensionContext } as vscode.ExtensionContext
manager = new PackageManagerManager(context) manager = new MarketplaceManager(context)
}) })
describe("Type Filter Behavior", () => { describe("Type Filter Behavior", () => {
let typeFilterTestItems: PackageManagerItem[] let typeFilterTestItems: MarketplaceItem[]
test("should include package with MCP server subcomponent when filtering by type 'mcp server'", () => { test("should include package with MCP server subcomponent when filtering by type 'mcp server'", () => {
const items: PackageManagerItem[] = [ const items: MarketplaceItem[] = [
{ {
name: "Data Platform Package", name: "Data Platform Package",
description: "A package containing MCP servers", description: "A package containing MCP servers",
@ -175,7 +175,7 @@ describe("PackageManagerManager", () => {
}) })
test("should include package when filtering by subcomponent type", () => { test("should include package when filtering by subcomponent type", () => {
const items: PackageManagerItem[] = [ const items: MarketplaceItem[] = [
{ {
name: "Data Platform Package", name: "Data Platform Package",
description: "A package containing MCP servers", 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", () => { test("should not include package when filtering by type with no matching subcomponents", () => {
// Create a package with no matching subcomponents // Create a package with no matching subcomponents
const noMatchPackage: PackageManagerItem = { const noMatchPackage: MarketplaceItem = {
name: "No Match Package", name: "No Match Package",
description: "A package with no matching subcomponents", description: "A package with no matching subcomponents",
type: "package", type: "package",
@ -294,7 +294,7 @@ describe("PackageManagerManager", () => {
test("should handle package with no subcomponents", () => { test("should handle package with no subcomponents", () => {
// Create a package with no subcomponents // Create a package with no subcomponents
const noSubcomponentsPackage: PackageManagerItem = { const noSubcomponentsPackage: MarketplaceItem = {
name: "No Subcomponents Package", name: "No Subcomponents Package",
description: "A package with no subcomponents", description: "A package with no subcomponents",
type: "package", type: "package",
@ -310,7 +310,7 @@ describe("PackageManagerManager", () => {
}) })
describe("Consistency with Search Term Behavior", () => { describe("Consistency with Search Term Behavior", () => {
let consistencyTestItems: PackageManagerItem[] let consistencyTestItems: MarketplaceItem[]
beforeEach(() => { beforeEach(() => {
// Create test items // Create test items
@ -376,7 +376,7 @@ describe("PackageManagerManager", () => {
}) })
describe("sortItems with subcomponents", () => { describe("sortItems with subcomponents", () => {
const testItems: PackageManagerItem[] = [ const testItems: MarketplaceItem[] = [
{ {
name: "B Package", name: "B Package",
description: "Package B", description: "Package B",
@ -463,7 +463,7 @@ describe("PackageManagerManager", () => {
url: "/test/c", url: "/test/c",
repoUrl: "https://example.com", repoUrl: "https://example.com",
items: [], items: [],
} as PackageManagerItem, } as MarketplaceItem,
] ]
const sorted = manager.sortItems(itemsWithEmpty, "name", "asc") const sorted = manager.sortItems(itemsWithEmpty, "name", "asc")
expect(sorted[2].name).toBe("C Package") expect(sorted[2].name).toBe("C Package")
@ -473,7 +473,7 @@ describe("PackageManagerManager", () => {
describe("filterItems with real data", () => { describe("filterItems with real data", () => {
it("should return all subcomponents with match info", () => { it("should return all subcomponents with match info", () => {
const testItems: PackageManagerItem[] = [ const testItems: MarketplaceItem[] = [
{ {
name: "Data Platform Package", name: "Data Platform Package",
description: "A test platform", 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 // 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 // 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 scanner = new MetadataScanner()
const items = await scanner.scanDirectory(templatePath, "https://example.com") const items = await scanner.scanDirectory(templatePath, "https://example.com")
@ -594,18 +594,68 @@ describe("PackageManagerManager", () => {
}) })
}) })
describe("Concurrency Control", () => { describe("Source Attribution", () => {
let manager: PackageManagerManager let manager: MarketplaceManager
beforeEach(() => { beforeEach(() => {
const mockContext = { const mockContext = {
globalStorageUri: { fsPath: "/test/path" }, globalStorageUri: { fsPath: "/test/path" },
} as vscode.ExtensionContext } 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 () => { it("should not allow concurrent operations on the same source", async () => {
const source: PackageManagerSource = { const source: MarketplaceSource = {
url: "https://github.com/test/repo", url: "https://github.com/test/repo",
enabled: true, enabled: true,
} }
@ -616,12 +666,12 @@ describe("Concurrency Control", () => {
metadata: { name: "test", description: "test", version: "1.0.0" }, metadata: { name: "test", description: "test", version: "1.0.0" },
items: [], items: [],
url: source.url, url: source.url,
} as PackageManagerRepository), } as MarketplaceRepository),
) )
// Start two concurrent operations // Start two concurrent operations
const operation1 = manager.getPackageManagerItems([source]) const operation1 = manager.getMarketplaceItems([source])
const operation2 = manager.getPackageManagerItems([source]) const operation2 = manager.getMarketplaceItems([source])
// Wait for both to complete // Wait for both to complete
const [result1, result2] = await Promise.all([operation1, operation2]) 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 () => { it("should not allow metadata scanning during git operations", async () => {
try { try {
const source1: PackageManagerSource = { const source1: MarketplaceSource = {
url: "https://github.com/test/repo1", url: "https://github.com/test/repo1",
enabled: true, enabled: true,
} }
const source2: PackageManagerSource = { const source2: MarketplaceSource = {
url: "https://github.com/test/repo2", url: "https://github.com/test/repo2",
enabled: true, enabled: true,
} }
@ -667,7 +717,7 @@ describe("Concurrency Control", () => {
}) })
// Process both sources // Process both sources
await manager.getPackageManagerItems([source1, source2]) await manager.getMarketplaceItems([source1, source2])
// Verify metadata scanning didn't occur during git operations // Verify metadata scanning didn't occur during git operations
expect(metadataScanDuringGit).toBe(false) expect(metadataScanDuringGit).toBe(false)
@ -677,7 +727,7 @@ describe("Concurrency Control", () => {
}) })
it("should queue metadata scans and process them sequentially", async () => { 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/repo1", enabled: true },
{ url: "https://github.com/test/repo2", enabled: true }, { url: "https://github.com/test/repo2", enabled: true },
{ url: "https://github.com/test/repo3", enabled: true }, { url: "https://github.com/test/repo3", enabled: true },
@ -721,7 +771,7 @@ describe("Concurrency Control", () => {
;(manager as any).gitFetcher = mockGitFetcher ;(manager as any).gitFetcher = mockGitFetcher
// Process all sources // Process all sources
await manager.getPackageManagerItems(sources) await manager.getMarketplaceItems(sources)
// Verify scans were called and only one was active at a time // Verify scans were called and only one was active at a time
expect(scanDirectorySpy).toHaveBeenCalledTimes(sources.length) expect(scanDirectorySpy).toHaveBeenCalledTimes(sources.length)

View file

@ -6,13 +6,12 @@ import {
validateSource, validateSource,
validateSources, validateSources,
ValidationError, ValidationError,
} from "../PackageManagerSourceValidation" } from "../../../shared/MarketplaceValidation"
import { PackageManagerSource } from "../types" import { MarketplaceSource } from "../types"
describe("PackageManagerSourceValidation", () => { describe("MarketplaceSourceValidation", () => {
describe("isValidGitRepositoryUrl", () => { describe("isValidGitRepositoryUrl", () => {
const validUrls = [ const validUrls = [
// Public Git hosting services
"https://github.com/username/repo", "https://github.com/username/repo",
"https://gitlab.com/username/repo", "https://gitlab.com/username/repo",
"https://bitbucket.org/username/repo", "https://bitbucket.org/username/repo",
@ -68,7 +67,7 @@ describe("PackageManagerSourceValidation", () => {
expect(errors).toHaveLength(1) expect(errors).toHaveLength(1)
expect(errors[0]).toEqual({ expect(errors[0]).toEqual({
field: "url", 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", () => { 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/user1/repo1", name: "Source 1", enabled: true },
{ url: "https://git.company.com/user2/repo2", name: "Source 2", enabled: true }, { url: "https://git.company.com/user2/repo2", name: "Source 2", enabled: true },
] ]
test("should accept unique sources", () => { test("should accept unique sources", () => {
const newSource: PackageManagerSource = { const newSource: MarketplaceSource = {
url: "https://git.company.com/user3/repo3", url: "https://git.company.com/user3/repo3",
name: "Source 3", name: "Source 3",
enabled: true, enabled: true,
@ -138,7 +137,7 @@ describe("PackageManagerSourceValidation", () => {
}) })
test("should reject duplicate URLs (case insensitive)", () => { test("should reject duplicate URLs (case insensitive)", () => {
const newSource: PackageManagerSource = { const newSource: MarketplaceSource = {
url: "HTTPS://GIT.COMPANY.COM/USER1/REPO1", url: "HTTPS://GIT.COMPANY.COM/USER1/REPO1",
name: "Different Name", name: "Different Name",
enabled: true, enabled: true,
@ -150,7 +149,7 @@ describe("PackageManagerSourceValidation", () => {
}) })
test("should reject duplicate names (case insensitive)", () => { test("should reject duplicate names (case insensitive)", () => {
const newSource: PackageManagerSource = { const newSource: MarketplaceSource = {
url: "https://git.company.com/user3/repo3", url: "https://git.company.com/user3/repo3",
name: "SOURCE 1", name: "SOURCE 1",
enabled: true, enabled: true,
@ -162,7 +161,7 @@ describe("PackageManagerSourceValidation", () => {
}) })
test("should detect duplicates within source list", () => { 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 1", enabled: true },
{ url: "https://git.company.com/user1/repo1", name: "Source 2", enabled: true }, // Duplicate URL { 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 { url: "https://git.company.com/user3/repo3", name: "Source 1", enabled: true }, // Duplicate name
@ -185,12 +184,12 @@ describe("PackageManagerSourceValidation", () => {
}) })
describe("validateSource", () => { describe("validateSource", () => {
const existingSources: PackageManagerSource[] = [ const existingSources: 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 },
] ]
test("should accept valid source", () => { test("should accept valid source", () => {
const source: PackageManagerSource = { const source: MarketplaceSource = {
url: "https://git.company.com/user2/repo2", url: "https://git.company.com/user2/repo2",
name: "Source 2", name: "Source 2",
enabled: true, enabled: true,
@ -200,7 +199,7 @@ describe("PackageManagerSourceValidation", () => {
}) })
test("should accumulate multiple validation errors", () => { test("should accumulate multiple validation errors", () => {
const source: PackageManagerSource = { const source: MarketplaceSource = {
url: "https://git.company.com/user1/repo1", // Duplicate URL 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 name: "This name is way too long to be valid\t", // Too long and has tab
enabled: true, enabled: true,
@ -212,7 +211,7 @@ describe("PackageManagerSourceValidation", () => {
describe("validateSources", () => { describe("validateSources", () => {
test("should accept valid source list", () => { 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/user1/repo1", name: "Source 1", enabled: true },
{ url: "https://git.company.com/user2/repo2", name: "Source 2", 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", () => { 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 },
{ url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true }, // Duplicate URL and name { 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 { 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", () => { 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) const errors = validateSources(sources)
expect(errors[0].message).toContain("Source #1") 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" 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" 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, url: DEFAULT_PACKAGE_MANAGER_REPO_URL,
name: DEFAULT_PACKAGE_MANAGER_REPO_NAME, name: DEFAULT_PACKAGE_MANAGER_REPO_NAME,
enabled: true, 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 name: string
description: string description: string
type: ComponentType 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 url: string
name?: string name?: string
enabled: boolean enabled: boolean
@ -104,9 +104,9 @@ export interface PackageManagerSource {
/** /**
* Represents a repository with its metadata and items * Represents a repository with its metadata and items
*/ */
export interface PackageManagerRepository { export interface MarketplaceRepository {
metadata: RepositoryMetadata metadata: RepositoryMetadata
items: PackageManagerItem[] items: MarketplaceItem[]
url: string url: string
error?: string error?: string
defaultBranch?: 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 { McpServer } from "./mcp"
import { GitCommit } from "../utils/git" import { GitCommit } from "../utils/git"
import { Mode } from "./modes" import { Mode } from "./modes"
import { PackageManagerItem, PackageManagerSource } from "../services/package-manager/types" import { MarketplaceItem, MarketplaceSource } from "../services/marketplace/types"
export type { ApiConfigMeta, ToolProgressStatus } export type { ApiConfigMeta, ToolProgressStatus }
@ -78,7 +78,7 @@ export interface ExtensionMessage {
| "settingsButtonClicked" | "settingsButtonClicked"
| "historyButtonClicked" | "historyButtonClicked"
| "promptsButtonClicked" | "promptsButtonClicked"
| "packageManagerButtonClicked" | "marketplaceButtonClicked"
| "didBecomeVisible" | "didBecomeVisible"
| "focusInput" | "focusInput"
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage" invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
@ -115,7 +115,7 @@ export interface ExtensionMessage {
label?: string label?: string
}> }>
error?: string error?: string
items?: PackageManagerItem[] items?: MarketplaceItem[]
url?: string // For repositoryRefreshComplete url?: string // For repositoryRefreshComplete
} }
@ -211,8 +211,8 @@ export type ExtensionState = Pick<
renderContext: "sidebar" | "editor" renderContext: "sidebar" | "editor"
settingsImportedAt?: number settingsImportedAt?: number
packageManagerSources?: PackageManagerSource[] marketplaceSources?: MarketplaceSource[]
packageManagerItems?: PackageManagerItem[] marketplaceItems?: MarketplaceItem[]
} }
export type { ClineMessage, ClineAsk, ClineSay } 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 { export interface ValidationError {
field: string field: string
message: 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 * Checks if a URL is a valid Git repository URL
* @param url The URL to validate * @param url The URL to validate
@ -60,17 +55,6 @@ export function validateSourceUrl(url: string): ValidationError[] {
return errors // Return early if URL is empty 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) // Check for non-visible characters (except spaces)
const nonVisibleCharRegex = /[^\S ]/ const nonVisibleCharRegex = /[^\S ]/
if (nonVisibleCharRegex.test(url)) { if (nonVisibleCharRegex.test(url)) {
@ -91,11 +75,6 @@ export function validateSourceUrl(url: string): ValidationError[] {
return errors 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[] { export function validateSourceName(name?: string): ValidationError[] {
const errors: ValidationError[] = [] const errors: ValidationError[] = []
@ -124,12 +103,6 @@ export function validateSourceName(name?: string): ValidationError[] {
return errors 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 // Cache for normalized strings to avoid repeated operations
const normalizeCache = new Map<string, string>() const normalizeCache = new Map<string, string>()
@ -143,14 +116,13 @@ function normalizeString(str: string): string {
} }
export function validateSourceDuplicates( export function validateSourceDuplicates(
sources: PackageManagerSource[], sources: MarketplaceSource[],
newSource?: PackageManagerSource, newSource?: MarketplaceSource,
): ValidationError[] { ): ValidationError[] {
const errors: ValidationError[] = [] const errors: ValidationError[] = []
const urlMap = new Map<string, number>() const urlMap = new Map<string, number>()
const nameMap = new Map<string, number>() const nameMap = new Map<string, number>()
// Process existing sources
// Process existing sources // Process existing sources
const seen = new Set<string>() 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 // Check new source against existing sources if provided
if (newSource) { if (newSource) {
const normalizedNewUrl = normalizeString(newSource.url) const normalizedNewUrl = normalizeString(newSource.url)
const normalizedNewName = newSource.name ? normalizeString(newSource.name) : null 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 // Check for duplicates with existing sources
for (let i = 0; i < sources.length; i++) { for (let i = 0; i < sources.length; i++) {
const source = sources[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 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( export function validateSource(
source: PackageManagerSource, source: MarketplaceSource,
existingSources: PackageManagerSource[] = [], existingSources: MarketplaceSource[] = [],
): ValidationError[] { ): ValidationError[] {
// Combine all validation errors // Combine all validation errors
return [ return [
@ -291,12 +219,7 @@ export function validateSource(
] ]
} }
/** export function validateSources(sources: MarketplaceSource[]): ValidationError[] {
* 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[] {
// Pre-allocate maximum possible size for errors array // Pre-allocate maximum possible size for errors array
const errors: ValidationError[] = new Array(sources.length * 2 + (sources.length * (sources.length - 1)) / 2) const errors: ValidationError[] = new Array(sources.length * 2 + (sources.length * (sources.length - 1)) / 2)
let errorIndex = 0 let errorIndex = 0

View file

@ -1,7 +1,7 @@
import { z } from "zod" import { z } from "zod"
import { ApiConfiguration, ApiProvider } from "./api" import { ApiConfiguration, ApiProvider } from "./api"
import { Mode, PromptComponent, ModeConfig } from "./modes" 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" export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
@ -126,11 +126,11 @@ export interface WebviewMessage {
| "maxReadFileLine" | "maxReadFileLine"
| "searchFiles" | "searchFiles"
| "toggleApiConfigPin" | "toggleApiConfigPin"
| "packageManagerSources" | "marketplaceSources"
| "fetchPackageManagerItems" | "fetchMarketplaceItems"
| "filterPackageManagerItems" | "filterMarketplaceItems"
| "packageManagerButtonClicked" | "marketplaceButtonClicked"
| "refreshPackageManagerSource" | "refreshMarketplaceSource"
| "repositoryRefreshComplete" | "repositoryRefreshComplete"
| "openExternal" | "openExternal"
text?: string text?: string
@ -158,7 +158,7 @@ export interface WebviewMessage {
source?: "global" | "project" source?: "global" | "project"
requestId?: string requestId?: string
ids?: string[] ids?: string[]
sources?: PackageManagerSource[] sources?: MarketplaceSource[]
filters?: { type?: string; search?: string; tags?: string[] } filters?: { type?: string; search?: string; tags?: string[] }
url?: string // For openExternal 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 { ExtensionMessage } from "../../src/shared/ExtensionMessage"
import TranslationProvider from "./i18n/TranslationContext" import TranslationProvider from "./i18n/TranslationContext"
import { PackageManagerViewStateManager } from "./components/package-manager/PackageManagerViewStateManager" import { MarketplaceViewStateManager } from "./components/marketplace/MarketplaceViewStateManager"
import { vscode } from "./utils/vscode" import { vscode } from "./utils/vscode"
import { telemetryClient } from "./utils/TelemetryClient" import { telemetryClient } from "./utils/TelemetryClient"
@ -14,11 +14,11 @@ import HistoryView from "./components/history/HistoryView"
import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView" import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView"
import WelcomeView from "./components/welcome/WelcomeView" import WelcomeView from "./components/welcome/WelcomeView"
import McpView from "./components/mcp/McpView" 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 PromptsView from "./components/prompts/PromptsView"
import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog" 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>> = { const tabsByMessageAction: Partial<Record<NonNullable<ExtensionMessage["action"]>, Tab>> = {
chatButtonClicked: "chat", chatButtonClicked: "chat",
@ -26,7 +26,7 @@ const tabsByMessageAction: Partial<Record<NonNullable<ExtensionMessage["action"]
promptsButtonClicked: "prompts", promptsButtonClicked: "prompts",
mcpButtonClicked: "mcp", mcpButtonClicked: "mcp",
historyButtonClicked: "history", historyButtonClicked: "history",
packageManagerButtonClicked: "packageManager", marketplaceButtonClicked: "marketplace",
} }
const App = () => { const App = () => {
@ -34,7 +34,7 @@ const App = () => {
useExtensionState() useExtensionState()
// Create a persistent state manager // Create a persistent state manager
const packageManagerStateManager = useMemo(() => new PackageManagerViewStateManager(), []) const marketplaceStateManager = useMemo(() => new MarketplaceViewStateManager(), [])
const [showAnnouncement, setShowAnnouncement] = useState(false) const [showAnnouncement, setShowAnnouncement] = useState(false)
const [tab, setTab] = useState<Tab>("chat") const [tab, setTab] = useState<Tab>("chat")
@ -124,8 +124,8 @@ const App = () => {
{tab === "settings" && ( {tab === "settings" && (
<SettingsView ref={settingsRef} onDone={() => setTab("chat")} targetSection={currentSection} /> <SettingsView ref={settingsRef} onDone={() => setTab("chat")} targetSection={currentSection} />
)} )}
{tab === "packageManager" && ( {tab === "marketplace" && (
<PackageManagerView stateManager={packageManagerStateManager} onDone={() => switchTab("chat")} /> <PackageManagerView stateManager={marketplaceStateManager} onDone={() => switchTab("chat")} />
)} )}
<ChatView <ChatView
ref={chatViewRef} ref={chatViewRef}

View file

@ -2,27 +2,28 @@ import { useState, useEffect, useMemo, useCallback } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Tab, TabContent, TabHeader } from "../common/Tab" import { Tab, TabContent, TabHeader } from "../common/Tab"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PackageManagerSource } from "../../../../src/services/package-manager/types" import { MarketplaceSource } from "../../../../src/services/marketplace/types"
import { PackageManagerViewStateManager } from "./PackageManagerViewStateManager" import { validateSource } from "../../../../src/shared/MarketplaceValidation"
import { MarketplaceViewStateManager } from "./MarketplaceViewStateManager"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "cmdk" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "cmdk"
import { PackageManagerItemCard } from "./components/PackageManagerItemCard" import { MarketplaceItemCard } from "./components/MarketplaceItemCard"
import { useStateManager } from "./useStateManager" import { useStateManager } from "./useStateManager"
import { useAppTranslation } from "@/i18n/TranslationContext" import { useAppTranslation } from "@/i18n/TranslationContext"
interface PackageManagerViewProps { interface MarketplaceViewProps {
onDone?: () => void onDone?: () => void
stateManager: PackageManagerViewStateManager stateManager: MarketplaceViewStateManager
} }
const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateManager }) => { const MarketplaceView: React.FC<MarketplaceViewProps> = ({ onDone, stateManager }) => {
const { t } = useAppTranslation() const { t } = useAppTranslation()
const [state, manager] = useStateManager(stateManager) const [state, manager] = useStateManager(stateManager)
const [tagSearch, setTagSearch] = useState("") const [tagSearch, setTagSearch] = useState("")
const [isTagInputActive, setIsTagInputActive] = useState(false) 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(() => { useEffect(() => {
if (state.allItems.length === 0 && !state.isFetching) { if (!state.allItems.length && !state.isFetching) {
manager.transition({ type: "FETCH_ITEMS" }) manager.transition({ type: "FETCH_ITEMS" })
} }
}, [manager, state.allItems.length, state.isFetching]) }, [manager, state.allItems.length, state.isFetching])
@ -44,7 +45,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
<Tab> <Tab>
<TabHeader className="flex justify-between items-center sticky top-0 z-10 bg-vscode-editor-background border-b border-vscode-panel-border"> <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"> <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>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <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", "bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground",
)} )}
onClick={() => manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "browse" } })}> onClick={() => manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "browse" } })}>
{t("package-manager:tabs.browse")} {t("marketplace:tabs.browse")}
</Button> </Button>
<Button <Button
variant={state.activeTab === "sources" ? "default" : "secondary"} 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", "bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground",
)} )}
onClick={() => manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "sources" } })}> onClick={() => manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "sources" } })}>
{t("package-manager:tabs.sources")} {t("marketplace:tabs.sources")}
</Button> </Button>
</div> </div>
</TabHeader> </TabHeader>
@ -74,7 +75,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
<div className="mb-4"> <div className="mb-4">
<input <input
type="text" type="text"
placeholder={t("package-manager:filters.search.placeholder")} placeholder={t("marketplace:filters.search.placeholder")}
value={state.filters.search} value={state.filters.search}
onChange={(e) => onChange={(e) =>
manager.transition({ manager.transition({
@ -88,7 +89,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
<div className="flex flex-wrap justify-between gap-2"> <div className="flex flex-wrap justify-between gap-2">
<div className="whitespace-nowrap"> <div className="whitespace-nowrap">
<label htmlFor="type-filter" className="mr-2"> <label htmlFor="type-filter" className="mr-2">
{t("package-manager:filters.type.label")} {t("marketplace:filters.type.label")}
</label> </label>
<select <select
id="type-filter" 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"> 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="">{t("marketplace:filters.type.all")}</option>
<option value="mode">{t("package-manager:filters.type.mode")}</option> <option value="mode">{t("marketplace:filters.type.mode")}</option>
<option value="mcp server"> <option value="mcp server">
{t("package-manager:filters.type.mcp server")} {t("marketplace:filters.type.mcp server")}
</option> </option>
<option value="prompt">{t("package-manager:filters.type.prompt")}</option> <option value="prompt">{t("marketplace:filters.type.prompt")}</option>
<option value="package">{t("package-manager:filters.type.package")}</option> <option value="package">{t("marketplace:filters.type.package")}</option>
</select> </select>
</div> </div>
<div className="whitespace-nowrap"> <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 <select
value={state.sortConfig.by} value={state.sortConfig.by}
onChange={(e) => 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"> 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"> <option value="lastUpdated">
{t("package-manager:filters.sort.lastUpdated")} {t("marketplace:filters.sort.lastUpdated")}
</option> </option>
</select> </select>
<button <button
@ -147,11 +148,9 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<div className="flex items-center"> <div className="flex items-center">
<label className="mr-2"> <label className="mr-2">{t("marketplace:filters.tags.label")}</label>
{t("package-manager:filters.tags.label")}
</label>
<span className="text-xs text-vscode-descriptionForeground"> <span className="text-xs text-vscode-descriptionForeground">
{t("package-manager:filters.tags.available", { {t("marketplace:filters.tags.available", {
count: allTags.length, count: allTags.length,
})} })}
</span> </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"> 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, count: state.filters.tags.length,
})} })}
</button> </button>
@ -173,7 +172,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
</div> </div>
<Command className="rounded-lg border border-vscode-dropdown-border"> <Command className="rounded-lg border border-vscode-dropdown-border">
<CommandInput <CommandInput
placeholder={t("package-manager:filters.tags.placeholder")} placeholder={t("marketplace:filters.tags.placeholder")}
value={tagSearch} value={tagSearch}
onValueChange={setTagSearch} onValueChange={setTagSearch}
onFocus={() => setIsTagInputActive(true)} onFocus={() => setIsTagInputActive(true)}
@ -187,7 +186,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
{(isTagInputActive || tagSearch) && ( {(isTagInputActive || tagSearch) && (
<CommandList className="max-h-[200px] overflow-y-auto bg-vscode-dropdown-background"> <CommandList className="max-h-[200px] overflow-y-auto bg-vscode-dropdown-background">
<CommandEmpty className="p-2 text-sm text-vscode-descriptionForeground"> <CommandEmpty className="p-2 text-sm text-vscode-descriptionForeground">
{t("package-manager:filters.tags.noResults")} {t("marketplace:filters.tags.noResults")}
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
{filteredTags.map((tag: string) => ( {filteredTags.map((tag: string) => (
@ -237,10 +236,10 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
</Command> </Command>
<div className="text-xs text-vscode-descriptionForeground mt-1"> <div className="text-xs text-vscode-descriptionForeground mt-1">
{state.filters.tags.length > 0 {state.filters.tags.length > 0
? t("package-manager:filters.tags.selected", { ? t("marketplace:filters.tags.selected", {
count: state.filters.tags.length, count: state.filters.tags.length,
}) })
: t("package-manager:filters.tags.clickToFilter")} : t("marketplace:filters.tags.clickToFilter")}
</div> </div>
</div> </div>
)} )}
@ -251,16 +250,12 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
// Use items directly from backend // Use items directly from backend
const items = state.displayItems || [] const items = state.displayItems || []
const isEmpty = items.length === 0 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 have no items to display
// Only show loading state if we're fetching and not filtering if (state.isFetching && isEmpty) {
if (
isLoading &&
!(state.filters.type || state.filters.search || state.filters.tags.length > 0)
) {
return ( return (
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground"> <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> </div>
) )
} }
@ -269,7 +264,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
if (isEmpty) { if (isEmpty) {
return ( return (
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground"> <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> </div>
) )
} }
@ -278,11 +273,11 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
return ( return (
<div> <div>
<p className="text-vscode-descriptionForeground mb-4"> <p className="text-vscode-descriptionForeground mb-4">
{t("package-manager:items.count", { count: items.length })} {t("marketplace:items.count", { count: items.length })}
</p> </p>
<div className="grid grid-cols-1 gap-4 pb-4"> <div className="grid grid-cols-1 gap-4 pb-4">
{items.map((item) => ( {items.map((item) => (
<PackageManagerItemCard <MarketplaceItemCard
key={`${item.repoUrl}-${item.name}`} key={`${item.repoUrl}-${item.name}`}
item={item} item={item}
filters={state.filters} filters={state.filters}
@ -301,7 +296,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
})()} })()}
</> </>
) : ( ) : (
<PackageManagerSourcesConfig <MarketplaceSourcesConfig
sources={state.sources} sources={state.sources}
refreshingUrls={state.refreshingUrls} refreshingUrls={state.refreshingUrls}
onRefreshSource={(url) => manager.transition({ type: "REFRESH_SOURCE", payload: { url } })} onRefreshSource={(url) => manager.transition({ type: "REFRESH_SOURCE", payload: { url } })}
@ -315,14 +310,14 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
) )
} }
interface PackageManagerSourcesConfigProps { export interface MarketplaceSourcesConfigProps {
sources: PackageManagerSource[] sources: MarketplaceSource[]
refreshingUrls: string[] refreshingUrls: string[]
onRefreshSource: (url: string) => void onRefreshSource: (url: string) => void
onSourcesChange: (sources: PackageManagerSource[]) => void onSourcesChange: (sources: MarketplaceSource[]) => void
} }
const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> = ({ export const MarketplaceSourcesConfig: React.FC<MarketplaceSourcesConfigProps> = ({
sources, sources,
refreshingUrls, refreshingUrls,
onRefreshSource, onRefreshSource,
@ -334,71 +329,46 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
const [error, setError] = useState("") const [error, setError] = useState("")
const handleAddSource = () => { const handleAddSource = () => {
if (!newSourceUrl) { // Check max sources limit first
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
}
}
const MAX_SOURCES = 10 const MAX_SOURCES = 10
if (sources.length >= MAX_SOURCES) { 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 return
} }
const newSource: PackageManagerSource = { // Create source object for validation
const sourceToValidate: MarketplaceSource = {
url: newSourceUrl, url: newSourceUrl,
name: newSourceName || undefined, name: newSourceName || undefined,
enabled: true, 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("") setNewSourceUrl("")
setNewSourceName("") setNewSourceName("")
setError("") setError("")
@ -422,15 +392,15 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
return ( return (
<div> <div>
<h4 className="text-vscode-foreground mb-2">{t("package-manager:sources.title")}</h4> <h4 className="text-vscode-foreground mb-2">{t("marketplace:sources.title")}</h4>
<p className="text-vscode-descriptionForeground mb-4">{t("package-manager:sources.description")}</p> <p className="text-vscode-descriptionForeground mb-4">{t("marketplace:sources.description")}</p>
<div className="mb-6"> <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"> <div className="flex flex-col gap-2 mb-2">
<input <input
type="text" type="text"
placeholder={t("package-manager:sources.add.urlPlaceholder")} placeholder={t("marketplace:sources.add.urlPlaceholder")}
value={newSourceUrl} value={newSourceUrl}
onChange={(e) => { onChange={(e) => {
setNewSourceUrl(e.target.value) 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" 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"> <p className="text-xs text-vscode-descriptionForeground mt-1 mb-2">
{t("package-manager:sources.add.urlFormats")} {t("marketplace:sources.add.urlFormats")}
</p> </p>
<input <input
type="text" type="text"
placeholder={t("package-manager:sources.add.namePlaceholder")} placeholder={t("marketplace:sources.add.namePlaceholder")}
value={newSourceName} value={newSourceName}
onChange={(e) => { onChange={(e) => {
setNewSourceName(e.target.value.slice(0, 20)) 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>} {error && <p className="text-red-500 mb-2">{error}</p>}
<Button onClick={handleAddSource}> <Button onClick={handleAddSource}>
<span className="codicon codicon-add mr-2"></span> <span className="codicon codicon-add mr-2"></span>
{t("package-manager:sources.add.button")} {t("marketplace:sources.add.button")}
</Button> </Button>
</div> </div>
<h5 className="text-vscode-foreground mb-2"> <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"> <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> </span>
</h5> </h5>
{sources.length === 0 ? ( {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"> <div className="grid grid-cols-1 gap-2">
{sources.map((source, index) => ( {sources.map((source, index) => (
@ -496,7 +466,7 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => onRefreshSource(source.url)} onClick={() => onRefreshSource(source.url)}
title={t("package-manager:sources.current.refresh")} title={t("marketplace:sources.current.refresh")}
className="text-vscode-foreground" className="text-vscode-foreground"
disabled={refreshingUrls.includes(source.url)}> disabled={refreshingUrls.includes(source.url)}>
<span <span
@ -506,7 +476,7 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleRemoveSource(index)} onClick={() => handleRemoveSource(index)}
title={t("package-manager:sources.current.remove")} title={t("marketplace:sources.current.remove")}
className="text-red-500"> className="text-red-500">
<span className="codicon codicon-trash"></span> <span className="codicon codicon-trash"></span>
</Button> </Button>
@ -519,16 +489,4 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
) )
} }
const isValidGitRepositoryUrl = (url: string): boolean => { export default MarketplaceView
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

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

View file

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

View file

@ -22,15 +22,15 @@ export const TypeGroup: React.FC<TypeGroupProps> = ({ type, items, className })
const typeLabel = useMemo(() => { const typeLabel = useMemo(() => {
switch (type) { switch (type) {
case "mode": case "mode":
return t("package-manager:type-group.modes") return t("marketplace:type-group.modes")
case "mcp server": case "mcp server":
return t("package-manager:type-group.mcp-servers") return t("marketplace:type-group.mcp-servers")
case "prompt": case "prompt":
return t("package-manager:type-group.prompts") return t("marketplace:type-group.prompts")
case "package": case "package":
return t("package-manager:type-group.packages") return t("marketplace:type-group.packages")
default: default:
return t("package-manager:type-group.generic-type", { return t("marketplace:type-group.generic-type", {
type: type.charAt(0).toUpperCase() + type.slice(1), type: type.charAt(0).toUpperCase() + type.slice(1),
}) })
} }
@ -57,7 +57,7 @@ export const TypeGroup: React.FC<TypeGroupProps> = ({ type, items, className })
)} )}
{item.matchInfo?.matched && ( {item.matchInfo?.matched && (
<span className="ml-2 text-xs bg-vscode-badge-background text-vscode-badge-foreground px-1 py-0.5 rounded"> <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> </span>
)} )}
</li> </li>

View file

@ -1,7 +1,7 @@
import React from "react" import React from "react"
import { screen, fireEvent } from "@testing-library/react" import { screen, fireEvent } from "@testing-library/react"
import { PackageManagerItemCard } from "../PackageManagerItemCard" import { MarketplaceItemCard } from "../MarketplaceItemCard"
import { PackageManagerItem } from "../../../../../../src/services/package-manager/types" import { MarketplaceItem } from "../../../../../../src/services/marketplace/types"
import { renderWithProviders } from "@/test/test-utils" import { renderWithProviders } from "@/test/test-utils"
// Mock vscode API // Mock vscode API
@ -12,8 +12,8 @@ jest.mock("@/utils/vscode", () => ({
}, },
})) }))
describe("PackageManagerItemCard", () => { describe("MarketplaceItemCard", () => {
const mockItem: PackageManagerItem = { const mockItem: MarketplaceItem = {
name: "Test Package", name: "Test Package",
description: "A test package", description: "A test package",
type: "package", type: "package",
@ -60,7 +60,7 @@ describe("PackageManagerItemCard", () => {
}) })
it("should render basic item information", () => { it("should render basic item information", () => {
renderWithProviders(<PackageManagerItemCard {...defaultProps} />) renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
expect(screen.getByText("Test Package")).toBeInTheDocument() expect(screen.getByText("Test Package")).toBeInTheDocument()
expect(screen.getByText("A test package")).toBeInTheDocument() expect(screen.getByText("A test package")).toBeInTheDocument()
@ -79,7 +79,7 @@ describe("PackageManagerItemCard", () => {
}) })
it("should render tags", () => { it("should render tags", () => {
renderWithProviders(<PackageManagerItemCard {...defaultProps} />) renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
expect(screen.getByText("test")).toBeInTheDocument() expect(screen.getByText("test")).toBeInTheDocument()
expect(screen.getByText("mock")).toBeInTheDocument() expect(screen.getByText("mock")).toBeInTheDocument()
@ -87,7 +87,7 @@ describe("PackageManagerItemCard", () => {
it("should handle tag clicks", () => { it("should handle tag clicks", () => {
const setFilters = jest.fn() const setFilters = jest.fn()
renderWithProviders(<PackageManagerItemCard {...defaultProps} setFilters={setFilters} />) renderWithProviders(<MarketplaceItemCard {...defaultProps} setFilters={setFilters} />)
fireEvent.click(screen.getByText("test")) fireEvent.click(screen.getByText("test"))
expect(setFilters).toHaveBeenCalledWith( expect(setFilters).toHaveBeenCalledWith(
@ -98,7 +98,7 @@ describe("PackageManagerItemCard", () => {
}) })
it("should render version and date information", () => { it("should render version and date information", () => {
renderWithProviders(<PackageManagerItemCard {...defaultProps} />) renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
expect(screen.getByText("1.0.0")).toBeInTheDocument() expect(screen.getByText("1.0.0")).toBeInTheDocument()
// Use a regex to match the date since it depends on the timezone // Use a regex to match the date since it depends on the timezone
@ -113,7 +113,7 @@ describe("PackageManagerItemCard", () => {
defaultBranch: "main", defaultBranch: "main",
path: "some/path", 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 const button = screen.getByRole("button", { name: /^$/ }) // Button with no text, only icon
fireEvent.click(button) fireEvent.click(button)
@ -130,7 +130,7 @@ describe("PackageManagerItemCard", () => {
defaultBranch: "main", defaultBranch: "main",
path: "some/path", path: "some/path",
} }
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithGitPath} />) renderWithProviders(<MarketplaceItemCard {...defaultProps} item={itemWithGitPath} />)
const button = screen.getByRole("button", { name: /View/i }) const button = screen.getByRole("button", { name: /View/i })
fireEvent.click(button) fireEvent.click(button)
@ -145,7 +145,7 @@ describe("PackageManagerItemCard", () => {
...mockItem, ...mockItem,
sourceUrl: "https://example.com/direct-link", 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 // Find the source button by its empty aria-label
const button = screen.getByRole("button", { const button = screen.getByRole("button", {
@ -156,7 +156,7 @@ describe("PackageManagerItemCard", () => {
}) })
it("should show text label when sourceUrl is not present", () => { it("should show text label when sourceUrl is not present", () => {
renderWithProviders(<PackageManagerItemCard {...defaultProps} />) renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
// Find the source button by its aria-label // Find the source button by its aria-label
const button = screen.getByRole("button", { const button = screen.getByRole("button", {
@ -170,28 +170,28 @@ describe("PackageManagerItemCard", () => {
describe("Details section", () => { describe("Details section", () => {
it("should render expandable details section with correct count when item has no components", () => { it("should render expandable details section with correct count when item has no components", () => {
const itemWithNoItems = { ...mockItem, items: [] } 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() expect(screen.getByText("0 components")).toBeInTheDocument()
}) })
it("should render expandable details section with correct count when item has components", () => { 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() expect(screen.getByText("2 components")).toBeInTheDocument()
}) })
it("should not render details section when item has no subcomponents", () => { it("should not render details section when item has no subcomponents", () => {
const itemWithoutItems = { ...mockItem, items: [] } const itemWithoutItems = { ...mockItem, items: [] }
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithoutItems} />) renderWithProviders(<MarketplaceItemCard {...defaultProps} item={itemWithoutItems} />)
expect(screen.queryByText("Component Details")).not.toBeInTheDocument() expect(screen.queryByText("Component Details")).not.toBeInTheDocument()
}) })
it("should show grouped items when expanded", () => { it("should show grouped items when expanded", () => {
renderWithProviders(<PackageManagerItemCard {...defaultProps} />) renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
fireEvent.click(screen.getByText("2 components")) fireEvent.click(screen.getByText("2 components"))
// These use the type-group translations // These use the type-group translations
@ -207,12 +207,33 @@ describe("PackageManagerItemCard", () => {
}) })
it("should maintain proper order of items within groups", () => { it("should maintain proper order of items within groups", () => {
renderWithProviders(<PackageManagerItemCard {...defaultProps} />) renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
fireEvent.click(screen.getByText("2 components")) fireEvent.click(screen.getByText("2 components"))
const items = screen.getAllByRole("listitem") const items = screen.getAllByRole("listitem")
expect(items[0]).toHaveTextContent("Test Server") expect(items[0]).toHaveTextContent("Test Server")
expect(items[1]).toHaveTextContent("Test Mode") 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 { groupItemsByType, formatItemText, getTotalItemCount, getUniqueTypes } from "../grouping"
import { PackageManagerItem } from "../../../../../../src/services/package-manager/types" import { MarketplaceItem } from "../../../../../../src/services/marketplace/types"
describe("grouping utilities", () => { describe("grouping utilities", () => {
const mockItems = [ const mockItems = [
@ -30,7 +30,7 @@ describe("grouping utilities", () => {
version: "1.1.0", version: "1.1.0",
}, },
}, },
] as PackageManagerItem["items"] ] as MarketplaceItem["items"]
describe("groupItemsByType", () => { describe("groupItemsByType", () => {
it("should group items by type correctly", () => { it("should group items by type correctly", () => {
@ -55,7 +55,7 @@ describe("grouping utilities", () => {
type: "mcp server", type: "mcp server",
path: "test/path", path: "test/path",
}, },
] as PackageManagerItem["items"] ] as MarketplaceItem["items"]
const result = groupItemsByType(itemsWithMissingData) const result = groupItemsByType(itemsWithMissingData)
expect(result["mcp server"].items[0].name).toBe("Unnamed item") expect(result["mcp server"].items[0].name).toBe("Unnamed item")
@ -75,7 +75,7 @@ describe("grouping utilities", () => {
path: "test/path", path: "test/path",
metadata: { name: "Test" }, metadata: { name: "Test" },
}, },
] as PackageManagerItem["items"] ] as MarketplaceItem["items"]
const result = groupItemsByType(itemsWithoutType) const result = groupItemsByType(itemsWithoutType)
expect(Object.keys(result)).toHaveLength(0) 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 { export interface GroupedItems {
[type: string]: { [type: string]: {
@ -24,7 +24,7 @@ export interface GroupedItems {
// Cache for group objects to avoid recreating them // Cache for group objects to avoid recreating them
const groupCache = new Map<string, { type: string; items: any[] }>() 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) { if (!items?.length) {
return {} 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 { CustomSupportPrompts } from "../../../src/shared/support-prompt"
import { experimentDefault, ExperimentId } from "../../../src/shared/experiments" import { experimentDefault, ExperimentId } from "../../../src/shared/experiments"
import { TelemetrySetting } from "../../../src/shared/TelemetrySetting" import { TelemetrySetting } from "../../../src/shared/TelemetrySetting"
import { PackageManagerSource } from "../../../src/services/package-manager/types" import { MarketplaceSource } from "../../../src/services/marketplace/types"
import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../../src/services/package-manager/constants" import { DEFAULT_MARKETPLACE_SOURCE } from "../../../src/services/marketplace/constants"
export interface ExtensionStateContextType extends ExtensionState { export interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean didHydrateState: boolean
@ -88,7 +88,7 @@ export interface ExtensionStateContextType extends ExtensionState {
pinnedApiConfigs?: Record<string, boolean> pinnedApiConfigs?: Record<string, boolean>
setPinnedApiConfigs: (value: Record<string, boolean>) => void setPinnedApiConfigs: (value: Record<string, boolean>) => void
togglePinnedApiConfig: (configName: string) => void togglePinnedApiConfig: (configName: string) => void
setPackageManagerSources: (value: PackageManagerSource[]) => void setMarketplaceSources: (value: MarketplaceSource[]) => void
} }
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined) 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). showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior).
renderContext: "sidebar", renderContext: "sidebar",
maxReadFileLine: 500, // Default max read file line limit maxReadFileLine: 500, // Default max read file line limit
packageManagerSources: [DEFAULT_PACKAGE_MANAGER_SOURCE], marketplaceSources: [DEFAULT_MARKETPLACE_SOURCE],
pinnedApiConfigs: {}, // Empty object for pinned API configs pinnedApiConfigs: {}, // Empty object for pinned API configs
terminalZshOhMy: false, // Default Oh My Zsh integration setting terminalZshOhMy: false, // Default Oh My Zsh integration setting
terminalZshP10k: false, // Default Powerlevel10k integration setting terminalZshP10k: false, // Default Powerlevel10k integration setting
@ -188,8 +188,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const newState = message.state! const newState = message.state!
console.log("DEBUG: ExtensionStateContext received state message:", { console.log("DEBUG: ExtensionStateContext received state message:", {
hasApiConfig: !!newState.apiConfiguration, hasApiConfig: !!newState.apiConfiguration,
hasPackageManagerItems: !!newState.packageManagerItems, hasMarketplaceItems: !!newState.marketplaceItems,
packageManagerItemsCount: newState.packageManagerItems?.length || 0, marketplaceItemsCount: newState.marketplaceItems?.length || 0,
}) })
setState((prevState) => mergeExtensionState(prevState, newState)) setState((prevState) => mergeExtensionState(prevState, newState))
@ -349,7 +349,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
return { ...prevState, pinnedApiConfigs: newPinned } 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> return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

View file

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

View file

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

View file

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