feat: installedMetadata MVP (#10)

* feat: `installedMetadata` MVP

* feat: removal support for installed items (+ general refactors)
This commit is contained in:
Trung Dang 2025-05-19 23:08:25 +07:00 committed by GitHub
parent e25b3e77c0
commit 1a11665672
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 646 additions and 193 deletions

8
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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,

View file

@ -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
}

View file

@ -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 (

View file

@ -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()

View 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}`)
}
}
}

View file

@ -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}"`)
}
}
}

View file

@ -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,

View file

@ -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",

View file

@ -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
})

View file

@ -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"
}

View file

@ -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
}

View file

@ -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

View file

@ -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 })

View file

@ -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({

View file

@ -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

View file

@ -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>
)

View file

@ -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" && (

View file

@ -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
})

View file

@ -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}}"
}