diff --git a/package-lock.json b/package-lock.json index 914e6cf8e4..c381052910 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index faed5cde96..9cb636d455 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b0ec06f0a2..fcd5514a53 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -74,7 +74,7 @@ export class ClineProvider extends EventEmitter 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 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 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 implements const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get("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 implements version: this.context.extension?.packageJSON?.version ?? "", marketplaceItems, marketplaceSources: marketplaceSources ?? [], + marketplaceInstalledMetadata, apiConfiguration, customInstructions, alwaysAllowReadOnly: alwaysAllowReadOnly ?? false, diff --git a/src/core/webview/marketplaceMessageHandler.ts b/src/core/webview/marketplaceMessageHandler.ts index 4c8a61a229..0af6a40ba5 100644 --- a/src/core/webview/marketplaceMessageHandler.ts +++ b/src/core/webview/marketplaceMessageHandler.ts @@ -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() + 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() - 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 } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 07147a1255..59bb72a726 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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 ( diff --git a/src/extension.ts b/src/extension.ts index a237a8c70e..f03c64ff3e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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() diff --git a/src/services/marketplace/InstalledMetadataManager.ts b/src/services/marketplace/InstalledMetadataManager.ts new file mode 100644 index 0000000000..924a6166a3 --- /dev/null +++ b/src/services/marketplace/InstalledMetadataManager.ts @@ -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 + +const ScopeInstalledMetadataSchema = z.record(ItemInstalledMetadataSchema) +export type ScopeInstalledMetadata = z.infer + +// 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + // 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}`) + } + } +} diff --git a/src/services/marketplace/MarketplaceManager.ts b/src/services/marketplace/MarketplaceManager.ts index b5c79190f9..694a2f2f77 100644 --- a/src/services/marketplace/MarketplaceManager.ts +++ b/src/services/marketplace/MarketplaceManager.ts @@ -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 = 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 { + /** + * Resolves the cwd for the specified target scope + */ + async resolveScopeCwd(target: InstallMarketplaceItemOptions["target"]): Promise { + 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}"`) } } } diff --git a/src/services/marketplace/MetadataScanner.ts b/src/services/marketplace/MetadataScanner.ts index 2b1651a1b1..890d25fc10 100644 --- a/src/services/marketplace/MetadataScanner.ts +++ b/src/services/marketplace/MetadataScanner.ts @@ -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, diff --git a/src/services/marketplace/__tests__/MarketplaceManager.test.ts b/src/services/marketplace/__tests__/MarketplaceManager.test.ts index 09ff1f2e00..8ec7585552 100644 --- a/src/services/marketplace/__tests__/MarketplaceManager.test.ts +++ b/src/services/marketplace/__tests__/MarketplaceManager.test.ts @@ -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", diff --git a/src/services/marketplace/schemas.ts b/src/services/marketplace/schemas.ts index 0d5304a884..5793bccd2d 100644 --- a/src/services/marketplace/schemas.ts +++ b/src/services/marketplace/schemas.ts @@ -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 }) diff --git a/src/services/marketplace/types.ts b/src/services/marketplace/types.ts index 0bd2037394..9fa2bcb98d 100644 --- a/src/services/marketplace/types.ts +++ b/src/services/marketplace/types.ts @@ -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 } + +export interface RemoveInstalledMarketplaceItemOptions { + /** + * Specify the target scope + * + * @default 'project' + */ + target?: "global" | "project" +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 9e9eeac35b..e7cdfda2e4 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -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 } diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 98d64a3cf5..5f3d4d0608 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -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 diff --git a/src/utils/globalContext.ts b/src/utils/globalContext.ts index c86548d319..882501850d 100644 --- a/src/utils/globalContext.ts +++ b/src/utils/globalContext.ts @@ -2,6 +2,10 @@ import { mkdir } from "fs/promises" import { join } from "path" import { ExtensionContext } from "vscode" +export async function getGlobalFsPath(context: ExtensionContext): Promise { + return context.globalStorageUri.fsPath +} + export async function ensureSettingsDirectoryExists(context: ExtensionContext): Promise { const settingsDir = join(context.globalStorageUri.fsPath, "settings") await mkdir(settingsDir, { recursive: true }) diff --git a/webview-ui/src/components/marketplace/MarketplaceView.tsx b/webview-ui/src/components/marketplace/MarketplaceView.tsx index bb887eb9f2..7b0a264045 100644 --- a/webview-ui/src/components/marketplace/MarketplaceView.tsx +++ b/webview-ui/src/components/marketplace/MarketplaceView.tsx @@ -318,8 +318,12 @@ const MarketplaceView: React.FC = ({ stateManager }) => {
{items.map((item) => ( manager.transition({ diff --git a/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts b/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts index 58c2ea142d..e3783501c1 100644 --- a/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts +++ b/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts @@ -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 diff --git a/webview-ui/src/components/marketplace/components/MarketplaceItemActionsMenu.tsx b/webview-ui/src/components/marketplace/components/MarketplaceItemActionsMenu.tsx index 163908987e..af4b2520d9 100644 --- a/webview-ui/src/components/marketplace/components/MarketplaceItemActionsMenu.tsx +++ b/webview-ui/src/components/marketplace/components/MarketplaceItemActionsMenu.tsx @@ -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 = ({ item }) => { +export const MarketplaceItemActionsMenu: React.FC = ({ item, installed }) => { const { t } = useAppTranslation() const itemSourceUrl = useMemo(() => { @@ -45,6 +54,14 @@ export const MarketplaceItemActionsMenu: React.FC { + vscode.postMessage({ + type: "removeInstalledMarketplaceItem", + mpItem: item, + mpInstallOptions: options, + }) + } + const showInstallButton = true return ( @@ -66,7 +83,7 @@ export const MarketplaceItemActionsMenu: React.FC handleInstall({ target: "project" })}> + handleInstall({ target: "project" })}> {t("marketplace:items.card.installProject")} @@ -79,6 +96,22 @@ export const MarketplaceItemActionsMenu: React.FC{t("marketplace:items.card.installGlobal")} )} + + {/* Remove (Project) */} + {installed.project && ( + handleRemove({ target: "project" })}> + + {t("marketplace:items.card.removeProject")} + + )} + + {/* Remove (Global) */} + {installed.global && ( + handleRemove({ target: "global" })}> + + {t("marketplace:items.card.removeGlobal")} + + )} ) diff --git a/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx index ca40611b95..cd53fd4988 100644 --- a/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx +++ b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx @@ -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) => void activeTab: ViewState["activeTab"] @@ -19,6 +24,7 @@ interface MarketplaceItemCardProps { export const MarketplaceItemCard: React.FC = ({ item, + installed, filters, setFilters, activeTab, @@ -68,7 +74,14 @@ export const MarketplaceItemCard: React.FC = ({
-

{item.name}

+

+ {item.name} +

{item.authorUrl && isValidUrl(item.authorUrl) ? (

{item.author ? ( @@ -163,7 +176,7 @@ export const MarketplaceItemCard: React.FC = ({ )}

- +
{item.type === "package" && ( diff --git a/webview-ui/src/components/marketplace/useStateManager.ts b/webview-ui/src/components/marketplace/useStateManager.ts index dff2e9c474..9ad4ffeb95 100644 --- a/webview-ui/src/components/marketplace/useStateManager.ts +++ b/webview-ui/src/components/marketplace/useStateManager.ts @@ -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 }) diff --git a/webview-ui/src/i18n/locales/en/marketplace.json b/webview-ui/src/i18n/locales/en/marketplace.json index e7f6dc6423..acf48b29c8 100644 --- a/webview-ui/src/i18n/locales/en/marketplace.json +++ b/webview-ui/src/i18n/locales/en/marketplace.json @@ -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}}" }