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