mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
fix failing tests from state management changes
This commit is contained in:
parent
968e19047b
commit
a122dc7465
92 changed files with 1464 additions and 1467 deletions
|
|
@ -249,7 +249,7 @@ classDiagram
|
|||
- Coordinates repository operations
|
||||
- Provides filtering and sorting
|
||||
|
||||
4. **packageManagerMessageHandler**
|
||||
4. **marketplaceMessageHandler**
|
||||
- Routes messages between UI and backend
|
||||
- Processes commands from the UI
|
||||
- Returns data and status updates
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ class PackageManagerManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get package manager items from sources
|
||||
* Get marketplace items from sources
|
||||
*/
|
||||
public async getPackageManagerItems(
|
||||
sources: PackageManagerSource[],
|
||||
|
|
@ -251,14 +251,14 @@ The PackageManagerSourceValidation component handles validation of marketplace s
|
|||
```typescript
|
||||
export class PackageManagerSourceValidation {
|
||||
/**
|
||||
* Validates a package manager source URL
|
||||
* Validates a marketplace source URL
|
||||
*/
|
||||
public static validateSourceUrl(url: string): ValidationError[] {
|
||||
// Implementation details
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a package manager source name
|
||||
* Validates a marketplace source name
|
||||
*/
|
||||
public static validateSourceName(name?: string): ValidationError[] {
|
||||
// Implementation details
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ This interface represents a complete repository:
|
|||
|
||||
```typescript
|
||||
/**
|
||||
* Represents an individual package manager item
|
||||
* Represents an individual marketplace item
|
||||
*/
|
||||
export interface PackageManagerItem {
|
||||
name: string
|
||||
|
|
@ -113,7 +113,7 @@ Enhanced match tracking:
|
|||
|
||||
```typescript
|
||||
/**
|
||||
* Error type for package manager source validation
|
||||
* Error type for marketplace source validation
|
||||
*/
|
||||
export interface ValidationError {
|
||||
field: string
|
||||
|
|
@ -154,7 +154,7 @@ Manages UI state:
|
|||
- **isFetching**: Loading state indicator
|
||||
- **activeTab**: Current view tab
|
||||
- **refreshingUrls**: Sources being refreshed
|
||||
- **sources**: Package manager sources
|
||||
- **sources**: Marketplace sources
|
||||
- **filters**: Active filters
|
||||
- **sortConfig**: Sort configuration
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
|
|||
<Tab>
|
||||
<TabHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3>Package Manager</h3>
|
||||
<h3>Marketplace</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={state.activeTab === "browse" ? "default" : "secondary"}
|
||||
|
|
|
|||
|
|
@ -420,7 +420,7 @@ Integration tests verify that different components work together correctly.
|
|||
### Backend Integration Tests
|
||||
|
||||
```typescript
|
||||
describe("Package Manager Integration", () => {
|
||||
describe("Marketplace Integration", () => {
|
||||
let manager: PackageManagerManager
|
||||
let metadataScanner: MetadataScanner
|
||||
let templateItems: PackageManagerItem[]
|
||||
|
|
@ -428,7 +428,7 @@ describe("Package Manager Integration", () => {
|
|||
beforeAll(async () => {
|
||||
// Load real data from template
|
||||
metadataScanner = new MetadataScanner()
|
||||
const templatePath = path.resolve(__dirname, "../../../../package-manager-template")
|
||||
const templatePath = path.resolve(__dirname, "../../../../marketplace-template")
|
||||
templateItems = await metadataScanner.scanDirectory(templatePath, "https://example.com")
|
||||
})
|
||||
|
||||
|
|
@ -507,7 +507,7 @@ describe("Package Manager Integration", () => {
|
|||
### Frontend Integration Tests
|
||||
|
||||
```typescript
|
||||
describe("Package Manager UI Integration", () => {
|
||||
describe("Marketplace UI Integration", () => {
|
||||
const mockItems: PackageManagerItem[] = [
|
||||
{
|
||||
name: "Test Package",
|
||||
|
|
@ -665,7 +665,7 @@ Real template data is used for integration tests:
|
|||
beforeAll(async () => {
|
||||
// Load real data from template
|
||||
metadataScanner = new MetadataScanner()
|
||||
const templatePath = path.resolve(__dirname, "../../../../package-manager-template")
|
||||
const templatePath = path.resolve(__dirname, "../../../../marketplace-template")
|
||||
templateItems = await metadataScanner.scanDirectory(templatePath, "https://example.com")
|
||||
})
|
||||
```
|
||||
|
|
@ -911,7 +911,7 @@ The Marketplace tests are organized by functionality rather than by file structu
|
|||
### Consolidated Test Files
|
||||
|
||||
```
|
||||
src/services/package-manager/__tests__/
|
||||
src/services/marketplace/__tests__/
|
||||
├── PackageManager.consolidated.test.ts # Combined tests
|
||||
├── searchUtils.test.ts # Search utility tests
|
||||
└── PackageSubcomponents.test.ts # Subcomponent tests
|
||||
|
|
@ -922,7 +922,7 @@ src/services/package-manager/__tests__/
|
|||
Tests are organized into logical groups:
|
||||
|
||||
```typescript
|
||||
describe("Package Manager", () => {
|
||||
describe("Marketplace", () => {
|
||||
// Shared setup
|
||||
|
||||
describe("Direct Filtering", () => {
|
||||
|
|
@ -964,7 +964,7 @@ module.exports = {
|
|||
lines: 85,
|
||||
statements: 85,
|
||||
},
|
||||
"src/services/package-manager/*.ts": {
|
||||
"src/services/marketplace/*.ts": {
|
||||
branches: 90,
|
||||
functions: 90,
|
||||
lines: 90,
|
||||
|
|
@ -1003,7 +1003,7 @@ describe("containsSearchTerm", () => {
|
|||
|
||||
```typescript
|
||||
// Optimized integration tests
|
||||
describe("Package Manager Integration", () => {
|
||||
describe("Marketplace Integration", () => {
|
||||
// Load template data once for all tests
|
||||
beforeAll(async () => {
|
||||
templateItems = await metadataScanner.scanDirectory(templatePath)
|
||||
|
|
@ -1098,7 +1098,7 @@ describe("Complex integration test", () => {
|
|||
it("should handle complex search", async () => {
|
||||
// Enable debug logging for this test
|
||||
const originalDebug = process.env.DEBUG
|
||||
process.env.DEBUG = "package-manager:*"
|
||||
process.env.DEBUG = "marketplace:*"
|
||||
|
||||
// Test logic...
|
||||
|
||||
|
|
@ -1167,4 +1167,4 @@ describe("Package filtering", () => {
|
|||
|
||||
---
|
||||
|
||||
**Previous**: [UI Component Design](./05-ui-components.md) | **Next**: [Extending the Package Manager](./07-extending.md)
|
||||
**Previous**: [UI Component Design](./05-ui-components.md) | **Next**: [Extending the Marketplace](./07-extending.md)
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ Register your template with the Marketplace:
|
|||
// In your extension code
|
||||
const registerTemplates = (context: vscode.ExtensionContext) => {
|
||||
const templatePath = path.join(context.extensionPath, "templates", "your-template")
|
||||
packageManager.registerTemplate(templatePath)
|
||||
marketplace.registerTemplate(templatePath)
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -336,7 +336,7 @@ const ViewModeSelector: React.FC<{
|
|||
4. **Integrate with the Main UI**:
|
||||
|
||||
```tsx
|
||||
<div className="package-manager-container">
|
||||
<div className="marketplace-container">
|
||||
<div className="toolbar">
|
||||
<ViewModeSelector viewMode={viewMode} setViewMode={setViewMode} />
|
||||
{/* Other toolbar items */}
|
||||
|
|
@ -476,7 +476,7 @@ const PackageManagerView: React.FC<{
|
|||
const PackageCard = componentOverrides.PackageCard || PackageManagerItemCard
|
||||
|
||||
return (
|
||||
<div className="package-manager">
|
||||
<div className="marketplace">
|
||||
{items.map((item) => (
|
||||
<PackageCard
|
||||
key={item.name}
|
||||
|
|
@ -584,8 +584,8 @@ class CustomSourceProvider implements SourceProvider {
|
|||
|
||||
```typescript
|
||||
// In your extension code
|
||||
const registerSourceProviders = (packageManager: PackageManagerManager) => {
|
||||
packageManager.registerSourceProvider(new CustomSourceProvider())
|
||||
const registerSourceProviders = (marketplace: PackageManagerManager) => {
|
||||
marketplace.registerSourceProvider(new CustomSourceProvider())
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -637,7 +637,7 @@ To add support for custom messages:
|
|||
const extendMessageHandler = () => {
|
||||
const originalHandler = handlePackageManagerMessages
|
||||
|
||||
return async (message: any, packageManager: PackageManagerManager) => {
|
||||
return async (message: any, marketplace: PackageManagerManager) => {
|
||||
// Handle custom messages
|
||||
if (message.type === "yourCustomMessage") {
|
||||
// Your custom message handling
|
||||
|
|
@ -650,7 +650,7 @@ const extendMessageHandler = () => {
|
|||
}
|
||||
|
||||
// Fall back to the original handler
|
||||
return originalHandler(message, packageManager)
|
||||
return originalHandler(message, marketplace)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -661,8 +661,8 @@ const extendMessageHandler = () => {
|
|||
// In your extension code
|
||||
const customMessageHandler = extendMessageHandler()
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("packageManager.handleMessage", (message) => {
|
||||
return customMessageHandler(message, packageManager)
|
||||
vscode.commands.registerCommand("marketplace.handleMessage", (message) => {
|
||||
return customMessageHandler(message, marketplace)
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
|
@ -728,8 +728,8 @@ class ApiSourceProvider implements SourceProvider {
|
|||
|
||||
```typescript
|
||||
// In your extension code
|
||||
const registerApiProvider = (packageManager: PackageManagerManager) => {
|
||||
packageManager.registerSourceProvider(new ApiSourceProvider("https://your-api.example.com"))
|
||||
const registerApiProvider = (marketplace: PackageManagerManager) => {
|
||||
marketplace.registerSourceProvider(new ApiSourceProvider("https://your-api.example.com"))
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -829,7 +829,7 @@ class LocalDevProvider {
|
|||
}
|
||||
```
|
||||
|
||||
2. **Integrate with the Package Manager**:
|
||||
2. **Integrate with the Marketplace**:
|
||||
|
||||
```typescript
|
||||
// In your extension code
|
||||
|
|
@ -845,15 +845,15 @@ const registerLocalDevTools = (context: vscode.ExtensionContext) => {
|
|||
|
||||
// Register commands
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("packageManager.createLocal", async (template, name) => {
|
||||
vscode.commands.registerCommand("marketplace.createLocal", async (template, name) => {
|
||||
return localDevProvider.createLocalPackage(template, name)
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand("packageManager.buildLocal", async (packagePath) => {
|
||||
vscode.commands.registerCommand("marketplace.buildLocal", async (packagePath) => {
|
||||
return localDevProvider.buildLocalPackage(packagePath)
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand("packageManager.testLocal", async (packagePath) => {
|
||||
vscode.commands.registerCommand("marketplace.testLocal", async (packagePath) => {
|
||||
return localDevProvider.testLocalPackage(packagePath)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"src/schemas/ipc.ts",
|
||||
"src/extension.ts",
|
||||
"scripts/**",
|
||||
"package-manager-template/**",
|
||||
"marketplace-template/**",
|
||||
"src/utils/git.ts"
|
||||
],
|
||||
"workspaces": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Package Manager Template
|
||||
# Marketplace Template
|
||||
|
||||
This template provides a basic structure for creating a package manager source repository. The structure follows the required format for Roo Code's package manager.
|
||||
This template provides a basic structure for creating a marketplace source repository. The structure follows the required format for Roo Code's marketplace.
|
||||
|
||||
## Structure
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ version: "1.0.0"
|
|||
|
||||
## Usage
|
||||
|
||||
1. Copy this template to create your own package manager repository
|
||||
1. Copy this template to create your own marketplace repository
|
||||
2. Update the metadata.en.yml with your repository information
|
||||
3. Add your MCP servers, roles, or other components
|
||||
4. Each component must have its own metadata.en.yml file with the required fields
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
name: "Example MCP Server"
|
||||
description: "An example MCP server for testing package manager functionality"
|
||||
description: "An example MCP server for testing marketplace functionality"
|
||||
type: "mcp server"
|
||||
version: "1.0.0"
|
||||
author: "JB Brown"
|
||||
5
marketplace-template/metadata.en.yml
Normal file
5
marketplace-template/metadata.en.yml
Normal 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/"
|
||||
|
|
@ -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/"
|
||||
|
|
@ -96,7 +96,7 @@
|
|||
"icon": "$(notebook)"
|
||||
},
|
||||
{
|
||||
"command": "roo-cline.packageManagerButtonClicked",
|
||||
"command": "roo-cline.marketplaceButtonClicked",
|
||||
"title": "Marketplace",
|
||||
"icon": "$(extensions)"
|
||||
},
|
||||
|
|
@ -261,7 +261,7 @@
|
|||
"when": "view == roo-cline.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "roo-cline.packageManagerButtonClicked",
|
||||
"command": "roo-cline.marketplaceButtonClicked",
|
||||
"group": "navigation@4",
|
||||
"when": "view == roo-cline.SidebarProvider"
|
||||
},
|
||||
|
|
@ -303,7 +303,7 @@
|
|||
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
|
||||
},
|
||||
{
|
||||
"command": "roo-cline.packageManagerButtonClicked",
|
||||
"command": "roo-cline.marketplaceButtonClicked",
|
||||
"group": "navigation@4",
|
||||
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -95,10 +95,10 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
|
|||
"roo-cline.helpButtonClicked": () => {
|
||||
vscode.env.openExternal(vscode.Uri.parse("https://docs.roocode.com"))
|
||||
},
|
||||
"roo-cline.packageManagerButtonClicked": () => {
|
||||
"roo-cline.marketplaceButtonClicked": () => {
|
||||
const visibleProvider = getVisibleProviderOrLog(outputChannel)
|
||||
if (!visibleProvider) return
|
||||
visibleProvider.postMessageToWebview({ type: "action", action: "packageManagerButtonClicked" })
|
||||
visibleProvider.postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" })
|
||||
},
|
||||
"roo-cline.showHumanRelayDialog": (params: { requestId: string; promptText: string }) => {
|
||||
const panel = getPanel()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import fs from "fs/promises"
|
|||
import EventEmitter from "events"
|
||||
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../services/package-manager/constants"
|
||||
import { DEFAULT_MARKETPLACE_SOURCE } from "../../services/marketplace/constants"
|
||||
import delay from "delay"
|
||||
import axios from "axios"
|
||||
import pWaitFor from "p-wait-for"
|
||||
|
|
@ -38,7 +38,7 @@ import { getTheme } from "../../integrations/theme/getTheme"
|
|||
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
|
||||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
import { McpServerManager } from "../../services/mcp/McpServerManager"
|
||||
import { PackageManagerManager } from "../../services/package-manager"
|
||||
import { MarketplaceManager } from "../../services/marketplace"
|
||||
import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { setSoundEnabled } from "../../utils/sound"
|
||||
|
|
@ -77,7 +77,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
|
|||
return this._workspaceTracker
|
||||
}
|
||||
protected mcpHub?: McpHub // Change from private to protected
|
||||
private packageManagerManager?: PackageManagerManager
|
||||
private marketplaceManager?: MarketplaceManager
|
||||
|
||||
public isViewLaunched = false
|
||||
public settingsImportedAt?: number
|
||||
|
|
@ -764,7 +764,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
|
|||
*/
|
||||
private setWebviewMessageListener(webview: vscode.Webview) {
|
||||
const onReceiveMessage = async (message: WebviewMessage) =>
|
||||
webviewMessageHandler(this, message, this.packageManagerManager)
|
||||
webviewMessageHandler(this, message, this.marketplaceManager)
|
||||
|
||||
webview.onDidReceiveMessage(onReceiveMessage, null, this.disposables)
|
||||
}
|
||||
|
|
@ -1221,7 +1221,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
|
|||
showRooIgnoredFiles,
|
||||
language,
|
||||
maxReadFileLine,
|
||||
packageManagerSources,
|
||||
marketplaceSources,
|
||||
} = await this.getState()
|
||||
|
||||
const telemetryKey = process.env.POSTHOG_API_KEY
|
||||
|
|
@ -1229,13 +1229,12 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
|
|||
const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get<string[]>("allowedCommands") || []
|
||||
const cwd = this.cwd
|
||||
|
||||
// Get package manager items from the manager
|
||||
const packageManagerItems = this.packageManagerManager?.getCurrentItems() || []
|
||||
const marketplaceItems = this.marketplaceManager?.getCurrentItems() || []
|
||||
|
||||
return {
|
||||
version: this.context.extension?.packageJSON?.version ?? "",
|
||||
packageManagerItems,
|
||||
packageManagerSources: packageManagerSources ?? [],
|
||||
marketplaceItems,
|
||||
marketplaceSources: marketplaceSources ?? [],
|
||||
apiConfiguration,
|
||||
customInstructions,
|
||||
alwaysAllowReadOnly: alwaysAllowReadOnly ?? false,
|
||||
|
|
@ -1393,7 +1392,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
|
|||
telemetrySetting: stateValues.telemetrySetting || "unset",
|
||||
showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? true,
|
||||
maxReadFileLine: stateValues.maxReadFileLine ?? 500,
|
||||
packageManagerSources: stateValues.packageManagerSources ?? [DEFAULT_PACKAGE_MANAGER_SOURCE],
|
||||
marketplaceSources: stateValues.marketplaceSources ?? [DEFAULT_MARKETPLACE_SOURCE],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1489,11 +1488,11 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
|
|||
}
|
||||
|
||||
/**
|
||||
* Set the package manager manager instance
|
||||
* @param packageManagerManager The package manager manager instance
|
||||
* Set the marketplace manager instance
|
||||
* @param marketplaceManager The marketplace manager instance
|
||||
*/
|
||||
public setPackageManagerManager(packageManagerManager: PackageManagerManager) {
|
||||
this.packageManagerManager = packageManagerManager
|
||||
public setMarketplaceManager(marketplaceManager: MarketplaceManager) {
|
||||
this.marketplaceManager = marketplaceManager
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,23 +3,23 @@ import { ClineProvider } from "./ClineProvider"
|
|||
import { WebviewMessage } from "../../shared/WebviewMessage"
|
||||
import { ExtensionMessage } from "../../shared/ExtensionMessage"
|
||||
import {
|
||||
PackageManagerManager,
|
||||
MarketplaceManager,
|
||||
ComponentType,
|
||||
PackageManagerItem,
|
||||
PackageManagerSource,
|
||||
MarketplaceItem,
|
||||
MarketplaceSource,
|
||||
validateSources,
|
||||
ValidationError,
|
||||
} from "../../services/package-manager"
|
||||
import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../services/package-manager/constants"
|
||||
} from "../../services/marketplace"
|
||||
import { DEFAULT_MARKETPLACE_SOURCE } from "../../services/marketplace/constants"
|
||||
import { GlobalState } from "../../schemas"
|
||||
|
||||
/**
|
||||
* Handle package manager-related messages from the webview
|
||||
* Handle marketplace-related messages from the webview
|
||||
*/
|
||||
export async function handlePackageManagerMessages(
|
||||
export async function handleMarketplaceMessages(
|
||||
provider: ClineProvider,
|
||||
message: WebviewMessage,
|
||||
packageManagerManager: PackageManagerManager,
|
||||
marketplaceManager: MarketplaceManager,
|
||||
): Promise<boolean> {
|
||||
// Utility function for updating global state
|
||||
const updateGlobalState = async <K extends keyof GlobalState>(key: K, value: GlobalState[K]) =>
|
||||
|
|
@ -27,37 +27,36 @@ export async function handlePackageManagerMessages(
|
|||
|
||||
switch (message.type) {
|
||||
case "webviewDidLaunch": {
|
||||
// For webviewDidLaunch, we don't do anything - package manager items will be loaded by explicit fetchPackageManagerItems
|
||||
// For webviewDidLaunch, we don't do anything - marketplace items will be loaded by explicit fetchMarketplaceItems
|
||||
return true
|
||||
}
|
||||
case "fetchPackageManagerItems": {
|
||||
case "fetchMarketplaceItems": {
|
||||
// Prevent multiple simultaneous fetches
|
||||
if (packageManagerManager.isFetching) {
|
||||
if (marketplaceManager.isFetching) {
|
||||
await provider.postMessageToWebview({
|
||||
type: "state",
|
||||
text: "Fetch already in progress",
|
||||
})
|
||||
packageManagerManager.isFetching = false
|
||||
marketplaceManager.isFetching = false
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if we need to force refresh using type assertion
|
||||
const forceRefresh = (message as any).forceRefresh === true
|
||||
try {
|
||||
packageManagerManager.isFetching = true
|
||||
marketplaceManager.isFetching = true
|
||||
|
||||
// Wrap the entire initialization in a try-catch block
|
||||
try {
|
||||
// Initialize default sources if none exist
|
||||
let sources =
|
||||
((await provider.contextProxy.getValue("packageManagerSources")) as PackageManagerSource[]) ||
|
||||
[]
|
||||
((await provider.contextProxy.getValue("marketplaceSources")) as MarketplaceSource[]) || []
|
||||
|
||||
if (!sources || sources.length === 0) {
|
||||
sources = [DEFAULT_PACKAGE_MANAGER_SOURCE]
|
||||
sources = [DEFAULT_MARKETPLACE_SOURCE]
|
||||
|
||||
// Save the default sources
|
||||
await provider.contextProxy.setValue("packageManagerSources", sources)
|
||||
await provider.contextProxy.setValue("marketplaceSources", sources)
|
||||
}
|
||||
|
||||
// Add timing information
|
||||
|
|
@ -74,23 +73,23 @@ export async function handlePackageManagerMessages(
|
|||
return true
|
||||
}
|
||||
|
||||
const result = await packageManagerManager.getPackageManagerItems(enabledSources)
|
||||
const result = await marketplaceManager.getMarketplaceItems(enabledSources)
|
||||
|
||||
// If there are errors but also items, show warning
|
||||
if (result.errors && result.items.length > 0) {
|
||||
vscode.window.showWarningMessage(
|
||||
`Some package manager sources failed to load:\n${result.errors.join("\n")}`,
|
||||
`Some marketplace sources failed to load:\n${result.errors.join("\n")}`,
|
||||
)
|
||||
}
|
||||
// If there are errors and no items, show error
|
||||
else if (result.errors && result.items.length === 0) {
|
||||
const errorMessage = `Failed to load package manager sources:\n${result.errors.join("\n")}`
|
||||
const errorMessage = `Failed to load marketplace sources:\n${result.errors.join("\n")}`
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
await provider.postMessageToWebview({
|
||||
type: "state",
|
||||
text: errorMessage,
|
||||
})
|
||||
packageManagerManager.isFetching = false
|
||||
marketplaceManager.isFetching = false
|
||||
}
|
||||
|
||||
const endTime = Date.now()
|
||||
|
|
@ -101,8 +100,8 @@ export async function handlePackageManagerMessages(
|
|||
// Send state to webview
|
||||
await provider.postStateToWebview()
|
||||
} catch (initError) {
|
||||
const errorMessage = `Package manager initialization failed: ${initError instanceof Error ? initError.message : String(initError)}`
|
||||
console.error("Error in package manager initialization:", initError)
|
||||
const errorMessage = `Marketplace initialization failed: ${initError instanceof Error ? initError.message : String(initError)}`
|
||||
console.error("Error in marketplace initialization:", initError)
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
await provider.postMessageToWebview({
|
||||
type: "state",
|
||||
|
|
@ -110,31 +109,31 @@ export async function handlePackageManagerMessages(
|
|||
})
|
||||
// The state will already be updated with empty items by PackageManagerManager
|
||||
await provider.postStateToWebview()
|
||||
packageManagerManager.isFetching = false
|
||||
marketplaceManager.isFetching = false
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = `Failed to fetch package manager items: ${error instanceof Error ? error.message : String(error)}`
|
||||
console.error("Failed to fetch package manager items:", error)
|
||||
const errorMessage = `Failed to fetch marketplace items: ${error instanceof Error ? error.message : String(error)}`
|
||||
console.error("Failed to fetch marketplace items:", error)
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
await provider.postMessageToWebview({
|
||||
type: "state",
|
||||
text: errorMessage,
|
||||
})
|
||||
packageManagerManager.isFetching = false
|
||||
marketplaceManager.isFetching = false
|
||||
}
|
||||
return true
|
||||
}
|
||||
case "packageManagerSources": {
|
||||
case "marketplaceSources": {
|
||||
if (message.sources) {
|
||||
// Enforce maximum of 10 sources
|
||||
const MAX_SOURCES = 10
|
||||
let updatedSources: PackageManagerSource[]
|
||||
let updatedSources: MarketplaceSource[]
|
||||
|
||||
if (message.sources.length > MAX_SOURCES) {
|
||||
// Truncate to maximum allowed and show warning
|
||||
updatedSources = message.sources.slice(0, MAX_SOURCES)
|
||||
vscode.window.showWarningMessage(
|
||||
`Maximum of ${MAX_SOURCES} package manager sources allowed. Additional sources have been removed.`,
|
||||
`Maximum of ${MAX_SOURCES} marketplace sources allowed. Additional sources have been removed.`,
|
||||
)
|
||||
} else {
|
||||
updatedSources = message.sources
|
||||
|
|
@ -162,19 +161,19 @@ export async function handlePackageManagerMessages(
|
|||
updatedSources = updatedSources.filter((_, index) => !invalidIndices.has(index))
|
||||
|
||||
// Show validation errors
|
||||
const errorMessage = `Package manager sources validation failed:\n${validationErrors.map((e: ValidationError) => e.message).join("\n")}`
|
||||
const errorMessage = `Marketplace sources validation failed:\n${validationErrors.map((e: ValidationError) => e.message).join("\n")}`
|
||||
console.error(errorMessage)
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
}
|
||||
|
||||
// Update the global state with the validated sources
|
||||
await updateGlobalState("packageManagerSources", updatedSources)
|
||||
await updateGlobalState("marketplaceSources", updatedSources)
|
||||
|
||||
// Clean up cache directories for repositories that are no longer in the sources list
|
||||
try {
|
||||
await packageManagerManager.cleanupCacheDirectories(updatedSources)
|
||||
await marketplaceManager.cleanupCacheDirectories(updatedSources)
|
||||
} catch (error) {
|
||||
console.error("Package Manager: Error during cache cleanup:", error)
|
||||
console.error("Marketplace: Error during cache cleanup:", error)
|
||||
}
|
||||
|
||||
// Update the webview with the new state
|
||||
|
|
@ -188,43 +187,42 @@ export async function handlePackageManagerMessages(
|
|||
vscode.env.openExternal(vscode.Uri.parse(message.url))
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Package Manager: Failed to open URL: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Marketplace: Failed to open URL: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to open URL: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.error("Package Manager: openExternal called without a URL")
|
||||
console.error("Marketplace: openExternal called without a URL")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
case "filterPackageManagerItems": {
|
||||
case "filterMarketplaceItems": {
|
||||
if (message.filters) {
|
||||
try {
|
||||
// Update filtered items and post state
|
||||
packageManagerManager.updateWithFilteredItems({
|
||||
marketplaceManager.updateWithFilteredItems({
|
||||
type: message.filters.type as ComponentType | undefined,
|
||||
search: message.filters.search,
|
||||
tags: message.filters.tags,
|
||||
})
|
||||
await provider.postStateToWebview()
|
||||
} catch (error) {
|
||||
console.error("Package Manager: Error filtering items:", error)
|
||||
vscode.window.showErrorMessage("Failed to filter package manager items")
|
||||
console.error("Marketplace: Error filtering items:", error)
|
||||
vscode.window.showErrorMessage("Failed to filter marketplace items")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
case "refreshPackageManagerSource": {
|
||||
case "refreshMarketplaceSource": {
|
||||
if (message.url) {
|
||||
try {
|
||||
// Get the current sources
|
||||
const sources =
|
||||
((await provider.contextProxy.getValue("packageManagerSources")) as PackageManagerSource[]) ||
|
||||
[]
|
||||
((await provider.contextProxy.getValue("marketplaceSources")) as MarketplaceSource[]) || []
|
||||
|
||||
// Find the source with the matching URL
|
||||
const source = sources.find((s) => s.url === message.url)
|
||||
|
|
@ -232,17 +230,14 @@ export async function handlePackageManagerMessages(
|
|||
if (source) {
|
||||
try {
|
||||
// Refresh the repository with the source name
|
||||
const refreshResult = await packageManagerManager.refreshRepository(
|
||||
message.url,
|
||||
source.name,
|
||||
)
|
||||
const refreshResult = await marketplaceManager.refreshRepository(message.url, source.name)
|
||||
if (refreshResult.error) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to refresh source: ${source.name || message.url} - ${refreshResult.error}`,
|
||||
)
|
||||
} else {
|
||||
vscode.window.showInformationMessage(
|
||||
`Successfully refreshed package manager source: ${source.name || message.url}`,
|
||||
`Successfully refreshed marketplace source: ${source.name || message.url}`,
|
||||
)
|
||||
}
|
||||
await provider.postStateToWebview()
|
||||
|
|
@ -254,12 +249,12 @@ export async function handlePackageManagerMessages(
|
|||
})
|
||||
}
|
||||
} else {
|
||||
console.error(`Package Manager: Source URL not found: ${message.url}`)
|
||||
console.error(`Marketplace: Source URL not found: ${message.url}`)
|
||||
vscode.window.showErrorMessage(`Source URL not found: ${message.url}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Package Manager: Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Marketplace: Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`,
|
||||
|
|
@ -42,16 +42,16 @@ import { getDiffStrategy } from "../diff/DiffStrategy"
|
|||
import { SYSTEM_PROMPT } from "../prompts/system"
|
||||
import { buildApiHandler } from "../../api"
|
||||
import { GlobalState } from "../../schemas"
|
||||
import { PackageManagerManager } from "../../services/package-manager"
|
||||
import { handlePackageManagerMessages } from "./packageManagerMessageHandler"
|
||||
import { MarketplaceManager } from "../../services/marketplace"
|
||||
import { handleMarketplaceMessages } from "./marketplaceMessageHandler"
|
||||
|
||||
// Track if package manager data has been loaded
|
||||
let packageManagerDataLoaded = false
|
||||
// Track if marketplace data has been loaded
|
||||
let marketplaceDataLoaded = false
|
||||
|
||||
export const webviewMessageHandler = async (
|
||||
provider: ClineProvider,
|
||||
message: WebviewMessage,
|
||||
packageManagerManager?: PackageManagerManager,
|
||||
marketplaceManager?: MarketplaceManager,
|
||||
) => {
|
||||
// Utility functions provided for concise get/update of global state via contextProxy API.
|
||||
const getGlobalState = <K extends keyof GlobalState>(key: K) => provider.contextProxy.getValue(key)
|
||||
|
|
@ -64,10 +64,10 @@ export const webviewMessageHandler = async (
|
|||
const customModes = await provider.customModesManager.getCustomModes()
|
||||
await updateGlobalState("customModes", customModes)
|
||||
|
||||
// Don't handle package manager messages in webviewDidLaunch
|
||||
// They will be handled by the fetchPackageManagerItems case
|
||||
// Don't handle marketplace messages in webviewDidLaunch
|
||||
// They will be handled by the fetchMarketplaceItems case
|
||||
console.log(
|
||||
`DEBUG: webviewDidLaunch - skipping package manager handling, will be triggered by explicit fetchPackageManagerItems`,
|
||||
`DEBUG: webviewDidLaunch - skipping marketplace handling, will be triggered by explicit fetchMarketplaceItems`,
|
||||
)
|
||||
|
||||
console.log(`DEBUG: About to call postStateToWebview`)
|
||||
|
|
@ -261,22 +261,15 @@ export const webviewMessageHandler = async (
|
|||
|
||||
provider.isViewLaunched = true
|
||||
break
|
||||
case "fetchPackageManagerItems":
|
||||
if (packageManagerManager) {
|
||||
console.log(`DEBUG: Handling explicit fetchPackageManagerItems message`)
|
||||
case "fetchMarketplaceItems":
|
||||
if (marketplaceManager) {
|
||||
try {
|
||||
// Use non-null assertion to tell TypeScript that packageManagerManager is definitely not undefined here
|
||||
console.log(`DEBUG: Before calling handlePackageManagerMessages for fetchPackageManagerItems`)
|
||||
const result = await handlePackageManagerMessages(provider, message, packageManagerManager!)
|
||||
console.log(
|
||||
`DEBUG: After calling handlePackageManagerMessages for fetchPackageManagerItems, result: ${result}`,
|
||||
)
|
||||
console.log(`DEBUG: Package manager message handled successfully: ${message.type}`)
|
||||
const result = await handleMarketplaceMessages(provider, message, marketplaceManager!)
|
||||
} catch (error) {
|
||||
console.error(`DEBUG: Error handling package manager message: ${error}`)
|
||||
console.error(`DEBUG: Error handling marketplace message: ${error}`)
|
||||
}
|
||||
} else {
|
||||
console.log(`DEBUG: packageManagerManager is undefined, skipping package manager message handling`)
|
||||
console.log(`DEBUG: marketplaceManager is undefined, skipping marketplace message handling`)
|
||||
}
|
||||
break
|
||||
case "newTask":
|
||||
|
|
@ -1406,21 +1399,19 @@ export const webviewMessageHandler = async (
|
|||
}
|
||||
}
|
||||
|
||||
// Handle package manager related messages
|
||||
// Handle package manager messages
|
||||
if (
|
||||
packageManagerManager &&
|
||||
(message.type === "packageManagerSources" ||
|
||||
marketplaceManager &&
|
||||
(message.type === "marketplaceSources" ||
|
||||
message.type === "openExternal" ||
|
||||
message.type === "refreshPackageManagerSource" ||
|
||||
message.type === "filterPackageManagerItems")
|
||||
message.type === "refreshMarketplaceSource" ||
|
||||
message.type === "filterMarketplaceItems")
|
||||
) {
|
||||
try {
|
||||
console.log(`DEBUG: Routing ${message.type} message to packageManagerMessageHandler`)
|
||||
const result = await handlePackageManagerMessages(provider, message, packageManagerManager)
|
||||
console.log(`DEBUG: Package manager message handled successfully: ${message.type}, result: ${result}`)
|
||||
console.log(`DEBUG: Routing ${message.type} message to marketplaceMessageHandler`)
|
||||
const result = await handleMarketplaceMessages(provider, message, marketplaceManager)
|
||||
console.log(`DEBUG: Marketplace message handled successfully: ${message.type}, result: ${result}`)
|
||||
} catch (error) {
|
||||
console.error(`DEBUG: Error handling package manager message: ${error}`)
|
||||
console.error(`DEBUG: Error handling marketplace message: ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
src/exports/roo-code.d.ts
vendored
2
src/exports/roo-code.d.ts
vendored
|
|
@ -352,7 +352,7 @@ type GlobalSettings = {
|
|||
}
|
||||
| undefined
|
||||
enhancementApiConfigId?: string | undefined
|
||||
packageManagerSources?:
|
||||
marketplaceSources?:
|
||||
| {
|
||||
url: string
|
||||
name?: string | undefined
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ type GlobalSettings = {
|
|||
}
|
||||
| undefined
|
||||
enhancementApiConfigId?: string | undefined
|
||||
packageManagerSources?:
|
||||
marketplaceSources?:
|
||||
| {
|
||||
url: string
|
||||
name?: string | undefined
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { ClineProvider } from "./core/webview/ClineProvider"
|
|||
import { CodeActionProvider } from "./core/CodeActionProvider"
|
||||
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
|
||||
import { McpServerManager } from "./services/mcp/McpServerManager"
|
||||
import { PackageManagerManager } from "./services/package-manager"
|
||||
import { MarketplaceManager } from "./services/marketplace"
|
||||
import { telemetryService } from "./services/telemetry/TelemetryService"
|
||||
import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry"
|
||||
import { API } from "./exports/api"
|
||||
|
|
@ -38,7 +38,7 @@ import { formatLanguage } from "./shared/language"
|
|||
|
||||
let outputChannel: vscode.OutputChannel
|
||||
let extensionContext: vscode.ExtensionContext
|
||||
let packageManagerManager: PackageManagerManager
|
||||
let marketplaceManager: MarketplaceManager
|
||||
|
||||
// This method is called when your extension is activated.
|
||||
// Your extension is activated the very first time the command is executed.
|
||||
|
|
@ -70,9 +70,8 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||
|
||||
const provider = new ClineProvider(context, outputChannel, "sidebar")
|
||||
|
||||
// Initialize package manager
|
||||
packageManagerManager = new PackageManagerManager(context)
|
||||
provider.setPackageManagerManager(packageManagerManager)
|
||||
marketplaceManager = new MarketplaceManager(context)
|
||||
provider.setMarketplaceManager(marketplaceManager)
|
||||
telemetryService.setProvider(provider)
|
||||
|
||||
context.subscriptions.push(
|
||||
|
|
@ -134,12 +133,11 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||
export async function deactivate() {
|
||||
outputChannel.appendLine("Roo-Code extension deactivated")
|
||||
|
||||
// Clean up package manager
|
||||
if (packageManagerManager) {
|
||||
if (marketplaceManager) {
|
||||
try {
|
||||
await packageManagerManager.cleanup()
|
||||
await marketplaceManager.cleanup()
|
||||
} catch (error) {
|
||||
console.error("Failed to clean up package manager:", error)
|
||||
console.error("Failed to clean up marketplace:", error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -567,7 +567,7 @@ export const globalSettingsSchema = z.object({
|
|||
customModePrompts: customModePromptsSchema.optional(),
|
||||
customSupportPrompts: customSupportPromptsSchema.optional(),
|
||||
enhancementApiConfigId: z.string().optional(),
|
||||
packageManagerSources: z
|
||||
marketplaceSources: z
|
||||
.array(
|
||||
z.object({
|
||||
url: z.string(),
|
||||
|
|
@ -652,7 +652,7 @@ const globalSettingsRecord: GlobalSettingsRecord = {
|
|||
customSupportPrompts: undefined,
|
||||
enhancementApiConfigId: undefined,
|
||||
cachedChromeHostUrl: undefined,
|
||||
packageManagerSources: undefined,
|
||||
marketplaceSources: undefined,
|
||||
}
|
||||
|
||||
export const GLOBAL_SETTINGS_KEYS = Object.keys(globalSettingsRecord) as Keys<GlobalSettings>[]
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import * as yaml from "js-yaml"
|
|||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import { MetadataScanner } from "./MetadataScanner"
|
||||
import { validateAnyMetadata } from "./schemas"
|
||||
import { LocalizationOptions, PackageManagerItem, PackageManagerRepository, RepositoryMetadata } from "./types"
|
||||
import { LocalizationOptions, MarketplaceItem, MarketplaceRepository, RepositoryMetadata } from "./types"
|
||||
import { getUserLocale } from "./utils"
|
||||
|
||||
/**
|
||||
* Handles fetching and caching package manager repositories
|
||||
* Handles fetching and caching marketplace repositories
|
||||
*/
|
||||
export class GitFetcher {
|
||||
private readonly cacheDir: string
|
||||
|
|
@ -19,7 +19,7 @@ export class GitFetcher {
|
|||
private activeGitInstances: Set<SimpleGit> = new Set()
|
||||
|
||||
constructor(context: vscode.ExtensionContext, localizationOptions?: LocalizationOptions) {
|
||||
this.cacheDir = path.join(context.globalStorageUri.fsPath, "package-manager-cache")
|
||||
this.cacheDir = path.join(context.globalStorageUri.fsPath, "marketplace-cache")
|
||||
this.localizationOptions = localizationOptions || {
|
||||
userLocale: getUserLocale(),
|
||||
fallbackLocale: "en",
|
||||
|
|
@ -85,11 +85,7 @@ export class GitFetcher {
|
|||
* @param sourceName Optional source repository name
|
||||
* @returns Repository data
|
||||
*/
|
||||
async fetchRepository(
|
||||
repoUrl: string,
|
||||
forceRefresh = false,
|
||||
sourceName?: string,
|
||||
): Promise<PackageManagerRepository> {
|
||||
async fetchRepository(repoUrl: string, forceRefresh = false, sourceName?: string): Promise<MarketplaceRepository> {
|
||||
// Ensure cache directory exists
|
||||
await fs.mkdir(this.cacheDir, { recursive: true })
|
||||
|
||||
|
|
@ -109,7 +105,7 @@ export class GitFetcher {
|
|||
// Parse repository metadata
|
||||
const metadata = await this.parseRepositoryMetadata(repoDir)
|
||||
|
||||
// Parse package manager items
|
||||
// Parse marketplace items
|
||||
// Get current branch using existing git instance
|
||||
const branch = (await this.git?.revparse(["--abbrev-ref", "HEAD"])) || "main"
|
||||
|
||||
|
|
@ -291,17 +287,17 @@ export class GitFetcher {
|
|||
}
|
||||
|
||||
/**
|
||||
* Parse package manager items
|
||||
* Parse marketplace items
|
||||
* @param repoDir Repository directory
|
||||
* @param repoUrl Repository URL
|
||||
* @param sourceName Source repository name
|
||||
* @returns Array of package manager items
|
||||
* @returns Array of marketplace items
|
||||
*/
|
||||
private async parsePackageManagerItems(
|
||||
repoDir: string,
|
||||
repoUrl: string,
|
||||
sourceName: string,
|
||||
): Promise<PackageManagerItem[]> {
|
||||
): Promise<MarketplaceItem[]> {
|
||||
return this.metadataScanner.scanDirectory(repoDir, repoUrl, sourceName)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,24 +3,25 @@ import * as path from "path"
|
|||
import * as fs from "fs/promises"
|
||||
import { GitFetcher } from "./GitFetcher"
|
||||
import {
|
||||
PackageManagerItem,
|
||||
PackageManagerRepository,
|
||||
PackageManagerSource,
|
||||
MarketplaceItem,
|
||||
MarketplaceRepository,
|
||||
MarketplaceSource,
|
||||
ComponentType,
|
||||
ComponentMetadata,
|
||||
LocalizationOptions,
|
||||
} from "./types"
|
||||
import { validateSource, validateSources } from "../../shared/MarketplaceValidation"
|
||||
import { getUserLocale } from "./utils"
|
||||
|
||||
/**
|
||||
* Service for managing package manager data
|
||||
* Service for managing marketplace data
|
||||
*/
|
||||
export class PackageManagerManager {
|
||||
private currentItems: PackageManagerItem[] = []
|
||||
export class MarketplaceManager {
|
||||
private currentItems: MarketplaceItem[] = []
|
||||
private static readonly CACHE_EXPIRY_MS = 3600000 // 1 hour
|
||||
|
||||
private gitFetcher: GitFetcher
|
||||
private cache: Map<string, { data: PackageManagerRepository; timestamp: number }> = new Map()
|
||||
private cache: Map<string, { data: MarketplaceRepository; timestamp: number }> = new Map()
|
||||
public isFetching = false
|
||||
|
||||
// Concurrency control
|
||||
|
|
@ -36,11 +37,6 @@ export class PackageManagerManager {
|
|||
this.gitFetcher = new GitFetcher(context, localizationOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets package manager items from all enabled sources
|
||||
* @param sources The package manager sources
|
||||
* @returns An array of PackageManagerItem objects
|
||||
*/
|
||||
/**
|
||||
* Queue an operation to run when no metadata scan is active
|
||||
*/
|
||||
|
|
@ -68,10 +64,8 @@ export class PackageManagerManager {
|
|||
}
|
||||
}
|
||||
|
||||
async getPackageManagerItems(
|
||||
sources: PackageManagerSource[],
|
||||
): Promise<{ items: PackageManagerItem[]; errors?: string[] }> {
|
||||
const items: PackageManagerItem[] = []
|
||||
async getMarketplaceItems(sources: MarketplaceSource[]): Promise<{ items: MarketplaceItem[]; errors?: string[] }> {
|
||||
const items: MarketplaceItem[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
// Filter enabled sources
|
||||
|
|
@ -91,12 +85,18 @@ export class PackageManagerManager {
|
|||
const repo = await this.getRepositoryData(source.url, false, source.name)
|
||||
|
||||
if (repo.items && repo.items.length > 0) {
|
||||
items.push(...repo.items)
|
||||
// Ensure each item is properly attributed to its source
|
||||
const itemsWithSource = repo.items.map((item) => ({
|
||||
...item,
|
||||
sourceName: source.name || this.getRepoNameFromUrl(source.url),
|
||||
sourceUrl: source.url,
|
||||
}))
|
||||
items.push(...itemsWithSource)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`PackageManagerManager: Failed to fetch data from ${source.url}:`, error)
|
||||
console.error(`MarketplaceManager: Failed to fetch data from ${source.url}:`, error)
|
||||
errors.push(`Source ${source.url}: ${errorMessage}`)
|
||||
} finally {
|
||||
this.unlockSource(source.url)
|
||||
|
|
@ -115,13 +115,6 @@ export class PackageManagerManager {
|
|||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets repository data from a URL, using cache if available
|
||||
* @param url The repository URL
|
||||
* @param forceRefresh Whether to bypass the cache and force a refresh
|
||||
* @param sourceName The name of the source
|
||||
* @returns A PackageManagerRepository object
|
||||
*/
|
||||
/**
|
||||
* Check if a source operation is in progress
|
||||
*/
|
||||
|
|
@ -147,12 +140,12 @@ export class PackageManagerManager {
|
|||
url: string,
|
||||
forceRefresh: boolean = false,
|
||||
sourceName?: string,
|
||||
): Promise<PackageManagerRepository> {
|
||||
): Promise<MarketplaceRepository> {
|
||||
try {
|
||||
// Check cache first (unless force refresh is requested)
|
||||
const cached = this.cache.get(url)
|
||||
|
||||
if (!forceRefresh && cached && Date.now() - cached.timestamp < PackageManagerManager.CACHE_EXPIRY_MS) {
|
||||
if (!forceRefresh && cached && Date.now() - cached.timestamp < MarketplaceManager.CACHE_EXPIRY_MS) {
|
||||
return cached.data
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +154,7 @@ export class PackageManagerManager {
|
|||
|
||||
// Create a timeout promise
|
||||
let timeoutId: NodeJS.Timeout | undefined
|
||||
const timeoutPromise = new Promise<PackageManagerRepository>((_, reject) => {
|
||||
const timeoutPromise = new Promise<MarketplaceRepository>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(`Repository fetch timed out after 30 seconds: ${url}`))
|
||||
}, 30000) // 30 second timeout
|
||||
|
|
@ -181,7 +174,7 @@ export class PackageManagerManager {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`PackageManagerManager: Error fetching repository data for ${url}:`, error)
|
||||
console.error(`MarketplaceManager: Error fetching repository data for ${url}:`, error)
|
||||
|
||||
// Return empty repository data instead of throwing
|
||||
return {
|
||||
|
|
@ -202,13 +195,13 @@ export class PackageManagerManager {
|
|||
* @param sourceName Optional name of the source
|
||||
* @returns The refreshed repository data
|
||||
*/
|
||||
async refreshRepository(url: string, sourceName?: string): Promise<PackageManagerRepository> {
|
||||
async refreshRepository(url: string, sourceName?: string): Promise<MarketplaceRepository> {
|
||||
try {
|
||||
// Force a refresh by bypassing the cache
|
||||
const data = await this.getRepositoryData(url, true, sourceName)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error(`PackageManagerManager: Failed to refresh repository ${url}:`, error)
|
||||
console.error(`MarketplaceManager: Failed to refresh repository ${url}:`, error)
|
||||
return {
|
||||
metadata: {
|
||||
name: "Unknown Repository",
|
||||
|
|
@ -231,12 +224,12 @@ export class PackageManagerManager {
|
|||
|
||||
/**
|
||||
* Cleans up cache directories for repositories that are no longer in the configured sources
|
||||
* @param currentSources The current list of package manager sources
|
||||
* @param currentSources The current list of marketplace sources
|
||||
*/
|
||||
async cleanupCacheDirectories(currentSources: PackageManagerSource[]): Promise<void> {
|
||||
async cleanupCacheDirectories(currentSources: MarketplaceSource[]): Promise<void> {
|
||||
try {
|
||||
// Get the cache directory path
|
||||
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "package-manager-cache")
|
||||
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "marketplace-cache")
|
||||
|
||||
// Check if cache directory exists
|
||||
try {
|
||||
|
|
@ -261,11 +254,11 @@ export class PackageManagerManager {
|
|||
const dirPath = path.join(cacheDir, dirName)
|
||||
await fs.rm(dirPath, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
console.error(`PackageManagerManager: Failed to delete directory ${dirName}:`, error)
|
||||
console.error(`MarketplaceManager: Failed to delete directory ${dirName}:`, error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("PackageManagerManager: Error cleaning up cache directories:", error)
|
||||
console.error("MarketplaceManager: Error cleaning up cache directories:", error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -282,7 +275,7 @@ export class PackageManagerManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Filters package manager items based on criteria
|
||||
* Filters marketplace items based on criteria
|
||||
* @param items The items to filter
|
||||
* @param filters The filter criteria
|
||||
* @returns Filtered items
|
||||
|
|
@ -293,7 +286,7 @@ export class PackageManagerManager {
|
|||
private filterCache = new Map<
|
||||
string,
|
||||
{
|
||||
items: PackageManagerItem[]
|
||||
items: MarketplaceItem[]
|
||||
timestamp: number
|
||||
}
|
||||
>()
|
||||
|
|
@ -302,11 +295,11 @@ export class PackageManagerManager {
|
|||
* Clear old entries from the filter cache
|
||||
*/
|
||||
private cleanupFilterCache(): void {
|
||||
if (this.filterCache.size > PackageManagerManager.MAX_CACHE_SIZE) {
|
||||
if (this.filterCache.size > MarketplaceManager.MAX_CACHE_SIZE) {
|
||||
// Sort by timestamp and keep only the most recent entries
|
||||
const entries = Array.from(this.filterCache.entries())
|
||||
.sort(([, a], [, b]) => b.timestamp - a.timestamp)
|
||||
.slice(0, PackageManagerManager.MAX_CACHE_SIZE)
|
||||
.slice(0, MarketplaceManager.MAX_CACHE_SIZE)
|
||||
|
||||
this.filterCache.clear()
|
||||
entries.forEach(([key, value]) => this.filterCache.set(key, value))
|
||||
|
|
@ -317,9 +310,9 @@ export class PackageManagerManager {
|
|||
* Filter items
|
||||
*/
|
||||
filterItems(
|
||||
items: PackageManagerItem[],
|
||||
items: MarketplaceItem[],
|
||||
filters: { type?: ComponentType; search?: string; tags?: string[] },
|
||||
): PackageManagerItem[] {
|
||||
): MarketplaceItem[] {
|
||||
// Create cache key from filters
|
||||
const cacheKey = JSON.stringify(filters)
|
||||
const cached = this.filterCache.get(cacheKey)
|
||||
|
|
@ -331,9 +324,9 @@ export class PackageManagerManager {
|
|||
this.cleanupFilterCache()
|
||||
|
||||
// Process items in batches to avoid memory spikes
|
||||
const allFilteredItems: PackageManagerItem[] = []
|
||||
for (let i = 0; i < items.length; i += PackageManagerManager.BATCH_SIZE) {
|
||||
const batch = items.slice(i, Math.min(i + PackageManagerManager.BATCH_SIZE, items.length))
|
||||
const allFilteredItems: MarketplaceItem[] = []
|
||||
for (let i = 0; i < items.length; i += MarketplaceManager.BATCH_SIZE) {
|
||||
const batch = items.slice(i, Math.min(i + MarketplaceManager.BATCH_SIZE, items.length))
|
||||
const filteredBatch = this.processItemBatch(batch, filters)
|
||||
allFilteredItems.push(...filteredBatch)
|
||||
}
|
||||
|
|
@ -347,16 +340,13 @@ export class PackageManagerManager {
|
|||
return allFilteredItems
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of items
|
||||
*/
|
||||
/**
|
||||
* Process a batch of items
|
||||
*/
|
||||
private processItemBatch(
|
||||
batch: PackageManagerItem[],
|
||||
batch: MarketplaceItem[],
|
||||
filters: { type?: ComponentType; search?: string; tags?: string[] },
|
||||
): PackageManagerItem[] {
|
||||
): MarketplaceItem[] {
|
||||
// Helper functions
|
||||
const normalizeText = (text: string) => text.toLowerCase().replace(/\s+/g, " ").trim()
|
||||
const searchTerm = filters.search ? normalizeText(filters.search) : ""
|
||||
|
|
@ -463,22 +453,22 @@ export class PackageManagerManager {
|
|||
|
||||
return null
|
||||
})
|
||||
.filter((item): item is PackageManagerItem => item !== null)
|
||||
.filter((item): item is MarketplaceItem => item !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts package manager items
|
||||
* Sorts marketplace items
|
||||
* @param items The items to sort
|
||||
* @param sortBy The field to sort by
|
||||
* @param sortOrder The sort order
|
||||
* @returns Sorted items
|
||||
*/
|
||||
sortItems(
|
||||
items: PackageManagerItem[],
|
||||
sortBy: keyof Pick<PackageManagerItem, "name" | "author" | "lastUpdated">,
|
||||
items: MarketplaceItem[],
|
||||
sortBy: keyof Pick<MarketplaceItem, "name" | "author" | "lastUpdated">,
|
||||
sortOrder: "asc" | "desc",
|
||||
sortSubcomponents: boolean = false,
|
||||
): PackageManagerItem[] {
|
||||
): MarketplaceItem[] {
|
||||
return [...items]
|
||||
.map((item) => {
|
||||
// Deep clone the item
|
||||
|
|
@ -506,11 +496,12 @@ export class PackageManagerManager {
|
|||
return sortOrder === "asc" ? comparison : -comparison
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current package manager items
|
||||
* Gets the current marketplace items
|
||||
* @returns The current items
|
||||
*/
|
||||
getCurrentItems(): PackageManagerItem[] {
|
||||
getCurrentItems(): MarketplaceItem[] {
|
||||
return this.currentItems
|
||||
}
|
||||
|
||||
|
|
@ -519,14 +510,14 @@ export class PackageManagerManager {
|
|||
* @param filters The filter criteria
|
||||
* @returns Filtered items
|
||||
*/
|
||||
updateWithFilteredItems(filters: { type?: ComponentType; search?: string; tags?: string[] }): PackageManagerItem[] {
|
||||
updateWithFilteredItems(filters: { type?: ComponentType; search?: string; tags?: string[] }): MarketplaceItem[] {
|
||||
const filteredItems = this.filterItems(this.currentItems, filters)
|
||||
this.currentItems = filteredItems
|
||||
return filteredItems
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up resources used by the package manager
|
||||
* Cleans up resources used by the marketplace
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
// Clean up cache directories for all sources
|
||||
|
|
@ -542,9 +533,9 @@ export class PackageManagerManager {
|
|||
*/
|
||||
private getSortValue(
|
||||
item:
|
||||
| PackageManagerItem
|
||||
| MarketplaceItem
|
||||
| { type: ComponentType; path: string; metadata?: ComponentMetadata; lastUpdated?: string },
|
||||
sortBy: keyof Pick<PackageManagerItem, "name" | "author" | "lastUpdated">,
|
||||
sortBy: keyof Pick<MarketplaceItem, "name" | "author" | "lastUpdated">,
|
||||
): string {
|
||||
if ("metadata" in item && item.metadata) {
|
||||
// Handle subcomponent
|
||||
|
|
@ -560,7 +551,7 @@ export class PackageManagerManager {
|
|||
}
|
||||
} else {
|
||||
// Handle parent item
|
||||
const parentItem = item as PackageManagerItem
|
||||
const parentItem = item as MarketplaceItem
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
return parentItem.name
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
ComponentType,
|
||||
LocalizationOptions,
|
||||
LocalizedMetadata,
|
||||
PackageManagerItem,
|
||||
MarketplaceItem,
|
||||
PackageMetadata,
|
||||
} from "./types"
|
||||
import { getUserLocale } from "./utils"
|
||||
|
|
@ -60,12 +60,12 @@ export class MetadataScanner {
|
|||
repoUrl: string,
|
||||
sourceName?: string,
|
||||
depth: number = 0,
|
||||
): AsyncGenerator<PackageManagerItem[]> {
|
||||
): AsyncGenerator<MarketplaceItem[]> {
|
||||
if (depth > MetadataScanner.MAX_DEPTH) {
|
||||
return
|
||||
}
|
||||
|
||||
const batch: PackageManagerItem[] = []
|
||||
const batch: MarketplaceItem[] = []
|
||||
const entries = await fs.readdir(rootDir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
|
|
@ -129,13 +129,13 @@ export class MetadataScanner {
|
|||
repoUrl: string,
|
||||
sourceName?: string,
|
||||
isRecursiveCall: boolean = false,
|
||||
): Promise<PackageManagerItem[]> {
|
||||
): Promise<MarketplaceItem[]> {
|
||||
// Only set originalRootDir on the first call
|
||||
if (!isRecursiveCall && !this.originalRootDir) {
|
||||
this.originalRootDir = rootDir
|
||||
}
|
||||
|
||||
const items: PackageManagerItem[] = []
|
||||
const items: MarketplaceItem[] = []
|
||||
const generator = this.scanDirectoryBatched(rootDir, repoUrl, sourceName)
|
||||
|
||||
for await (const batch of generator) {
|
||||
|
|
@ -233,7 +233,7 @@ export class MetadataScanner {
|
|||
repoUrl: string,
|
||||
rootDir: string,
|
||||
sourceName?: string,
|
||||
): Promise<PackageManagerItem | null> {
|
||||
): Promise<MarketplaceItem | null> {
|
||||
// Skip if no type or invalid type
|
||||
if (!metadata.type || !this.isValidComponentType(metadata.type)) {
|
||||
return null
|
||||
|
|
@ -307,7 +307,7 @@ export class MetadataScanner {
|
|||
*/
|
||||
private async scanPackageSubcomponents(
|
||||
packageDir: string,
|
||||
packageItem: PackageManagerItem,
|
||||
packageItem: MarketplaceItem,
|
||||
parentPath: string = "",
|
||||
): Promise<void> {
|
||||
try {
|
||||
|
|
@ -74,7 +74,7 @@ describe("GitFetcher", () => {
|
|||
let gitFetcher: GitFetcher
|
||||
const mockSimpleGit = simpleGit as jest.MockedFunction<typeof simpleGit>
|
||||
const testRepoUrl = "https://github.com/test/repo"
|
||||
const testRepoDir = path.join(mockContext.globalStorageUri.fsPath, "package-manager-cache", "repo")
|
||||
const testRepoDir = path.join(mockContext.globalStorageUri.fsPath, "marketplace-cache", "repo")
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
|
@ -87,15 +87,15 @@ describe("GitFetcher", () => {
|
|||
if (!options?.recursive || !options?.force) {
|
||||
return Promise.reject(new Error("Invalid rm call: missing recursive or force options"))
|
||||
}
|
||||
// Allow any path under package-manager-cache directory
|
||||
// Allow any path under marketplace-cache directory
|
||||
const normalizedPath = path.normalize(pathToRemove)
|
||||
const normalizedCachePath = path.normalize(
|
||||
path.join(mockContext.globalStorageUri.fsPath, "package-manager-cache"),
|
||||
path.join(mockContext.globalStorageUri.fsPath, "marketplace-cache"),
|
||||
)
|
||||
if (normalizedPath.startsWith(normalizedCachePath)) {
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
return Promise.reject(new Error(`Invalid rm call: path ${pathToRemove} not in package-manager-cache`))
|
||||
return Promise.reject(new Error(`Invalid rm call: path ${pathToRemove} not in marketplace-cache`))
|
||||
})
|
||||
|
||||
// Setup fs.stat mock for repository structure validation
|
||||
|
|
@ -375,7 +375,7 @@ describe("GitFetcher", () => {
|
|||
|
||||
// Verify that simpleGit's clone was called with the correct arguments
|
||||
const mockGit = mockSimpleGit()
|
||||
expect(mockGit.clone).toHaveBeenCalledWith(url, expect.stringContaining("package-manager-cache"))
|
||||
expect(mockGit.clone).toHaveBeenCalledWith(url, expect.stringContaining("marketplace-cache"))
|
||||
})
|
||||
|
||||
it("should handle paths with special characters when cloning", async () => {
|
||||
|
|
@ -389,7 +389,7 @@ describe("GitFetcher", () => {
|
|||
|
||||
// Verify that simpleGit's clone was called with the correct arguments
|
||||
const mockGit = mockSimpleGit()
|
||||
expect(mockGit.clone).toHaveBeenCalledWith(url, expect.stringContaining("package-manager-cache"))
|
||||
expect(mockGit.clone).toHaveBeenCalledWith(url, expect.stringContaining("marketplace-cache"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { PackageManagerManager } from "../PackageManagerManager"
|
||||
import { PackageManagerItem, PackageManagerSource, PackageManagerRepository, ComponentType } from "../types"
|
||||
import { MarketplaceManager } from "../MarketplaceManager"
|
||||
import { MarketplaceItem, MarketplaceSource, MarketplaceRepository, ComponentType } from "../types"
|
||||
import { MetadataScanner } from "../MetadataScanner"
|
||||
import { GitFetcher } from "../GitFetcher"
|
||||
import * as path from "path"
|
||||
|
|
@ -31,15 +31,15 @@ describe("PackageManagerManager", () => {
|
|||
storageUri: { fsPath: "" },
|
||||
} as unknown as vscode.ExtensionContext
|
||||
|
||||
let manager: PackageManagerManager
|
||||
let manager: MarketplaceManager
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a new manager instance with the mock context for each test
|
||||
manager = new PackageManagerManager(mockContext)
|
||||
manager = new MarketplaceManager(mockContext)
|
||||
})
|
||||
|
||||
it("should correctly filter items by search term", () => {
|
||||
const items: PackageManagerItem[] = [
|
||||
const items: MarketplaceItem[] = [
|
||||
{
|
||||
name: "Test Item 1",
|
||||
description: "First test item",
|
||||
|
|
@ -63,7 +63,7 @@ describe("PackageManagerManager", () => {
|
|||
})
|
||||
|
||||
it("should correctly filter items by type", () => {
|
||||
const items: PackageManagerItem[] = [
|
||||
const items: MarketplaceItem[] = [
|
||||
{
|
||||
name: "Mode Item",
|
||||
description: "A mode",
|
||||
|
|
@ -87,7 +87,7 @@ describe("PackageManagerManager", () => {
|
|||
})
|
||||
|
||||
it("should preserve original items when filtering", () => {
|
||||
const items: PackageManagerItem[] = [
|
||||
const items: MarketplaceItem[] = [
|
||||
{
|
||||
name: "Test Item 1",
|
||||
description: "First test item",
|
||||
|
|
@ -110,13 +110,13 @@ describe("PackageManagerManager", () => {
|
|||
})
|
||||
})
|
||||
|
||||
let manager: PackageManagerManager
|
||||
let manager: MarketplaceManager
|
||||
let metadataScanner: MetadataScanner
|
||||
let realItems: PackageManagerItem[]
|
||||
let realItems: MarketplaceItem[]
|
||||
|
||||
beforeAll(async () => {
|
||||
// Load real data from the template
|
||||
const templatePath = path.resolve(__dirname, "../../../../package-manager-template")
|
||||
const templatePath = path.resolve(__dirname, "../../../../marketplace-template")
|
||||
metadataScanner = new MetadataScanner()
|
||||
realItems = await metadataScanner.scanDirectory(templatePath, "https://example.com")
|
||||
})
|
||||
|
|
@ -125,14 +125,14 @@ describe("PackageManagerManager", () => {
|
|||
const context = {
|
||||
globalStorageUri: { fsPath: path.resolve(__dirname, "../../../../mock/settings/path") },
|
||||
} as vscode.ExtensionContext
|
||||
manager = new PackageManagerManager(context)
|
||||
manager = new MarketplaceManager(context)
|
||||
})
|
||||
|
||||
describe("Type Filter Behavior", () => {
|
||||
let typeFilterTestItems: PackageManagerItem[]
|
||||
let typeFilterTestItems: MarketplaceItem[]
|
||||
|
||||
test("should include package with MCP server subcomponent when filtering by type 'mcp server'", () => {
|
||||
const items: PackageManagerItem[] = [
|
||||
const items: MarketplaceItem[] = [
|
||||
{
|
||||
name: "Data Platform Package",
|
||||
description: "A package containing MCP servers",
|
||||
|
|
@ -175,7 +175,7 @@ describe("PackageManagerManager", () => {
|
|||
})
|
||||
|
||||
test("should include package when filtering by subcomponent type", () => {
|
||||
const items: PackageManagerItem[] = [
|
||||
const items: MarketplaceItem[] = [
|
||||
{
|
||||
name: "Data Platform Package",
|
||||
description: "A package containing MCP servers",
|
||||
|
|
@ -265,7 +265,7 @@ describe("PackageManagerManager", () => {
|
|||
|
||||
test("should not include package when filtering by type with no matching subcomponents", () => {
|
||||
// Create a package with no matching subcomponents
|
||||
const noMatchPackage: PackageManagerItem = {
|
||||
const noMatchPackage: MarketplaceItem = {
|
||||
name: "No Match Package",
|
||||
description: "A package with no matching subcomponents",
|
||||
type: "package",
|
||||
|
|
@ -294,7 +294,7 @@ describe("PackageManagerManager", () => {
|
|||
|
||||
test("should handle package with no subcomponents", () => {
|
||||
// Create a package with no subcomponents
|
||||
const noSubcomponentsPackage: PackageManagerItem = {
|
||||
const noSubcomponentsPackage: MarketplaceItem = {
|
||||
name: "No Subcomponents Package",
|
||||
description: "A package with no subcomponents",
|
||||
type: "package",
|
||||
|
|
@ -310,7 +310,7 @@ describe("PackageManagerManager", () => {
|
|||
})
|
||||
|
||||
describe("Consistency with Search Term Behavior", () => {
|
||||
let consistencyTestItems: PackageManagerItem[]
|
||||
let consistencyTestItems: MarketplaceItem[]
|
||||
|
||||
beforeEach(() => {
|
||||
// Create test items
|
||||
|
|
@ -376,7 +376,7 @@ describe("PackageManagerManager", () => {
|
|||
})
|
||||
|
||||
describe("sortItems with subcomponents", () => {
|
||||
const testItems: PackageManagerItem[] = [
|
||||
const testItems: MarketplaceItem[] = [
|
||||
{
|
||||
name: "B Package",
|
||||
description: "Package B",
|
||||
|
|
@ -463,7 +463,7 @@ describe("PackageManagerManager", () => {
|
|||
url: "/test/c",
|
||||
repoUrl: "https://example.com",
|
||||
items: [],
|
||||
} as PackageManagerItem,
|
||||
} as MarketplaceItem,
|
||||
]
|
||||
const sorted = manager.sortItems(itemsWithEmpty, "name", "asc")
|
||||
expect(sorted[2].name).toBe("C Package")
|
||||
|
|
@ -473,7 +473,7 @@ describe("PackageManagerManager", () => {
|
|||
|
||||
describe("filterItems with real data", () => {
|
||||
it("should return all subcomponents with match info", () => {
|
||||
const testItems: PackageManagerItem[] = [
|
||||
const testItems: MarketplaceItem[] = [
|
||||
{
|
||||
name: "Data Platform Package",
|
||||
description: "A test platform",
|
||||
|
|
@ -546,11 +546,11 @@ describe("PackageManagerManager", () => {
|
|||
})
|
||||
})
|
||||
|
||||
// This test was skipped because it depends on the actual content of the package-manager-template
|
||||
// This test was skipped because it depends on the actual content of the marketplace-template
|
||||
// which may change over time
|
||||
it("should find data validator in package-manager-template", async () => {
|
||||
it("should find data validator in marketplace-template", async () => {
|
||||
// Load real data from the template
|
||||
const templatePath = path.resolve(__dirname, "../../../../package-manager-template")
|
||||
const templatePath = path.resolve(__dirname, "../../../../marketplace-template")
|
||||
const scanner = new MetadataScanner()
|
||||
const items = await scanner.scanDirectory(templatePath, "https://example.com")
|
||||
|
||||
|
|
@ -594,18 +594,68 @@ describe("PackageManagerManager", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("Concurrency Control", () => {
|
||||
let manager: PackageManagerManager
|
||||
describe("Source Attribution", () => {
|
||||
let manager: MarketplaceManager
|
||||
|
||||
beforeEach(() => {
|
||||
const mockContext = {
|
||||
globalStorageUri: { fsPath: "/test/path" },
|
||||
} as vscode.ExtensionContext
|
||||
manager = new PackageManagerManager(mockContext)
|
||||
manager = new MarketplaceManager(mockContext)
|
||||
})
|
||||
|
||||
it("should maintain source attribution for items", async () => {
|
||||
const sources: MarketplaceSource[] = [
|
||||
{ url: "https://github.com/test/repo1", name: "Source 1", enabled: true },
|
||||
{ url: "https://github.com/test/repo2", name: "Source 2", enabled: true },
|
||||
]
|
||||
|
||||
// Mock getRepositoryData to return different items for each source
|
||||
jest.spyOn(manager as any, "getRepositoryData")
|
||||
.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
metadata: { name: "test", description: "test", version: "1.0.0" },
|
||||
items: [
|
||||
{
|
||||
name: "Item 1",
|
||||
type: "mode",
|
||||
description: "Test item",
|
||||
url: "test1",
|
||||
repoUrl: "https://github.com/test/repo1",
|
||||
},
|
||||
],
|
||||
url: sources[0].url,
|
||||
}),
|
||||
)
|
||||
.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
metadata: { name: "test", description: "test", version: "1.0.0" },
|
||||
items: [],
|
||||
url: sources[1].url,
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await manager.getMarketplaceItems(sources)
|
||||
|
||||
// Verify items maintain their source attribution
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0].sourceName).toBe("Source 1")
|
||||
expect(result.items[0].sourceUrl).toBe("https://github.com/test/repo1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Concurrency Control", () => {
|
||||
let manager: MarketplaceManager
|
||||
|
||||
beforeEach(() => {
|
||||
const mockContext = {
|
||||
globalStorageUri: { fsPath: "/test/path" },
|
||||
} as vscode.ExtensionContext
|
||||
manager = new MarketplaceManager(mockContext)
|
||||
})
|
||||
|
||||
it("should not allow concurrent operations on the same source", async () => {
|
||||
const source: PackageManagerSource = {
|
||||
const source: MarketplaceSource = {
|
||||
url: "https://github.com/test/repo",
|
||||
enabled: true,
|
||||
}
|
||||
|
|
@ -616,12 +666,12 @@ describe("Concurrency Control", () => {
|
|||
metadata: { name: "test", description: "test", version: "1.0.0" },
|
||||
items: [],
|
||||
url: source.url,
|
||||
} as PackageManagerRepository),
|
||||
} as MarketplaceRepository),
|
||||
)
|
||||
|
||||
// Start two concurrent operations
|
||||
const operation1 = manager.getPackageManagerItems([source])
|
||||
const operation2 = manager.getPackageManagerItems([source])
|
||||
const operation1 = manager.getMarketplaceItems([source])
|
||||
const operation2 = manager.getMarketplaceItems([source])
|
||||
|
||||
// Wait for both to complete
|
||||
const [result1, result2] = await Promise.all([operation1, operation2])
|
||||
|
|
@ -635,11 +685,11 @@ describe("Concurrency Control", () => {
|
|||
|
||||
it("should not allow metadata scanning during git operations", async () => {
|
||||
try {
|
||||
const source1: PackageManagerSource = {
|
||||
const source1: MarketplaceSource = {
|
||||
url: "https://github.com/test/repo1",
|
||||
enabled: true,
|
||||
}
|
||||
const source2: PackageManagerSource = {
|
||||
const source2: MarketplaceSource = {
|
||||
url: "https://github.com/test/repo2",
|
||||
enabled: true,
|
||||
}
|
||||
|
|
@ -667,7 +717,7 @@ describe("Concurrency Control", () => {
|
|||
})
|
||||
|
||||
// Process both sources
|
||||
await manager.getPackageManagerItems([source1, source2])
|
||||
await manager.getMarketplaceItems([source1, source2])
|
||||
|
||||
// Verify metadata scanning didn't occur during git operations
|
||||
expect(metadataScanDuringGit).toBe(false)
|
||||
|
|
@ -677,7 +727,7 @@ describe("Concurrency Control", () => {
|
|||
})
|
||||
|
||||
it("should queue metadata scans and process them sequentially", async () => {
|
||||
const sources: PackageManagerSource[] = [
|
||||
const sources: MarketplaceSource[] = [
|
||||
{ url: "https://github.com/test/repo1", enabled: true },
|
||||
{ url: "https://github.com/test/repo2", enabled: true },
|
||||
{ url: "https://github.com/test/repo3", enabled: true },
|
||||
|
|
@ -721,7 +771,7 @@ describe("Concurrency Control", () => {
|
|||
;(manager as any).gitFetcher = mockGitFetcher
|
||||
|
||||
// Process all sources
|
||||
await manager.getPackageManagerItems(sources)
|
||||
await manager.getMarketplaceItems(sources)
|
||||
|
||||
// Verify scans were called and only one was active at a time
|
||||
expect(scanDirectorySpy).toHaveBeenCalledTimes(sources.length)
|
||||
|
|
@ -6,13 +6,12 @@ import {
|
|||
validateSource,
|
||||
validateSources,
|
||||
ValidationError,
|
||||
} from "../PackageManagerSourceValidation"
|
||||
import { PackageManagerSource } from "../types"
|
||||
} from "../../../shared/MarketplaceValidation"
|
||||
import { MarketplaceSource } from "../types"
|
||||
|
||||
describe("PackageManagerSourceValidation", () => {
|
||||
describe("MarketplaceSourceValidation", () => {
|
||||
describe("isValidGitRepositoryUrl", () => {
|
||||
const validUrls = [
|
||||
// Public Git hosting services
|
||||
"https://github.com/username/repo",
|
||||
"https://gitlab.com/username/repo",
|
||||
"https://bitbucket.org/username/repo",
|
||||
|
|
@ -68,7 +67,7 @@ describe("PackageManagerSourceValidation", () => {
|
|||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toEqual({
|
||||
field: "url",
|
||||
message: "Invalid URL format",
|
||||
message: "URL must be a valid Git repository URL (e.g., https://git.example.com/username/repo)",
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -122,13 +121,13 @@ describe("PackageManagerSourceValidation", () => {
|
|||
})
|
||||
|
||||
describe("validateSourceDuplicates", () => {
|
||||
const existingSources: PackageManagerSource[] = [
|
||||
const existingSources: MarketplaceSource[] = [
|
||||
{ url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true },
|
||||
{ url: "https://git.company.com/user2/repo2", name: "Source 2", enabled: true },
|
||||
]
|
||||
|
||||
test("should accept unique sources", () => {
|
||||
const newSource: PackageManagerSource = {
|
||||
const newSource: MarketplaceSource = {
|
||||
url: "https://git.company.com/user3/repo3",
|
||||
name: "Source 3",
|
||||
enabled: true,
|
||||
|
|
@ -138,7 +137,7 @@ describe("PackageManagerSourceValidation", () => {
|
|||
})
|
||||
|
||||
test("should reject duplicate URLs (case insensitive)", () => {
|
||||
const newSource: PackageManagerSource = {
|
||||
const newSource: MarketplaceSource = {
|
||||
url: "HTTPS://GIT.COMPANY.COM/USER1/REPO1",
|
||||
name: "Different Name",
|
||||
enabled: true,
|
||||
|
|
@ -150,7 +149,7 @@ describe("PackageManagerSourceValidation", () => {
|
|||
})
|
||||
|
||||
test("should reject duplicate names (case insensitive)", () => {
|
||||
const newSource: PackageManagerSource = {
|
||||
const newSource: MarketplaceSource = {
|
||||
url: "https://git.company.com/user3/repo3",
|
||||
name: "SOURCE 1",
|
||||
enabled: true,
|
||||
|
|
@ -162,7 +161,7 @@ describe("PackageManagerSourceValidation", () => {
|
|||
})
|
||||
|
||||
test("should detect duplicates within source list", () => {
|
||||
const sourcesWithDuplicates: PackageManagerSource[] = [
|
||||
const sourcesWithDuplicates: MarketplaceSource[] = [
|
||||
{ url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true },
|
||||
{ url: "https://git.company.com/user1/repo1", name: "Source 2", enabled: true }, // Duplicate URL
|
||||
{ url: "https://git.company.com/user3/repo3", name: "Source 1", enabled: true }, // Duplicate name
|
||||
|
|
@ -185,12 +184,12 @@ describe("PackageManagerSourceValidation", () => {
|
|||
})
|
||||
|
||||
describe("validateSource", () => {
|
||||
const existingSources: PackageManagerSource[] = [
|
||||
const existingSources: MarketplaceSource[] = [
|
||||
{ url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true },
|
||||
]
|
||||
|
||||
test("should accept valid source", () => {
|
||||
const source: PackageManagerSource = {
|
||||
const source: MarketplaceSource = {
|
||||
url: "https://git.company.com/user2/repo2",
|
||||
name: "Source 2",
|
||||
enabled: true,
|
||||
|
|
@ -200,7 +199,7 @@ describe("PackageManagerSourceValidation", () => {
|
|||
})
|
||||
|
||||
test("should accumulate multiple validation errors", () => {
|
||||
const source: PackageManagerSource = {
|
||||
const source: MarketplaceSource = {
|
||||
url: "https://git.company.com/user1/repo1", // Duplicate URL
|
||||
name: "This name is way too long to be valid\t", // Too long and has tab
|
||||
enabled: true,
|
||||
|
|
@ -212,7 +211,7 @@ describe("PackageManagerSourceValidation", () => {
|
|||
|
||||
describe("validateSources", () => {
|
||||
test("should accept valid source list", () => {
|
||||
const sources: PackageManagerSource[] = [
|
||||
const sources: MarketplaceSource[] = [
|
||||
{ url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true },
|
||||
{ url: "https://git.company.com/user2/repo2", name: "Source 2", enabled: true },
|
||||
]
|
||||
|
|
@ -221,7 +220,7 @@ describe("PackageManagerSourceValidation", () => {
|
|||
})
|
||||
|
||||
test("should detect multiple issues across sources", () => {
|
||||
const sources: PackageManagerSource[] = [
|
||||
const sources: MarketplaceSource[] = [
|
||||
{ url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true },
|
||||
{ url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true }, // Duplicate URL and name
|
||||
{ url: "invalid-url", name: "This name is way too long\t", enabled: true }, // Invalid URL and name
|
||||
|
|
@ -231,7 +230,7 @@ describe("PackageManagerSourceValidation", () => {
|
|||
})
|
||||
|
||||
test("should include source index in error messages", () => {
|
||||
const sources: PackageManagerSource[] = [{ url: "invalid-url", name: "Source 1", enabled: true }]
|
||||
const sources: MarketplaceSource[] = [{ url: "invalid-url", name: "Source 1", enabled: true }]
|
||||
const errors = validateSources(sources)
|
||||
expect(errors[0].message).toContain("Source #1")
|
||||
})
|
||||
|
|
@ -1,21 +1,21 @@
|
|||
/**
|
||||
* Constants for the package manager
|
||||
* Constants for the marketplace
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default package manager repository URL
|
||||
* Default marketplace repository URL
|
||||
*/
|
||||
export const DEFAULT_PACKAGE_MANAGER_REPO_URL = "https://github.com/RooVetGit/Roo-Code-Marketplace"
|
||||
|
||||
/**
|
||||
* Default package manager repository name
|
||||
* Default marketplace repository name
|
||||
*/
|
||||
export const DEFAULT_PACKAGE_MANAGER_REPO_NAME = "Roo Code"
|
||||
|
||||
/**
|
||||
* Default package manager source
|
||||
* Default marketplace source
|
||||
*/
|
||||
export const DEFAULT_PACKAGE_MANAGER_SOURCE = {
|
||||
export const DEFAULT_MARKETPLACE_SOURCE = {
|
||||
url: DEFAULT_PACKAGE_MANAGER_REPO_URL,
|
||||
name: DEFAULT_PACKAGE_MANAGER_REPO_NAME,
|
||||
enabled: true,
|
||||
4
src/services/marketplace/index.ts
Normal file
4
src/services/marketplace/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./GitFetcher"
|
||||
export * from "./MarketplaceManager"
|
||||
export * from "./types"
|
||||
export * from "../../shared/MarketplaceValidation"
|
||||
|
|
@ -65,9 +65,9 @@ export interface SubcomponentMetadata extends ComponentMetadata {
|
|||
}
|
||||
|
||||
/**
|
||||
* Represents an individual package manager item
|
||||
* Represents an individual marketplace item
|
||||
*/
|
||||
export interface PackageManagerItem {
|
||||
export interface MarketplaceItem {
|
||||
name: string
|
||||
description: string
|
||||
type: ComponentType
|
||||
|
|
@ -93,9 +93,9 @@ export interface PackageManagerItem {
|
|||
}
|
||||
|
||||
/**
|
||||
* Represents a Git repository source for package manager items
|
||||
* Represents a Git repository source for marketplace items
|
||||
*/
|
||||
export interface PackageManagerSource {
|
||||
export interface MarketplaceSource {
|
||||
url: string
|
||||
name?: string
|
||||
enabled: boolean
|
||||
|
|
@ -104,9 +104,9 @@ export interface PackageManagerSource {
|
|||
/**
|
||||
* Represents a repository with its metadata and items
|
||||
*/
|
||||
export interface PackageManagerRepository {
|
||||
export interface MarketplaceRepository {
|
||||
metadata: RepositoryMetadata
|
||||
items: PackageManagerItem[]
|
||||
items: MarketplaceItem[]
|
||||
url: string
|
||||
error?: string
|
||||
defaultBranch?: string
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from "./GitFetcher"
|
||||
export * from "./PackageManagerManager"
|
||||
export * from "./types"
|
||||
export * from "./PackageManagerSourceValidation"
|
||||
|
|
@ -15,7 +15,7 @@ import {
|
|||
import { McpServer } from "./mcp"
|
||||
import { GitCommit } from "../utils/git"
|
||||
import { Mode } from "./modes"
|
||||
import { PackageManagerItem, PackageManagerSource } from "../services/package-manager/types"
|
||||
import { MarketplaceItem, MarketplaceSource } from "../services/marketplace/types"
|
||||
|
||||
export type { ApiConfigMeta, ToolProgressStatus }
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ export interface ExtensionMessage {
|
|||
| "settingsButtonClicked"
|
||||
| "historyButtonClicked"
|
||||
| "promptsButtonClicked"
|
||||
| "packageManagerButtonClicked"
|
||||
| "marketplaceButtonClicked"
|
||||
| "didBecomeVisible"
|
||||
| "focusInput"
|
||||
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
|
||||
|
|
@ -115,7 +115,7 @@ export interface ExtensionMessage {
|
|||
label?: string
|
||||
}>
|
||||
error?: string
|
||||
items?: PackageManagerItem[]
|
||||
items?: MarketplaceItem[]
|
||||
url?: string // For repositoryRefreshComplete
|
||||
}
|
||||
|
||||
|
|
@ -211,8 +211,8 @@ export type ExtensionState = Pick<
|
|||
|
||||
renderContext: "sidebar" | "editor"
|
||||
settingsImportedAt?: number
|
||||
packageManagerSources?: PackageManagerSource[]
|
||||
packageManagerItems?: PackageManagerItem[]
|
||||
marketplaceSources?: MarketplaceSource[]
|
||||
marketplaceItems?: MarketplaceItem[]
|
||||
}
|
||||
|
||||
export type { ClineMessage, ClineAsk, ClineSay }
|
||||
|
|
|
|||
|
|
@ -1,21 +1,16 @@
|
|||
/**
|
||||
* Validation utilities for package manager sources
|
||||
* Shared validation utilities for marketplace sources
|
||||
*/
|
||||
import { PackageManagerSource } from "./types"
|
||||
import { MarketplaceSource } from "../services/marketplace/types"
|
||||
|
||||
/**
|
||||
* Error type for package manager source validation
|
||||
* Error type for marketplace source validation
|
||||
*/
|
||||
export interface ValidationError {
|
||||
field: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a package manager source URL
|
||||
* @param url The URL to validate
|
||||
* @returns An array of validation errors, empty if valid
|
||||
*/
|
||||
/**
|
||||
* Checks if a URL is a valid Git repository URL
|
||||
* @param url The URL to validate
|
||||
|
|
@ -60,17 +55,6 @@ export function validateSourceUrl(url: string): ValidationError[] {
|
|||
return errors // Return early if URL is empty
|
||||
}
|
||||
|
||||
// Check if URL is valid format
|
||||
try {
|
||||
new URL(url)
|
||||
} catch (e) {
|
||||
errors.push({
|
||||
field: "url",
|
||||
message: "Invalid URL format",
|
||||
})
|
||||
return errors // Return early if URL is not valid
|
||||
}
|
||||
|
||||
// Check for non-visible characters (except spaces)
|
||||
const nonVisibleCharRegex = /[^\S ]/
|
||||
if (nonVisibleCharRegex.test(url)) {
|
||||
|
|
@ -91,11 +75,6 @@ export function validateSourceUrl(url: string): ValidationError[] {
|
|||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a package manager source name
|
||||
* @param name The name to validate
|
||||
* @returns An array of validation errors, empty if valid
|
||||
*/
|
||||
export function validateSourceName(name?: string): ValidationError[] {
|
||||
const errors: ValidationError[] = []
|
||||
|
||||
|
|
@ -124,12 +103,6 @@ export function validateSourceName(name?: string): ValidationError[] {
|
|||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a list of package manager sources for duplicates
|
||||
* @param sources The list of sources to validate
|
||||
* @param newSource The new source to check against the list (optional)
|
||||
* @returns An array of validation errors, empty if valid
|
||||
*/
|
||||
// Cache for normalized strings to avoid repeated operations
|
||||
const normalizeCache = new Map<string, string>()
|
||||
|
||||
|
|
@ -143,14 +116,13 @@ function normalizeString(str: string): string {
|
|||
}
|
||||
|
||||
export function validateSourceDuplicates(
|
||||
sources: PackageManagerSource[],
|
||||
newSource?: PackageManagerSource,
|
||||
sources: MarketplaceSource[],
|
||||
newSource?: MarketplaceSource,
|
||||
): ValidationError[] {
|
||||
const errors: ValidationError[] = []
|
||||
const urlMap = new Map<string, number>()
|
||||
const nameMap = new Map<string, number>()
|
||||
|
||||
// Process existing sources
|
||||
// Process existing sources
|
||||
const seen = new Set<string>()
|
||||
|
||||
|
|
@ -203,43 +175,11 @@ export function validateSourceDuplicates(
|
|||
}
|
||||
}
|
||||
|
||||
// Check new source against existing sources if provided
|
||||
if (newSource) {
|
||||
if (newSource.url) {
|
||||
const normalizedNewUrl = normalizeString(newSource.url)
|
||||
const existingUrlIndex = urlMap.get(normalizedNewUrl)
|
||||
if (existingUrlIndex !== undefined) {
|
||||
errors.push({
|
||||
field: "url",
|
||||
message: `URL is a duplicate of Source #${existingUrlIndex + 1}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (newSource.name) {
|
||||
const normalizedNewName = normalizeString(newSource.name)
|
||||
const existingNameIndex = nameMap.get(normalizedNewName)
|
||||
if (existingNameIndex !== undefined) {
|
||||
errors.push({
|
||||
field: "name",
|
||||
message: `Name is a duplicate of Source #${existingNameIndex + 1}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check new source against existing sources if provided
|
||||
if (newSource) {
|
||||
const normalizedNewUrl = normalizeString(newSource.url)
|
||||
const normalizedNewName = newSource.name ? normalizeString(newSource.name) : null
|
||||
|
||||
// Add new source to maps temporarily
|
||||
const newIndex = sources.length
|
||||
urlMap.set(normalizedNewUrl, newIndex)
|
||||
if (normalizedNewName) {
|
||||
nameMap.set(normalizedNewName, newIndex)
|
||||
}
|
||||
|
||||
// Check for duplicates with existing sources
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const source = sources[i]
|
||||
|
|
@ -262,26 +202,14 @@ export function validateSourceDuplicates(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove temporary entries
|
||||
urlMap.delete(normalizedNewUrl)
|
||||
if (normalizedNewName) {
|
||||
nameMap.delete(normalizedNewName)
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a package manager source
|
||||
* @param source The source to validate
|
||||
* @param existingSources Existing sources to check for duplicates
|
||||
* @returns An array of validation errors, empty if valid
|
||||
*/
|
||||
export function validateSource(
|
||||
source: PackageManagerSource,
|
||||
existingSources: PackageManagerSource[] = [],
|
||||
source: MarketplaceSource,
|
||||
existingSources: MarketplaceSource[] = [],
|
||||
): ValidationError[] {
|
||||
// Combine all validation errors
|
||||
return [
|
||||
|
|
@ -291,12 +219,7 @@ export function validateSource(
|
|||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a list of package manager sources
|
||||
* @param sources The sources to validate
|
||||
* @returns An array of validation errors, empty if valid
|
||||
*/
|
||||
export function validateSources(sources: PackageManagerSource[]): ValidationError[] {
|
||||
export function validateSources(sources: MarketplaceSource[]): ValidationError[] {
|
||||
// Pre-allocate maximum possible size for errors array
|
||||
const errors: ValidationError[] = new Array(sources.length * 2 + (sources.length * (sources.length - 1)) / 2)
|
||||
let errorIndex = 0
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { z } from "zod"
|
||||
import { ApiConfiguration, ApiProvider } from "./api"
|
||||
import { Mode, PromptComponent, ModeConfig } from "./modes"
|
||||
import { PackageManagerSource } from "../services/package-manager/types"
|
||||
import { MarketplaceSource } from "../services/marketplace/types"
|
||||
|
||||
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
|
||||
|
||||
|
|
@ -126,11 +126,11 @@ export interface WebviewMessage {
|
|||
| "maxReadFileLine"
|
||||
| "searchFiles"
|
||||
| "toggleApiConfigPin"
|
||||
| "packageManagerSources"
|
||||
| "fetchPackageManagerItems"
|
||||
| "filterPackageManagerItems"
|
||||
| "packageManagerButtonClicked"
|
||||
| "refreshPackageManagerSource"
|
||||
| "marketplaceSources"
|
||||
| "fetchMarketplaceItems"
|
||||
| "filterMarketplaceItems"
|
||||
| "marketplaceButtonClicked"
|
||||
| "refreshMarketplaceSource"
|
||||
| "repositoryRefreshComplete"
|
||||
| "openExternal"
|
||||
text?: string
|
||||
|
|
@ -158,7 +158,7 @@ export interface WebviewMessage {
|
|||
source?: "global" | "project"
|
||||
requestId?: string
|
||||
ids?: string[]
|
||||
sources?: PackageManagerSource[]
|
||||
sources?: MarketplaceSource[]
|
||||
filters?: { type?: string; search?: string; tags?: string[] }
|
||||
url?: string // For openExternal
|
||||
}
|
||||
|
|
|
|||
34
src/shared/__tests__/MarketplaceValidation.test.ts
Normal file
34
src/shared/__tests__/MarketplaceValidation.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
|||
|
||||
import { ExtensionMessage } from "../../src/shared/ExtensionMessage"
|
||||
import TranslationProvider from "./i18n/TranslationContext"
|
||||
import { PackageManagerViewStateManager } from "./components/package-manager/PackageManagerViewStateManager"
|
||||
import { MarketplaceViewStateManager } from "./components/marketplace/MarketplaceViewStateManager"
|
||||
|
||||
import { vscode } from "./utils/vscode"
|
||||
import { telemetryClient } from "./utils/TelemetryClient"
|
||||
|
|
@ -14,11 +14,11 @@ import HistoryView from "./components/history/HistoryView"
|
|||
import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView"
|
||||
import WelcomeView from "./components/welcome/WelcomeView"
|
||||
import McpView from "./components/mcp/McpView"
|
||||
import PackageManagerView from "./components/package-manager/PackageManagerView"
|
||||
import PackageManagerView from "./components/marketplace/MarketplaceView"
|
||||
import PromptsView from "./components/prompts/PromptsView"
|
||||
import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog"
|
||||
|
||||
type Tab = "settings" | "history" | "mcp" | "prompts" | "chat" | "packageManager"
|
||||
type Tab = "settings" | "history" | "mcp" | "prompts" | "chat" | "marketplace"
|
||||
|
||||
const tabsByMessageAction: Partial<Record<NonNullable<ExtensionMessage["action"]>, Tab>> = {
|
||||
chatButtonClicked: "chat",
|
||||
|
|
@ -26,7 +26,7 @@ const tabsByMessageAction: Partial<Record<NonNullable<ExtensionMessage["action"]
|
|||
promptsButtonClicked: "prompts",
|
||||
mcpButtonClicked: "mcp",
|
||||
historyButtonClicked: "history",
|
||||
packageManagerButtonClicked: "packageManager",
|
||||
marketplaceButtonClicked: "marketplace",
|
||||
}
|
||||
|
||||
const App = () => {
|
||||
|
|
@ -34,7 +34,7 @@ const App = () => {
|
|||
useExtensionState()
|
||||
|
||||
// Create a persistent state manager
|
||||
const packageManagerStateManager = useMemo(() => new PackageManagerViewStateManager(), [])
|
||||
const marketplaceStateManager = useMemo(() => new MarketplaceViewStateManager(), [])
|
||||
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false)
|
||||
const [tab, setTab] = useState<Tab>("chat")
|
||||
|
|
@ -124,8 +124,8 @@ const App = () => {
|
|||
{tab === "settings" && (
|
||||
<SettingsView ref={settingsRef} onDone={() => setTab("chat")} targetSection={currentSection} />
|
||||
)}
|
||||
{tab === "packageManager" && (
|
||||
<PackageManagerView stateManager={packageManagerStateManager} onDone={() => switchTab("chat")} />
|
||||
{tab === "marketplace" && (
|
||||
<PackageManagerView stateManager={marketplaceStateManager} onDone={() => switchTab("chat")} />
|
||||
)}
|
||||
<ChatView
|
||||
ref={chatViewRef}
|
||||
|
|
|
|||
|
|
@ -2,27 +2,28 @@ import { useState, useEffect, useMemo, useCallback } from "react"
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { Tab, TabContent, TabHeader } from "../common/Tab"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PackageManagerSource } from "../../../../src/services/package-manager/types"
|
||||
import { PackageManagerViewStateManager } from "./PackageManagerViewStateManager"
|
||||
import { MarketplaceSource } from "../../../../src/services/marketplace/types"
|
||||
import { validateSource } from "../../../../src/shared/MarketplaceValidation"
|
||||
import { MarketplaceViewStateManager } from "./MarketplaceViewStateManager"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "cmdk"
|
||||
import { PackageManagerItemCard } from "./components/PackageManagerItemCard"
|
||||
import { MarketplaceItemCard } from "./components/MarketplaceItemCard"
|
||||
import { useStateManager } from "./useStateManager"
|
||||
import { useAppTranslation } from "@/i18n/TranslationContext"
|
||||
|
||||
interface PackageManagerViewProps {
|
||||
interface MarketplaceViewProps {
|
||||
onDone?: () => void
|
||||
stateManager: PackageManagerViewStateManager
|
||||
stateManager: MarketplaceViewStateManager
|
||||
}
|
||||
const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateManager }) => {
|
||||
const MarketplaceView: React.FC<MarketplaceViewProps> = ({ onDone, stateManager }) => {
|
||||
const { t } = useAppTranslation()
|
||||
const [state, manager] = useStateManager(stateManager)
|
||||
|
||||
const [tagSearch, setTagSearch] = useState("")
|
||||
const [isTagInputActive, setIsTagInputActive] = useState(false)
|
||||
|
||||
// Fetch items only on first mount or when no items exist
|
||||
// Fetch items on first mount or when returning to empty state
|
||||
useEffect(() => {
|
||||
if (state.allItems.length === 0 && !state.isFetching) {
|
||||
if (!state.allItems.length && !state.isFetching) {
|
||||
manager.transition({ type: "FETCH_ITEMS" })
|
||||
}
|
||||
}, [manager, state.allItems.length, state.isFetching])
|
||||
|
|
@ -44,7 +45,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
<Tab>
|
||||
<TabHeader className="flex justify-between items-center sticky top-0 z-10 bg-vscode-editor-background border-b border-vscode-panel-border">
|
||||
<div className="flex items-center">
|
||||
<h3 className="text-vscode-foreground m-0">{t("package-manager:title")}</h3>
|
||||
<h3 className="text-vscode-foreground m-0">{t("marketplace:title")}</h3>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
|
@ -54,7 +55,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
"bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground",
|
||||
)}
|
||||
onClick={() => manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "browse" } })}>
|
||||
{t("package-manager:tabs.browse")}
|
||||
{t("marketplace:tabs.browse")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={state.activeTab === "sources" ? "default" : "secondary"}
|
||||
|
|
@ -63,7 +64,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
"bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground",
|
||||
)}
|
||||
onClick={() => manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "sources" } })}>
|
||||
{t("package-manager:tabs.sources")}
|
||||
{t("marketplace:tabs.sources")}
|
||||
</Button>
|
||||
</div>
|
||||
</TabHeader>
|
||||
|
|
@ -74,7 +75,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("package-manager:filters.search.placeholder")}
|
||||
placeholder={t("marketplace:filters.search.placeholder")}
|
||||
value={state.filters.search}
|
||||
onChange={(e) =>
|
||||
manager.transition({
|
||||
|
|
@ -88,7 +89,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
<div className="flex flex-wrap justify-between gap-2">
|
||||
<div className="whitespace-nowrap">
|
||||
<label htmlFor="type-filter" className="mr-2">
|
||||
{t("package-manager:filters.type.label")}
|
||||
{t("marketplace:filters.type.label")}
|
||||
</label>
|
||||
<select
|
||||
id="type-filter"
|
||||
|
|
@ -100,18 +101,18 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
})
|
||||
}
|
||||
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded">
|
||||
<option value="">{t("package-manager:filters.type.all")}</option>
|
||||
<option value="mode">{t("package-manager:filters.type.mode")}</option>
|
||||
<option value="">{t("marketplace:filters.type.all")}</option>
|
||||
<option value="mode">{t("marketplace:filters.type.mode")}</option>
|
||||
<option value="mcp server">
|
||||
{t("package-manager:filters.type.mcp server")}
|
||||
{t("marketplace:filters.type.mcp server")}
|
||||
</option>
|
||||
<option value="prompt">{t("package-manager:filters.type.prompt")}</option>
|
||||
<option value="package">{t("package-manager:filters.type.package")}</option>
|
||||
<option value="prompt">{t("marketplace:filters.type.prompt")}</option>
|
||||
<option value="package">{t("marketplace:filters.type.package")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="whitespace-nowrap">
|
||||
<label className="mr-2">{t("package-manager:filters.sort.label")}</label>
|
||||
<label className="mr-2">{t("marketplace:filters.sort.label")}</label>
|
||||
<select
|
||||
value={state.sortConfig.by}
|
||||
onChange={(e) =>
|
||||
|
|
@ -121,9 +122,9 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
})
|
||||
}
|
||||
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded mr-2">
|
||||
<option value="name">{t("package-manager:filters.sort.name")}</option>
|
||||
<option value="name">{t("marketplace:filters.sort.name")}</option>
|
||||
<option value="lastUpdated">
|
||||
{t("package-manager:filters.sort.lastUpdated")}
|
||||
{t("marketplace:filters.sort.lastUpdated")}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
|
|
@ -147,11 +148,9 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center">
|
||||
<label className="mr-2">
|
||||
{t("package-manager:filters.tags.label")}
|
||||
</label>
|
||||
<label className="mr-2">{t("marketplace:filters.tags.label")}</label>
|
||||
<span className="text-xs text-vscode-descriptionForeground">
|
||||
{t("package-manager:filters.tags.available", {
|
||||
{t("marketplace:filters.tags.available", {
|
||||
count: allTags.length,
|
||||
})}
|
||||
</span>
|
||||
|
|
@ -165,7 +164,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
})
|
||||
}
|
||||
className="p-1 bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground rounded text-xs">
|
||||
{t("package-manager:filters.tags.clear", {
|
||||
{t("marketplace:filters.tags.clear", {
|
||||
count: state.filters.tags.length,
|
||||
})}
|
||||
</button>
|
||||
|
|
@ -173,7 +172,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
</div>
|
||||
<Command className="rounded-lg border border-vscode-dropdown-border">
|
||||
<CommandInput
|
||||
placeholder={t("package-manager:filters.tags.placeholder")}
|
||||
placeholder={t("marketplace:filters.tags.placeholder")}
|
||||
value={tagSearch}
|
||||
onValueChange={setTagSearch}
|
||||
onFocus={() => setIsTagInputActive(true)}
|
||||
|
|
@ -187,7 +186,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
{(isTagInputActive || tagSearch) && (
|
||||
<CommandList className="max-h-[200px] overflow-y-auto bg-vscode-dropdown-background">
|
||||
<CommandEmpty className="p-2 text-sm text-vscode-descriptionForeground">
|
||||
{t("package-manager:filters.tags.noResults")}
|
||||
{t("marketplace:filters.tags.noResults")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredTags.map((tag: string) => (
|
||||
|
|
@ -237,10 +236,10 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
</Command>
|
||||
<div className="text-xs text-vscode-descriptionForeground mt-1">
|
||||
{state.filters.tags.length > 0
|
||||
? t("package-manager:filters.tags.selected", {
|
||||
? t("marketplace:filters.tags.selected", {
|
||||
count: state.filters.tags.length,
|
||||
})
|
||||
: t("package-manager:filters.tags.clickToFilter")}
|
||||
: t("marketplace:filters.tags.clickToFilter")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -251,16 +250,12 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
// Use items directly from backend
|
||||
const items = state.displayItems || []
|
||||
const isEmpty = items.length === 0
|
||||
const isLoading = state.isFetching
|
||||
// Show loading state if fetching and not filtering
|
||||
// Only show loading state if we're fetching and not filtering
|
||||
if (
|
||||
isLoading &&
|
||||
!(state.filters.type || state.filters.search || state.filters.tags.length > 0)
|
||||
) {
|
||||
|
||||
// Only show loading state if we're fetching and have no items to display
|
||||
if (state.isFetching && isEmpty) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground">
|
||||
<p>{t("package-manager:items.refresh.refreshing")}</p>
|
||||
<p>{t("marketplace:items.refresh.refreshing")}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -269,7 +264,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
if (isEmpty) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground">
|
||||
<p>{t("package-manager:items.empty.noItems")}</p>
|
||||
<p>{t("marketplace:items.empty.noItems")}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -278,11 +273,11 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
return (
|
||||
<div>
|
||||
<p className="text-vscode-descriptionForeground mb-4">
|
||||
{t("package-manager:items.count", { count: items.length })}
|
||||
{t("marketplace:items.count", { count: items.length })}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-4 pb-4">
|
||||
{items.map((item) => (
|
||||
<PackageManagerItemCard
|
||||
<MarketplaceItemCard
|
||||
key={`${item.repoUrl}-${item.name}`}
|
||||
item={item}
|
||||
filters={state.filters}
|
||||
|
|
@ -301,7 +296,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
})()}
|
||||
</>
|
||||
) : (
|
||||
<PackageManagerSourcesConfig
|
||||
<MarketplaceSourcesConfig
|
||||
sources={state.sources}
|
||||
refreshingUrls={state.refreshingUrls}
|
||||
onRefreshSource={(url) => manager.transition({ type: "REFRESH_SOURCE", payload: { url } })}
|
||||
|
|
@ -315,14 +310,14 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone, stateMa
|
|||
)
|
||||
}
|
||||
|
||||
interface PackageManagerSourcesConfigProps {
|
||||
sources: PackageManagerSource[]
|
||||
export interface MarketplaceSourcesConfigProps {
|
||||
sources: MarketplaceSource[]
|
||||
refreshingUrls: string[]
|
||||
onRefreshSource: (url: string) => void
|
||||
onSourcesChange: (sources: PackageManagerSource[]) => void
|
||||
onSourcesChange: (sources: MarketplaceSource[]) => void
|
||||
}
|
||||
|
||||
const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> = ({
|
||||
export const MarketplaceSourcesConfig: React.FC<MarketplaceSourcesConfigProps> = ({
|
||||
sources,
|
||||
refreshingUrls,
|
||||
onRefreshSource,
|
||||
|
|
@ -334,71 +329,46 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
|
|||
const [error, setError] = useState("")
|
||||
|
||||
const handleAddSource = () => {
|
||||
if (!newSourceUrl) {
|
||||
setError(t("package-manager:sources.errors.emptyUrl"))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(newSourceUrl)
|
||||
} catch (e) {
|
||||
setError(t("package-manager:sources.errors.invalidUrl"))
|
||||
return
|
||||
}
|
||||
|
||||
const nonVisibleCharRegex = /[^\S ]/
|
||||
if (nonVisibleCharRegex.test(newSourceUrl)) {
|
||||
setError(t("package-manager:sources.errors.nonVisibleChars"))
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidGitRepositoryUrl(newSourceUrl)) {
|
||||
setError(t("package-manager:sources.errors.invalidGitUrl"))
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedNewUrl = newSourceUrl.toLowerCase().replace(/\s+/g, "")
|
||||
if (sources.some((source) => source.url.toLowerCase().replace(/\s+/g, "") === normalizedNewUrl)) {
|
||||
setError(t("package-manager:sources.errors.duplicateUrl"))
|
||||
return
|
||||
}
|
||||
|
||||
if (newSourceName) {
|
||||
if (newSourceName.length > 20) {
|
||||
setError(t("package-manager:sources.errors.nameTooLong"))
|
||||
return
|
||||
}
|
||||
|
||||
if (nonVisibleCharRegex.test(newSourceName)) {
|
||||
setError(t("package-manager:sources.errors.nonVisibleCharsName"))
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedNewName = newSourceName.toLowerCase().replace(/\s+/g, "")
|
||||
if (
|
||||
sources.some(
|
||||
(source) => source.name && source.name.toLowerCase().replace(/\s+/g, "") === normalizedNewName,
|
||||
)
|
||||
) {
|
||||
setError(t("package-manager:sources.errors.duplicateName"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check max sources limit first
|
||||
const MAX_SOURCES = 10
|
||||
if (sources.length >= MAX_SOURCES) {
|
||||
setError(t("package-manager:sources.errors.maxSources", { max: MAX_SOURCES }))
|
||||
setError(t("marketplace:sources.errors.maxSources", { max: MAX_SOURCES }))
|
||||
return
|
||||
}
|
||||
|
||||
const newSource: PackageManagerSource = {
|
||||
// Create source object for validation
|
||||
const sourceToValidate: MarketplaceSource = {
|
||||
url: newSourceUrl,
|
||||
name: newSourceName || undefined,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
onSourcesChange([...sources, newSource])
|
||||
// Validate using shared validation
|
||||
const validationErrors = validateSource(sourceToValidate, sources)
|
||||
if (validationErrors.length > 0) {
|
||||
// Map validation errors to UI error messages
|
||||
const errorMessages: Record<string, string> = {
|
||||
"url:empty": "marketplace:sources.errors.emptyUrl",
|
||||
"url:nonvisible": "marketplace:sources.errors.nonVisibleChars",
|
||||
"url:invalid": "marketplace:sources.errors.invalidGitUrl",
|
||||
"url:duplicate": "marketplace:sources.errors.duplicateUrl",
|
||||
"name:length": "marketplace:sources.errors.nameTooLong",
|
||||
"name:nonvisible": "marketplace:sources.errors.nonVisibleCharsName",
|
||||
"name:duplicate": "marketplace:sources.errors.duplicateName",
|
||||
}
|
||||
|
||||
const error = validationErrors[0]
|
||||
const errorKey = `${error.field}:${error.message.toLowerCase().split(" ")[0]}`
|
||||
setError(t(errorMessages[errorKey] || "marketplace:sources.errors.invalidGitUrl"))
|
||||
return
|
||||
}
|
||||
|
||||
// Add the validated source
|
||||
onSourcesChange([...sources, sourceToValidate])
|
||||
|
||||
onSourcesChange([...sources, sourceToValidate])
|
||||
|
||||
// Reset form state
|
||||
setNewSourceUrl("")
|
||||
setNewSourceName("")
|
||||
setError("")
|
||||
|
|
@ -422,15 +392,15 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-vscode-foreground mb-2">{t("package-manager:sources.title")}</h4>
|
||||
<p className="text-vscode-descriptionForeground mb-4">{t("package-manager:sources.description")}</p>
|
||||
<h4 className="text-vscode-foreground mb-2">{t("marketplace:sources.title")}</h4>
|
||||
<p className="text-vscode-descriptionForeground mb-4">{t("marketplace:sources.description")}</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<h5 className="text-vscode-foreground mb-2">{t("package-manager:sources.add.title")}</h5>
|
||||
<h5 className="text-vscode-foreground mb-2">{t("marketplace:sources.add.title")}</h5>
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("package-manager:sources.add.urlPlaceholder")}
|
||||
placeholder={t("marketplace:sources.add.urlPlaceholder")}
|
||||
value={newSourceUrl}
|
||||
onChange={(e) => {
|
||||
setNewSourceUrl(e.target.value)
|
||||
|
|
@ -439,11 +409,11 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
|
|||
className="p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded"
|
||||
/>
|
||||
<p className="text-xs text-vscode-descriptionForeground mt-1 mb-2">
|
||||
{t("package-manager:sources.add.urlFormats")}
|
||||
{t("marketplace:sources.add.urlFormats")}
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("package-manager:sources.add.namePlaceholder")}
|
||||
placeholder={t("marketplace:sources.add.namePlaceholder")}
|
||||
value={newSourceName}
|
||||
onChange={(e) => {
|
||||
setNewSourceName(e.target.value.slice(0, 20))
|
||||
|
|
@ -456,17 +426,17 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
|
|||
{error && <p className="text-red-500 mb-2">{error}</p>}
|
||||
<Button onClick={handleAddSource}>
|
||||
<span className="codicon codicon-add mr-2"></span>
|
||||
{t("package-manager:sources.add.button")}
|
||||
{t("marketplace:sources.add.button")}
|
||||
</Button>
|
||||
</div>
|
||||
<h5 className="text-vscode-foreground mb-2">
|
||||
{t("package-manager:sources.current.title")}{" "}
|
||||
{t("marketplace:sources.current.title")}{" "}
|
||||
<span className="text-vscode-descriptionForeground text-sm">
|
||||
{t("package-manager:sources.current.count", { current: sources.length, max: 10 })}
|
||||
{t("marketplace:sources.current.count", { current: sources.length, max: 10 })}
|
||||
</span>
|
||||
</h5>
|
||||
{sources.length === 0 ? (
|
||||
<p className="text-vscode-descriptionForeground">{t("package-manager:sources.current.empty")}</p>
|
||||
<p className="text-vscode-descriptionForeground">{t("marketplace:sources.current.empty")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{sources.map((source, index) => (
|
||||
|
|
@ -496,7 +466,7 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRefreshSource(source.url)}
|
||||
title={t("package-manager:sources.current.refresh")}
|
||||
title={t("marketplace:sources.current.refresh")}
|
||||
className="text-vscode-foreground"
|
||||
disabled={refreshingUrls.includes(source.url)}>
|
||||
<span
|
||||
|
|
@ -506,7 +476,7 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveSource(index)}
|
||||
title={t("package-manager:sources.current.remove")}
|
||||
title={t("marketplace:sources.current.remove")}
|
||||
className="text-red-500">
|
||||
<span className="codicon codicon-trash"></span>
|
||||
</Button>
|
||||
|
|
@ -519,16 +489,4 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
|
|||
)
|
||||
}
|
||||
|
||||
const isValidGitRepositoryUrl = (url: string): boolean => {
|
||||
const trimmedUrl = url.trim()
|
||||
|
||||
const httpsPattern =
|
||||
/^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org|dev\.azure\.com)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\/.+)*(\.git)?$/
|
||||
const sshPattern = /^git@(github\.com|gitlab\.com|bitbucket\.org):([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(\.git)?$/
|
||||
const gitProtocolPattern =
|
||||
/^git:\/\/(github\.com|gitlab\.com|bitbucket\.org)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\.git)?$/
|
||||
|
||||
return httpsPattern.test(trimmedUrl) || sshPattern.test(trimmedUrl) || gitProtocolPattern.test(trimmedUrl)
|
||||
}
|
||||
|
||||
export default PackageManagerView
|
||||
export default MarketplaceView
|
||||
|
|
@ -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" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,13 +1,9 @@
|
|||
import { PackageManagerViewStateManager } from "../PackageManagerViewStateManager"
|
||||
import { MarketplaceViewStateManager } from "../MarketplaceViewStateManager"
|
||||
import { vscode } from "../../../utils/vscode"
|
||||
import {
|
||||
ComponentType,
|
||||
PackageManagerItem,
|
||||
PackageManagerSource,
|
||||
} from "../../../../../src/services/package-manager/types"
|
||||
import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../../../../src/services/package-manager/constants"
|
||||
import { ComponentType, MarketplaceItem, MarketplaceSource } from "../../../../../src/services/marketplace/types"
|
||||
import { DEFAULT_MARKETPLACE_SOURCE } from "../../../../../src/services/marketplace/constants"
|
||||
|
||||
const createTestItem = (overrides = {}): PackageManagerItem => ({
|
||||
const createTestItem = (overrides = {}): MarketplaceItem => ({
|
||||
name: "test",
|
||||
type: "mode" as ComponentType,
|
||||
description: "Test mode",
|
||||
|
|
@ -20,7 +16,7 @@ const createTestItem = (overrides = {}): PackageManagerItem => ({
|
|||
...overrides,
|
||||
})
|
||||
|
||||
const createTestSources = (): PackageManagerSource[] => [
|
||||
const createTestSources = (): MarketplaceSource[] => [
|
||||
{ url: "https://github.com/test/repo1", enabled: true },
|
||||
{ url: "https://github.com/test/repo2", enabled: true },
|
||||
{ url: "https://github.com/test/repo3", enabled: true },
|
||||
|
|
@ -33,13 +29,13 @@ jest.mock("../../../utils/vscode", () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
describe("PackageManagerViewStateManager", () => {
|
||||
let manager: PackageManagerViewStateManager
|
||||
describe("MarketplaceViewStateManager", () => {
|
||||
let manager: MarketplaceViewStateManager
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
jest.useFakeTimers()
|
||||
manager = new PackageManagerViewStateManager()
|
||||
manager = new MarketplaceViewStateManager()
|
||||
manager.initialize() // Send initial sources
|
||||
})
|
||||
|
||||
|
|
@ -57,7 +53,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [DEFAULT_PACKAGE_MANAGER_SOURCE],
|
||||
sources: [DEFAULT_MARKETPLACE_SOURCE],
|
||||
filters: {
|
||||
type: "",
|
||||
search: "",
|
||||
|
|
@ -73,13 +69,13 @@ describe("PackageManagerViewStateManager", () => {
|
|||
it("should send initial sources when initialized", () => {
|
||||
manager.initialize()
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "packageManagerSources",
|
||||
sources: [DEFAULT_PACKAGE_MANAGER_SOURCE],
|
||||
type: "marketplaceSources",
|
||||
sources: [DEFAULT_MARKETPLACE_SOURCE],
|
||||
})
|
||||
})
|
||||
|
||||
it("should initialize with default source", () => {
|
||||
const manager = new PackageManagerViewStateManager()
|
||||
const manager = new MarketplaceViewStateManager()
|
||||
|
||||
// Initial state should include default source
|
||||
const state = manager.getState()
|
||||
|
|
@ -93,7 +89,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
|
||||
// Verify initial message was sent to update sources
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "packageManagerSources",
|
||||
type: "marketplaceSources",
|
||||
sources: [
|
||||
{
|
||||
url: "https://github.com/RooVetGit/Roo-Code-Marketplace",
|
||||
|
|
@ -111,7 +107,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
type: "fetchMarketplaceItems",
|
||||
bool: true,
|
||||
})
|
||||
|
||||
|
|
@ -155,34 +151,123 @@ describe("PackageManagerViewStateManager", () => {
|
|||
})
|
||||
|
||||
describe("Race Conditions", () => {
|
||||
it("should handle rapid tab switching during initial load", async () => {
|
||||
// Start initial load
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
it("should maintain items state when repeatedly switching tabs", async () => {
|
||||
// Start with initial items
|
||||
const initialItems = [createTestItem({ name: "Initial Item" })]
|
||||
await manager.transition({
|
||||
type: "FETCH_COMPLETE",
|
||||
payload: { items: initialItems },
|
||||
})
|
||||
|
||||
// Quickly switch to sources tab
|
||||
// First switch to sources
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "sources" },
|
||||
})
|
||||
|
||||
// Switch back to browse before load completes
|
||||
// Switch back to browse
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "browse" },
|
||||
})
|
||||
|
||||
// Complete the initial load
|
||||
// Verify items are preserved after first switch
|
||||
let state = manager.getState()
|
||||
expect(state.displayItems).toEqual(initialItems)
|
||||
expect(state.allItems).toEqual(initialItems)
|
||||
|
||||
// Simulate receiving empty response during fetch
|
||||
await manager.handleMessage({
|
||||
type: "state",
|
||||
state: { packageManagerItems: [createTestItem()] },
|
||||
state: { marketplaceItems: [] },
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.activeTab).toBe("browse")
|
||||
expect(state.allItems).toHaveLength(1)
|
||||
// Verify items are still preserved
|
||||
state = manager.getState()
|
||||
expect(state.displayItems).toEqual(initialItems)
|
||||
expect(state.allItems).toEqual(initialItems)
|
||||
|
||||
// Switch to sources again
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "sources" },
|
||||
})
|
||||
|
||||
// Switch back to browse again
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "browse" },
|
||||
})
|
||||
|
||||
// Verify items are still preserved after second switch
|
||||
state = manager.getState()
|
||||
expect(state.displayItems).toEqual(initialItems)
|
||||
expect(state.allItems).toEqual(initialItems)
|
||||
|
||||
// Simulate another empty response
|
||||
await manager.handleMessage({
|
||||
type: "state",
|
||||
state: { marketplaceItems: [] },
|
||||
})
|
||||
|
||||
// Final verification that items are still preserved
|
||||
state = manager.getState()
|
||||
expect(state.displayItems).toEqual(initialItems)
|
||||
expect(state.allItems).toEqual(initialItems)
|
||||
})
|
||||
|
||||
it("should preserve items when receiving empty response", async () => {
|
||||
// Start with initial items
|
||||
const initialItems = [createTestItem({ name: "Initial Item" })]
|
||||
await manager.transition({
|
||||
type: "FETCH_COMPLETE",
|
||||
payload: { items: initialItems },
|
||||
})
|
||||
|
||||
// Verify initial state
|
||||
let state = manager.getState()
|
||||
expect(state.allItems).toEqual(initialItems)
|
||||
expect(state.displayItems).toEqual(initialItems)
|
||||
|
||||
// Simulate receiving an empty response
|
||||
await manager.handleMessage({
|
||||
type: "state",
|
||||
state: { marketplaceItems: [] },
|
||||
})
|
||||
|
||||
// Verify items are preserved
|
||||
state = manager.getState()
|
||||
expect(state.allItems).toEqual(initialItems)
|
||||
expect(state.displayItems).toEqual(initialItems)
|
||||
expect(state.isFetching).toBe(false)
|
||||
})
|
||||
|
||||
it("should preserve items when switching tabs", async () => {
|
||||
// Start with initial items
|
||||
const initialItems = [createTestItem({ name: "Initial Item" })]
|
||||
await manager.transition({
|
||||
type: "FETCH_COMPLETE",
|
||||
payload: { items: initialItems },
|
||||
})
|
||||
|
||||
// Switch to sources tab
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "sources" },
|
||||
})
|
||||
|
||||
// Switch back to browse
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "browse" },
|
||||
})
|
||||
|
||||
// Verify that items are preserved
|
||||
const state = manager.getState()
|
||||
expect(state.displayItems).toEqual(initialItems)
|
||||
expect(state.allItems).toEqual(initialItems)
|
||||
})
|
||||
|
||||
it("should handle rapid filtering during initial load", async () => {
|
||||
// Start initial load
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
|
|
@ -196,7 +281,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
// Complete the initial load
|
||||
await manager.handleMessage({
|
||||
type: "state",
|
||||
state: { packageManagerItems: [createTestItem()] },
|
||||
state: { marketplaceItems: [createTestItem()] },
|
||||
})
|
||||
|
||||
// Fast-forward past debounce time
|
||||
|
|
@ -208,7 +293,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
expect(state.displayItems).toBeDefined()
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "filterPackageManagerItems",
|
||||
type: "filterMarketplaceItems",
|
||||
filters: expect.objectContaining({ type: "mode" }),
|
||||
}),
|
||||
)
|
||||
|
|
@ -233,7 +318,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
// Each filter update should be sent immediately
|
||||
expect(vscode.postMessage).toHaveBeenCalledTimes(2)
|
||||
expect(vscode.postMessage).toHaveBeenLastCalledWith({
|
||||
type: "filterPackageManagerItems",
|
||||
type: "filterMarketplaceItems",
|
||||
filters: {
|
||||
search: "test",
|
||||
type: "mode",
|
||||
|
|
@ -266,21 +351,27 @@ describe("PackageManagerViewStateManager", () => {
|
|||
|
||||
// Get all calls to postMessage
|
||||
const calls = (vscode.postMessage as jest.Mock).mock.calls
|
||||
const sourcesMessages = calls.filter((call) => call[0].type === "packageManagerSources")
|
||||
const sourcesMessages = calls.filter((call) => call[0].type === "marketplaceSources")
|
||||
const lastSourcesMessage = sourcesMessages[sourcesMessages.length - 1]
|
||||
|
||||
// Verify state has default source
|
||||
const state = manager.getState()
|
||||
expect(state.sources).toEqual([DEFAULT_PACKAGE_MANAGER_SOURCE])
|
||||
expect(state.sources).toEqual([DEFAULT_MARKETPLACE_SOURCE])
|
||||
|
||||
// Verify the last sources message was sent with default source
|
||||
expect(lastSourcesMessage[0]).toEqual({
|
||||
type: "packageManagerSources",
|
||||
sources: [DEFAULT_PACKAGE_MANAGER_SOURCE],
|
||||
type: "marketplaceSources",
|
||||
sources: [DEFAULT_MARKETPLACE_SOURCE],
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle rapid source operations during fetch", async () => {
|
||||
it("should handle rapid source operations during fetch when in browse tab", async () => {
|
||||
// Switch to browse tab first
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "browse" },
|
||||
})
|
||||
|
||||
// Start a fetch
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
|
||||
|
|
@ -295,7 +386,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
// Complete the fetch
|
||||
await manager.handleMessage({
|
||||
type: "state",
|
||||
state: { packageManagerItems: [createTestItem()] },
|
||||
state: { marketplaceItems: [createTestItem()] },
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
|
|
@ -303,38 +394,6 @@ describe("PackageManagerViewStateManager", () => {
|
|||
expect(state.allItems).toHaveLength(1)
|
||||
expect(state.isFetching).toBe(false)
|
||||
})
|
||||
|
||||
it("should trigger fetch after adding a new source and switching to browse", async () => {
|
||||
// Reset mock before test
|
||||
;(vscode.postMessage as jest.Mock).mockClear()
|
||||
|
||||
// Add a new source
|
||||
const newSource = { url: "https://github.com/test/repo1", enabled: true }
|
||||
await manager.transition({
|
||||
type: "UPDATE_SOURCES",
|
||||
payload: { sources: [DEFAULT_PACKAGE_MANAGER_SOURCE, newSource] },
|
||||
})
|
||||
|
||||
// Switch to browse tab
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "browse" },
|
||||
})
|
||||
|
||||
// Run any pending timers
|
||||
jest.runAllTimers()
|
||||
|
||||
// Verify that a fetch was triggered
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
bool: true,
|
||||
})
|
||||
|
||||
// Verify state
|
||||
const state = manager.getState()
|
||||
expect(state.isFetching).toBe(true)
|
||||
expect(state.activeTab).toBe("browse")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error Handling", () => {
|
||||
|
|
@ -399,7 +458,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
// Should send all updates immediately
|
||||
expect(vscode.postMessage).toHaveBeenCalledTimes(3)
|
||||
expect(vscode.postMessage).toHaveBeenLastCalledWith({
|
||||
type: "filterPackageManagerItems",
|
||||
type: "filterMarketplaceItems",
|
||||
filters: {
|
||||
type: "",
|
||||
search: "test3",
|
||||
|
|
@ -437,7 +496,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
|
||||
// Should send filter message with empty filters immediately
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "filterPackageManagerItems",
|
||||
type: "filterMarketplaceItems",
|
||||
filters: {
|
||||
type: "",
|
||||
search: "",
|
||||
|
|
@ -476,7 +535,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
|
||||
// Should maintain type filter when search is cleared
|
||||
expect(vscode.postMessage).toHaveBeenLastCalledWith({
|
||||
type: "filterPackageManagerItems",
|
||||
type: "filterMarketplaceItems",
|
||||
filters: {
|
||||
type: "mode",
|
||||
search: "",
|
||||
|
|
@ -513,9 +572,9 @@ describe("PackageManagerViewStateManager", () => {
|
|||
expect(state.refreshingUrls).not.toContain(url)
|
||||
})
|
||||
|
||||
it("should handle package manager button click with error", () => {
|
||||
it("should handle marketplace button click with error", () => {
|
||||
manager.handleMessage({
|
||||
type: "packageManagerButtonClicked",
|
||||
type: "marketplaceButtonClicked",
|
||||
text: "error",
|
||||
})
|
||||
|
||||
|
|
@ -523,15 +582,15 @@ describe("PackageManagerViewStateManager", () => {
|
|||
expect(state.isFetching).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle package manager button click for refresh", () => {
|
||||
it("should handle marketplace button click for refresh", () => {
|
||||
manager.handleMessage({
|
||||
type: "packageManagerButtonClicked",
|
||||
type: "marketplaceButtonClicked",
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.isFetching).toBe(true)
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
type: "fetchMarketplaceItems",
|
||||
bool: true,
|
||||
})
|
||||
})
|
||||
|
|
@ -548,20 +607,55 @@ describe("PackageManagerViewStateManager", () => {
|
|||
expect(state.activeTab).toBe("sources")
|
||||
})
|
||||
|
||||
it("should trigger fetch when switching to browse tab with no items", async () => {
|
||||
it("should trigger initial fetch when switching to browse with no items", async () => {
|
||||
jest.clearAllMocks() // Clear mock to ignore initialize() call
|
||||
|
||||
// Start in sources tab
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "sources" },
|
||||
})
|
||||
|
||||
// Switch to browse tab
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "browse" },
|
||||
})
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
type: "fetchMarketplaceItems",
|
||||
bool: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("should not trigger fetch when switching to browse tab with existing items", async () => {
|
||||
it("should not trigger fetch when switching to browse with existing items", async () => {
|
||||
jest.clearAllMocks() // Clear mock to ignore initialize() call
|
||||
|
||||
// Add some items first
|
||||
await manager.transition({
|
||||
type: "FETCH_COMPLETE",
|
||||
payload: { items: [createTestItem()] },
|
||||
})
|
||||
|
||||
// Switch to sources tab
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "sources" },
|
||||
})
|
||||
|
||||
// Switch back to browse tab
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "browse" },
|
||||
})
|
||||
|
||||
expect(vscode.postMessage).not.toHaveBeenCalledWith({
|
||||
type: "fetchMarketplaceItems",
|
||||
bool: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("should automatically fetch when sources are modified and viewing browse tab", async () => {
|
||||
jest.clearAllMocks() // Clear mock to ignore initialize() call
|
||||
|
||||
// Add some items first
|
||||
|
|
@ -576,36 +670,15 @@ describe("PackageManagerViewStateManager", () => {
|
|||
payload: { tab: "browse" },
|
||||
})
|
||||
|
||||
expect(vscode.postMessage).not.toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
bool: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("should trigger fetch when switching to browse tab after source modification", async () => {
|
||||
jest.clearAllMocks() // Clear mock to ignore initialize() call
|
||||
|
||||
// Add some items first
|
||||
await manager.transition({
|
||||
type: "FETCH_COMPLETE",
|
||||
payload: { items: [createTestItem()] },
|
||||
})
|
||||
|
||||
// Modify sources
|
||||
await manager.transition({
|
||||
type: "UPDATE_SOURCES",
|
||||
payload: { sources: [{ url: "https://github.com/test/repo1", enabled: true }] },
|
||||
})
|
||||
|
||||
// Switch to browse tab
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "browse" },
|
||||
})
|
||||
|
||||
// Should trigger fetch due to source modification
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
type: "fetchMarketplaceItems",
|
||||
bool: true,
|
||||
})
|
||||
})
|
||||
|
|
@ -617,7 +690,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
})
|
||||
|
||||
expect(vscode.postMessage).not.toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
type: "fetchMarketplaceItems",
|
||||
bool: true,
|
||||
})
|
||||
})
|
||||
|
|
@ -679,13 +752,19 @@ describe("PackageManagerViewStateManager", () => {
|
|||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it("should trigger fetch for remaining source after source deletion", async () => {
|
||||
it("should trigger fetch for remaining source after source deletion when in browse tab", async () => {
|
||||
// Start with two sources
|
||||
const sources = [
|
||||
{ url: "https://github.com/test/repo1", enabled: true },
|
||||
{ url: "https://github.com/test/repo2", enabled: true },
|
||||
]
|
||||
|
||||
// Switch to browse tab
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "browse" },
|
||||
})
|
||||
|
||||
await manager.transition({
|
||||
type: "UPDATE_SOURCES",
|
||||
payload: { sources },
|
||||
|
|
@ -702,7 +781,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
|
||||
// Verify that a fetch was triggered for the remaining source
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
type: "fetchMarketplaceItems",
|
||||
bool: true,
|
||||
})
|
||||
|
||||
|
|
@ -737,11 +816,11 @@ describe("PackageManagerViewStateManager", () => {
|
|||
|
||||
// Get all calls to postMessage
|
||||
const calls = (vscode.postMessage as jest.Mock).mock.calls
|
||||
const sourcesMessage = calls.find((call) => call[0].type === "packageManagerSources")
|
||||
const sourcesMessage = calls.find((call) => call[0].type === "marketplaceSources")
|
||||
|
||||
// Verify that the sources message was sent with default source
|
||||
expect(sourcesMessage[0]).toEqual({
|
||||
type: "packageManagerSources",
|
||||
type: "marketplaceSources",
|
||||
sources: [
|
||||
{
|
||||
url: "https://github.com/RooVetGit/Roo-Code-Marketplace",
|
||||
|
|
@ -766,7 +845,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
const state = manager.getState()
|
||||
expect(state.sources).toEqual(sources)
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "packageManagerSources",
|
||||
type: "marketplaceSources",
|
||||
sources,
|
||||
})
|
||||
})
|
||||
|
|
@ -782,7 +861,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
const state = manager.getState()
|
||||
expect(state.refreshingUrls).toContain(url)
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "refreshPackageManagerSource",
|
||||
type: "refreshMarketplaceSource",
|
||||
url,
|
||||
})
|
||||
})
|
||||
|
|
@ -833,7 +912,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
manager.handleMessage({
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: [initialItems[0]], // Only Item 1
|
||||
marketplaceItems: [initialItems[0]], // Only Item 1
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -861,7 +940,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
jest.advanceTimersByTime(300)
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "filterPackageManagerItems",
|
||||
type: "filterMarketplaceItems",
|
||||
filters: {
|
||||
type: "mode",
|
||||
search: "test",
|
||||
|
|
@ -973,7 +1052,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
})
|
||||
|
||||
describe("Message Handling", () => {
|
||||
it("should restore sources from packageManagerSources on webview launch", () => {
|
||||
it("should restore sources from marketplaceSources on webview launch", () => {
|
||||
const savedSources = [
|
||||
{
|
||||
url: "https://github.com/RooVetGit/Roo-Code-Marketplace",
|
||||
|
|
@ -990,7 +1069,7 @@ describe("PackageManagerViewStateManager", () => {
|
|||
// Simulate VS Code restart by sending initial state with saved sources
|
||||
manager.handleMessage({
|
||||
type: "state",
|
||||
state: { packageManagerSources: savedSources },
|
||||
state: { marketplaceSources: savedSources },
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
|
|
@ -1000,11 +1079,11 @@ describe("PackageManagerViewStateManager", () => {
|
|||
it("should use default source when state message has no sources", () => {
|
||||
manager.handleMessage({
|
||||
type: "state",
|
||||
state: { packageManagerItems: [] },
|
||||
state: { marketplaceItems: [] },
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.sources).toEqual([DEFAULT_PACKAGE_MANAGER_SOURCE])
|
||||
expect(state.sources).toEqual([DEFAULT_MARKETPLACE_SOURCE])
|
||||
})
|
||||
|
||||
it("should update sources when receiving state message", () => {
|
||||
|
|
@ -1030,13 +1109,13 @@ describe("PackageManagerViewStateManager", () => {
|
|||
expect(state.sources).toEqual(customSources)
|
||||
})
|
||||
|
||||
it("should handle state message with package manager items", () => {
|
||||
it("should handle state message with marketplace items", () => {
|
||||
const testItems = [createTestItem()]
|
||||
|
||||
// We need to use any here since we're testing the raw message handling
|
||||
manager.handleMessage({
|
||||
type: "state",
|
||||
state: { packageManagerItems: testItems },
|
||||
state: { marketplaceItems: testItems },
|
||||
} as any)
|
||||
|
||||
const state = manager.getState()
|
||||
|
|
@ -1062,9 +1141,9 @@ describe("PackageManagerViewStateManager", () => {
|
|||
expect(state.refreshingUrls).not.toContain(url)
|
||||
})
|
||||
|
||||
it("should handle packageManagerButtonClicked message with error", () => {
|
||||
it("should handle marketplaceButtonClicked message with error", () => {
|
||||
manager.handleMessage({
|
||||
type: "packageManagerButtonClicked",
|
||||
type: "marketplaceButtonClicked",
|
||||
text: "error",
|
||||
})
|
||||
|
||||
|
|
@ -1072,9 +1151,9 @@ describe("PackageManagerViewStateManager", () => {
|
|||
expect(state.isFetching).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle packageManagerButtonClicked message for refresh", () => {
|
||||
it("should handle marketplaceButtonClicked message for refresh", () => {
|
||||
manager.handleMessage({
|
||||
type: "packageManagerButtonClicked",
|
||||
type: "marketplaceButtonClicked",
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
|
|
@ -1,22 +1,22 @@
|
|||
import React, { useMemo, useCallback } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PackageManagerItem } from "../../../../../src/services/package-manager/types"
|
||||
import { MarketplaceItem } from "../../../../../src/services/marketplace/types"
|
||||
import { vscode } from "@/utils/vscode"
|
||||
import { groupItemsByType, GroupedItems } from "../utils/grouping"
|
||||
import { ExpandableSection } from "./ExpandableSection"
|
||||
import { TypeGroup } from "./TypeGroup"
|
||||
import { ViewState } from "../PackageManagerViewStateManager"
|
||||
import { ViewState } from "../MarketplaceViewStateManager"
|
||||
import { useAppTranslation } from "@/i18n/TranslationContext"
|
||||
|
||||
interface PackageManagerItemCardProps {
|
||||
item: PackageManagerItem
|
||||
interface MarketplaceItemCardProps {
|
||||
item: MarketplaceItem
|
||||
filters: ViewState["filters"]
|
||||
setFilters: (filters: Partial<ViewState["filters"]>) => void
|
||||
activeTab: ViewState["activeTab"]
|
||||
setActiveTab: (tab: ViewState["activeTab"]) => void
|
||||
}
|
||||
|
||||
export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
|
||||
export const MarketplaceItemCard: React.FC<MarketplaceItemCardProps> = ({
|
||||
item,
|
||||
filters,
|
||||
setFilters,
|
||||
|
|
@ -36,15 +36,15 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
|
|||
const typeLabel = useMemo(() => {
|
||||
switch (item.type) {
|
||||
case "mode":
|
||||
return t("package-manager:filters.type.mode")
|
||||
return t("marketplace:filters.type.mode")
|
||||
case "mcp server":
|
||||
return t("package-manager:filters.type.mcp server")
|
||||
return t("marketplace:filters.type.mcp server")
|
||||
case "prompt":
|
||||
return t("package-manager:filters.type.prompt")
|
||||
return t("marketplace:filters.type.prompt")
|
||||
case "package":
|
||||
return t("package-manager:filters.type.package")
|
||||
return t("marketplace:filters.type.package")
|
||||
default:
|
||||
return t("package-manager:filters.type.all")
|
||||
return t("marketplace:filters.type.all")
|
||||
}
|
||||
}, [item.type, t])
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
|
|||
url: item.authorUrl,
|
||||
})
|
||||
}}>
|
||||
{t("package-manager:items.card.by", { author: item.author })}
|
||||
{t("marketplace:items.card.by", { author: item.author })}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
|
|
@ -124,13 +124,13 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
|
|||
url: item.authorUrl,
|
||||
})
|
||||
}}>
|
||||
{t("package-manager:items.card.viewSource")}
|
||||
{t("marketplace:items.card.viewSource")}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
) : item.author ? (
|
||||
<p className="text-sm text-vscode-descriptionForeground">
|
||||
{t("package-manager:items.card.by", { author: item.author })}
|
||||
{t("marketplace:items.card.by", { author: item.author })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -165,8 +165,8 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
|
|||
}}
|
||||
title={
|
||||
filters.tags.includes(tag)
|
||||
? t("package-manager:filters.tags.clear", { count: tag })
|
||||
: t("package-manager:filters.tags.clickToFilter")
|
||||
? t("marketplace:filters.tags.clear", { count: tag })
|
||||
: t("marketplace:filters.tags.clickToFilter")
|
||||
}>
|
||||
{tag}
|
||||
</button>
|
||||
|
|
@ -199,31 +199,33 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
|
|||
aria-label={
|
||||
item.sourceUrl && isValidUrl(item.sourceUrl)
|
||||
? ""
|
||||
: item.sourceName || t("package-manager:items.card.viewSource")
|
||||
: item.sourceName || t("marketplace:items.card.viewSource")
|
||||
}>
|
||||
<span
|
||||
className={`codicon codicon-link-external${!item.sourceUrl || !isValidUrl(item.sourceUrl) ? " mr-2" : ""}`}></span>
|
||||
{(!item.sourceUrl || !isValidUrl(item.sourceUrl)) &&
|
||||
(item.sourceName || t("package-manager:items.card.viewSource"))}
|
||||
(item.sourceName || t("marketplace:items.card.viewSource"))}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-vscode-panel-border mt-4">
|
||||
<ExpandableSection
|
||||
title={t("package-manager:items.components", { count: item.items?.length ?? 0 })}
|
||||
badge={(() => {
|
||||
const matchCount = item.items?.filter((subItem) => subItem.matchInfo?.matched).length ?? 0
|
||||
return matchCount > 0 ? t("package-manager:items.components", { count: matchCount }) : undefined
|
||||
})()}
|
||||
defaultExpanded={item.items?.some((subItem) => subItem.matchInfo?.matched) ?? false}>
|
||||
<div className="space-y-4">
|
||||
{groupedItems &&
|
||||
Object.entries(groupedItems).map(([type, group]) => (
|
||||
<TypeGroup key={type} type={type} items={group.items} />
|
||||
))}
|
||||
</div>
|
||||
</ExpandableSection>
|
||||
</div>
|
||||
{item.type === "package" && (
|
||||
<div className="border-t border-vscode-panel-border mt-4">
|
||||
<ExpandableSection
|
||||
title={t("marketplace:items.components", { count: item.items?.length ?? 0 })}
|
||||
badge={(() => {
|
||||
const matchCount = item.items?.filter((subItem) => subItem.matchInfo?.matched).length ?? 0
|
||||
return matchCount > 0 ? t("marketplace:items.components", { count: matchCount }) : undefined
|
||||
})()}
|
||||
defaultExpanded={item.items?.some((subItem) => subItem.matchInfo?.matched) ?? false}>
|
||||
<div className="space-y-4">
|
||||
{groupedItems &&
|
||||
Object.entries(groupedItems).map(([type, group]) => (
|
||||
<TypeGroup key={type} type={type} items={group.items} />
|
||||
))}
|
||||
</div>
|
||||
</ExpandableSection>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -22,15 +22,15 @@ export const TypeGroup: React.FC<TypeGroupProps> = ({ type, items, className })
|
|||
const typeLabel = useMemo(() => {
|
||||
switch (type) {
|
||||
case "mode":
|
||||
return t("package-manager:type-group.modes")
|
||||
return t("marketplace:type-group.modes")
|
||||
case "mcp server":
|
||||
return t("package-manager:type-group.mcp-servers")
|
||||
return t("marketplace:type-group.mcp-servers")
|
||||
case "prompt":
|
||||
return t("package-manager:type-group.prompts")
|
||||
return t("marketplace:type-group.prompts")
|
||||
case "package":
|
||||
return t("package-manager:type-group.packages")
|
||||
return t("marketplace:type-group.packages")
|
||||
default:
|
||||
return t("package-manager:type-group.generic-type", {
|
||||
return t("marketplace:type-group.generic-type", {
|
||||
type: type.charAt(0).toUpperCase() + type.slice(1),
|
||||
})
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ export const TypeGroup: React.FC<TypeGroupProps> = ({ type, items, className })
|
|||
)}
|
||||
{item.matchInfo?.matched && (
|
||||
<span className="ml-2 text-xs bg-vscode-badge-background text-vscode-badge-foreground px-1 py-0.5 rounded">
|
||||
{t("package-manager:type-group.match")}
|
||||
{t("marketplace:type-group.match")}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react"
|
||||
import { screen, fireEvent } from "@testing-library/react"
|
||||
import { PackageManagerItemCard } from "../PackageManagerItemCard"
|
||||
import { PackageManagerItem } from "../../../../../../src/services/package-manager/types"
|
||||
import { MarketplaceItemCard } from "../MarketplaceItemCard"
|
||||
import { MarketplaceItem } from "../../../../../../src/services/marketplace/types"
|
||||
import { renderWithProviders } from "@/test/test-utils"
|
||||
|
||||
// Mock vscode API
|
||||
|
|
@ -12,8 +12,8 @@ jest.mock("@/utils/vscode", () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
describe("PackageManagerItemCard", () => {
|
||||
const mockItem: PackageManagerItem = {
|
||||
describe("MarketplaceItemCard", () => {
|
||||
const mockItem: MarketplaceItem = {
|
||||
name: "Test Package",
|
||||
description: "A test package",
|
||||
type: "package",
|
||||
|
|
@ -60,7 +60,7 @@ describe("PackageManagerItemCard", () => {
|
|||
})
|
||||
|
||||
it("should render basic item information", () => {
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText("Test Package")).toBeInTheDocument()
|
||||
expect(screen.getByText("A test package")).toBeInTheDocument()
|
||||
|
|
@ -79,7 +79,7 @@ describe("PackageManagerItemCard", () => {
|
|||
})
|
||||
|
||||
it("should render tags", () => {
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText("test")).toBeInTheDocument()
|
||||
expect(screen.getByText("mock")).toBeInTheDocument()
|
||||
|
|
@ -87,7 +87,7 @@ describe("PackageManagerItemCard", () => {
|
|||
|
||||
it("should handle tag clicks", () => {
|
||||
const setFilters = jest.fn()
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} setFilters={setFilters} />)
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} setFilters={setFilters} />)
|
||||
|
||||
fireEvent.click(screen.getByText("test"))
|
||||
expect(setFilters).toHaveBeenCalledWith(
|
||||
|
|
@ -98,7 +98,7 @@ describe("PackageManagerItemCard", () => {
|
|||
})
|
||||
|
||||
it("should render version and date information", () => {
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText("1.0.0")).toBeInTheDocument()
|
||||
// Use a regex to match the date since it depends on the timezone
|
||||
|
|
@ -113,7 +113,7 @@ describe("PackageManagerItemCard", () => {
|
|||
defaultBranch: "main",
|
||||
path: "some/path",
|
||||
}
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithSourceUrl} />)
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={itemWithSourceUrl} />)
|
||||
|
||||
const button = screen.getByRole("button", { name: /^$/ }) // Button with no text, only icon
|
||||
fireEvent.click(button)
|
||||
|
|
@ -130,7 +130,7 @@ describe("PackageManagerItemCard", () => {
|
|||
defaultBranch: "main",
|
||||
path: "some/path",
|
||||
}
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithGitPath} />)
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={itemWithGitPath} />)
|
||||
const button = screen.getByRole("button", { name: /View/i })
|
||||
fireEvent.click(button)
|
||||
|
||||
|
|
@ -145,7 +145,7 @@ describe("PackageManagerItemCard", () => {
|
|||
...mockItem,
|
||||
sourceUrl: "https://example.com/direct-link",
|
||||
}
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithSourceUrl} />)
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={itemWithSourceUrl} />)
|
||||
|
||||
// Find the source button by its empty aria-label
|
||||
const button = screen.getByRole("button", {
|
||||
|
|
@ -156,7 +156,7 @@ describe("PackageManagerItemCard", () => {
|
|||
})
|
||||
|
||||
it("should show text label when sourceUrl is not present", () => {
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
|
||||
|
||||
// Find the source button by its aria-label
|
||||
const button = screen.getByRole("button", {
|
||||
|
|
@ -170,28 +170,28 @@ describe("PackageManagerItemCard", () => {
|
|||
describe("Details section", () => {
|
||||
it("should render expandable details section with correct count when item has no components", () => {
|
||||
const itemWithNoItems = { ...mockItem, items: [] }
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithNoItems} />)
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={itemWithNoItems} />)
|
||||
|
||||
// The component uses t("package-manager:items.components", { count: 0 })
|
||||
// The component uses t("marketplace:items.components", { count: 0 })
|
||||
expect(screen.getByText("0 components")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should render expandable details section with correct count when item has components", () => {
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
|
||||
|
||||
// The component uses t("package-manager:items.components", { count: 2 })
|
||||
// The component uses t("marketplace:items.components", { count: 2 })
|
||||
expect(screen.getByText("2 components")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should not render details section when item has no subcomponents", () => {
|
||||
const itemWithoutItems = { ...mockItem, items: [] }
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithoutItems} />)
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={itemWithoutItems} />)
|
||||
|
||||
expect(screen.queryByText("Component Details")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should show grouped items when expanded", () => {
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText("2 components"))
|
||||
|
||||
// These use the type-group translations
|
||||
|
|
@ -207,12 +207,33 @@ describe("PackageManagerItemCard", () => {
|
|||
})
|
||||
|
||||
it("should maintain proper order of items within groups", () => {
|
||||
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText("2 components"))
|
||||
|
||||
const items = screen.getAllByRole("listitem")
|
||||
expect(items[0]).toHaveTextContent("Test Server")
|
||||
expect(items[1]).toHaveTextContent("Test Mode")
|
||||
})
|
||||
|
||||
it("should show expandable section for package type", () => {
|
||||
const packageItem = { ...mockItem, type: "package" as const }
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={packageItem} />)
|
||||
|
||||
expect(screen.getByText("2 components")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should not show expandable section for mode type", () => {
|
||||
const modeItem = { ...mockItem, type: "mode" as const }
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={modeItem} />)
|
||||
|
||||
expect(screen.queryByText("2 components")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should not show expandable section for mcp server type", () => {
|
||||
const mcpServerItem = { ...mockItem, type: "mcp server" as const }
|
||||
renderWithProviders(<MarketplaceItemCard {...defaultProps} item={mcpServerItem} />)
|
||||
|
||||
expect(screen.queryByText("2 components")).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
43
webview-ui/src/components/marketplace/useStateManager.ts
Normal file
43
webview-ui/src/components/marketplace/useStateManager.ts
Normal 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
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { groupItemsByType, formatItemText, getTotalItemCount, getUniqueTypes } from "../grouping"
|
||||
import { PackageManagerItem } from "../../../../../../src/services/package-manager/types"
|
||||
import { MarketplaceItem } from "../../../../../../src/services/marketplace/types"
|
||||
|
||||
describe("grouping utilities", () => {
|
||||
const mockItems = [
|
||||
|
|
@ -30,7 +30,7 @@ describe("grouping utilities", () => {
|
|||
version: "1.1.0",
|
||||
},
|
||||
},
|
||||
] as PackageManagerItem["items"]
|
||||
] as MarketplaceItem["items"]
|
||||
|
||||
describe("groupItemsByType", () => {
|
||||
it("should group items by type correctly", () => {
|
||||
|
|
@ -55,7 +55,7 @@ describe("grouping utilities", () => {
|
|||
type: "mcp server",
|
||||
path: "test/path",
|
||||
},
|
||||
] as PackageManagerItem["items"]
|
||||
] as MarketplaceItem["items"]
|
||||
|
||||
const result = groupItemsByType(itemsWithMissingData)
|
||||
expect(result["mcp server"].items[0].name).toBe("Unnamed item")
|
||||
|
|
@ -75,7 +75,7 @@ describe("grouping utilities", () => {
|
|||
path: "test/path",
|
||||
metadata: { name: "Test" },
|
||||
},
|
||||
] as PackageManagerItem["items"]
|
||||
] as MarketplaceItem["items"]
|
||||
|
||||
const result = groupItemsByType(itemsWithoutType)
|
||||
expect(Object.keys(result)).toHaveLength(0)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { PackageManagerItem } from "../../../../../src/services/package-manager/types"
|
||||
import { MarketplaceItem } from "../../../../../src/services/marketplace/types"
|
||||
|
||||
export interface GroupedItems {
|
||||
[type: string]: {
|
||||
|
|
@ -24,7 +24,7 @@ export interface GroupedItems {
|
|||
// Cache for group objects to avoid recreating them
|
||||
const groupCache = new Map<string, { type: string; items: any[] }>()
|
||||
|
||||
export function groupItemsByType(items: PackageManagerItem["items"] = []): GroupedItems {
|
||||
export function groupItemsByType(items: MarketplaceItem["items"] = []): GroupedItems {
|
||||
if (!items?.length) {
|
||||
return {}
|
||||
}
|
||||
|
|
@ -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" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [] },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -11,8 +11,8 @@ import { Mode, CustomModePrompts, defaultModeSlug, defaultPrompts, ModeConfig }
|
|||
import { CustomSupportPrompts } from "../../../src/shared/support-prompt"
|
||||
import { experimentDefault, ExperimentId } from "../../../src/shared/experiments"
|
||||
import { TelemetrySetting } from "../../../src/shared/TelemetrySetting"
|
||||
import { PackageManagerSource } from "../../../src/services/package-manager/types"
|
||||
import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../../src/services/package-manager/constants"
|
||||
import { MarketplaceSource } from "../../../src/services/marketplace/types"
|
||||
import { DEFAULT_MARKETPLACE_SOURCE } from "../../../src/services/marketplace/constants"
|
||||
|
||||
export interface ExtensionStateContextType extends ExtensionState {
|
||||
didHydrateState: boolean
|
||||
|
|
@ -88,7 +88,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
pinnedApiConfigs?: Record<string, boolean>
|
||||
setPinnedApiConfigs: (value: Record<string, boolean>) => void
|
||||
togglePinnedApiConfig: (configName: string) => void
|
||||
setPackageManagerSources: (value: PackageManagerSource[]) => void
|
||||
setMarketplaceSources: (value: MarketplaceSource[]) => void
|
||||
}
|
||||
|
||||
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
|
||||
|
|
@ -161,7 +161,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior).
|
||||
renderContext: "sidebar",
|
||||
maxReadFileLine: 500, // Default max read file line limit
|
||||
packageManagerSources: [DEFAULT_PACKAGE_MANAGER_SOURCE],
|
||||
marketplaceSources: [DEFAULT_MARKETPLACE_SOURCE],
|
||||
pinnedApiConfigs: {}, // Empty object for pinned API configs
|
||||
terminalZshOhMy: false, // Default Oh My Zsh integration setting
|
||||
terminalZshP10k: false, // Default Powerlevel10k integration setting
|
||||
|
|
@ -188,8 +188,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
const newState = message.state!
|
||||
console.log("DEBUG: ExtensionStateContext received state message:", {
|
||||
hasApiConfig: !!newState.apiConfiguration,
|
||||
hasPackageManagerItems: !!newState.packageManagerItems,
|
||||
packageManagerItemsCount: newState.packageManagerItems?.length || 0,
|
||||
hasMarketplaceItems: !!newState.marketplaceItems,
|
||||
marketplaceItemsCount: newState.marketplaceItems?.length || 0,
|
||||
})
|
||||
|
||||
setState((prevState) => mergeExtensionState(prevState, newState))
|
||||
|
|
@ -349,7 +349,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
|
||||
return { ...prevState, pinnedApiConfigs: newPinned }
|
||||
}),
|
||||
setPackageManagerSources: (value) => setState((prevState) => ({ ...prevState, packageManagerSources: value })),
|
||||
setMarketplaceSources: (value) => setState((prevState) => ({ ...prevState, marketplaceSources: value })),
|
||||
}
|
||||
|
||||
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ i18next.use(initReactI18next).init({
|
|||
interpolation: {
|
||||
escapeValue: false, // React already escapes by default
|
||||
},
|
||||
defaultNS: "package-manager",
|
||||
ns: ["package-manager"],
|
||||
defaultNS: "marketplace",
|
||||
ns: ["marketplace"],
|
||||
})
|
||||
|
||||
export function loadTranslations() {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const setupI18nForTests = () => {
|
|||
chat: {
|
||||
test: "Test",
|
||||
},
|
||||
"package-manager": {
|
||||
marketplace: {
|
||||
items: {
|
||||
card: {
|
||||
by: "by {{author}}",
|
||||
|
|
|
|||
|
|
@ -19,15 +19,15 @@ i18next.use(initReactI18next).init({
|
|||
},
|
||||
resources: {
|
||||
en: {
|
||||
"package-manager": {
|
||||
title: "Package Manager",
|
||||
marketplace: {
|
||||
title: "Marketplace",
|
||||
tabs: {
|
||||
browse: "Browse",
|
||||
sources: "Sources",
|
||||
},
|
||||
filters: {
|
||||
search: {
|
||||
placeholder: "Search package manager items...",
|
||||
placeholder: "Search marketplace items...",
|
||||
},
|
||||
type: {
|
||||
label: "Filter by type:",
|
||||
|
|
@ -55,7 +55,7 @@ i18next.use(initReactI18next).init({
|
|||
},
|
||||
items: {
|
||||
empty: {
|
||||
noItems: "No package manager items found",
|
||||
noItems: "No marketplace items found",
|
||||
withFilters: "Try adjusting your filters",
|
||||
noSources: "Try adding a source in the Sources tab",
|
||||
},
|
||||
|
|
@ -88,8 +88,8 @@ i18next.use(initReactI18next).init({
|
|||
// Minimal mock state
|
||||
const mockExtensionState = {
|
||||
language: "en",
|
||||
packageManagerSources: [{ url: "test-url", enabled: true }],
|
||||
setPackageManagerSources: jest.fn(),
|
||||
marketplaceSources: [{ url: "test-url", enabled: true }],
|
||||
setMarketplaceSources: jest.fn(),
|
||||
experiments: {
|
||||
search_and_replace: false,
|
||||
insert_content: false,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue