mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-10 22:41:14 +00:00
feat: installedMetadata MVP (#10)
* feat: `installedMetadata` MVP * feat: removal support for installed items (+ general refactors)
This commit is contained in:
parent
e25b3e77c0
commit
1a11665672
21 changed files with 646 additions and 193 deletions
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -27,7 +27,7 @@
|
|||
"cheerio": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone-deep": "^4.0.1",
|
||||
"config-rocket": "^0.5.3",
|
||||
"config-rocket": "^0.5.8",
|
||||
"default-shell": "^2.2.0",
|
||||
"delay": "^6.0.0",
|
||||
"diff": "^5.2.0",
|
||||
|
|
@ -11039,9 +11039,9 @@
|
|||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
|
||||
},
|
||||
"node_modules/config-rocket": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/config-rocket/-/config-rocket-0.5.3.tgz",
|
||||
"integrity": "sha512-8vG9hsAhCpdDvNN/9c9ZVA2MR+JTGYseJjtYPSbKA1pVP/2e0wMmeTE07tibcc2InC1VSghXM6xIlqxKVSz20A==",
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/config-rocket/-/config-rocket-0.5.8.tgz",
|
||||
"integrity": "sha512-89J5RCDPk/6BFsjmNBWqxbfhEubuVrN3WogVG0IvGgXXT1w3H7gJzxdYwVShVwiRVszI2kweOEXjMwXFumxY9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"citty": "^0.1.6",
|
||||
|
|
|
|||
|
|
@ -447,7 +447,7 @@
|
|||
"cheerio": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone-deep": "^4.0.1",
|
||||
"config-rocket": "^0.5.3",
|
||||
"config-rocket": "^0.5.8",
|
||||
"default-shell": "^2.2.0",
|
||||
"delay": "^6.0.0",
|
||||
"diff": "^5.2.0",
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
|
|||
return this._workspaceTracker
|
||||
}
|
||||
protected mcpHub?: McpHub // Change from private to protected
|
||||
private marketplaceManager?: MarketplaceManager
|
||||
private marketplaceManager: MarketplaceManager
|
||||
|
||||
public isViewLaunched = false
|
||||
public settingsImportedAt?: number
|
||||
|
|
@ -114,6 +114,8 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
|
|||
.catch((error) => {
|
||||
this.log(`Failed to initialize MCP Hub: ${error}`)
|
||||
})
|
||||
|
||||
this.marketplaceManager = new MarketplaceManager(this.context)
|
||||
}
|
||||
|
||||
// Adds a new Cline instance to clineStack, marking the start of a new task.
|
||||
|
|
@ -218,6 +220,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
|
|||
this._workspaceTracker = undefined
|
||||
await this.mcpHub?.unregisterClient()
|
||||
this.mcpHub = undefined
|
||||
this.marketplaceManager?.cleanup()
|
||||
this.customModesManager?.dispose()
|
||||
this.log("Disposed all disposables")
|
||||
ClineProvider.activeInstances.delete(this)
|
||||
|
|
@ -1226,7 +1229,8 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
|
|||
const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get<string[]>("allowedCommands") || []
|
||||
const cwd = this.cwd
|
||||
|
||||
const marketplaceItems = this.marketplaceManager?.getCurrentItems() || []
|
||||
const marketplaceItems = this.marketplaceManager.getCurrentItems() || []
|
||||
const marketplaceInstalledMetadata = this.marketplaceManager.IMM.fullMetadata
|
||||
// Check if there's a system prompt override for the current mode
|
||||
const currentMode = mode ?? defaultModeSlug
|
||||
const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode)
|
||||
|
|
@ -1235,6 +1239,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
|
|||
version: this.context.extension?.packageJSON?.version ?? "",
|
||||
marketplaceItems,
|
||||
marketplaceSources: marketplaceSources ?? [],
|
||||
marketplaceInstalledMetadata,
|
||||
apiConfiguration,
|
||||
customInstructions,
|
||||
alwaysAllowReadOnly: alwaysAllowReadOnly ?? false,
|
||||
|
|
|
|||
|
|
@ -24,10 +24,83 @@ export async function handleMarketplaceMessages(
|
|||
await provider.contextProxy.setValue(key, value)
|
||||
|
||||
switch (message.type) {
|
||||
case "webviewDidLaunch": {
|
||||
// For webviewDidLaunch, we don't do anything - marketplace items will be loaded by explicit fetchMarketplaceItems
|
||||
case "openExternal": {
|
||||
if (message.url) {
|
||||
try {
|
||||
vscode.env.openExternal(vscode.Uri.parse(message.url))
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Marketplace: Failed to open URL: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to open URL: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.error("Marketplace: openExternal called without a URL")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
case "marketplaceSources": {
|
||||
if (message.sources) {
|
||||
// Enforce maximum of 10 sources
|
||||
const MAX_SOURCES = 10
|
||||
let updatedSources: MarketplaceSource[]
|
||||
|
||||
if (message.sources.length > MAX_SOURCES) {
|
||||
// Truncate to maximum allowed and show warning
|
||||
updatedSources = message.sources.slice(0, MAX_SOURCES)
|
||||
vscode.window.showWarningMessage(
|
||||
`Maximum of ${MAX_SOURCES} marketplace sources allowed. Additional sources have been removed.`,
|
||||
)
|
||||
} else {
|
||||
updatedSources = message.sources
|
||||
}
|
||||
|
||||
// Validate sources using the validation utility
|
||||
const validationErrors = validateSources(updatedSources)
|
||||
|
||||
// Filter out invalid sources
|
||||
if (validationErrors.length > 0) {
|
||||
// Create a map of invalid indices
|
||||
const invalidIndices = new Set<number>()
|
||||
validationErrors.forEach((error: ValidationError) => {
|
||||
// Extract index from error message (Source #X: ...)
|
||||
const match = error.message.match(/Source #(\d+):/)
|
||||
if (match && match[1]) {
|
||||
const index = parseInt(match[1], 10) - 1 // Convert to 0-based index
|
||||
if (index >= 0 && index < updatedSources.length) {
|
||||
invalidIndices.add(index)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Filter out invalid sources
|
||||
updatedSources = updatedSources.filter((_, index) => !invalidIndices.has(index))
|
||||
|
||||
// Show validation errors
|
||||
const errorMessage = `Marketplace sources validation failed:\n${validationErrors.map((e: ValidationError) => e.message).join("\n")}`
|
||||
console.error(errorMessage)
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
}
|
||||
|
||||
// Update the global state with the validated sources
|
||||
await updateGlobalState("marketplaceSources", updatedSources)
|
||||
|
||||
// Clean up cache directories for repositories that are no longer in the sources list
|
||||
try {
|
||||
await marketplaceManager.cleanupCacheDirectories(updatedSources)
|
||||
} catch (error) {
|
||||
console.error("Marketplace: Error during cache cleanup:", error)
|
||||
}
|
||||
|
||||
// Update the webview with the new state
|
||||
await provider.postStateToWebview()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
case "fetchMarketplaceItems": {
|
||||
// Prevent multiple simultaneous fetches
|
||||
if (marketplaceManager.isFetching) {
|
||||
|
|
@ -116,81 +189,6 @@ export async function handleMarketplaceMessages(
|
|||
}
|
||||
return true
|
||||
}
|
||||
case "marketplaceSources": {
|
||||
if (message.sources) {
|
||||
// Enforce maximum of 10 sources
|
||||
const MAX_SOURCES = 10
|
||||
let updatedSources: MarketplaceSource[]
|
||||
|
||||
if (message.sources.length > MAX_SOURCES) {
|
||||
// Truncate to maximum allowed and show warning
|
||||
updatedSources = message.sources.slice(0, MAX_SOURCES)
|
||||
vscode.window.showWarningMessage(
|
||||
`Maximum of ${MAX_SOURCES} marketplace sources allowed. Additional sources have been removed.`,
|
||||
)
|
||||
} else {
|
||||
updatedSources = message.sources
|
||||
}
|
||||
|
||||
// Validate sources using the validation utility
|
||||
const validationErrors = validateSources(updatedSources)
|
||||
|
||||
// Filter out invalid sources
|
||||
if (validationErrors.length > 0) {
|
||||
// Create a map of invalid indices
|
||||
const invalidIndices = new Set<number>()
|
||||
validationErrors.forEach((error: ValidationError) => {
|
||||
// Extract index from error message (Source #X: ...)
|
||||
const match = error.message.match(/Source #(\d+):/)
|
||||
if (match && match[1]) {
|
||||
const index = parseInt(match[1], 10) - 1 // Convert to 0-based index
|
||||
if (index >= 0 && index < updatedSources.length) {
|
||||
invalidIndices.add(index)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Filter out invalid sources
|
||||
updatedSources = updatedSources.filter((_, index) => !invalidIndices.has(index))
|
||||
|
||||
// Show validation errors
|
||||
const errorMessage = `Marketplace sources validation failed:\n${validationErrors.map((e: ValidationError) => e.message).join("\n")}`
|
||||
console.error(errorMessage)
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
}
|
||||
|
||||
// Update the global state with the validated sources
|
||||
await updateGlobalState("marketplaceSources", updatedSources)
|
||||
|
||||
// Clean up cache directories for repositories that are no longer in the sources list
|
||||
try {
|
||||
await marketplaceManager.cleanupCacheDirectories(updatedSources)
|
||||
} catch (error) {
|
||||
console.error("Marketplace: Error during cache cleanup:", error)
|
||||
}
|
||||
|
||||
// Update the webview with the new state
|
||||
await provider.postStateToWebview()
|
||||
}
|
||||
return true
|
||||
}
|
||||
case "openExternal": {
|
||||
if (message.url) {
|
||||
try {
|
||||
vscode.env.openExternal(vscode.Uri.parse(message.url))
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Marketplace: Failed to open URL: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to open URL: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.error("Marketplace: openExternal called without a URL")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
case "filterMarketplaceItems": {
|
||||
if (message.filters) {
|
||||
|
|
@ -210,47 +208,6 @@ export async function handleMarketplaceMessages(
|
|||
return true
|
||||
}
|
||||
|
||||
case "installMarketplaceItem": {
|
||||
if (message.mpItem) {
|
||||
try {
|
||||
await marketplaceManager.installMarketplaceItem(message.mpItem, message.mpInstallOptions)
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to install item "${message.mpItem.name}":\n${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.error("Marketplace: installMarketplaceItem called without `mpItem`")
|
||||
}
|
||||
return true
|
||||
}
|
||||
case "installMarketplaceItemWithParameters":
|
||||
if (message.payload) {
|
||||
const result = installMarketplaceItemWithParametersPayloadSchema.safeParse(message.payload)
|
||||
|
||||
if (result.success) {
|
||||
const { item, parameters } = result.data
|
||||
|
||||
try {
|
||||
await marketplaceManager.installMarketplaceItem(item, { parameters })
|
||||
} catch (error) {
|
||||
console.error(`Error submitting marketplace parameters: ${error}`)
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to install item "${item.name}":\n${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.error("Invalid payload for installMarketplaceItemWithParameters message:", message.payload)
|
||||
vscode.window.showErrorMessage(
|
||||
'Invalid "payload" received for installation: item or parameters missing.',
|
||||
)
|
||||
}
|
||||
}
|
||||
return true
|
||||
case "cancelMarketplaceInstall":
|
||||
vscode.window.showInformationMessage("Marketplace installation cancelled.")
|
||||
return true
|
||||
|
||||
case "refreshMarketplaceSource": {
|
||||
if (message.url) {
|
||||
try {
|
||||
|
|
@ -298,6 +255,68 @@ export async function handleMarketplaceMessages(
|
|||
return true
|
||||
}
|
||||
|
||||
case "installMarketplaceItem": {
|
||||
if (message.mpItem) {
|
||||
try {
|
||||
await marketplaceManager
|
||||
.installMarketplaceItem(message.mpItem, message.mpInstallOptions)
|
||||
.then(async (r) => r === "$COMMIT" && (await provider.postStateToWebview()))
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to install item "${message.mpItem.name}":\n${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.error("Marketplace: installMarketplaceItem called without `mpItem`")
|
||||
}
|
||||
return true
|
||||
}
|
||||
case "installMarketplaceItemWithParameters":
|
||||
if (message.payload) {
|
||||
const result = installMarketplaceItemWithParametersPayloadSchema.safeParse(message.payload)
|
||||
|
||||
if (result.success) {
|
||||
const { item, parameters } = result.data
|
||||
|
||||
try {
|
||||
await marketplaceManager
|
||||
.installMarketplaceItem(item, { parameters })
|
||||
.then(async (r) => r === "$COMMIT" && (await provider.postStateToWebview()))
|
||||
} catch (error) {
|
||||
console.error(`Error submitting marketplace parameters: ${error}`)
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to install item "${item.name}":\n${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.error("Invalid payload for installMarketplaceItemWithParameters message:", message.payload)
|
||||
vscode.window.showErrorMessage(
|
||||
'Invalid "payload" received for installation: item or parameters missing.',
|
||||
)
|
||||
}
|
||||
}
|
||||
return true
|
||||
case "cancelMarketplaceInstall": {
|
||||
vscode.window.showInformationMessage("Marketplace installation cancelled.")
|
||||
return true
|
||||
}
|
||||
case "removeInstalledMarketplaceItem": {
|
||||
if (message.mpItem) {
|
||||
try {
|
||||
await marketplaceManager
|
||||
.removeInstalledMarketplaceItem(message.mpItem, message.mpInstallOptions)
|
||||
.then(async (r) => r === "$COMMIT" && (await provider.postStateToWebview()))
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to remove item "${message.mpItem.name}":\n${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.error("Marketplace: removeInstalledMarketplaceItem called without `mpItem`")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,14 +43,15 @@ import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-
|
|||
import { getModels } from "../../api/providers/fetchers/cache"
|
||||
|
||||
const marketplaceMessages = new Set([
|
||||
"marketplaceSources",
|
||||
"openExternal",
|
||||
"marketplaceSources",
|
||||
"fetchMarketplaceItems",
|
||||
"filterMarketplaceItems",
|
||||
"refreshMarketplaceSource",
|
||||
"installMarketplaceItem",
|
||||
"installMarketplaceItemWithParameters",
|
||||
"cancelMarketplaceInstall",
|
||||
"refreshMarketplaceSource",
|
||||
"filterMarketplaceItems",
|
||||
"removeInstalledMarketplaceItem",
|
||||
])
|
||||
|
||||
export const webviewMessageHandler = async (
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { ClineProvider } from "./core/webview/ClineProvider"
|
|||
import { CodeActionProvider } from "./core/CodeActionProvider"
|
||||
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
|
||||
import { McpServerManager } from "./services/mcp/McpServerManager"
|
||||
import { MarketplaceManager } from "./services/marketplace"
|
||||
import { telemetryService } from "./services/telemetry/TelemetryService"
|
||||
import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry"
|
||||
import { API } from "./exports/api"
|
||||
|
|
@ -39,7 +38,6 @@ import { formatLanguage } from "./shared/language"
|
|||
|
||||
let outputChannel: vscode.OutputChannel
|
||||
let extensionContext: vscode.ExtensionContext
|
||||
let marketplaceManager: MarketplaceManager
|
||||
|
||||
// This method is called when your extension is activated.
|
||||
// Your extension is activated the very first time the command is executed.
|
||||
|
|
@ -72,9 +70,6 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||
const contextProxy = await ContextProxy.getInstance(context)
|
||||
const provider = new ClineProvider(context, outputChannel, "sidebar", contextProxy)
|
||||
|
||||
marketplaceManager = new MarketplaceManager(context)
|
||||
provider.setMarketplaceManager(marketplaceManager)
|
||||
|
||||
telemetryService.setProvider(provider)
|
||||
|
||||
context.subscriptions.push(
|
||||
|
|
@ -136,14 +131,6 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||
export async function deactivate() {
|
||||
outputChannel.appendLine("Roo-Code extension deactivated")
|
||||
|
||||
if (marketplaceManager) {
|
||||
try {
|
||||
await marketplaceManager.cleanup()
|
||||
} catch (error) {
|
||||
console.error("Failed to clean up marketplace:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up MCP server manager
|
||||
await McpServerManager.cleanup(extensionContext)
|
||||
telemetryService.shutdown()
|
||||
|
|
|
|||
205
src/services/marketplace/InstalledMetadataManager.ts
Normal file
205
src/services/marketplace/InstalledMetadataManager.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import * as yaml from "js-yaml"
|
||||
import { z } from "zod"
|
||||
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
|
||||
|
||||
const ItemInstalledMetadataSchema = z.object({
|
||||
version: z.string(),
|
||||
modes: z.array(z.string()).optional(),
|
||||
mcps: z.array(z.string()).optional(),
|
||||
files: z.array(z.string()).optional(),
|
||||
})
|
||||
export type ItemInstalledMetadata = z.infer<typeof ItemInstalledMetadataSchema>
|
||||
|
||||
const ScopeInstalledMetadataSchema = z.record(ItemInstalledMetadataSchema)
|
||||
export type ScopeInstalledMetadata = z.infer<typeof ScopeInstalledMetadataSchema>
|
||||
|
||||
// Full metadata structure
|
||||
export interface FullInstallatedMetadata {
|
||||
project: ScopeInstalledMetadata
|
||||
global: ScopeInstalledMetadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages installed marketplace item metadata for both project and global scopes.
|
||||
*/
|
||||
export class InstalledMetadataManager {
|
||||
public fullMetadata: FullInstallatedMetadata = {
|
||||
project: {},
|
||||
global: {},
|
||||
}
|
||||
|
||||
constructor(private readonly context: vscode.ExtensionContext) {}
|
||||
|
||||
/**
|
||||
* Loads and validates metadata from a YAML file at the given path.
|
||||
*
|
||||
* Returns an empty object if the file doesn't exist or is invalid.
|
||||
*
|
||||
* Throws errors for issues other than file not found or validation errors.
|
||||
*/
|
||||
private async loadMetadataFile(filePath: string): Promise<ScopeInstalledMetadata> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8")
|
||||
const data = yaml.load(content)
|
||||
|
||||
const validationResult = ScopeInstalledMetadataSchema.safeParse(data)
|
||||
|
||||
if (validationResult.success) {
|
||||
return validationResult.data
|
||||
} else {
|
||||
console.warn(
|
||||
`InstalledMetadataManager: Invalid metadata structure in ${filePath}. Validation errors:`,
|
||||
validationResult.error.flatten(),
|
||||
)
|
||||
return {} // Return empty for validation errors
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === "ENOENT") {
|
||||
return {} // File not found is expected
|
||||
}
|
||||
|
||||
// Re-throw unexpected errors (e.g., permissions issues, YAML parsing errors)
|
||||
console.error(`InstalledMetadataManager: Error reading or parsing metadata file ${filePath}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads project-specific installed metadata from .roo/.marketplace/metadata.yml.
|
||||
*/
|
||||
async reloadProject(): Promise<ScopeInstalledMetadata> {
|
||||
const metadataPath = await this.getMetadataFilePath("project")
|
||||
if (!metadataPath) {
|
||||
this.fullMetadata.project = {}
|
||||
} else {
|
||||
try {
|
||||
this.fullMetadata.project = await this.loadMetadataFile(metadataPath)
|
||||
console.debug("Project metadata reloaded:", this.fullMetadata.project)
|
||||
} catch (error) {
|
||||
console.error("InstalledMetadataManager: Failed to reload project metadata:", error)
|
||||
this.fullMetadata.project = {} // Reset on load failure
|
||||
}
|
||||
}
|
||||
return this.fullMetadata.project
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads global installed metadata from the extension's global storage.
|
||||
*/
|
||||
async reloadGlobal(): Promise<ScopeInstalledMetadata> {
|
||||
const metadataPath = await this.getMetadataFilePath("global")
|
||||
if (!metadataPath) {
|
||||
this.fullMetadata.global = {}
|
||||
} else {
|
||||
try {
|
||||
this.fullMetadata.global = await this.loadMetadataFile(metadataPath)
|
||||
console.debug("Global metadata reloaded:", this.fullMetadata.global)
|
||||
} catch (error) {
|
||||
console.error("InstalledMetadataManager: Failed to reload global metadata:", error)
|
||||
this.fullMetadata.global = {} // Reset on load failure
|
||||
}
|
||||
}
|
||||
return this.fullMetadata.global
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the metadata for a specific installed item.
|
||||
* @param scope The scope ('project' or 'global')
|
||||
* @param itemId The ID of the item
|
||||
* @returns The item's metadata or undefined if not found.
|
||||
*/
|
||||
getInstalledItem(scope: "project" | "global", itemId: string): ItemInstalledMetadata | undefined {
|
||||
return this.fullMetadata[scope]?.[itemId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file path for the metadata file based on the scope.
|
||||
* @param scope The scope ('project' or 'global')
|
||||
* @returns The full file path or undefined if scope is project and no workspace is open.
|
||||
*/
|
||||
private async getMetadataFilePath(scope: "project" | "global"): Promise<string | undefined> {
|
||||
if (scope === "project") {
|
||||
if (!vscode.workspace.workspaceFolders?.length) {
|
||||
console.error("InstalledMetadataManager: Cannot get project metadata path, no workspace folder open.")
|
||||
return undefined
|
||||
}
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders[0].uri.fsPath
|
||||
return path.join(workspaceFolder, ".roo", ".marketplace", "metadata.yml")
|
||||
} else {
|
||||
// Global scope
|
||||
try {
|
||||
const globalSettingsPath = await ensureSettingsDirectoryExists(this.context)
|
||||
return path.join(globalSettingsPath, ".marketplace", "metadata.yml")
|
||||
} catch (error) {
|
||||
console.error("InstalledMetadataManager: Failed to get global settings directory path:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the metadata for a given scope to its corresponding YAML file.
|
||||
*
|
||||
* Throws an error if the file path cannot be determined or if saving fails.
|
||||
*
|
||||
* @param scope The scope ('project' or 'global')
|
||||
* @param metadata The metadata object to save.
|
||||
*/
|
||||
private async saveMetadataFile(scope: "project" | "global", metadata: ScopeInstalledMetadata): Promise<void> {
|
||||
const filePath = await this.getMetadataFilePath(scope)
|
||||
if (!filePath) {
|
||||
throw new Error(`InstalledMetadataManager: Could not determine metadata file path for scope '${scope}'.`)
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure the directory exists
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
|
||||
// Serialize metadata to YAML
|
||||
const yamlContent = yaml.dump(metadata)
|
||||
|
||||
// Write to file
|
||||
await fs.writeFile(filePath, yamlContent, "utf-8")
|
||||
console.debug(`InstalledMetadataManager: Metadata saved successfully to ${filePath}`)
|
||||
} catch (error) {
|
||||
console.error(`InstalledMetadataManager: Error saving metadata file ${filePath}:`, error)
|
||||
throw error // Re-throw save errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or updates metadata for an installed item and saves it.
|
||||
* @param scope The scope ('project' or 'global')
|
||||
* @param itemId The ID of the item
|
||||
* @param details The metadata details of the item
|
||||
*/
|
||||
async addInstalledItem(scope: "project" | "global", itemId: string, details: ItemInstalledMetadata): Promise<void> {
|
||||
// Add/update the item
|
||||
this.fullMetadata[scope][itemId] = details
|
||||
|
||||
// Save the updated metadata for the entire scope
|
||||
await this.saveMetadataFile(scope, this.fullMetadata[scope])
|
||||
console.log(`Installed item added/updated: ${scope}/${itemId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes metadata for an installed item and saves the changes.
|
||||
* @param scope The scope ('project' or 'global')
|
||||
* @param itemId The ID of the item
|
||||
*/
|
||||
async removeInstalledItem(scope: "project" | "global", itemId: string): Promise<void> {
|
||||
// Check if item exists
|
||||
if (this.fullMetadata[scope]?.[itemId]) {
|
||||
delete this.fullMetadata[scope][itemId]
|
||||
|
||||
// Save the updated metadata
|
||||
await this.saveMetadataFile(scope, this.fullMetadata[scope])
|
||||
console.log(`Installed item removed: ${scope}/${itemId}`)
|
||||
} else {
|
||||
console.warn(`InstalledMetadataManager: Item not found for removal: ${scope}/${itemId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
ComponentMetadata,
|
||||
LocalizationOptions,
|
||||
InstallMarketplaceItemOptions,
|
||||
RemoveInstalledMarketplaceItemOptions,
|
||||
} from "./types"
|
||||
import { getUserLocale } from "./utils"
|
||||
import { GlobalFileNames } from "../../shared/globalFileNames"
|
||||
|
|
@ -17,6 +18,7 @@ import { assertsMpContext, createHookable, MarketplaceContext, registerMarketpla
|
|||
import { assertsBinarySha256, unpackFromUint8, extractRocketConfigFromUint8 } from "config-rocket/cli"
|
||||
import { getPanel } from "../../activate/registerCommands"
|
||||
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
|
||||
import { InstalledMetadataManager, ItemInstalledMetadata } from "./InstalledMetadataManager"
|
||||
|
||||
/**
|
||||
* Service for managing marketplace data
|
||||
|
|
@ -25,6 +27,8 @@ export class MarketplaceManager {
|
|||
private currentItems: MarketplaceItem[] = []
|
||||
private static readonly CACHE_EXPIRY_MS = 3600000 // 1 hour
|
||||
|
||||
IMM: InstalledMetadataManager
|
||||
|
||||
private gitFetcher: GitFetcher
|
||||
private cache: Map<string, { data: MarketplaceRepository; timestamp: number }> = new Map()
|
||||
public isFetching = false
|
||||
|
|
@ -40,6 +44,10 @@ export class MarketplaceManager {
|
|||
fallbackLocale: "en",
|
||||
}
|
||||
this.gitFetcher = new GitFetcher(context, localizationOptions)
|
||||
this.IMM = new InstalledMetadataManager(context)
|
||||
// Initial loading for the metadatas
|
||||
void this.IMM.reloadProject()
|
||||
void this.IMM.reloadGlobal()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -574,21 +582,32 @@ export class MarketplaceManager {
|
|||
}
|
||||
}
|
||||
|
||||
async installMarketplaceItem(item: MarketplaceItem, options?: InstallMarketplaceItemOptions): Promise<void | any> {
|
||||
/**
|
||||
* Resolves the cwd for the specified target scope
|
||||
*/
|
||||
async resolveScopeCwd(target: InstallMarketplaceItemOptions["target"]): Promise<string> {
|
||||
if (target === "project" && !vscode.workspace.workspaceFolders?.length)
|
||||
throw new Error("Cannot load current workspace folder")
|
||||
|
||||
return target === "project"
|
||||
? vscode.workspace.workspaceFolders![0].uri.fsPath
|
||||
: await ensureSettingsDirectoryExists(this.context)
|
||||
}
|
||||
|
||||
async installMarketplaceItem(
|
||||
item: MarketplaceItem,
|
||||
options?: InstallMarketplaceItemOptions,
|
||||
): Promise<"$COMMIT" | any> {
|
||||
// Temporary added due to hosting with _doInstall
|
||||
const _IMM = this.IMM
|
||||
|
||||
const { target = "project", parameters } = options || {}
|
||||
|
||||
vscode.window.showInformationMessage(`Installing item: "${item.name}"`)
|
||||
|
||||
if (target === "project" && !vscode.workspace.workspaceFolders?.length)
|
||||
return vscode.window.showErrorMessage("Cannot load current workspace folder")
|
||||
const cwd = await this.resolveScopeCwd(target)
|
||||
|
||||
const cwd =
|
||||
target === "project"
|
||||
? vscode.workspace.workspaceFolders![0].uri.fsPath
|
||||
: await ensureSettingsDirectoryExists(this.context)
|
||||
|
||||
if (!item.binaryUrl || !item.binaryHash)
|
||||
return vscode.window.showErrorMessage("Item does not have a binary URL or hash")
|
||||
if (!item.binaryUrl || !item.binaryHash) throw new Error("Item does not have a binary URL or hash.")
|
||||
|
||||
// Creates `mpContext` to delegate context to `roo-rocket`
|
||||
const mpContext: MarketplaceContext =
|
||||
|
|
@ -603,6 +622,7 @@ export class MarketplaceManager {
|
|||
}
|
||||
assertsMpContext(mpContext)
|
||||
|
||||
// Fetch the binary
|
||||
const binaryUint8 = await fetchBinary(item.binaryUrl)
|
||||
|
||||
// `parameters` only exists in flows where we already check everything and then requires parameters input
|
||||
|
|
@ -615,7 +635,6 @@ export class MarketplaceManager {
|
|||
// Extract config and check if it has prompt parameters.
|
||||
const config = await extractRocketConfigFromUint8(binaryUint8)
|
||||
const configHavePromptParameters = config?.parameters?.some((param) => param.resolver.operation === "prompt")
|
||||
|
||||
if (configHavePromptParameters) {
|
||||
vscode.window.showInformationMessage(`"${item.name}" is configurable, opening UI form...`)
|
||||
|
||||
|
|
@ -629,12 +648,12 @@ export class MarketplaceManager {
|
|||
},
|
||||
})
|
||||
} else {
|
||||
vscode.window.showErrorMessage("Could not open UI form: Webview panel not found.")
|
||||
throw new Error("Could not open UI form: Webview panel not found.")
|
||||
}
|
||||
return false // Stop installation process here, wait for parameters from frontend
|
||||
}
|
||||
|
||||
await _doInstall()
|
||||
return await _doInstall()
|
||||
async function _doInstall() {
|
||||
// Create a custom hookable instance to support global installations
|
||||
const customHookable = createHookable()
|
||||
|
|
@ -650,13 +669,101 @@ export class MarketplaceManager {
|
|||
if (parameter.resolver.operation === "prompt") throw new Error("Unexpected prompt operation")
|
||||
})
|
||||
|
||||
vscode.window.showInformationMessage(`"${item.name}" is installing...`)
|
||||
// Register hooks to build `ItemInstalledMetadata`
|
||||
const itemInstalledMetadata: ItemInstalledMetadata = {
|
||||
version: item.version,
|
||||
modes: [],
|
||||
mcps: [],
|
||||
files: [],
|
||||
}
|
||||
customHookable.hook("onFileOutput", ({ filePath, data }) => {
|
||||
if (filePath.endsWith("/.roomodes")) {
|
||||
const parsedData = JSON.parse(data)
|
||||
if (parsedData?.customModes?.length) {
|
||||
parsedData.customModes.forEach((mode: any) => {
|
||||
itemInstalledMetadata.modes?.push(mode.slug)
|
||||
})
|
||||
}
|
||||
} else if (filePath.endsWith("/.roo/mcp.json")) {
|
||||
const parsedData = JSON.parse(data)
|
||||
const mcpSlugs = Object.keys(parsedData?.mcpServers ?? {})
|
||||
if (mcpSlugs.length) {
|
||||
mcpSlugs.forEach((mcpSlug: any) => {
|
||||
itemInstalledMetadata.mcps?.push(mcpSlug)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
itemInstalledMetadata.files?.push(path.relative(cwd, filePath))
|
||||
}
|
||||
})
|
||||
|
||||
vscode.window.showInformationMessage(`"${item.name}" is unpacking...`)
|
||||
await unpackFromUint8(binaryUint8, {
|
||||
hookable: customHookable,
|
||||
nonAssemblyBehavior: true,
|
||||
cwd,
|
||||
}).then(() => {
|
||||
_IMM.addInstalledItem("project", item.id, itemInstalledMetadata)
|
||||
})
|
||||
vscode.window.showInformationMessage(`"${item.name}" installed successfully`)
|
||||
|
||||
return "$COMMIT"
|
||||
}
|
||||
}
|
||||
|
||||
async removeInstalledMarketplaceItem(
|
||||
item: MarketplaceItem,
|
||||
options?: RemoveInstalledMarketplaceItemOptions,
|
||||
): Promise<"$COMMIT" | any> {
|
||||
const { target = "project" } = options || {}
|
||||
|
||||
vscode.window.showInformationMessage(`Removing item: "${item.name}"`)
|
||||
|
||||
const cwd = await this.resolveScopeCwd(target)
|
||||
const modesFilePath = path.join(cwd, target === "project" ? ".roomodes" : GlobalFileNames.customModes)
|
||||
const mcpsFilePath = path.join(cwd, target === "project" ? ".roo/mcp.json" : GlobalFileNames.mcpSettings)
|
||||
|
||||
const itemInstalledMetadata = this.IMM.getInstalledItem(target, item.id)
|
||||
if (itemInstalledMetadata) {
|
||||
if (itemInstalledMetadata.modes) {
|
||||
if (await fs.access(modesFilePath).catch(() => true))
|
||||
vscode.window.showWarningMessage(`"${item.name}": modes file not found`)
|
||||
else {
|
||||
const parsedModesFile = JSON.parse(await fs.readFile(modesFilePath, "utf-8"))
|
||||
parsedModesFile.customModes = parsedModesFile.customModes.filter(
|
||||
(m: any) => !itemInstalledMetadata.modes!.includes(m.slug),
|
||||
)
|
||||
await fs.writeFile(modesFilePath, JSON.stringify(parsedModesFile, null, 2), "utf-8")
|
||||
}
|
||||
}
|
||||
if (itemInstalledMetadata.mcps) {
|
||||
if (await fs.access(mcpsFilePath).catch(() => true))
|
||||
vscode.window.showWarningMessage(`"${item.name}": mcps file not found`)
|
||||
else {
|
||||
const parsedMcpsFile = JSON.parse(await fs.readFile(mcpsFilePath, "utf-8"))
|
||||
itemInstalledMetadata.mcps.forEach((mcp) => {
|
||||
delete parsedMcpsFile.mcpServers[mcp]
|
||||
})
|
||||
await fs.writeFile(mcpsFilePath, JSON.stringify(parsedMcpsFile, null, 2), "utf-8")
|
||||
}
|
||||
}
|
||||
if (itemInstalledMetadata.files) {
|
||||
for (const file of itemInstalledMetadata.files) {
|
||||
try {
|
||||
await fs.rm(path.join(cwd, file))
|
||||
} catch (error) {
|
||||
vscode.window.showWarningMessage(
|
||||
`"${item.name}": failed to remove file "${file}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.IMM.removeInstalledItem(target, item.id)
|
||||
vscode.window.showInformationMessage(`"${item.name}" removed successfully`)
|
||||
return "$COMMIT"
|
||||
} else {
|
||||
throw new Error(`is not installed in scope "${target}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -241,14 +241,15 @@ export class MetadataScanner {
|
|||
// Always use the original root directory for path calculations
|
||||
const effectiveRootDir = this.originalRootDir || rootDir
|
||||
// Always calculate path relative to the original root directory
|
||||
const fullPath = path.relative(effectiveRootDir, componentDir).replace(/\\/g, "/")
|
||||
const relativePath = path.relative(effectiveRootDir, componentDir).replace(/\\/g, "/")
|
||||
// Don't encode spaces in URL to match test expectations
|
||||
const urlPath = fullPath
|
||||
const urlPath = relativePath
|
||||
.split("/")
|
||||
.map((part) => encodeURIComponent(part))
|
||||
.join("/")
|
||||
// Create the item with the correct path and URL
|
||||
return {
|
||||
id: metadata.id || `${metadata.type}#${relativePath || metadata.name}`,
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
type: metadata.type,
|
||||
|
|
@ -259,7 +260,7 @@ export class MetadataScanner {
|
|||
url: `${repoUrl}/tree/main/${urlPath}`,
|
||||
repoUrl,
|
||||
sourceName,
|
||||
path: fullPath,
|
||||
path: relativePath,
|
||||
lastUpdated: await this.getLastModifiedDate(componentDir),
|
||||
items: [], // Initialize empty items array for all components
|
||||
author: metadata.author,
|
||||
|
|
|
|||
|
|
@ -41,15 +41,19 @@ describe("MarketplaceManager", () => {
|
|||
it("should correctly filter items by search term", () => {
|
||||
const items: MarketplaceItem[] = [
|
||||
{
|
||||
id: "test-item-1",
|
||||
name: "Test Item 1",
|
||||
description: "First test item",
|
||||
version: "zxc",
|
||||
type: "mode",
|
||||
url: "test1",
|
||||
repoUrl: "test1",
|
||||
},
|
||||
{
|
||||
id: "another-item",
|
||||
name: "Another Item",
|
||||
description: "Second item",
|
||||
version: "zxc",
|
||||
type: "mode",
|
||||
url: "test2",
|
||||
repoUrl: "test2",
|
||||
|
|
@ -65,15 +69,19 @@ describe("MarketplaceManager", () => {
|
|||
it("should correctly filter items by type", () => {
|
||||
const items: MarketplaceItem[] = [
|
||||
{
|
||||
id: "mode-item",
|
||||
name: "Mode Item",
|
||||
description: "A mode",
|
||||
version: "zxc",
|
||||
type: "mode",
|
||||
url: "test1",
|
||||
repoUrl: "test1",
|
||||
},
|
||||
{
|
||||
id: "server-item",
|
||||
name: "Server Item",
|
||||
description: "A server",
|
||||
version: "zxc",
|
||||
type: "mcp",
|
||||
url: "test2",
|
||||
repoUrl: "test2",
|
||||
|
|
@ -89,15 +97,19 @@ describe("MarketplaceManager", () => {
|
|||
it("should preserve original items when filtering", () => {
|
||||
const items: MarketplaceItem[] = [
|
||||
{
|
||||
id: "test-item-1",
|
||||
name: "Test Item 1",
|
||||
description: "First test item",
|
||||
version: "zxc",
|
||||
type: "mode",
|
||||
url: "test1",
|
||||
repoUrl: "test1",
|
||||
},
|
||||
{
|
||||
id: "another-item",
|
||||
name: "Another Item",
|
||||
description: "Second item",
|
||||
version: "zxc",
|
||||
type: "mode",
|
||||
url: "test2",
|
||||
repoUrl: "test2",
|
||||
|
|
@ -125,8 +137,10 @@ describe("MarketplaceManager", () => {
|
|||
test("should include package with MCP server subcomponent when filtering by type 'mcp'", () => {
|
||||
const items: MarketplaceItem[] = [
|
||||
{
|
||||
id: "data-platform-package",
|
||||
name: "Data Platform Package",
|
||||
description: "A package containing MCP servers",
|
||||
version: "zxc",
|
||||
type: "package" as MarketplaceItemType,
|
||||
url: "test/package",
|
||||
repoUrl: "https://example.com",
|
||||
|
|
@ -144,8 +158,10 @@ describe("MarketplaceManager", () => {
|
|||
],
|
||||
},
|
||||
{
|
||||
id: "standalone-server",
|
||||
name: "Standalone Server",
|
||||
description: "A standalone MCP server",
|
||||
version: "zxc",
|
||||
type: "mcp" as MarketplaceItemType,
|
||||
url: "test/server",
|
||||
repoUrl: "https://example.com",
|
||||
|
|
@ -168,8 +184,10 @@ describe("MarketplaceManager", () => {
|
|||
test("should include package when filtering by subcomponent type", () => {
|
||||
const items: MarketplaceItem[] = [
|
||||
{
|
||||
id: "data-platform-package",
|
||||
name: "Data Platform Package",
|
||||
description: "A package containing MCP servers",
|
||||
version: "zxc",
|
||||
type: "package" as MarketplaceItemType,
|
||||
url: "test/package",
|
||||
repoUrl: "https://example.com",
|
||||
|
|
@ -200,8 +218,10 @@ describe("MarketplaceManager", () => {
|
|||
// Create test items
|
||||
typeFilterTestItems = [
|
||||
{
|
||||
id: "test-package",
|
||||
name: "Test Package",
|
||||
description: "A test package",
|
||||
version: "zxc",
|
||||
type: "package",
|
||||
url: "test/package",
|
||||
repoUrl: "https://example.com",
|
||||
|
|
@ -229,8 +249,10 @@ describe("MarketplaceManager", () => {
|
|||
],
|
||||
},
|
||||
{
|
||||
id: "test-mode",
|
||||
name: "Test Mode",
|
||||
description: "A standalone test mode",
|
||||
version: "zxc",
|
||||
type: "mode",
|
||||
url: "test/standalone-mode",
|
||||
repoUrl: "https://example.com",
|
||||
|
|
@ -257,8 +279,10 @@ describe("MarketplaceManager", () => {
|
|||
test("should not include package when filtering by type with no matching subcomponents", () => {
|
||||
// Create a package with no matching subcomponents
|
||||
const noMatchPackage: MarketplaceItem = {
|
||||
id: "no-match-package",
|
||||
name: "No Match Package",
|
||||
description: "A package with no matching subcomponents",
|
||||
version: "zxc",
|
||||
type: "package",
|
||||
url: "test/no-match",
|
||||
repoUrl: "https://example.com",
|
||||
|
|
@ -286,8 +310,10 @@ describe("MarketplaceManager", () => {
|
|||
test("should handle package with no subcomponents", () => {
|
||||
// Create a package with no subcomponents
|
||||
const noSubcomponentsPackage: MarketplaceItem = {
|
||||
id: "no-subcomponents-package",
|
||||
name: "No Subcomponents Package",
|
||||
description: "A package with no subcomponents",
|
||||
version: "zxc",
|
||||
type: "package",
|
||||
url: "test/no-subcomponents",
|
||||
repoUrl: "https://example.com",
|
||||
|
|
@ -307,8 +333,10 @@ describe("MarketplaceManager", () => {
|
|||
// Create test items
|
||||
consistencyTestItems = [
|
||||
{
|
||||
id: "test-package",
|
||||
name: "Test Package",
|
||||
description: "A test package",
|
||||
version: "zxc",
|
||||
type: "package",
|
||||
url: "test/package",
|
||||
repoUrl: "https://example.com",
|
||||
|
|
@ -369,6 +397,7 @@ describe("MarketplaceManager", () => {
|
|||
describe("sortItems with subcomponents", () => {
|
||||
const testItems: MarketplaceItem[] = [
|
||||
{
|
||||
id: "b-package",
|
||||
name: "B Package",
|
||||
description: "Package B",
|
||||
type: "package",
|
||||
|
|
@ -401,6 +430,7 @@ describe("MarketplaceManager", () => {
|
|||
],
|
||||
},
|
||||
{
|
||||
id: "a-package",
|
||||
name: "A Package",
|
||||
description: "Package A",
|
||||
type: "package",
|
||||
|
|
@ -447,6 +477,7 @@ describe("MarketplaceManager", () => {
|
|||
const itemsWithEmpty = [
|
||||
...testItems,
|
||||
{
|
||||
id: "c-package",
|
||||
name: "C Package",
|
||||
description: "Package C",
|
||||
type: "package" as const,
|
||||
|
|
@ -466,6 +497,7 @@ describe("MarketplaceManager", () => {
|
|||
it("should return all subcomponents with match info", () => {
|
||||
const testItems: MarketplaceItem[] = [
|
||||
{
|
||||
id: "data-platform-package",
|
||||
name: "Data Platform Package",
|
||||
description: "A test platform",
|
||||
type: "package",
|
||||
|
|
@ -561,6 +593,7 @@ describe("Source Attribution", () => {
|
|||
metadata: { name: "test", description: "test", version: "1.0.0" },
|
||||
items: [
|
||||
{
|
||||
id: "item-1",
|
||||
name: "Item 1",
|
||||
type: "mode",
|
||||
description: "Test item",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ import { z } from "zod"
|
|||
* Base metadata schema with common fields
|
||||
*/
|
||||
export const baseMetadataSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.string(),
|
||||
version: z.string().regex(/^\d+\.\d+\.\d+$/, "Version must be in semver format (e.g., 1.0.0)"),
|
||||
version: z.string(),
|
||||
binaryUrl: z.string().url("Binary URL must be a valid URL").optional(),
|
||||
binaryHash: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
|
|
@ -125,6 +126,7 @@ export const parameterSchema = z.record(z.string(), z.any())
|
|||
* Schema for a marketplace item
|
||||
*/
|
||||
export const marketplaceItemSchema = baseMetadataSchema.extend({
|
||||
id: z.string(),
|
||||
type: marketplaceItemTypeSchema,
|
||||
url: z.string(),
|
||||
repoUrl: z.string(),
|
||||
|
|
@ -173,5 +175,4 @@ export const marketplaceItemSchema = baseMetadataSchema.extend({
|
|||
})
|
||||
.optional(),
|
||||
parameters: z.record(z.string(), z.any()).optional(),
|
||||
version: z.string().optional(), // Override version to make it optional
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export type MarketplaceItemType = "mode" | "prompt" | "package" | "mcp"
|
|||
* Base metadata interface
|
||||
*/
|
||||
export interface BaseMetadata {
|
||||
id?: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
|
|
@ -69,9 +70,10 @@ export interface SubcomponentMetadata extends ComponentMetadata {
|
|||
}
|
||||
|
||||
/**
|
||||
* Represents an individual marketplace item
|
||||
* Represents an individual parsed marketplace item
|
||||
*/
|
||||
export interface MarketplaceItem {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: MarketplaceItemType
|
||||
|
|
@ -81,7 +83,7 @@ export interface MarketplaceItem {
|
|||
author?: string
|
||||
authorUrl?: string
|
||||
tags?: string[]
|
||||
version?: string
|
||||
version: string
|
||||
binaryUrl?: string
|
||||
binaryHash?: string
|
||||
lastUpdated?: string
|
||||
|
|
@ -136,7 +138,7 @@ export interface LocalizationOptions {
|
|||
|
||||
export interface InstallMarketplaceItemOptions {
|
||||
/**
|
||||
* Specify the installation target
|
||||
* Specify the target scope
|
||||
*
|
||||
* @default 'project'
|
||||
*/
|
||||
|
|
@ -146,3 +148,12 @@ export interface InstallMarketplaceItemOptions {
|
|||
*/
|
||||
parameters?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface RemoveInstalledMarketplaceItemOptions {
|
||||
/**
|
||||
* Specify the target scope
|
||||
*
|
||||
* @default 'project'
|
||||
*/
|
||||
target?: "global" | "project"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { McpServer } from "./mcp"
|
|||
import { Mode } from "./modes"
|
||||
import { MarketplaceItem, MarketplaceSource } from "../services/marketplace/types"
|
||||
import { RouterModels } from "./api"
|
||||
import { FullInstallatedMetadata } from "src/services/marketplace/InstalledMetadataManager"
|
||||
|
||||
export type { ApiConfigMeta, ToolProgressStatus }
|
||||
|
||||
|
|
@ -208,6 +209,7 @@ export type ExtensionState = Pick<
|
|||
settingsImportedAt?: number
|
||||
marketplaceSources?: MarketplaceSource[]
|
||||
marketplaceItems?: MarketplaceItem[]
|
||||
marketplaceInstalledMetadata?: FullInstallatedMetadata
|
||||
historyPreviewCollapsed?: boolean
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -125,18 +125,19 @@ export interface WebviewMessage {
|
|||
| "maxReadFileLine"
|
||||
| "searchFiles"
|
||||
| "toggleApiConfigPin"
|
||||
| "repositoryRefreshComplete"
|
||||
| "setHistoryPreviewCollapsed"
|
||||
| "openExternal"
|
||||
| "marketplaceSources"
|
||||
| "fetchMarketplaceItems"
|
||||
| "filterMarketplaceItems"
|
||||
| "marketplaceButtonClicked"
|
||||
| "installMarketplaceItem"
|
||||
| "refreshMarketplaceSource"
|
||||
| "repositoryRefreshComplete"
|
||||
| "openExternal"
|
||||
| "setHistoryPreviewCollapsed"
|
||||
| "installMarketplaceItem"
|
||||
| "installMarketplaceItemWithParameters"
|
||||
| "cancelMarketplaceInstall"
|
||||
| "openMarketplaceInstallSidebarWithConfig" // New message type
|
||||
| "removeInstalledMarketplaceItem"
|
||||
| "openMarketplaceInstallSidebarWithConfig"
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
askResponse?: ClineAskResponse
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { mkdir } from "fs/promises"
|
|||
import { join } from "path"
|
||||
import { ExtensionContext } from "vscode"
|
||||
|
||||
export async function getGlobalFsPath(context: ExtensionContext): Promise<string> {
|
||||
return context.globalStorageUri.fsPath
|
||||
}
|
||||
|
||||
export async function ensureSettingsDirectoryExists(context: ExtensionContext): Promise<string> {
|
||||
const settingsDir = join(context.globalStorageUri.fsPath, "settings")
|
||||
await mkdir(settingsDir, { recursive: true })
|
||||
|
|
|
|||
|
|
@ -318,8 +318,12 @@ const MarketplaceView: React.FC<MarketplaceViewProps> = ({ stateManager }) => {
|
|||
<div className="grid grid-cols-1 gap-4 pb-4">
|
||||
{items.map((item) => (
|
||||
<MarketplaceItemCard
|
||||
key={`${item.repoUrl}-${item.name}`}
|
||||
key={`${item.repoUrl}-${item.id}`}
|
||||
item={item}
|
||||
installed={{
|
||||
project: state.installedMetadata.project[item.id],
|
||||
global: state.installedMetadata.global[item.id],
|
||||
}}
|
||||
filters={state.filters}
|
||||
setFilters={(filters) =>
|
||||
manager.transition({
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { MarketplaceItem, MarketplaceSource, MatchInfo } from "../../../../src/s
|
|||
import { vscode } from "../../utils/vscode"
|
||||
import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
|
||||
import { DEFAULT_MARKETPLACE_SOURCE } from "../../../../src/services/marketplace/constants"
|
||||
import { FullInstallatedMetadata } from "../../../../src/services/marketplace/InstalledMetadataManager"
|
||||
|
||||
export interface ViewState {
|
||||
allItems: MarketplaceItem[]
|
||||
|
|
@ -23,6 +24,7 @@ export interface ViewState {
|
|||
activeTab: "browse" | "sources"
|
||||
refreshingUrls: string[]
|
||||
sources: MarketplaceSource[]
|
||||
installedMetadata: FullInstallatedMetadata
|
||||
filters: {
|
||||
type: string
|
||||
search: string
|
||||
|
|
@ -34,6 +36,12 @@ export interface ViewState {
|
|||
}
|
||||
}
|
||||
|
||||
// Define a default empty metadata structure
|
||||
const defaultInstalledMetadata: FullInstallatedMetadata = {
|
||||
project: {},
|
||||
global: {},
|
||||
}
|
||||
|
||||
type TransitionPayloads = {
|
||||
FETCH_ITEMS: undefined
|
||||
FETCH_COMPLETE: { items: MarketplaceItem[] }
|
||||
|
|
@ -79,6 +87,7 @@ export class MarketplaceViewStateManager {
|
|||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [DEFAULT_MARKETPLACE_SOURCE],
|
||||
installedMetadata: defaultInstalledMetadata,
|
||||
filters: {
|
||||
type: "",
|
||||
search: "",
|
||||
|
|
@ -139,6 +148,7 @@ export class MarketplaceViewStateManager {
|
|||
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] : []
|
||||
const installedMetadata = this.state.installedMetadata
|
||||
|
||||
// Create minimal new state object
|
||||
return {
|
||||
|
|
@ -147,6 +157,7 @@ export class MarketplaceViewStateManager {
|
|||
displayItems,
|
||||
refreshingUrls,
|
||||
sources: this.state.sources.length ? [...this.state.sources] : [DEFAULT_MARKETPLACE_SOURCE],
|
||||
installedMetadata,
|
||||
filters: {
|
||||
...this.state.filters,
|
||||
tags,
|
||||
|
|
@ -166,13 +177,13 @@ export class MarketplaceViewStateManager {
|
|||
// This is used during timeout handling to prevent disrupting the user
|
||||
this.stateChangeHandlers.forEach((handler) => {
|
||||
// Store the current active tab
|
||||
const currentTab = newState.activeTab;
|
||||
const currentTab = newState.activeTab
|
||||
|
||||
// Create a state update that won't change the active tab
|
||||
const safeState = {
|
||||
...newState,
|
||||
// Don't change these properties to avoid UI disruption
|
||||
activeTab: currentTab
|
||||
activeTab: currentTab,
|
||||
}
|
||||
handler(safeState)
|
||||
})
|
||||
|
|
@ -251,7 +262,7 @@ export class MarketplaceViewStateManager {
|
|||
// Only update the isFetching status without affecting other UI elements
|
||||
return {
|
||||
...state,
|
||||
isFetching: false
|
||||
isFetching: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -589,17 +600,28 @@ export class MarketplaceViewStateManager {
|
|||
}
|
||||
|
||||
// Update sources if present
|
||||
if (message.state.sources || message.state.marketplaceSources) {
|
||||
const sources = message.state.marketplaceSources || message.state.sources
|
||||
const sources = message.state.marketplaceSources || message.state.sources
|
||||
if (sources) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
sources: sources?.length > 0 ? [...sources] : [DEFAULT_MARKETPLACE_SOURCE],
|
||||
sources: sources.length > 0 ? [...sources] : [DEFAULT_MARKETPLACE_SOURCE],
|
||||
}
|
||||
this.notifyStateChange()
|
||||
// Don't notify yet, combine with other state updates below
|
||||
}
|
||||
|
||||
// Update installedMetadata if present
|
||||
const installedMetadata = message.state.marketplaceInstalledMetadata
|
||||
if (installedMetadata) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
installedMetadata,
|
||||
}
|
||||
// Don't notify yet
|
||||
}
|
||||
|
||||
// Handle state updates for marketplace items
|
||||
if (message.state.marketplaceItems !== undefined) {
|
||||
const marketplaceItems = message.state.marketplaceItems
|
||||
if (marketplaceItems !== undefined) {
|
||||
const newItems = message.state.marketplaceItems
|
||||
const currentItems = this.state.allItems || []
|
||||
const hasNewItems = newItems.length > 0
|
||||
|
|
@ -618,16 +640,17 @@ export class MarketplaceViewStateManager {
|
|||
allItems: sortedItems,
|
||||
displayItems: newDisplayItems,
|
||||
}
|
||||
|
||||
// Only notify with full state update if we're in the browse tab
|
||||
// or if this is the first time we're getting items
|
||||
if (isOnBrowseTab || !hasCurrentItems) {
|
||||
this.notifyStateChange()
|
||||
} else {
|
||||
// If we're not in the browse tab, update state but don't force a tab switch
|
||||
this.notifyStateChange(true) // preserve tab
|
||||
}
|
||||
// Notification is handled below after all state parts are processed
|
||||
}
|
||||
|
||||
// Notify state change once after processing all parts (sources, metadata, items)
|
||||
// This prevents multiple redraws for a single 'state' message
|
||||
// Determine if notification should preserve tab based on item update logic
|
||||
const isOnBrowseTab = this.state.activeTab === "browse"
|
||||
const hasCurrentItems = (this.state.allItems || []).length > 0
|
||||
const preserveTab = !isOnBrowseTab && hasCurrentItems && marketplaceItems !== undefined
|
||||
|
||||
this.notifyStateChange(preserveTab)
|
||||
}
|
||||
|
||||
// Handle repository refresh completion
|
||||
|
|
|
|||
|
|
@ -1,17 +1,26 @@
|
|||
import React, { useCallback, useMemo } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { MoreVertical, ExternalLink, Download } from "lucide-react"
|
||||
import { InstallMarketplaceItemOptions, MarketplaceItem } from "../../../../../src/services/marketplace/types"
|
||||
import { MoreVertical, ExternalLink, Download, Trash } from "lucide-react"
|
||||
import {
|
||||
InstallMarketplaceItemOptions,
|
||||
MarketplaceItem,
|
||||
RemoveInstalledMarketplaceItemOptions,
|
||||
} from "../../../../../src/services/marketplace/types"
|
||||
import { vscode } from "@/utils/vscode"
|
||||
import { useAppTranslation } from "@/i18n/TranslationContext"
|
||||
import { isValidUrl } from "@roo/utils/url"
|
||||
import { ItemInstalledMetadata } from "@roo/services/marketplace/InstalledMetadataManager"
|
||||
|
||||
interface MarketplaceItemActionsMenuProps {
|
||||
item: MarketplaceItem
|
||||
installed: {
|
||||
project: ItemInstalledMetadata | undefined
|
||||
global: ItemInstalledMetadata | undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const MarketplaceItemActionsMenu: React.FC<MarketplaceItemActionsMenuProps> = ({ item }) => {
|
||||
export const MarketplaceItemActionsMenu: React.FC<MarketplaceItemActionsMenuProps> = ({ item, installed }) => {
|
||||
const { t } = useAppTranslation()
|
||||
|
||||
const itemSourceUrl = useMemo(() => {
|
||||
|
|
@ -45,6 +54,14 @@ export const MarketplaceItemActionsMenu: React.FC<MarketplaceItemActionsMenuProp
|
|||
})
|
||||
}
|
||||
|
||||
const handleRemove = (options?: RemoveInstalledMarketplaceItemOptions) => {
|
||||
vscode.postMessage({
|
||||
type: "removeInstalledMarketplaceItem",
|
||||
mpItem: item,
|
||||
mpInstallOptions: options,
|
||||
})
|
||||
}
|
||||
|
||||
const showInstallButton = true
|
||||
|
||||
return (
|
||||
|
|
@ -66,7 +83,7 @@ export const MarketplaceItemActionsMenu: React.FC<MarketplaceItemActionsMenuProp
|
|||
|
||||
{/* Install (Project) */}
|
||||
{showInstallButton && (
|
||||
<DropdownMenuItem onClick={() => handleInstall({ target: "project" })}>
|
||||
<DropdownMenuItem className="" onClick={() => handleInstall({ target: "project" })}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<span>{t("marketplace:items.card.installProject")}</span>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -79,6 +96,22 @@ export const MarketplaceItemActionsMenu: React.FC<MarketplaceItemActionsMenuProp
|
|||
<span>{t("marketplace:items.card.installGlobal")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Remove (Project) */}
|
||||
{installed.project && (
|
||||
<DropdownMenuItem onClick={() => handleRemove({ target: "project" })}>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
<span>{t("marketplace:items.card.removeProject")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Remove (Global) */}
|
||||
{installed.global && (
|
||||
<DropdownMenuItem onClick={() => handleRemove({ target: "global" })}>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
<span>{t("marketplace:items.card.removeGlobal")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,9 +8,14 @@ import { ViewState } from "../MarketplaceViewStateManager"
|
|||
import { useAppTranslation } from "@/i18n/TranslationContext"
|
||||
import { MarketplaceItemActionsMenu } from "./MarketplaceItemActionsMenu"
|
||||
import { isValidUrl } from "@roo/utils/url"
|
||||
import { ItemInstalledMetadata } from "@roo/services/marketplace/InstalledMetadataManager"
|
||||
|
||||
interface MarketplaceItemCardProps {
|
||||
item: MarketplaceItem
|
||||
installed: {
|
||||
project: ItemInstalledMetadata | undefined
|
||||
global: ItemInstalledMetadata | undefined
|
||||
}
|
||||
filters: ViewState["filters"]
|
||||
setFilters: (filters: Partial<ViewState["filters"]>) => void
|
||||
activeTab: ViewState["activeTab"]
|
||||
|
|
@ -19,6 +24,7 @@ interface MarketplaceItemCardProps {
|
|||
|
||||
export const MarketplaceItemCard: React.FC<MarketplaceItemCardProps> = ({
|
||||
item,
|
||||
installed,
|
||||
filters,
|
||||
setFilters,
|
||||
activeTab,
|
||||
|
|
@ -68,7 +74,14 @@ export const MarketplaceItemCard: React.FC<MarketplaceItemCardProps> = ({
|
|||
<div className="border border-vscode-panel-border rounded-md p-4 bg-vscode-panel-background">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-vscode-foreground">{item.name}</h3>
|
||||
<h3
|
||||
className={
|
||||
"text-lg font-semibold text-vscode-foreground" +
|
||||
// Example currently highlights installed item
|
||||
(installed.project || installed.global ? " bg-amber-300" : "")
|
||||
}>
|
||||
{item.name}
|
||||
</h3>
|
||||
{item.authorUrl && isValidUrl(item.authorUrl) ? (
|
||||
<p className="text-sm text-vscode-descriptionForeground">
|
||||
{item.author ? (
|
||||
|
|
@ -163,7 +176,7 @@ export const MarketplaceItemCard: React.FC<MarketplaceItemCardProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<MarketplaceItemActionsMenu item={item} />
|
||||
<MarketplaceItemActionsMenu item={item} installed={installed} />
|
||||
</div>
|
||||
|
||||
{item.type === "package" && (
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ export function useStateManager(existingManager?: MarketplaceViewStateManager) {
|
|||
prevState.displayItems !== newState.displayItems ||
|
||||
prevState.filters !== newState.filters ||
|
||||
prevState.sources !== newState.sources ||
|
||||
prevState.refreshingUrls !== newState.refreshingUrls
|
||||
prevState.refreshingUrls !== newState.refreshingUrls ||
|
||||
prevState.installedMetadata !== newState.installedMetadata
|
||||
|
||||
return hasChanged ? newState : prevState
|
||||
})
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@
|
|||
"from": "from {{source}}",
|
||||
"installProject": "Install (Project)",
|
||||
"installGlobal": "Install (Global)",
|
||||
"removeProject": "Remove (Project)",
|
||||
"removeGlobal": "Remove (Global)",
|
||||
"viewSource": "View",
|
||||
"viewOnSource": "View on {{source}}"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue