diff --git a/PRIVACY.md b/PRIVACY.md index bcd9186b70..306d52f059 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Roo Code Privacy Policy -**Last Updated: March 7th, 2025** +**Last Updated: June 10th, 2025** Roo Code respects your privacy and is committed to transparency about how we handle your data. Below is a simple breakdown of where key pieces of data go—and, importantly, where they don’t. @@ -11,6 +11,7 @@ Roo Code respects your privacy and is committed to transparency about how we han - **Prompts & AI Requests**: When you use AI-powered features, your prompts and relevant project context are sent to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not store or process this data. These AI providers have their own privacy policies and may store data per their terms of service. - **API Keys & Credentials**: If you enter an API key (e.g., to connect an AI model), it is stored locally on your device and never sent to us or any third party, except the provider you have chosen. - **Telemetry (Usage Data)**: We only collect feature usage and error data if you explicitly opt-in. This telemetry is powered by PostHog and helps us understand feature usage to improve Roo Code. This includes your VS Code machine ID and feature usage patterns and exception reports. We do **not** collect personally identifiable information, your code, or AI prompts. +- **Marketplace Requests**: When you browse or search the Marketplace for Model Configuration Profiles (MCPs) or Custom Modes, Roo Code makes a secure API call to Roo Code’s backend servers to retrieve listing information. These requests send only the query parameters (e.g., extension version, search term) necessary to fulfill the request and do not include your code, prompts, or personally identifiable information. ### **How We Use Your Data (If Collected)** diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index 07ea14c784..9770f349c6 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -1 +1,2 @@ export * from "./CloudService" +export * from "./Config" diff --git a/packages/telemetry/src/TelemetryService.ts b/packages/telemetry/src/TelemetryService.ts index 4f2427d998..956f49313a 100644 --- a/packages/telemetry/src/TelemetryService.ts +++ b/packages/telemetry/src/TelemetryService.ts @@ -152,6 +152,47 @@ export class TelemetryService { this.captureEvent(TelemetryEventName.CONSECUTIVE_MISTAKE_ERROR, { taskId }) } + /** + * Captures a marketplace item installation event + * @param itemId The unique identifier of the marketplace item + * @param itemType The type of item (mode or mcp) + * @param itemName The human-readable name of the item + * @param target The installation target (project or global) + * @param properties Additional properties like hasParameters, installationMethod + */ + public captureMarketplaceItemInstalled( + itemId: string, + itemType: string, + itemName: string, + target: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + properties?: Record, + ): void { + this.captureEvent(TelemetryEventName.MARKETPLACE_ITEM_INSTALLED, { + itemId, + itemType, + itemName, + target, + ... (properties || {}), + }) + } + + /** + * Captures a marketplace item removal event + * @param itemId The unique identifier of the marketplace item + * @param itemType The type of item (mode or mcp) + * @param itemName The human-readable name of the item + * @param target The removal target (project or global) + */ + public captureMarketplaceItemRemoved(itemId: string, itemType: string, itemName: string, target: string): void { + this.captureEvent(TelemetryEventName.MARKETPLACE_ITEM_REMOVED, { + itemId, + itemType, + itemName, + target, + }) + } + /** * Captures a title button click event * @param button The button that was clicked diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index 57e848c534..195d5a2cdd 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -6,7 +6,12 @@ import type { Keys, Equals, AssertEqual } from "./type-fu.js" * ExperimentId */ -export const experimentIds = ["powerSteering", "concurrentFileReads", "disableCompletionCommand"] as const +export const experimentIds = [ + "powerSteering", + "marketplace", + "concurrentFileReads", + "disableCompletionCommand", +] as const export const experimentIdsSchema = z.enum(experimentIds) @@ -18,6 +23,7 @@ export type ExperimentId = z.infer export const experimentsSchema = z.object({ powerSteering: z.boolean(), + marketplace: z.boolean(), concurrentFileReads: z.boolean(), disableCompletionCommand: z.boolean(), }) diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 9c10a63b5e..9861f4425d 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -41,6 +41,9 @@ export enum TelemetryEventName { AUTHENTICATION_INITIATED = "Authentication Initiated", + MARKETPLACE_ITEM_INSTALLED = "Marketplace Item Installed", + MARKETPLACE_ITEM_REMOVED = "Marketplace Item Removed", + SCHEMA_VALIDATION_ERROR = "Schema Validation Error", DIFF_APPLICATION_ERROR = "Diff Application Error", SHELL_INTEGRATION_ERROR = "Shell Integration Error", @@ -106,6 +109,8 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [ TelemetryEventName.PROMPT_ENHANCED, TelemetryEventName.TITLE_BUTTON_CLICKED, TelemetryEventName.AUTHENTICATION_INITIATED, + TelemetryEventName.MARKETPLACE_ITEM_INSTALLED, + TelemetryEventName.MARKETPLACE_ITEM_REMOVED, TelemetryEventName.SCHEMA_VALIDATION_ERROR, TelemetryEventName.DIFF_APPLICATION_ERROR, TelemetryEventName.SHELL_INTEGRATION_ERROR, diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index 5dfe1a6397..cc164aadbe 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -33,6 +33,7 @@ export const commandIds = [ "promptsButtonClicked", "mcpButtonClicked", "historyButtonClicked", + "marketplaceButtonClicked", "popoutButtonClicked", "accountButtonClicked", "settingsButtonClicked", diff --git a/src/__mocks__/vscode.js b/src/__mocks__/vscode.js index c40d6dc680..f153bb936b 100644 --- a/src/__mocks__/vscode.js +++ b/src/__mocks__/vscode.js @@ -27,6 +27,7 @@ const vscode = { onDidSaveTextDocument: jest.fn(), createFileSystemWatcher: jest.fn().mockReturnValue({ onDidCreate: jest.fn().mockReturnValue({ dispose: jest.fn() }), + onDidChange: jest.fn().mockReturnValue({ dispose: jest.fn() }), onDidDelete: jest.fn().mockReturnValue({ dispose: jest.fn() }), dispose: jest.fn(), }), diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 3f575b74cb..3ec5d151e1 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -146,6 +146,11 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt visibleProvider.postMessageToWebview({ type: "action", action: "historyButtonClicked" }) }, + marketplaceButtonClicked: () => { + const visibleProvider = getVisibleProviderOrLog(outputChannel) + if (!visibleProvider) return + visibleProvider.postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" }) + }, showHumanRelayDialog: (params: { requestId: string; promptText: string }) => { const panel = getPanel() diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index dc830688e1..4c2b01ae23 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -10,6 +10,7 @@ import { fileExistsAtPath } from "../../utils/fs" import { arePathsEqual, getWorkspacePath } from "../../utils/path" import { logger } from "../../utils/logging" import { GlobalFileNames } from "../../shared/globalFileNames" +import { ensureSettingsDirectoryExists } from "../../utils/globalContext" const ROOMODES_FILENAME = ".roomodes" @@ -26,8 +27,9 @@ export class CustomModesManager { private readonly context: vscode.ExtensionContext, private readonly onUpdate: () => Promise, ) { - // TODO: We really shouldn't have async methods in the constructor. - this.watchCustomModesFiles() + this.watchCustomModesFiles().catch((error) => { + console.error("[CustomModesManager] Failed to setup file watchers:", error) + }) } private async queueWrite(operation: () => Promise): Promise { @@ -117,7 +119,7 @@ export class CustomModesManager { } public async getCustomModesFilePath(): Promise { - const settingsDir = await this.ensureSettingsDirectoryExists() + const settingsDir = await ensureSettingsDirectoryExists(this.context) const filePath = path.join(settingsDir, GlobalFileNames.customModes) const fileExists = await fileExistsAtPath(filePath) @@ -129,64 +131,98 @@ export class CustomModesManager { } private async watchCustomModesFiles(): Promise { + // Skip if test environment is detected + if (process.env.NODE_ENV === "test" || process.env.JEST_WORKER_ID !== undefined) { + return + } + const settingsPath = await this.getCustomModesFilePath() // Watch settings file - this.disposables.push( - vscode.workspace.onDidSaveTextDocument(async (document) => { - if (arePathsEqual(document.uri.fsPath, settingsPath)) { - const content = await fs.readFile(settingsPath, "utf-8") + const settingsWatcher = vscode.workspace.createFileSystemWatcher(settingsPath) - const errorMessage = - "Invalid custom modes format. Please ensure your settings follow the correct YAML format." + const handleSettingsChange = async () => { + try { + // Ensure that the settings file exists (especially important for delete events) + await this.getCustomModesFilePath() + const content = await fs.readFile(settingsPath, "utf-8") - let config: any + const errorMessage = + "Invalid custom modes format. Please ensure your settings follow the correct YAML format." - try { - config = yaml.parse(content) - } catch (error) { - console.error(error) - vscode.window.showErrorMessage(errorMessage) - return - } + let config: any - const result = customModesSettingsSchema.safeParse(config) + try { + config = yaml.parse(content) + } catch (error) { + console.error(error) + vscode.window.showErrorMessage(errorMessage) + return + } - if (!result.success) { - vscode.window.showErrorMessage(errorMessage) - return - } + const result = customModesSettingsSchema.safeParse(config) + + if (!result.success) { + vscode.window.showErrorMessage(errorMessage) + return + } - // Get modes from .roomodes if it exists (takes precedence) - const roomodesPath = await this.getWorkspaceRoomodes() - const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] + // Get modes from .roomodes if it exists (takes precedence) + const roomodesPath = await this.getWorkspaceRoomodes() + const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] - // Merge modes from both sources (.roomodes takes precedence) - const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes) + // Merge modes from both sources (.roomodes takes precedence) + const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes) + await this.context.globalState.update("customModes", mergedModes) + this.clearCache() + await this.onUpdate() + } catch (error) { + console.error(`[CustomModesManager] Error handling settings file change:`, error) + } + } + + this.disposables.push(settingsWatcher.onDidChange(handleSettingsChange)) + this.disposables.push(settingsWatcher.onDidCreate(handleSettingsChange)) + this.disposables.push(settingsWatcher.onDidDelete(handleSettingsChange)) + this.disposables.push(settingsWatcher) + + // Watch .roomodes file - watch the path even if it doesn't exist yet + const workspaceFolders = vscode.workspace.workspaceFolders + if (workspaceFolders && workspaceFolders.length > 0) { + const workspaceRoot = getWorkspacePath() + const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME) + const roomodesWatcher = vscode.workspace.createFileSystemWatcher(roomodesPath) + + const handleRoomodesChange = async () => { + try { + const settingsModes = await this.loadModesFromFile(settingsPath) + const roomodesModes = await this.loadModesFromFile(roomodesPath) + // .roomodes takes precedence + const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes) await this.context.globalState.update("customModes", mergedModes) this.clearCache() await this.onUpdate() + } catch (error) { + console.error(`[CustomModesManager] Error handling .roomodes file change:`, error) } - }), - ) - - // Watch .roomodes file if it exists - const roomodesPath = await this.getWorkspaceRoomodes() + } - if (roomodesPath) { + this.disposables.push(roomodesWatcher.onDidChange(handleRoomodesChange)) + this.disposables.push(roomodesWatcher.onDidCreate(handleRoomodesChange)) this.disposables.push( - vscode.workspace.onDidSaveTextDocument(async (document) => { - if (arePathsEqual(document.uri.fsPath, roomodesPath)) { + roomodesWatcher.onDidDelete(async () => { + // When .roomodes is deleted, refresh with only settings modes + try { const settingsModes = await this.loadModesFromFile(settingsPath) - const roomodesModes = await this.loadModesFromFile(roomodesPath) - // .roomodes takes precedence - const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes) - await this.context.globalState.update("customModes", mergedModes) + await this.context.globalState.update("customModes", settingsModes) this.clearCache() await this.onUpdate() + } catch (error) { + console.error(`[CustomModesManager] Error handling .roomodes file deletion:`, error) } }), ) + this.disposables.push(roomodesWatcher) } } @@ -362,12 +398,6 @@ export class CustomModesManager { } } - private async ensureSettingsDirectoryExists(): Promise { - const settingsDir = path.join(this.context.globalStorageUri.fsPath, "settings") - await fs.mkdir(settingsDir, { recursive: true }) - return settingsDir - } - public async resetCustomModes(): Promise { try { const filePath = await this.getCustomModesFilePath() diff --git a/src/core/config/__tests__/CustomModesManager.test.ts b/src/core/config/__tests__/CustomModesManager.test.ts index cb49c68a05..14aff33712 100644 --- a/src/core/config/__tests__/CustomModesManager.test.ts +++ b/src/core/config/__tests__/CustomModesManager.test.ts @@ -1,6 +1,5 @@ // npx jest src/core/config/__tests__/CustomModesManager.test.ts -import * as vscode from "vscode" import * as path from "path" import * as fs from "fs/promises" @@ -14,14 +13,67 @@ import { GlobalFileNames } from "../../../shared/globalFileNames" import { CustomModesManager } from "../CustomModesManager" -jest.mock("vscode") +jest.mock("vscode", () => { + type Disposable = { dispose: () => void } + + type _Event = (listener: (e: T) => any, thisArgs?: any, disposables?: Disposable[]) => Disposable + + const MOCK_EMITTER_REGISTRY = new Map any>>() + + return { + EventEmitter: jest.fn().mockImplementation(() => { + const emitterInstanceKey = {} + MOCK_EMITTER_REGISTRY.set(emitterInstanceKey, new Set()) + + return { + event: function (listener: (e: T) => any): Disposable { + const listeners = MOCK_EMITTER_REGISTRY.get(emitterInstanceKey) + listeners!.add(listener as any) + return { + dispose: () => { + listeners!.delete(listener as any) + }, + } + }, + + fire: function (data: T): void { + const listeners = MOCK_EMITTER_REGISTRY.get(emitterInstanceKey) + listeners!.forEach((fn) => fn(data)) + }, + + dispose: () => { + MOCK_EMITTER_REGISTRY.get(emitterInstanceKey)!.clear() + MOCK_EMITTER_REGISTRY.delete(emitterInstanceKey) + }, + } + }), + Uri: { + file: jest.fn().mockImplementation((path) => ({ fsPath: path })), + }, + window: { + showErrorMessage: jest.fn(), + }, + workspace: { + workspaceFolders: undefined, // Will be set in tests + onDidSaveTextDocument: jest.fn().mockReturnValue({ dispose: jest.fn() }), + createFileSystemWatcher: jest.fn().mockReturnValue({ + onDidCreate: jest.fn().mockReturnValue({ dispose: jest.fn() }), + onDidChange: jest.fn().mockReturnValue({ dispose: jest.fn() }), + onDidDelete: jest.fn().mockReturnValue({ dispose: jest.fn() }), + dispose: jest.fn(), + }), + }, + } +}) + +const vscode = require("vscode") jest.mock("fs/promises") jest.mock("../../../utils/fs") jest.mock("../../../utils/path") describe("CustomModesManager", () => { let manager: CustomModesManager - let mockContext: vscode.ExtensionContext + let mockContext: any let mockOnUpdate: jest.Mock let mockWorkspaceFolders: { uri: { fsPath: string } }[] @@ -30,7 +82,7 @@ describe("CustomModesManager", () => { const mockSettingsPath = path.join(mockStoragePath, "settings", GlobalFileNames.customModes) const mockRoomodes = `${path.sep}mock${path.sep}workspace${path.sep}.roomodes` - beforeEach(() => { + beforeEach(async () => { mockOnUpdate = jest.fn() mockContext = { globalState: { @@ -40,10 +92,10 @@ describe("CustomModesManager", () => { globalStorageUri: { fsPath: mockStoragePath, }, - } as unknown as vscode.ExtensionContext + } mockWorkspaceFolders = [{ uri: { fsPath: "/mock/workspace" } }] - ;(vscode.workspace as any).workspaceFolders = mockWorkspaceFolders + vscode.workspace.workspaceFolders = mockWorkspaceFolders ;(vscode.workspace.onDidSaveTextDocument as jest.Mock).mockReturnValue({ dispose: jest.fn() }) ;(getWorkspacePath as jest.Mock).mockReturnValue("/mock/workspace") ;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => { @@ -635,30 +687,6 @@ describe("CustomModesManager", () => { expect(fs.writeFile).toHaveBeenCalledWith(settingsPath, expect.stringMatching(/^customModes: \[\]/)) }) - - it("watches file for changes", async () => { - const configPath = path.join(mockStoragePath, "settings", GlobalFileNames.customModes) - - ;(fs.readFile as jest.Mock).mockResolvedValue(yaml.stringify({ customModes: [] })) - ;(arePathsEqual as jest.Mock).mockImplementation((path1: string, path2: string) => { - return path.normalize(path1) === path.normalize(path2) - }) - // Get the registered callback - const registerCall = (vscode.workspace.onDidSaveTextDocument as jest.Mock).mock.calls[0] - expect(registerCall).toBeDefined() - const [callback] = registerCall - - // Simulate file save event - const mockDocument = { - uri: { fsPath: configPath }, - } - await callback(mockDocument) - - // Verify file was processed - expect(fs.readFile).toHaveBeenCalledWith(configPath, "utf-8") - expect(mockContext.globalState.update).toHaveBeenCalled() - expect(mockOnUpdate).toHaveBeenCalled() - }) }) describe("deleteCustomMode", () => { @@ -709,7 +737,7 @@ describe("CustomModesManager", () => { it("handles errors gracefully", async () => { const mockShowError = jest.fn() - ;(vscode.window.showErrorMessage as jest.Mock) = mockShowError + vscode.window.showErrorMessage = mockShowError ;(fs.writeFile as jest.Mock).mockRejectedValue(new Error("Write error")) await manager.deleteCustomMode("non-existent-mode") diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 880ac3bdf4..166db4d3f5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -47,6 +47,7 @@ import { getTheme } from "../../integrations/theme/getTheme" import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker" import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" +import { MarketplaceManager } from "../../services/marketplace" import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService" import { CodeIndexManager } from "../../services/code-index/manager" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" @@ -101,6 +102,7 @@ export class ClineProvider return this._workspaceTracker } protected mcpHub?: McpHub // Change from private to protected + private marketplaceManager: MarketplaceManager public isViewLaunched = false public settingsImportedAt?: number @@ -147,6 +149,8 @@ export class ClineProvider .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. @@ -262,6 +266,7 @@ export class ClineProvider 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) @@ -493,6 +498,9 @@ export class ClineProvider // If the extension is starting a new session, clear previous task state. await this.removeClineFromStack() + // Set initial VSCode context for experiments + await this.updateVSCodeContext() + this.log("Webview view resolved") } @@ -769,7 +777,8 @@ export class ClineProvider * @param webview A reference to the extension webview */ private setWebviewMessageListener(webview: vscode.Webview) { - const onReceiveMessage = async (message: WebviewMessage) => webviewMessageHandler(this, message) + const onReceiveMessage = async (message: WebviewMessage) => + webviewMessageHandler(this, message, this.marketplaceManager) const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage) this.webviewDisposables.push(messageDisposable) @@ -1231,6 +1240,23 @@ export class ClineProvider async postStateToWebview() { const state = await this.getStateToPostToWebview() this.postMessageToWebview({ type: "state", state }) + + // Update VSCode context for experiments + await this.updateVSCodeContext() + } + + /** + * Updates VSCode context variables for experiments so they can be used in when clauses + */ + private async updateVSCodeContext() { + const { experiments } = await this.getState() + + // Set context for marketplace experiment + await vscode.commands.executeCommand( + "setContext", + `${Package.name}.marketplaceEnabled`, + experiments.marketplace ?? false, + ) } /** @@ -1320,12 +1346,23 @@ export class ClineProvider const allowedCommands = vscode.workspace.getConfiguration(Package.name).get("allowedCommands") || [] const cwd = this.cwd + // Only fetch marketplace data if the feature is enabled + let marketplaceItems: any[] = [] + let marketplaceInstalledMetadata: any = { project: {}, global: {} } + + if (experiments.marketplace) { + marketplaceItems = (await this.marketplaceManager.getCurrentItems()) || [] + marketplaceInstalledMetadata = await this.marketplaceManager.getInstallationMetadata() + } + // Check if there's a system prompt override for the current mode const currentMode = mode ?? defaultModeSlug const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode) return { version: this.context.extension?.packageJSON?.version ?? "", + marketplaceItems, + marketplaceInstalledMetadata, apiConfiguration, customInstructions, alwaysAllowReadOnly: alwaysAllowReadOnly ?? false, diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index e84a78057a..6ced4989a4 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -146,6 +146,9 @@ jest.mock("vscode", () => ({ QuickFix: { value: "quickfix" }, RefactorRewrite: { value: "refactor.rewrite" }, }, + commands: { + executeCommand: jest.fn().mockResolvedValue(undefined), + }, window: { showInformationMessage: jest.fn(), showErrorMessage: jest.fn(), diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 91834d2fd0..6568b4aaee 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -42,7 +42,13 @@ import { getCommand } from "../../utils/commands" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) -export const webviewMessageHandler = async (provider: ClineProvider, message: WebviewMessage) => { +import { MarketplaceManager, MarketplaceItemType } from "../../services/marketplace" + +export const webviewMessageHandler = async ( + provider: ClineProvider, + message: WebviewMessage, + marketplaceManager?: MarketplaceManager, +) => { // Utility functions provided for concise get/update of global state via contextProxy API. const getGlobalState = (key: K) => provider.contextProxy.getValue(key) const updateGlobalState = async (key: K, value: GlobalState[K]) => @@ -425,6 +431,11 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We case "openMention": openMention(message.text) break + case "openExternal": + if (message.url) { + vscode.env.openExternal(vscode.Uri.parse(message.url)) + } + break case "checkpointDiff": const result = checkoutDiffPayloadSchema.safeParse(message.payload) @@ -518,6 +529,9 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We provider.log(`Attempting to delete MCP server: ${message.serverName}`) await provider.getMcpHub()?.deleteServer(message.serverName, message.source as "global" | "project") provider.log(`Successfully deleted MCP server: ${message.serverName}`) + + // Refresh the webview state + await provider.postStateToWebview() } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) provider.log(`Failed to delete MCP server: ${errorMessage}`) @@ -1461,5 +1475,116 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We } break } + case "filterMarketplaceItems": { + // Check if marketplace is enabled before making API calls + const { experiments } = await provider.getState() + if (!experiments.marketplace) { + console.log("Marketplace: Feature disabled, skipping API call") + break + } + + if (marketplaceManager && message.filters) { + try { + await marketplaceManager.updateWithFilteredItems({ + type: message.filters.type as MarketplaceItemType | undefined, + search: message.filters.search, + tags: message.filters.tags, + }) + await provider.postStateToWebview() + } catch (error) { + console.error("Marketplace: Error filtering items:", error) + vscode.window.showErrorMessage("Failed to filter marketplace items") + } + } + break + } + + case "installMarketplaceItem": { + // Check if marketplace is enabled before installing + const { experiments } = await provider.getState() + if (!experiments.marketplace) { + console.log("Marketplace: Feature disabled, skipping installation") + break + } + + if (marketplaceManager && message.mpItem && message.mpInstallOptions) { + try { + const configFilePath = await marketplaceManager.installMarketplaceItem( + message.mpItem, + message.mpInstallOptions, + ) + await provider.postStateToWebview() + console.log(`Marketplace item installed and config file opened: ${configFilePath}`) + // Send success message to webview + provider.postMessageToWebview({ + type: "marketplaceInstallResult", + success: true, + slug: message.mpItem.id, + }) + } catch (error) { + console.error(`Error installing marketplace item: ${error}`) + // Send error message to webview + provider.postMessageToWebview({ + type: "marketplaceInstallResult", + success: false, + error: error instanceof Error ? error.message : String(error), + slug: message.mpItem.id, + }) + } + } + break + } + + case "removeInstalledMarketplaceItem": { + // Check if marketplace is enabled before removing + const { experiments } = await provider.getState() + if (!experiments.marketplace) { + console.log("Marketplace: Feature disabled, skipping removal") + break + } + + if (marketplaceManager && message.mpItem && message.mpInstallOptions) { + try { + await marketplaceManager.removeInstalledMarketplaceItem(message.mpItem, message.mpInstallOptions) + await provider.postStateToWebview() + } catch (error) { + console.error(`Error removing marketplace item: ${error}`) + } + } + break + } + + case "installMarketplaceItemWithParameters": { + // Check if marketplace is enabled before installing with parameters + const { experiments } = await provider.getState() + if (!experiments.marketplace) { + console.log("Marketplace: Feature disabled, skipping installation with parameters") + break + } + + if (marketplaceManager && message.payload && "item" in message.payload && "parameters" in message.payload) { + try { + const configFilePath = await marketplaceManager.installMarketplaceItem(message.payload.item, { + parameters: message.payload.parameters, + }) + await provider.postStateToWebview() + console.log(`Marketplace item with parameters installed and config file opened: ${configFilePath}`) + } catch (error) { + console.error(`Error installing marketplace item with parameters: ${error}`) + vscode.window.showErrorMessage( + `Failed to install marketplace item: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + break + } + + case "switchTab": { + if (message.tab) { + // Send a message to the webview to switch to the specified tab + await provider.postMessageToWebview({ type: "action", action: "switchTab", tab: message.tab }) + } + break + } } } diff --git a/src/i18n/locales/ca/marketplace.json b/src/i18n/locales/ca/marketplace.json new file mode 100644 index 0000000000..6c64374447 --- /dev/null +++ b/src/i18n/locales/ca/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "Modes", + "mcps": "Servidors MCP", + "match": "coincidència" + }, + "item-card": { + "type-mode": "Mode", + "type-mcp": "Servidor MCP", + "type-other": "Altre", + "by-author": "per {{author}}", + "authors-profile": "Perfil de l'autor", + "remove-tag-filter": "Eliminar filtre d'etiqueta: {{tag}}", + "filter-by-tag": "Filtrar per etiqueta: {{tag}}", + "component-details": "Detalls del component", + "view": "Veure", + "source": "Font" + }, + "filters": { + "search": { + "placeholder": "Cercar al marketplace..." + }, + "type": { + "label": "Tipus", + "all": "Tots els tipus", + "mode": "Mode", + "mcpServer": "Servidor MCP" + }, + "sort": { + "label": "Ordenar per", + "name": "Nom", + "lastUpdated": "Última actualització" + }, + "tags": { + "label": "Etiquetes", + "clear": "Netejar etiquetes", + "placeholder": "Cercar etiquetes...", + "noResults": "No s'han trobat etiquetes.", + "selected": "Mostrant elements amb qualsevol de les etiquetes seleccionades" + }, + "title": "Marketplace" + }, + "done": "Fet", + "tabs": { + "installed": "Instal·lat", + "browse": "Navegar", + "settings": "Configuració" + }, + "items": { + "empty": { + "noItems": "No s'han trobat elements del marketplace.", + "emptyHint": "Prova d'ajustar els filtres o termes de cerca" + } + }, + "installation": { + "installing": "Instal·lant element: \"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\" instal·lat correctament", + "installError": "Error en instal·lar \"{{itemName}}\": {{errorMessage}}", + "removing": "Eliminant element: \"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\" eliminat correctament", + "removeError": "Error en eliminar \"{{itemName}}\": {{errorMessage}}" + } +} diff --git a/src/i18n/locales/de/marketplace.json b/src/i18n/locales/de/marketplace.json new file mode 100644 index 0000000000..2981441cf0 --- /dev/null +++ b/src/i18n/locales/de/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "Modi", + "mcps": "MCP-Server", + "match": "Übereinstimmung" + }, + "item-card": { + "type-mode": "Modus", + "type-mcp": "MCP-Server", + "type-other": "Andere", + "by-author": "von {{author}}", + "authors-profile": "Autorenprofil", + "remove-tag-filter": "Tag-Filter entfernen: {{tag}}", + "filter-by-tag": "Nach Tag filtern: {{tag}}", + "component-details": "Komponentendetails", + "view": "Anzeigen", + "source": "Quelle" + }, + "filters": { + "search": { + "placeholder": "Marketplace durchsuchen..." + }, + "type": { + "label": "Typ", + "all": "Alle Typen", + "mode": "Modus", + "mcpServer": "MCP-Server" + }, + "sort": { + "label": "Sortieren nach", + "name": "Name", + "lastUpdated": "Zuletzt aktualisiert" + }, + "tags": { + "label": "Tags", + "clear": "Tags löschen", + "placeholder": "Tags suchen...", + "noResults": "Keine Tags gefunden.", + "selected": "Zeige Elemente mit einem der ausgewählten Tags" + }, + "title": "Marketplace" + }, + "done": "Fertig", + "tabs": { + "installed": "Installiert", + "browse": "Durchsuchen", + "settings": "Einstellungen" + }, + "items": { + "empty": { + "noItems": "Keine Marketplace-Elemente gefunden.", + "emptyHint": "Versuche deine Filter oder Suchbegriffe anzupassen" + } + }, + "installation": { + "installing": "Element wird installiert: \"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\" erfolgreich installiert", + "installError": "Installation von \"{{itemName}}\" fehlgeschlagen: {{errorMessage}}", + "removing": "Element wird entfernt: \"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\" erfolgreich entfernt", + "removeError": "Entfernung von \"{{itemName}}\" fehlgeschlagen: {{errorMessage}}" + } +} diff --git a/src/i18n/locales/en/marketplace.json b/src/i18n/locales/en/marketplace.json new file mode 100644 index 0000000000..f141aaeb05 --- /dev/null +++ b/src/i18n/locales/en/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "Modes", + "mcps": "MCP Servers", + "match": "match" + }, + "item-card": { + "type-mode": "Mode", + "type-mcp": "MCP Server", + "type-other": "Other", + "by-author": "by {{author}}", + "authors-profile": "Author's Profile", + "remove-tag-filter": "Remove tag filter: {{tag}}", + "filter-by-tag": "Filter by tag: {{tag}}", + "component-details": "Component Details", + "view": "View", + "source": "Source" + }, + "filters": { + "search": { + "placeholder": "Search marketplace..." + }, + "type": { + "label": "Type", + "all": "All Types", + "mode": "Mode", + "mcpServer": "MCP Server" + }, + "sort": { + "label": "Sort By", + "name": "Name", + "lastUpdated": "Last Updated" + }, + "tags": { + "label": "Tags", + "clear": "Clear tags", + "placeholder": "Search tags...", + "noResults": "No tags found.", + "selected": "Showing items with any of the selected tags" + }, + "title": "Marketplace" + }, + "done": "Done", + "tabs": { + "installed": "Installed", + "browse": "Browse", + "settings": "Settings" + }, + "items": { + "empty": { + "noItems": "No marketplace items found.", + "emptyHint": "Try adjusting your filters or search terms" + } + }, + "installation": { + "installing": "Installing item: \"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\" installed successfully", + "installError": "Failed to install \"{{itemName}}\": {{errorMessage}}", + "removing": "Removing item: \"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\" removed successfully", + "removeError": "Failed to remove \"{{itemName}}\": {{errorMessage}}" + } +} diff --git a/src/i18n/locales/es/marketplace.json b/src/i18n/locales/es/marketplace.json new file mode 100644 index 0000000000..e12e1d1dc1 --- /dev/null +++ b/src/i18n/locales/es/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "Modos", + "mcps": "Servidores MCP", + "match": "coincidencia" + }, + "item-card": { + "type-mode": "Modo", + "type-mcp": "Servidor MCP", + "type-other": "Otro", + "by-author": "por {{author}}", + "authors-profile": "Perfil del autor", + "remove-tag-filter": "Eliminar filtro de etiqueta: {{tag}}", + "filter-by-tag": "Filtrar por etiqueta: {{tag}}", + "component-details": "Detalles del componente", + "view": "Ver", + "source": "Fuente" + }, + "filters": { + "search": { + "placeholder": "Buscar en marketplace..." + }, + "type": { + "label": "Tipo", + "all": "Todos los tipos", + "mode": "Modo", + "mcpServer": "Servidor MCP" + }, + "sort": { + "label": "Ordenar por", + "name": "Nombre", + "lastUpdated": "Última actualización" + }, + "tags": { + "label": "Etiquetas", + "clear": "Limpiar etiquetas", + "placeholder": "Buscar etiquetas...", + "noResults": "No se encontraron etiquetas.", + "selected": "Mostrando elementos con cualquiera de las etiquetas seleccionadas" + }, + "title": "Marketplace" + }, + "done": "Hecho", + "tabs": { + "installed": "Instalado", + "browse": "Explorar", + "settings": "Configuración" + }, + "items": { + "empty": { + "noItems": "No se encontraron elementos del marketplace.", + "emptyHint": "Intenta ajustar tus filtros o términos de búsqueda" + } + }, + "installation": { + "installing": "Instalando elemento: \"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\" instalado correctamente", + "installError": "Error al instalar \"{{itemName}}\": {{errorMessage}}", + "removing": "Eliminando elemento: \"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\" eliminado correctamente", + "removeError": "Error al eliminar \"{{itemName}}\": {{errorMessage}}" + } +} diff --git a/src/i18n/locales/fr/marketplace.json b/src/i18n/locales/fr/marketplace.json new file mode 100644 index 0000000000..7a42b0033e --- /dev/null +++ b/src/i18n/locales/fr/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "Modes", + "mcps": "Serveurs MCP", + "match": "correspondance" + }, + "item-card": { + "type-mode": "Mode", + "type-mcp": "Serveur MCP", + "type-other": "Autre", + "by-author": "par {{author}}", + "authors-profile": "Profil de l'auteur", + "remove-tag-filter": "Supprimer le filtre d'étiquette : {{tag}}", + "filter-by-tag": "Filtrer par étiquette : {{tag}}", + "component-details": "Détails du composant", + "view": "Voir", + "source": "Source" + }, + "filters": { + "search": { + "placeholder": "Rechercher dans le marketplace..." + }, + "type": { + "label": "Type", + "all": "Tous les types", + "mode": "Mode", + "mcpServer": "Serveur MCP" + }, + "sort": { + "label": "Trier par", + "name": "Nom", + "lastUpdated": "Dernière mise à jour" + }, + "tags": { + "label": "Étiquettes", + "clear": "Effacer les étiquettes", + "placeholder": "Rechercher des étiquettes...", + "noResults": "Aucune étiquette trouvée.", + "selected": "Affichage des éléments avec l'une des étiquettes sélectionnées" + }, + "title": "Marketplace" + }, + "done": "Terminé", + "tabs": { + "installed": "Installé", + "browse": "Parcourir", + "settings": "Paramètres" + }, + "items": { + "empty": { + "noItems": "Aucun élément du marketplace trouvé.", + "emptyHint": "Essayez d'ajuster vos filtres ou termes de recherche" + } + }, + "installation": { + "installing": "Installation de l'élément : \"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\" installé avec succès", + "installError": "Échec de l'installation de \"{{itemName}}\" : {{errorMessage}}", + "removing": "Suppression de l'élément : \"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\" supprimé avec succès", + "removeError": "Échec de la suppression de \"{{itemName}}\" : {{errorMessage}}" + } +} diff --git a/src/i18n/locales/hi/marketplace.json b/src/i18n/locales/hi/marketplace.json new file mode 100644 index 0000000000..94013c20e4 --- /dev/null +++ b/src/i18n/locales/hi/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "मोड्स", + "mcps": "MCP सर्वर", + "match": "मैच" + }, + "item-card": { + "type-mode": "मोड", + "type-mcp": "MCP सर्वर", + "type-other": "अन्य", + "by-author": "{{author}} द्वारा", + "authors-profile": "लेखक की प्रोफ़ाइल", + "remove-tag-filter": "टैग फ़िल्टर हटाएं: {{tag}}", + "filter-by-tag": "टैग द्वारा फ़िल्टर करें: {{tag}}", + "component-details": "कंपोनेंट विवरण", + "view": "देखें", + "source": "स्रोत" + }, + "filters": { + "search": { + "placeholder": "मार्केटप्लेस खोजें..." + }, + "type": { + "label": "प्रकार", + "all": "सभी प्रकार", + "mode": "मोड", + "mcpServer": "MCP सर्वर" + }, + "sort": { + "label": "इसके द्वारा क्रमबद्ध करें", + "name": "नाम", + "lastUpdated": "अंतिम अपडेट" + }, + "tags": { + "label": "टैग्स", + "clear": "टैग्स साफ़ करें", + "placeholder": "टैग्स खोजें...", + "noResults": "कोई टैग नहीं मिले।", + "selected": "चयनित टैग्स में से किसी भी के साथ आइटम दिखा रहे हैं" + }, + "title": "मार्केटप्लेस" + }, + "done": "हो गया", + "tabs": { + "installed": "इंस्टॉल किया गया", + "browse": "ब्राउज़ करें", + "settings": "सेटिंग्स" + }, + "items": { + "empty": { + "noItems": "कोई मार्केटप्लेस आइटम नहीं मिले।", + "emptyHint": "अपने फ़िल्टर या खोज शब्दों को समायोजित करने का प्रयास करें" + } + }, + "installation": { + "installing": "आइटम इंस्टॉल कर रहे हैं: \"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\" सफलतापूर्वक इंस्टॉल हुआ", + "installError": "\"{{itemName}}\" इंस्टॉल करने में विफल: {{errorMessage}}", + "removing": "आइटम हटा रहे हैं: \"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\" सफलतापूर्वक हटाया गया", + "removeError": "\"{{itemName}}\" हटाने में विफल: {{errorMessage}}" + } +} diff --git a/src/i18n/locales/it/marketplace.json b/src/i18n/locales/it/marketplace.json new file mode 100644 index 0000000000..3cdcd2b76c --- /dev/null +++ b/src/i18n/locales/it/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "Modalità", + "mcps": "Server MCP", + "match": "corrispondenza" + }, + "item-card": { + "type-mode": "Modalità", + "type-mcp": "Server MCP", + "type-other": "Altro", + "by-author": "di {{author}}", + "authors-profile": "Profilo dell'autore", + "remove-tag-filter": "Rimuovi filtro tag: {{tag}}", + "filter-by-tag": "Filtra per tag: {{tag}}", + "component-details": "Dettagli componente", + "view": "Visualizza", + "source": "Sorgente" + }, + "filters": { + "search": { + "placeholder": "Cerca nel marketplace..." + }, + "type": { + "label": "Tipo", + "all": "Tutti i tipi", + "mode": "Modalità", + "mcpServer": "Server MCP" + }, + "sort": { + "label": "Ordina per", + "name": "Nome", + "lastUpdated": "Ultimo aggiornamento" + }, + "tags": { + "label": "Tag", + "clear": "Cancella tag", + "placeholder": "Cerca tag...", + "noResults": "Nessun tag trovato.", + "selected": "Mostrando elementi con uno qualsiasi dei tag selezionati" + }, + "title": "Marketplace" + }, + "done": "Fatto", + "tabs": { + "installed": "Installato", + "browse": "Sfoglia", + "settings": "Impostazioni" + }, + "items": { + "empty": { + "noItems": "Nessun elemento del marketplace trovato.", + "emptyHint": "Prova ad aggiustare i tuoi filtri o termini di ricerca" + } + }, + "installation": { + "installing": "Installazione elemento: \"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\" installato con successo", + "installError": "Installazione di \"{{itemName}}\" fallita: {{errorMessage}}", + "removing": "Rimozione elemento: \"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\" rimosso con successo", + "removeError": "Rimozione di \"{{itemName}}\" fallita: {{errorMessage}}" + } +} diff --git a/src/i18n/locales/ja/marketplace.json b/src/i18n/locales/ja/marketplace.json new file mode 100644 index 0000000000..26cff2ade9 --- /dev/null +++ b/src/i18n/locales/ja/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "モード", + "mcps": "MCPサーバー", + "match": "マッチ" + }, + "item-card": { + "type-mode": "モード", + "type-mcp": "MCPサーバー", + "type-other": "その他", + "by-author": "{{author}}による", + "authors-profile": "作者のプロフィール", + "remove-tag-filter": "タグフィルターを削除: {{tag}}", + "filter-by-tag": "タグでフィルター: {{tag}}", + "component-details": "コンポーネントの詳細", + "view": "表示", + "source": "ソース" + }, + "filters": { + "search": { + "placeholder": "マーケットプレイスを検索..." + }, + "type": { + "label": "タイプ", + "all": "すべてのタイプ", + "mode": "モード", + "mcpServer": "MCPサーバー" + }, + "sort": { + "label": "並び替え", + "name": "名前", + "lastUpdated": "最終更新" + }, + "tags": { + "label": "タグ", + "clear": "タグをクリア", + "placeholder": "タグを検索...", + "noResults": "タグが見つかりません。", + "selected": "選択されたタグのいずれかを持つアイテムを表示" + }, + "title": "マーケットプレイス" + }, + "done": "完了", + "tabs": { + "installed": "インストール済み", + "browse": "参照", + "settings": "設定" + }, + "items": { + "empty": { + "noItems": "マーケットプレイスのアイテムが見つかりません。", + "emptyHint": "フィルターや検索用語を調整してみてください" + } + }, + "installation": { + "installing": "アイテムをインストール中: \"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\"のインストールが完了しました", + "installError": "\"{{itemName}}\"のインストールに失敗しました: {{errorMessage}}", + "removing": "アイテムを削除中: \"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\"の削除が完了しました", + "removeError": "\"{{itemName}}\"の削除に失敗しました: {{errorMessage}}" + } +} diff --git a/src/i18n/locales/ko/marketplace.json b/src/i18n/locales/ko/marketplace.json new file mode 100644 index 0000000000..52bd03edf7 --- /dev/null +++ b/src/i18n/locales/ko/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "모드", + "mcps": "MCP 서버", + "match": "일치" + }, + "item-card": { + "type-mode": "모드", + "type-mcp": "MCP 서버", + "type-other": "기타", + "by-author": "{{author}} 작성", + "authors-profile": "작성자 프로필", + "remove-tag-filter": "태그 필터 제거: {{tag}}", + "filter-by-tag": "태그로 필터링: {{tag}}", + "component-details": "컴포넌트 세부사항", + "view": "보기", + "source": "소스" + }, + "filters": { + "search": { + "placeholder": "마켓플레이스 검색..." + }, + "type": { + "label": "유형", + "all": "모든 유형", + "mode": "모드", + "mcpServer": "MCP 서버" + }, + "sort": { + "label": "정렬 기준", + "name": "이름", + "lastUpdated": "마지막 업데이트" + }, + "tags": { + "label": "태그", + "clear": "태그 지우기", + "placeholder": "태그 검색...", + "noResults": "태그를 찾을 수 없습니다.", + "selected": "선택된 태그 중 하나를 가진 항목 표시" + }, + "title": "마켓플레이스" + }, + "done": "완료", + "tabs": { + "installed": "설치됨", + "browse": "찾아보기", + "settings": "설정" + }, + "items": { + "empty": { + "noItems": "마켓플레이스 항목을 찾을 수 없습니다.", + "emptyHint": "필터나 검색어를 조정해 보세요" + } + }, + "installation": { + "installing": "항목 설치 중: \"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\" 설치 완료", + "installError": "\"{{itemName}}\" 설치 실패: {{errorMessage}}", + "removing": "항목 제거 중: \"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\" 제거 완료", + "removeError": "\"{{itemName}}\" 제거 실패: {{errorMessage}}" + } +} diff --git a/src/i18n/locales/nl/marketplace.json b/src/i18n/locales/nl/marketplace.json new file mode 100644 index 0000000000..5628b8f628 --- /dev/null +++ b/src/i18n/locales/nl/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "Modi", + "mcps": "MCP Servers", + "match": "overeenkomst" + }, + "item-card": { + "type-mode": "Modus", + "type-mcp": "MCP Server", + "type-other": "Andere", + "by-author": "door {{author}}", + "authors-profile": "Auteursprofiel", + "remove-tag-filter": "Tag filter verwijderen: {{tag}}", + "filter-by-tag": "Filteren op tag: {{tag}}", + "component-details": "Component details", + "view": "Bekijken", + "source": "Bron" + }, + "filters": { + "search": { + "placeholder": "Marketplace doorzoeken..." + }, + "type": { + "label": "Type", + "all": "Alle types", + "mode": "Modus", + "mcpServer": "MCP Server" + }, + "sort": { + "label": "Sorteren op", + "name": "Naam", + "lastUpdated": "Laatst bijgewerkt" + }, + "tags": { + "label": "Tags", + "clear": "Tags wissen", + "placeholder": "Tags zoeken...", + "noResults": "Geen tags gevonden.", + "selected": "Items tonen met een van de geselecteerde tags" + }, + "title": "Marketplace" + }, + "done": "Klaar", + "tabs": { + "installed": "Geïnstalleerd", + "browse": "Bladeren", + "settings": "Instellingen" + }, + "items": { + "empty": { + "noItems": "Geen marketplace items gevonden.", + "emptyHint": "Probeer je filters of zoektermen aan te passen" + } + }, + "installation": { + "installing": "Item installeren: \"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\" succesvol geïnstalleerd", + "installError": "Installatie van \"{{itemName}}\" mislukt: {{errorMessage}}", + "removing": "Item verwijderen: \"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\" succesvol verwijderd", + "removeError": "Verwijdering van \"{{itemName}}\" mislukt: {{errorMessage}}" + } +} diff --git a/src/i18n/locales/pl/marketplace.json b/src/i18n/locales/pl/marketplace.json new file mode 100644 index 0000000000..029dbd95a3 --- /dev/null +++ b/src/i18n/locales/pl/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "Tryby", + "mcps": "Serwery MCP", + "match": "dopasowanie" + }, + "item-card": { + "type-mode": "Tryb", + "type-mcp": "Serwer MCP", + "type-other": "Inne", + "by-author": "przez {{author}}", + "authors-profile": "Profil autora", + "remove-tag-filter": "Usuń filtr tagu: {{tag}}", + "filter-by-tag": "Filtruj według tagu: {{tag}}", + "component-details": "Szczegóły komponentu", + "view": "Zobacz", + "source": "Źródło" + }, + "filters": { + "search": { + "placeholder": "Przeszukaj marketplace..." + }, + "type": { + "label": "Typ", + "all": "Wszystkie typy", + "mode": "Tryb", + "mcpServer": "Serwer MCP" + }, + "sort": { + "label": "Sortuj według", + "name": "Nazwa", + "lastUpdated": "Ostatnia aktualizacja" + }, + "tags": { + "label": "Tagi", + "clear": "Wyczyść tagi", + "placeholder": "Szukaj tagów...", + "noResults": "Nie znaleziono tagów.", + "selected": "Pokazywanie elementów z dowolnym z wybranych tagów" + }, + "title": "Marketplace" + }, + "done": "Gotowe", + "tabs": { + "installed": "Zainstalowane", + "browse": "Przeglądaj", + "settings": "Ustawienia" + }, + "items": { + "empty": { + "noItems": "Nie znaleziono elementów marketplace.", + "emptyHint": "Spróbuj dostosować filtry lub terminy wyszukiwania" + } + }, + "installation": { + "installing": "Instalowanie elementu: \"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\" zainstalowano pomyślnie", + "installError": "Instalacja \"{{itemName}}\" nie powiodła się: {{errorMessage}}", + "removing": "Usuwanie elementu: \"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\" usunięto pomyślnie", + "removeError": "Usunięcie \"{{itemName}}\" nie powiodło się: {{errorMessage}}" + } +} diff --git a/src/i18n/locales/pt-BR/marketplace.json b/src/i18n/locales/pt-BR/marketplace.json new file mode 100644 index 0000000000..b0af013888 --- /dev/null +++ b/src/i18n/locales/pt-BR/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "Modos", + "mcps": "Servidores MCP", + "match": "correspondência" + }, + "item-card": { + "type-mode": "Modo", + "type-mcp": "Servidor MCP", + "type-other": "Outro", + "by-author": "por {{author}}", + "authors-profile": "Perfil do autor", + "remove-tag-filter": "Remover filtro de tag: {{tag}}", + "filter-by-tag": "Filtrar por tag: {{tag}}", + "component-details": "Detalhes do componente", + "view": "Visualizar", + "source": "Fonte" + }, + "filters": { + "search": { + "placeholder": "Pesquisar marketplace..." + }, + "type": { + "label": "Tipo", + "all": "Todos os tipos", + "mode": "Modo", + "mcpServer": "Servidor MCP" + }, + "sort": { + "label": "Ordenar por", + "name": "Nome", + "lastUpdated": "Última atualização" + }, + "tags": { + "label": "Tags", + "clear": "Limpar tags", + "placeholder": "Pesquisar tags...", + "noResults": "Nenhuma tag encontrada.", + "selected": "Mostrando itens com qualquer uma das tags selecionadas" + }, + "title": "Marketplace" + }, + "done": "Concluído", + "tabs": { + "installed": "Instalado", + "browse": "Navegar", + "settings": "Configurações" + }, + "items": { + "empty": { + "noItems": "Nenhum item do marketplace encontrado.", + "emptyHint": "Tente ajustar seus filtros ou termos de pesquisa" + } + }, + "installation": { + "installing": "Instalando item: \"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\" instalado com sucesso", + "installError": "Falha ao instalar \"{{itemName}}\": {{errorMessage}}", + "removing": "Removendo item: \"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\" removido com sucesso", + "removeError": "Falha ao remover \"{{itemName}}\": {{errorMessage}}" + } +} diff --git a/src/i18n/locales/ru/marketplace.json b/src/i18n/locales/ru/marketplace.json new file mode 100644 index 0000000000..a84b1ce3e9 --- /dev/null +++ b/src/i18n/locales/ru/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "Режимы", + "mcps": "MCP серверы", + "match": "совпадение" + }, + "item-card": { + "type-mode": "Режим", + "type-mcp": "MCP сервер", + "type-other": "Другое", + "by-author": "от {{author}}", + "authors-profile": "Профиль автора", + "remove-tag-filter": "Удалить фильтр тега: {{tag}}", + "filter-by-tag": "Фильтровать по тегу: {{tag}}", + "component-details": "Детали компонента", + "view": "Просмотр", + "source": "Источник" + }, + "filters": { + "search": { + "placeholder": "Поиск в marketplace..." + }, + "type": { + "label": "Тип", + "all": "Все типы", + "mode": "Режим", + "mcpServer": "MCP сервер" + }, + "sort": { + "label": "Сортировать по", + "name": "Имя", + "lastUpdated": "Последнее обновление" + }, + "tags": { + "label": "Теги", + "clear": "Очистить теги", + "placeholder": "Поиск тегов...", + "noResults": "Теги не найдены.", + "selected": "Показ элементов с любым из выбранных тегов" + }, + "title": "Marketplace" + }, + "done": "Готово", + "tabs": { + "installed": "Установлено", + "browse": "Обзор", + "settings": "Настройки" + }, + "items": { + "empty": { + "noItems": "Элементы marketplace не найдены.", + "emptyHint": "Попробуйте настроить фильтры или поисковые термины" + } + }, + "installation": { + "installing": "Установка элемента: \"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\" успешно установлен", + "installError": "Не удалось установить \"{{itemName}}\": {{errorMessage}}", + "removing": "Удаление элемента: \"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\" успешно удален", + "removeError": "Не удалось удалить \"{{itemName}}\": {{errorMessage}}" + } +} diff --git a/src/i18n/locales/tr/marketplace.json b/src/i18n/locales/tr/marketplace.json new file mode 100644 index 0000000000..b08381d71c --- /dev/null +++ b/src/i18n/locales/tr/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "Modlar", + "mcps": "MCP Sunucuları", + "match": "eşleşme" + }, + "item-card": { + "type-mode": "Mod", + "type-mcp": "MCP Sunucusu", + "type-other": "Diğer", + "by-author": "{{author}} tarafından", + "authors-profile": "Yazarın Profili", + "remove-tag-filter": "Etiket filtresini kaldır: {{tag}}", + "filter-by-tag": "Etikete göre filtrele: {{tag}}", + "component-details": "Bileşen Detayları", + "view": "Görüntüle", + "source": "Kaynak" + }, + "filters": { + "search": { + "placeholder": "Marketplace'te ara..." + }, + "type": { + "label": "Tür", + "all": "Tüm Türler", + "mode": "Mod", + "mcpServer": "MCP Sunucusu" + }, + "sort": { + "label": "Sırala", + "name": "İsim", + "lastUpdated": "Son Güncelleme" + }, + "tags": { + "label": "Etiketler", + "clear": "Etiketleri temizle", + "placeholder": "Etiket ara...", + "noResults": "Etiket bulunamadı.", + "selected": "Seçilen etiketlerden herhangi birine sahip öğeleri göster" + }, + "title": "Marketplace" + }, + "done": "Tamam", + "tabs": { + "installed": "Yüklü", + "browse": "Gözat", + "settings": "Ayarlar" + }, + "items": { + "empty": { + "noItems": "Marketplace öğesi bulunamadı.", + "emptyHint": "Filtrelerinizi veya arama terimlerinizi ayarlamayı deneyin" + } + }, + "installation": { + "installing": "Öğe yükleniyor: \"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\" başarıyla yüklendi", + "installError": "\"{{itemName}}\" yüklenemedi: {{errorMessage}}", + "removing": "Öğe kaldırılıyor: \"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\" başarıyla kaldırıldı", + "removeError": "\"{{itemName}}\" kaldırılamadı: {{errorMessage}}" + } +} diff --git a/src/i18n/locales/vi/marketplace.json b/src/i18n/locales/vi/marketplace.json new file mode 100644 index 0000000000..be39054309 --- /dev/null +++ b/src/i18n/locales/vi/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "Chế độ", + "mcps": "Máy chủ MCP", + "match": "khớp" + }, + "item-card": { + "type-mode": "Chế độ", + "type-mcp": "Máy chủ MCP", + "type-other": "Khác", + "by-author": "bởi {{author}}", + "authors-profile": "Hồ sơ tác giả", + "remove-tag-filter": "Xóa bộ lọc thẻ: {{tag}}", + "filter-by-tag": "Lọc theo thẻ: {{tag}}", + "component-details": "Chi tiết thành phần", + "view": "Xem", + "source": "Nguồn" + }, + "filters": { + "search": { + "placeholder": "Tìm kiếm marketplace..." + }, + "type": { + "label": "Loại", + "all": "Tất cả loại", + "mode": "Chế độ", + "mcpServer": "Máy chủ MCP" + }, + "sort": { + "label": "Sắp xếp theo", + "name": "Tên", + "lastUpdated": "Cập nhật lần cuối" + }, + "tags": { + "label": "Thẻ", + "clear": "Xóa thẻ", + "placeholder": "Tìm thẻ...", + "noResults": "Không tìm thấy thẻ nào.", + "selected": "Hiển thị các mục có bất kỳ thẻ nào được chọn" + }, + "title": "Marketplace" + }, + "done": "Hoàn thành", + "tabs": { + "installed": "Đã cài đặt", + "browse": "Duyệt", + "settings": "Cài đặt" + }, + "items": { + "empty": { + "noItems": "Không tìm thấy mục marketplace nào.", + "emptyHint": "Thử điều chỉnh bộ lọc hoặc từ khóa tìm kiếm" + } + }, + "installation": { + "installing": "Đang cài đặt mục: \"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\" đã được cài đặt thành công", + "installError": "Cài đặt \"{{itemName}}\" thất bại: {{errorMessage}}", + "removing": "Đang xóa mục: \"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\" đã được xóa thành công", + "removeError": "Xóa \"{{itemName}}\" thất bại: {{errorMessage}}" + } +} diff --git a/src/i18n/locales/zh-CN/marketplace.json b/src/i18n/locales/zh-CN/marketplace.json new file mode 100644 index 0000000000..50a2ade635 --- /dev/null +++ b/src/i18n/locales/zh-CN/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "模式", + "mcps": "MCP 服务", + "match": "匹配" + }, + "item-card": { + "type-mode": "模式", + "type-mcp": "MCP 服务", + "type-other": "其他", + "by-author": "作者:{{author}}", + "authors-profile": "作者资料", + "remove-tag-filter": "移除标签过滤器:{{tag}}", + "filter-by-tag": "按标签过滤:{{tag}}", + "component-details": "组件详情", + "view": "查看", + "source": "来源" + }, + "filters": { + "search": { + "placeholder": "搜索 Marketplace..." + }, + "type": { + "label": "类型", + "all": "所有类型", + "mode": "模式", + "mcpServer": "MCP 服务" + }, + "sort": { + "label": "排序方式", + "name": "名称", + "lastUpdated": "最后更新" + }, + "tags": { + "label": "标签", + "clear": "清除标签", + "placeholder": "搜索标签...", + "noResults": "未找到标签。", + "selected": "显示包含任一选中标签的项目" + }, + "title": "Marketplace" + }, + "done": "完成", + "tabs": { + "installed": "已安装", + "browse": "浏览", + "settings": "设置" + }, + "items": { + "empty": { + "noItems": "未找到 Marketplace 项目。", + "emptyHint": "尝试调整过滤器或搜索条件" + } + }, + "installation": { + "installing": "正在安装项目:\"{{itemName}}\"", + "installSuccess": "\"{{itemName}}\" 安装成功", + "installError": "\"{{itemName}}\" 安装失败:{{errorMessage}}", + "removing": "正在移除项目:\"{{itemName}}\"", + "removeSuccess": "\"{{itemName}}\" 移除成功", + "removeError": "\"{{itemName}}\" 移除失败:{{errorMessage}}" + } +} diff --git a/src/i18n/locales/zh-TW/marketplace.json b/src/i18n/locales/zh-TW/marketplace.json new file mode 100644 index 0000000000..3eaeb0e7e0 --- /dev/null +++ b/src/i18n/locales/zh-TW/marketplace.json @@ -0,0 +1,63 @@ +{ + "type-group": { + "modes": "模式", + "mcps": "MCP 伺服器", + "match": "符合" + }, + "item-card": { + "type-mode": "模式", + "type-mcp": "MCP 伺服器", + "type-other": "其他", + "by-author": "作者:{{author}}", + "authors-profile": "作者檔案", + "remove-tag-filter": "移除標籤篩選器:{{tag}}", + "filter-by-tag": "依標籤篩選:{{tag}}", + "component-details": "元件詳情", + "view": "檢視", + "source": "來源" + }, + "filters": { + "search": { + "placeholder": "搜尋 Marketplace..." + }, + "type": { + "label": "類型", + "all": "所有類型", + "mode": "模式", + "mcpServer": "MCP 伺服器" + }, + "sort": { + "label": "排序方式", + "name": "名稱", + "lastUpdated": "最後更新" + }, + "tags": { + "label": "標籤", + "clear": "清除標籤", + "placeholder": "搜尋標籤...", + "noResults": "找不到標籤。", + "selected": "顯示包含任一選取標籤的項目" + }, + "title": "Marketplace" + }, + "done": "完成", + "tabs": { + "installed": "已安裝", + "browse": "瀏覽", + "settings": "設定" + }, + "items": { + "empty": { + "noItems": "找不到 Marketplace 項目。", + "emptyHint": "嘗試調整篩選器或搜尋條件" + } + }, + "installation": { + "installing": "正在安裝項目:「{{itemName}}」", + "installSuccess": "「{{itemName}}」安裝成功", + "installError": "「{{itemName}}」安裝失敗:{{errorMessage}}", + "removing": "正在移除項目:「{{itemName}}」", + "removeSuccess": "「{{itemName}}」移除成功", + "removeError": "「{{itemName}}」移除失敗:{{errorMessage}}" + } +} diff --git a/src/package.json b/src/package.json index b1f83c5bb3..69a4cbb989 100644 --- a/src/package.json +++ b/src/package.json @@ -90,6 +90,11 @@ "title": "%command.history.title%", "icon": "$(history)" }, + { + "command": "roo-cline.marketplaceButtonClicked", + "title": "%command.marketplace.title%", + "icon": "$(extensions)" + }, { "command": "roo-cline.popoutButtonClicked", "title": "%command.openInEditor.title%", @@ -225,23 +230,28 @@ "when": "view == roo-cline.SidebarProvider" }, { - "command": "roo-cline.historyButtonClicked", + "command": "roo-cline.marketplaceButtonClicked", "group": "navigation@4", + "when": "view == roo-cline.SidebarProvider && roo-cline.marketplaceEnabled" + }, + { + "command": "roo-cline.historyButtonClicked", + "group": "navigation@5", "when": "view == roo-cline.SidebarProvider" }, { "command": "roo-cline.popoutButtonClicked", - "group": "navigation@5", + "group": "navigation@6", "when": "view == roo-cline.SidebarProvider" }, { "command": "roo-cline.accountButtonClicked", - "group": "navigation@6", + "group": "navigation@7", "when": "view == roo-cline.SidebarProvider && config.roo-cline.rooCodeCloudEnabled" }, { "command": "roo-cline.settingsButtonClicked", - "group": "navigation@7", + "group": "navigation@8", "when": "view == roo-cline.SidebarProvider" } ], @@ -262,12 +272,12 @@ "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" }, { - "command": "roo-cline.historyButtonClicked", + "command": "roo-cline.marketplaceButtonClicked", "group": "navigation@4", - "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" + "when": "activeWebviewPanelId == roo-cline.TabPanelProvider && roo-cline.marketplaceEnabled" }, { - "command": "roo-cline.popoutButtonClicked", + "command": "roo-cline.historyButtonClicked", "group": "navigation@5", "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" }, diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json index 415639642a..a9c3a93dad 100644 --- a/src/package.nls.ca.json +++ b/src/package.nls.ca.json @@ -20,6 +20,7 @@ "command.mcpServers.title": "Servidors MCP", "command.prompts.title": "Modes", "command.history.title": "Historial", + "command.marketplace.title": "Mercat", "command.openInEditor.title": "Obrir a l'Editor", "command.settings.title": "Configuració", "command.documentation.title": "Documentació", diff --git a/src/package.nls.de.json b/src/package.nls.de.json index c122dc74e8..e9496d7ede 100644 --- a/src/package.nls.de.json +++ b/src/package.nls.de.json @@ -20,6 +20,7 @@ "command.mcpServers.title": "MCP Server", "command.prompts.title": "Modi", "command.history.title": "Verlauf", + "command.marketplace.title": "Marktplatz", "command.openInEditor.title": "Im Editor Öffnen", "command.settings.title": "Einstellungen", "command.documentation.title": "Dokumentation", diff --git a/src/package.nls.es.json b/src/package.nls.es.json index 1f56d20f6c..1b3e09c17b 100644 --- a/src/package.nls.es.json +++ b/src/package.nls.es.json @@ -20,6 +20,7 @@ "command.mcpServers.title": "Servidores MCP", "command.prompts.title": "Modos", "command.history.title": "Historial", + "command.marketplace.title": "Mercado", "command.openInEditor.title": "Abrir en Editor", "command.settings.title": "Configuración", "command.documentation.title": "Documentación", diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json index 1453b083cb..0782ecab05 100644 --- a/src/package.nls.fr.json +++ b/src/package.nls.fr.json @@ -20,6 +20,7 @@ "command.mcpServers.title": "Serveurs MCP", "command.prompts.title": "Modes", "command.history.title": "Historique", + "command.marketplace.title": "Marché", "command.openInEditor.title": "Ouvrir dans l'Éditeur", "command.settings.title": "Paramètres", "command.documentation.title": "Documentation", diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json index 8c18239545..a1855f4cb6 100644 --- a/src/package.nls.hi.json +++ b/src/package.nls.hi.json @@ -20,6 +20,7 @@ "command.mcpServers.title": "एमसीपी सर्वर", "command.prompts.title": "मोड्स", "command.history.title": "इतिहास", + "command.marketplace.title": "मार्केटप्लेस", "command.openInEditor.title": "एडिटर में खोलें", "command.settings.title": "सेटिंग्स", "command.documentation.title": "दस्तावेज़ीकरण", diff --git a/src/package.nls.it.json b/src/package.nls.it.json index 1eb5f089ad..0d491db802 100644 --- a/src/package.nls.it.json +++ b/src/package.nls.it.json @@ -20,6 +20,7 @@ "command.mcpServers.title": "Server MCP", "command.prompts.title": "Modi", "command.history.title": "Cronologia", + "command.marketplace.title": "Marketplace", "command.openInEditor.title": "Apri nell'Editor", "command.settings.title": "Impostazioni", "command.documentation.title": "Documentazione", diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json index 54a38d5c7e..0f8949b1f7 100644 --- a/src/package.nls.ja.json +++ b/src/package.nls.ja.json @@ -9,6 +9,7 @@ "command.mcpServers.title": "MCPサーバー", "command.prompts.title": "モード", "command.history.title": "履歴", + "command.marketplace.title": "マーケットプレイス", "command.openInEditor.title": "エディタで開く", "command.settings.title": "設定", "command.documentation.title": "ドキュメント", diff --git a/src/package.nls.json b/src/package.nls.json index d43010b788..b05dac3b36 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -9,6 +9,7 @@ "command.mcpServers.title": "MCP Servers", "command.prompts.title": "Modes", "command.history.title": "History", + "command.marketplace.title": "Marketplace", "command.openInEditor.title": "Open in Editor", "command.settings.title": "Settings", "command.documentation.title": "Documentation", diff --git a/src/package.nls.ko.json b/src/package.nls.ko.json index 76af9bff86..beddd14f83 100644 --- a/src/package.nls.ko.json +++ b/src/package.nls.ko.json @@ -20,6 +20,7 @@ "command.mcpServers.title": "MCP 서버", "command.prompts.title": "모드", "command.history.title": "기록", + "command.marketplace.title": "마켓플레이스", "command.openInEditor.title": "에디터에서 열기", "command.settings.title": "설정", "command.documentation.title": "문서", diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json index 86d173d336..6ef27343c7 100644 --- a/src/package.nls.nl.json +++ b/src/package.nls.nl.json @@ -9,6 +9,7 @@ "command.mcpServers.title": "MCP Servers", "command.prompts.title": "Modi", "command.history.title": "Geschiedenis", + "command.marketplace.title": "Marktplaats", "command.openInEditor.title": "Openen in Editor", "command.settings.title": "Instellingen", "command.documentation.title": "Documentatie", diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json index 80da3e6a6e..1565299f43 100644 --- a/src/package.nls.pl.json +++ b/src/package.nls.pl.json @@ -20,6 +20,7 @@ "command.mcpServers.title": "Serwery MCP", "command.prompts.title": "Tryby", "command.history.title": "Historia", + "command.marketplace.title": "Marketplace", "command.openInEditor.title": "Otwórz w Edytorze", "command.settings.title": "Ustawienia", "command.documentation.title": "Dokumentacja", diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json index f00397e059..ce21b7d7f6 100644 --- a/src/package.nls.pt-BR.json +++ b/src/package.nls.pt-BR.json @@ -20,6 +20,7 @@ "command.mcpServers.title": "Servidores MCP", "command.prompts.title": "Modos", "command.history.title": "Histórico", + "command.marketplace.title": "Marketplace", "command.openInEditor.title": "Abrir no Editor", "command.settings.title": "Configurações", "command.documentation.title": "Documentação", diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json index 8a7ebb7ba4..5c2b6a030b 100644 --- a/src/package.nls.ru.json +++ b/src/package.nls.ru.json @@ -9,6 +9,7 @@ "command.mcpServers.title": "MCP серверы", "command.prompts.title": "Режимы", "command.history.title": "История", + "command.marketplace.title": "Маркетплейс", "command.openInEditor.title": "Открыть в редакторе", "command.settings.title": "Настройки", "command.documentation.title": "Документация", diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json index 2fcf4a7f58..59d50324d6 100644 --- a/src/package.nls.tr.json +++ b/src/package.nls.tr.json @@ -20,6 +20,7 @@ "command.mcpServers.title": "MCP Sunucuları", "command.prompts.title": "Modlar", "command.history.title": "Geçmiş", + "command.marketplace.title": "Marketplace", "command.openInEditor.title": "Düzenleyicide Aç", "command.settings.title": "Ayarlar", "command.documentation.title": "Dokümantasyon", diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json index 7416d9a974..33f54ebe5c 100644 --- a/src/package.nls.vi.json +++ b/src/package.nls.vi.json @@ -20,6 +20,7 @@ "command.mcpServers.title": "Máy Chủ MCP", "command.prompts.title": "Chế Độ", "command.history.title": "Lịch Sử", + "command.marketplace.title": "Marketplace", "command.openInEditor.title": "Mở trong Trình Soạn Thảo", "command.settings.title": "Cài Đặt", "command.documentation.title": "Tài Liệu", diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index 76ad466c66..ad10328e20 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -20,6 +20,7 @@ "command.mcpServers.title": "MCP 服务器", "command.prompts.title": "模式", "command.history.title": "历史记录", + "command.marketplace.title": "应用市场", "command.openInEditor.title": "在编辑器中打开", "command.settings.title": "设置", "command.documentation.title": "文档", diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index 4e62a4b01f..b903fc6859 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -20,6 +20,7 @@ "command.mcpServers.title": "MCP 伺服器", "command.prompts.title": "模式", "command.history.title": "歷史記錄", + "command.marketplace.title": "應用市場", "command.openInEditor.title": "在編輯器中開啟", "command.settings.title": "設定", "command.documentation.title": "文件", diff --git a/src/services/marketplace/MarketplaceManager.ts b/src/services/marketplace/MarketplaceManager.ts new file mode 100644 index 0000000000..8f88aa4d57 --- /dev/null +++ b/src/services/marketplace/MarketplaceManager.ts @@ -0,0 +1,282 @@ +import * as vscode from "vscode" +import * as fs from "fs/promises" +import * as path from "path" +import * as yaml from "yaml" +import { RemoteConfigLoader } from "./RemoteConfigLoader" +import { SimpleInstaller } from "./SimpleInstaller" +import { MarketplaceItem, MarketplaceItemType } from "./types" +import { GlobalFileNames } from "../../shared/globalFileNames" +import { ensureSettingsDirectoryExists } from "../../utils/globalContext" +import { t } from "../../i18n" +import { TelemetryService } from "@roo-code/telemetry" + +export class MarketplaceManager { + private configLoader: RemoteConfigLoader + private installer: SimpleInstaller + + constructor(private readonly context: vscode.ExtensionContext) { + this.configLoader = new RemoteConfigLoader() + this.installer = new SimpleInstaller(context) + } + + async getMarketplaceItems(): Promise<{ items: MarketplaceItem[]; errors?: string[] }> { + try { + const items = await this.configLoader.loadAllItems() + + return { items } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error("Failed to load marketplace items:", error) + + return { + items: [], + errors: [errorMessage], + } + } + } + + async getCurrentItems(): Promise { + const result = await this.getMarketplaceItems() + return result.items + } + + filterItems( + items: MarketplaceItem[], + filters: { type?: MarketplaceItemType; search?: string; tags?: string[] }, + ): MarketplaceItem[] { + return items.filter((item) => { + // Type filter + if (filters.type && item.type !== filters.type) { + return false + } + + // Search filter + if (filters.search) { + const searchTerm = filters.search.toLowerCase() + const searchableText = `${item.name} ${item.description}`.toLowerCase() + if (!searchableText.includes(searchTerm)) { + return false + } + } + + // Tags filter + if (filters.tags?.length) { + if (!item.tags?.some((tag) => filters.tags!.includes(tag))) { + return false + } + } + + return true + }) + } + + async updateWithFilteredItems(filters: { + type?: MarketplaceItemType + search?: string + tags?: string[] + }): Promise { + const allItems = await this.getCurrentItems() + + if (!filters.type && !filters.search && (!filters.tags || filters.tags.length === 0)) { + return allItems + } + + return this.filterItems(allItems, filters) + } + + async installMarketplaceItem( + item: MarketplaceItem, + options?: { target?: "global" | "project"; parameters?: Record }, + ): Promise { + const { target = "project", parameters } = options || {} + + vscode.window.showInformationMessage(t("marketplace:installation.installing", { itemName: item.name })) + + try { + const result = await this.installer.installItem(item, { target, parameters }) + vscode.window.showInformationMessage(t("marketplace:installation.installSuccess", { itemName: item.name })) + + // Capture telemetry for successful installation + const telemetryProperties: Record = {} + if (parameters && Object.keys(parameters).length > 0) { + telemetryProperties.hasParameters = true + // For MCP items with multiple installation methods, track which one was used + if (item.type === "mcp" && parameters._selectedIndex !== undefined && Array.isArray(item.content)) { + const selectedMethod = item.content[parameters._selectedIndex] + if (selectedMethod && selectedMethod.name) { + telemetryProperties.installationMethodName = selectedMethod.name + } + } + } + + TelemetryService.instance.captureMarketplaceItemInstalled( + item.id, + item.type, + item.name, + target, + telemetryProperties, + ) + + // Open the config file that was modified, optionally at the specific line + const document = await vscode.workspace.openTextDocument(result.filePath) + const options: vscode.TextDocumentShowOptions = {} + + if (result.line !== undefined) { + // Position cursor at the line where content was added + options.selection = new vscode.Range(result.line - 1, 0, result.line - 1, 0) + } + + await vscode.window.showTextDocument(document, options) + + return result.filePath + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + vscode.window.showErrorMessage( + t("marketplace:installation.installError", { itemName: item.name, errorMessage }), + ) + throw error + } + } + + async removeInstalledMarketplaceItem( + item: MarketplaceItem, + options?: { target?: "global" | "project" }, + ): Promise { + const { target = "project" } = options || {} + + vscode.window.showInformationMessage(t("marketplace:installation.removing", { itemName: item.name })) + + try { + await this.installer.removeItem(item, { target }) + vscode.window.showInformationMessage(t("marketplace:installation.removeSuccess", { itemName: item.name })) + + // Capture telemetry for successful removal + TelemetryService.instance.captureMarketplaceItemRemoved(item.id, item.type, item.name, target) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + vscode.window.showErrorMessage( + t("marketplace:installation.removeError", { itemName: item.name, errorMessage }), + ) + throw error + } + } + + async cleanup(): Promise { + // Clear API cache if needed + this.configLoader.clearCache() + } + + /** + * Get installation metadata by checking config files for installed items + */ + async getInstallationMetadata(): Promise<{ + project: Record + global: Record + }> { + const metadata = { + project: {} as Record, + global: {} as Record, + } + + // Check project-level installations + await this.checkProjectInstallations(metadata.project) + + // Check global-level installations + await this.checkGlobalInstallations(metadata.global) + + return metadata + } + + /** + * Check for project-level installed items + */ + private async checkProjectInstallations(metadata: Record): Promise { + try { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0] + if (!workspaceFolder) { + return // No workspace, no project installations + } + + // Check modes in .roomodes + const projectModesPath = path.join(workspaceFolder.uri.fsPath, ".roomodes") + try { + const content = await fs.readFile(projectModesPath, "utf-8") + const data = yaml.parse(content) + if (data?.customModes && Array.isArray(data.customModes)) { + for (const mode of data.customModes) { + if (mode.slug) { + metadata[mode.slug] = { + type: "mode", + } + } + } + } + } catch (error) { + // File doesn't exist or can't be read, skip + } + + // Check MCPs in .roo/mcp.json + const projectMcpPath = path.join(workspaceFolder.uri.fsPath, ".roo", "mcp.json") + try { + const content = await fs.readFile(projectMcpPath, "utf-8") + const data = JSON.parse(content) + if (data?.mcpServers && typeof data.mcpServers === "object") { + for (const serverName of Object.keys(data.mcpServers)) { + metadata[serverName] = { + type: "mcp", + } + } + } + } catch (error) { + // File doesn't exist or can't be read, skip + } + } catch (error) { + console.error("Error checking project installations:", error) + } + } + + /** + * Check for global-level installed items + */ + private async checkGlobalInstallations(metadata: Record): Promise { + try { + const globalSettingsPath = await ensureSettingsDirectoryExists(this.context) + + // Check global modes + const globalModesPath = path.join(globalSettingsPath, GlobalFileNames.customModes) + try { + const content = await fs.readFile(globalModesPath, "utf-8") + const data = yaml.parse(content) + if (data?.customModes && Array.isArray(data.customModes)) { + for (const mode of data.customModes) { + if (mode.slug) { + metadata[mode.slug] = { + type: "mode", + } + } + } + } + } catch (error) { + // File doesn't exist or can't be read, skip + } + + // Check global MCPs + const globalMcpPath = path.join(globalSettingsPath, GlobalFileNames.mcpSettings) + try { + const content = await fs.readFile(globalMcpPath, "utf-8") + const data = JSON.parse(content) + if (data?.mcpServers && typeof data.mcpServers === "object") { + for (const serverName of Object.keys(data.mcpServers)) { + metadata[serverName] = { + type: "mcp", + } + } + } + } catch (error) { + // File doesn't exist or can't be read, skip + } + } catch (error) { + console.error("Error checking global installations:", error) + } + } +} diff --git a/src/services/marketplace/RemoteConfigLoader.ts b/src/services/marketplace/RemoteConfigLoader.ts new file mode 100644 index 0000000000..3b822159dd --- /dev/null +++ b/src/services/marketplace/RemoteConfigLoader.ts @@ -0,0 +1,129 @@ +import axios from "axios" +import * as yaml from "yaml" +import { z } from "zod" +import { getRooCodeApiUrl } from "@roo-code/cloud" +import { MarketplaceItem, MarketplaceItemType } from "./types" +import { modeMarketplaceItemSchema, mcpMarketplaceItemSchema } from "./schemas" + +// Response schemas for YAML API responses +const modeMarketplaceResponse = z.object({ + items: z.array(modeMarketplaceItemSchema), +}) + +const mcpMarketplaceResponse = z.object({ + items: z.array(mcpMarketplaceItemSchema), +}) + +export class RemoteConfigLoader { + private apiBaseUrl: string + private cache: Map = new Map() + private cacheDuration = 5 * 60 * 1000 // 5 minutes + + constructor() { + this.apiBaseUrl = getRooCodeApiUrl() + } + + async loadAllItems(): Promise { + const items: MarketplaceItem[] = [] + + const [modes, mcps] = await Promise.all([this.fetchModes(), this.fetchMcps()]) + + items.push(...modes, ...mcps) + return items + } + + private async fetchModes(): Promise { + const cacheKey = "modes" + const cached = this.getFromCache(cacheKey) + if (cached) return cached + + const data = await this.fetchWithRetry(`${this.apiBaseUrl}/api/marketplace/modes`) + + // Parse and validate YAML response + const yamlData = yaml.parse(data) + const validated = modeMarketplaceResponse.parse(yamlData) + + const items = validated.items.map((item) => ({ + type: "mode" as MarketplaceItemType, + ...item, + })) + + this.setCache(cacheKey, items) + return items + } + + private async fetchMcps(): Promise { + const cacheKey = "mcps" + const cached = this.getFromCache(cacheKey) + if (cached) return cached + + const data = await this.fetchWithRetry(`${this.apiBaseUrl}/api/marketplace/mcps`) + + // Parse and validate YAML response + const yamlData = yaml.parse(data) + const validated = mcpMarketplaceResponse.parse(yamlData) + + const items = validated.items.map((item) => ({ + type: "mcp" as MarketplaceItemType, + ...item, + })) + + this.setCache(cacheKey, items) + return items + } + + private async fetchWithRetry(url: string, maxRetries = 3): Promise { + let lastError: Error + + for (let i = 0; i < maxRetries; i++) { + try { + const response = await axios.get(url, { + timeout: 10000, // 10 second timeout + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }) + return response.data as T + } catch (error) { + lastError = error as Error + if (i < maxRetries - 1) { + // Exponential backoff: 1s, 2s, 4s + const delay = Math.pow(2, i) * 1000 + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + throw lastError! + } + + async getItem(id: string, type: MarketplaceItemType): Promise { + const items = await this.loadAllItems() + return items.find((item) => item.id === id && item.type === type) || null + } + + private getFromCache(key: string): MarketplaceItem[] | null { + const cached = this.cache.get(key) + if (!cached) return null + + const now = Date.now() + if (now - cached.timestamp > this.cacheDuration) { + this.cache.delete(key) + return null + } + + return cached.data + } + + private setCache(key: string, data: MarketplaceItem[]): void { + this.cache.set(key, { + data, + timestamp: Date.now(), + }) + } + + clearCache(): void { + this.cache.clear() + } +} diff --git a/src/services/marketplace/SimpleInstaller.ts b/src/services/marketplace/SimpleInstaller.ts new file mode 100644 index 0000000000..82e44696fd --- /dev/null +++ b/src/services/marketplace/SimpleInstaller.ts @@ -0,0 +1,347 @@ +import * as vscode from "vscode" +import * as path from "path" +import * as fs from "fs/promises" +import * as yaml from "yaml" +import { MarketplaceItem, MarketplaceItemType, InstallMarketplaceItemOptions, McpParameter } from "./types" +import { GlobalFileNames } from "../../shared/globalFileNames" +import { ensureSettingsDirectoryExists } from "../../utils/globalContext" + +export interface InstallOptions extends InstallMarketplaceItemOptions { + target: "project" | "global" + selectedIndex?: number // Which installation method to use (for array content) +} + +export class SimpleInstaller { + constructor(private readonly context: vscode.ExtensionContext) {} + + async installItem(item: MarketplaceItem, options: InstallOptions): Promise<{ filePath: string; line?: number }> { + const { target } = options + + switch (item.type) { + case "mode": + return await this.installMode(item, target) + case "mcp": + return await this.installMcp(item, target, options) + default: + throw new Error(`Unsupported item type: ${item.type}`) + } + } + + private async installMode( + item: MarketplaceItem, + target: "project" | "global", + ): Promise<{ filePath: string; line?: number }> { + if (!item.content) { + throw new Error("Mode item missing content") + } + + // Modes should always have string content, not array + if (Array.isArray(item.content)) { + throw new Error("Mode content should not be an array") + } + + const filePath = await this.getModeFilePath(target) + const modeData = yaml.parse(item.content) + + // Read existing file or create new structure + let existingData: any = { customModes: [] } + try { + const existing = await fs.readFile(filePath, "utf-8") + existingData = yaml.parse(existing) || { customModes: [] } + } catch (error: any) { + if (error.code === "ENOENT") { + // File doesn't exist, use default structure - this is fine + existingData = { customModes: [] } + } else if (error.name === "YAMLParseError" || error.message?.includes("YAML")) { + // YAML parsing error - don't overwrite the file! + const fileName = target === "project" ? ".roomodes" : "custom-modes.yaml" + throw new Error( + `Cannot install mode: The ${fileName} file contains invalid YAML. ` + + `Please fix the syntax errors in the file before installing new modes.`, + ) + } else { + // Other unexpected errors - re-throw + throw error + } + } + + // Ensure customModes array exists + if (!existingData.customModes) { + existingData.customModes = [] + } + + // The content is now a single mode object directly + if (!modeData.slug) { + throw new Error("Invalid mode content: mode missing slug") + } + + // Remove existing mode with same slug if it exists + existingData.customModes = existingData.customModes.filter((mode: any) => mode.slug !== modeData.slug) + + // Add the new mode + existingData.customModes.push(modeData) + const addedModeIndex = existingData.customModes.length - 1 + + // Write back to file + await fs.mkdir(path.dirname(filePath), { recursive: true }) + const yamlContent = yaml.stringify(existingData) + await fs.writeFile(filePath, yamlContent, "utf-8") + + // Calculate approximate line number where the new mode was added + let line: number | undefined + if (addedModeIndex >= 0) { + const lines = yamlContent.split("\n") + // Find the line containing the slug of the added mode + const addedMode = existingData.customModes[addedModeIndex] + if (addedMode?.slug) { + const slugLineIndex = lines.findIndex( + (l) => l.includes(`slug: ${addedMode.slug}`) || l.includes(`slug: "${addedMode.slug}"`), + ) + if (slugLineIndex >= 0) { + line = slugLineIndex + 1 // Convert to 1-based line number + } + } + } + + return { filePath, line } + } + + private async installMcp( + item: MarketplaceItem, + target: "project" | "global", + options?: InstallOptions, + ): Promise<{ filePath: string; line?: number }> { + if (!item.content) { + throw new Error("MCP item missing content") + } + + // Get the content to use + let contentToUse: string + if (Array.isArray(item.content)) { + // Array of McpInstallationMethod objects + const index = options?.selectedIndex ?? 0 + const method = item.content[index] || item.content[0] + contentToUse = method.content + } else { + contentToUse = item.content + } + + // Get method-specific parameters if using array content + let methodParameters: McpParameter[] = [] + if (Array.isArray(item.content)) { + const index = options?.selectedIndex ?? 0 + const method = item.content[index] || item.content[0] + methodParameters = method.parameters || [] + } + + // Merge parameters (method-specific override global) + const allParameters = [...(item.parameters || []), ...methodParameters] + const uniqueParameters = Array.from(new Map(allParameters.map((p) => [p.key, p])).values()) + + // Replace parameters if provided + if (options?.parameters && uniqueParameters.length > 0) { + for (const param of uniqueParameters) { + const value = options.parameters[param.key] + if (value !== undefined) { + contentToUse = contentToUse.replace(new RegExp(`{{${param.key}}}`, "g"), String(value)) + } + } + } + + // Handle _selectedIndex from parameters if provided + if (options?.parameters?._selectedIndex !== undefined && Array.isArray(item.content)) { + const index = options.parameters._selectedIndex + if (index >= 0 && index < item.content.length) { + // Array of McpInstallationMethod objects + const method = item.content[index] + contentToUse = method.content + methodParameters = method.parameters || [] + + // Re-merge parameters with the newly selected method + const allParametersForNewMethod = [...(item.parameters || []), ...methodParameters] + const uniqueParametersForNewMethod = Array.from( + new Map(allParametersForNewMethod.map((p) => [p.key, p])).values(), + ) + + // Re-apply parameter replacements to the newly selected content + for (const param of uniqueParametersForNewMethod) { + const value = options.parameters[param.key] + if (value !== undefined) { + contentToUse = contentToUse.replace(new RegExp(`{{${param.key}}}`, "g"), String(value)) + } + } + } + } + + const filePath = await this.getMcpFilePath(target) + const mcpData = JSON.parse(contentToUse) + + // Read existing file or create new structure + let existingData: any = { mcpServers: {} } + try { + const existing = await fs.readFile(filePath, "utf-8") + existingData = JSON.parse(existing) || { mcpServers: {} } + } catch (error: any) { + if (error.code === "ENOENT") { + // File doesn't exist, use default structure + existingData = { mcpServers: {} } + } else if (error instanceof SyntaxError) { + // JSON parsing error - don't overwrite the file! + const fileName = target === "project" ? ".roo/mcp.json" : "mcp-settings.json" + throw new Error( + `Cannot install MCP server: The ${fileName} file contains invalid JSON. ` + + `Please fix the syntax errors in the file before installing new servers.`, + ) + } else { + // Other unexpected errors - re-throw + throw error + } + } + + // Ensure mcpServers object exists + if (!existingData.mcpServers) { + existingData.mcpServers = {} + } + + // Use the item id as the server name + const serverName = item.id + + // Add or update the single server + existingData.mcpServers[serverName] = mcpData + + // Write back to file + await fs.mkdir(path.dirname(filePath), { recursive: true }) + const jsonContent = JSON.stringify(existingData, null, 2) + await fs.writeFile(filePath, jsonContent, "utf-8") + + // Calculate approximate line number where the new server was added + let line: number | undefined + if (serverName) { + const lines = jsonContent.split("\n") + // Find the line containing the server name + const serverLineIndex = lines.findIndex((l) => l.includes(`"${serverName}"`)) + if (serverLineIndex >= 0) { + line = serverLineIndex + 1 // Convert to 1-based line number + } + } + + return { filePath, line } + } + + async removeItem(item: MarketplaceItem, options: InstallOptions): Promise { + const { target } = options + + switch (item.type) { + case "mode": + await this.removeMode(item, target) + break + case "mcp": + await this.removeMcp(item, target) + break + default: + throw new Error(`Unsupported item type: ${item.type}`) + } + } + + private async removeMode(item: MarketplaceItem, target: "project" | "global"): Promise { + const filePath = await this.getModeFilePath(target) + + try { + const existing = await fs.readFile(filePath, "utf-8") + let existingData: any + + try { + existingData = yaml.parse(existing) + } catch (parseError) { + // If we can't parse the file, we can't safely remove a mode + const fileName = target === "project" ? ".roomodes" : "custom-modes.yaml" + throw new Error( + `Cannot remove mode: The ${fileName} file contains invalid YAML. ` + + `Please fix the syntax errors before removing modes.`, + ) + } + + if (existingData?.customModes) { + // Parse the item content to get the slug + let content: string + if (Array.isArray(item.content)) { + // Array of McpInstallationMethod objects - use first method + content = item.content[0].content + } else { + content = item.content + } + const modeData = yaml.parse(content || "") + + if (!modeData.slug) { + return // Nothing to remove if no slug + } + + // Remove mode with matching slug + existingData.customModes = existingData.customModes.filter((mode: any) => mode.slug !== modeData.slug) + + // Always write back the file, even if empty + await fs.writeFile(filePath, yaml.stringify(existingData), "utf-8") + } + } catch (error: any) { + if (error.code === "ENOENT") { + // File doesn't exist, nothing to remove + return + } + throw error + } + } + + private async removeMcp(item: MarketplaceItem, target: "project" | "global"): Promise { + const filePath = await this.getMcpFilePath(target) + + try { + const existing = await fs.readFile(filePath, "utf-8") + const existingData = JSON.parse(existing) + + if (existingData?.mcpServers) { + // Parse the item content to get server names + let content: string + if (Array.isArray(item.content)) { + // Array of McpInstallationMethod objects - use first method + content = item.content[0].content + } else { + content = item.content + } + + const serverName = item.id + delete existingData.mcpServers[serverName] + + // Always write back the file, even if empty + await fs.writeFile(filePath, JSON.stringify(existingData, null, 2), "utf-8") + } + } catch (error) { + // File doesn't exist or other error, nothing to remove + } + } + + private async getModeFilePath(target: "project" | "global"): Promise { + if (target === "project") { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0] + if (!workspaceFolder) { + throw new Error("No workspace folder found") + } + return path.join(workspaceFolder.uri.fsPath, ".roomodes") + } else { + const globalSettingsPath = await ensureSettingsDirectoryExists(this.context) + return path.join(globalSettingsPath, GlobalFileNames.customModes) + } + } + + private async getMcpFilePath(target: "project" | "global"): Promise { + if (target === "project") { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0] + if (!workspaceFolder) { + throw new Error("No workspace folder found") + } + return path.join(workspaceFolder.uri.fsPath, ".roo", "mcp.json") + } else { + const globalSettingsPath = await ensureSettingsDirectoryExists(this.context) + return path.join(globalSettingsPath, GlobalFileNames.mcpSettings) + } + } +} diff --git a/src/services/marketplace/__tests__/MarketplaceManager.spec.ts b/src/services/marketplace/__tests__/MarketplaceManager.spec.ts new file mode 100644 index 0000000000..c561e2aaae --- /dev/null +++ b/src/services/marketplace/__tests__/MarketplaceManager.spec.ts @@ -0,0 +1,237 @@ +import { MarketplaceManager } from "../MarketplaceManager" +import { vi } from "vitest" + +// Mock dependencies for vitest +vi.mock("fs/promises", () => ({ + readFile: vi.fn(), +})) +vi.mock("yaml", () => ({ + parse: vi.fn(), +})) +vi.mock("vscode", () => ({ + workspace: { + workspaceFolders: [ + { + uri: { fsPath: "/test/workspace" }, + }, + ], + openTextDocument: vi.fn(), + }, + window: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + showTextDocument: vi.fn(), + }, + Range: class MockRange { + start: { line: number; character: number } + end: { line: number; character: number } + + constructor(startLine: number, startCharacter: number, endLine: number, endCharacter: number) { + this.start = { line: startLine, character: startCharacter } + this.end = { line: endLine, character: endCharacter } + } + }, +})) +vi.mock("../../../shared/globalFileNames", () => ({ + GlobalFileNames: { + mcpSettings: "mcp_settings.json", + customModes: "custom_modes.yaml", + }, +})) +vi.mock("../../../utils/globalContext", () => ({ + ensureSettingsDirectoryExists: vi.fn().mockResolvedValue("/mock/global/settings"), +})) + +// Import the mocked modules +import * as fs from "fs/promises" +import * as yaml from "yaml" + +const mockFs = fs as any +const mockYaml = yaml as any + +// Create a mock vscode module for type safety +const mockVscode = { + workspace: { + workspaceFolders: [ + { + uri: { fsPath: "/test/workspace" }, + }, + ], + }, +} as any + +describe("MarketplaceManager", () => { + let marketplaceManager: MarketplaceManager + let mockContext: any + + beforeEach(() => { + vi.clearAllMocks() + + // Mock VSCode workspace + mockVscode.workspace = { + workspaceFolders: [ + { + uri: { fsPath: "/test/workspace" }, + }, + ], + } as any + + // Mock extension context + mockContext = {} as any + + marketplaceManager = new MarketplaceManager(mockContext) + }) + + describe("getInstallationMetadata", () => { + it("should return empty metadata when no config files exist", async () => { + // Mock file read failures (files don't exist) + mockFs.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory")) + + const result = await marketplaceManager.getInstallationMetadata() + + expect(result).toEqual({ + project: {}, + global: {}, + }) + }) + + it("should parse project MCP configuration correctly", async () => { + const mockMcpConfig = { + mcpServers: { + "test-mcp": { + command: "node", + args: ["test.js"], + }, + }, + } + + mockFs.readFile.mockImplementation((filePath: any) => { + // Normalize path separators for cross-platform compatibility + const normalizedPath = filePath.replace(/\\/g, "/") + if (normalizedPath.includes(".roo/mcp.json")) { + return Promise.resolve(JSON.stringify(mockMcpConfig)) + } + return Promise.reject(new Error("ENOENT")) + }) + + const result = await marketplaceManager.getInstallationMetadata() + + expect(result.project["test-mcp"]).toEqual({ + type: "mcp", + }) + }) + + it("should parse project modes configuration correctly", async () => { + const mockModesConfig = { + customModes: [ + { + slug: "test-mode", + name: "Test Mode", + description: "A test mode", + }, + ], + } + + mockFs.readFile.mockImplementation((filePath: any) => { + // Normalize path separators for cross-platform compatibility + const normalizedPath = filePath.replace(/\\/g, "/") + if (normalizedPath.includes(".roomodes")) { + return Promise.resolve("mock-yaml-content") + } + return Promise.reject(new Error("ENOENT")) + }) + + mockYaml.parse.mockReturnValue(mockModesConfig) + + const result = await marketplaceManager.getInstallationMetadata() + + expect(result.project["test-mode"]).toEqual({ + type: "mode", + }) + }) + + it("should parse global configurations correctly", async () => { + const mockGlobalMcp = { + mcpServers: { + "global-mcp": { + command: "node", + args: ["global.js"], + }, + }, + } + + const mockGlobalModes = { + customModes: [ + { + slug: "global-mode", + name: "Global Mode", + description: "A global mode", + }, + ], + } + + mockFs.readFile.mockImplementation((filePath: any) => { + // Normalize path separators for cross-platform compatibility + const normalizedPath = filePath.replace(/\\/g, "/") + if (normalizedPath.includes("mcp_settings.json")) { + return Promise.resolve(JSON.stringify(mockGlobalMcp)) + } + if (normalizedPath.includes("custom_modes.yaml")) { + return Promise.resolve("mock-yaml-content") + } + return Promise.reject(new Error("ENOENT")) + }) + + mockYaml.parse.mockReturnValue(mockGlobalModes) + + const result = await marketplaceManager.getInstallationMetadata() + + expect(result.global["global-mcp"]).toEqual({ + type: "mcp", + }) + expect(result.global["global-mode"]).toEqual({ + type: "mode", + }) + }) + + it("should handle mixed project and global installations", async () => { + const mockProjectMcp = { + mcpServers: { + "project-mcp": { command: "node", args: ["project.js"] }, + }, + } + + const mockGlobalModes = { + customModes: [ + { + slug: "global-mode", + name: "Global Mode", + }, + ], + } + + mockFs.readFile.mockImplementation((filePath: any) => { + // Normalize path separators for cross-platform compatibility + const normalizedPath = filePath.replace(/\\/g, "/") + if (normalizedPath.includes(".roo/mcp.json")) { + return Promise.resolve(JSON.stringify(mockProjectMcp)) + } + if (normalizedPath.includes("custom_modes.yaml")) { + return Promise.resolve("mock-yaml-content") + } + return Promise.reject(new Error("ENOENT")) + }) + + mockYaml.parse.mockReturnValue(mockGlobalModes) + + const result = await marketplaceManager.getInstallationMetadata() + + expect(result.project["project-mcp"]).toEqual({ + type: "mcp", + }) + expect(result.global["global-mode"]).toEqual({ + type: "mode", + }) + }) + }) +}) diff --git a/src/services/marketplace/__tests__/MarketplaceManager.test.ts b/src/services/marketplace/__tests__/MarketplaceManager.test.ts new file mode 100644 index 0000000000..46781d7f32 --- /dev/null +++ b/src/services/marketplace/__tests__/MarketplaceManager.test.ts @@ -0,0 +1,269 @@ +import { MarketplaceManager } from "../MarketplaceManager" +import { MarketplaceItem } from "../types" + +// Mock axios +jest.mock("axios") + +// Mock the cloud config +jest.mock("@roo-code/cloud", () => ({ + getRooCodeApiUrl: () => "https://test.api.com", +})) + +// Mock TelemetryService +jest.mock("../../../../packages/telemetry/src/TelemetryService", () => ({ + TelemetryService: { + instance: { + captureMarketplaceItemInstalled: jest.fn(), + captureMarketplaceItemRemoved: jest.fn(), + }, + }, +})) + +// Mock vscode first +jest.mock("vscode", () => ({ + workspace: { + workspaceFolders: [ + { + uri: { fsPath: "/test/workspace" }, + name: "test", + index: 0, + }, + ], + openTextDocument: jest.fn(), + }, + window: { + showInformationMessage: jest.fn(), + showErrorMessage: jest.fn(), + showTextDocument: jest.fn(), + }, + Range: jest.fn().mockImplementation((startLine, startChar, endLine, endChar) => ({ + start: { line: startLine, character: startChar }, + end: { line: endLine, character: endChar }, + })), +})) + +const mockContext = { + subscriptions: [], + workspaceState: { + get: jest.fn(), + update: jest.fn(), + }, + globalState: { + get: jest.fn(), + update: jest.fn(), + }, + extensionUri: { fsPath: "/test/extension" }, +} as any + +// Mock fs +jest.mock("fs/promises", () => ({ + readFile: jest.fn(), + access: jest.fn(), + writeFile: jest.fn(), + mkdir: jest.fn(), +})) + +// Mock yaml +jest.mock("yaml", () => ({ + parse: jest.fn(), + stringify: jest.fn(), +})) + +describe("MarketplaceManager", () => { + let manager: MarketplaceManager + + beforeEach(() => { + manager = new MarketplaceManager(mockContext) + jest.clearAllMocks() + }) + + describe("filterItems", () => { + it("should filter items by search term", () => { + const items: MarketplaceItem[] = [ + { + id: "test-mode", + name: "Test Mode", + description: "A test mode for testing", + type: "mode", + content: "# Test Mode\nThis is a test mode.", + }, + { + id: "other-mode", + name: "Other Mode", + description: "Another mode", + type: "mode", + content: "# Other Mode\nThis is another mode.", + }, + ] + + const filtered = manager.filterItems(items, { search: "test" }) + + expect(filtered).toHaveLength(1) + expect(filtered[0].name).toBe("Test Mode") + }) + + it("should filter items by type", () => { + const items: MarketplaceItem[] = [ + { + id: "test-mode", + name: "Test Mode", + description: "A test mode", + type: "mode", + content: "# Test Mode", + }, + { + id: "test-mcp", + name: "Test MCP", + description: "A test MCP", + type: "mcp", + content: '{"command": "node", "args": ["server.js"]}', + }, + ] + + const filtered = manager.filterItems(items, { type: "mode" }) + + expect(filtered).toHaveLength(1) + expect(filtered[0].type).toBe("mode") + }) + + it("should return empty array when no items match", () => { + const items: MarketplaceItem[] = [ + { + id: "test-mode", + name: "Test Mode", + description: "A test mode", + type: "mode", + content: "# Test Mode", + }, + ] + + const filtered = manager.filterItems(items, { search: "nonexistent" }) + + expect(filtered).toHaveLength(0) + }) + }) + + describe("getMarketplaceItems", () => { + it("should return items from API", async () => { + // Mock the config loader to return test data + const mockItems: MarketplaceItem[] = [ + { + id: "test-mode", + name: "Test Mode", + description: "A test mode", + type: "mode", + content: "# Test Mode", + }, + ] + + // Mock the loadAllItems method + jest.spyOn(manager["configLoader"], "loadAllItems").mockResolvedValue(mockItems) + + const result = await manager.getMarketplaceItems() + + expect(result.items).toHaveLength(1) + expect(result.items[0].name).toBe("Test Mode") + }) + + it("should handle API errors gracefully", async () => { + // Mock the config loader to throw an error + jest.spyOn(manager["configLoader"], "loadAllItems").mockRejectedValue(new Error("API request failed")) + + const result = await manager.getMarketplaceItems() + + expect(result.items).toHaveLength(0) + expect(result.errors).toEqual(["API request failed"]) + }) + }) + + describe("installMarketplaceItem", () => { + it("should install a mode item", async () => { + const item: MarketplaceItem = { + id: "test-mode", + name: "Test Mode", + description: "A test mode", + type: "mode", + content: "# Test Mode\nThis is a test mode.", + } + + // Mock the installer + jest.spyOn(manager["installer"], "installItem").mockResolvedValue({ + filePath: "/test/path/.roomodes", + line: 5, + }) + + const result = await manager.installMarketplaceItem(item) + + expect(manager["installer"].installItem).toHaveBeenCalledWith(item, { target: "project" }) + expect(result).toBe("/test/path/.roomodes") + }) + + it("should install an MCP item", async () => { + const item: MarketplaceItem = { + id: "test-mcp", + name: "Test MCP", + description: "A test MCP", + type: "mcp", + content: '{"command": "node", "args": ["server.js"]}', + } + + // Mock the installer + jest.spyOn(manager["installer"], "installItem").mockResolvedValue({ + filePath: "/test/path/.roo/mcp.json", + line: 3, + }) + + const result = await manager.installMarketplaceItem(item) + + expect(manager["installer"].installItem).toHaveBeenCalledWith(item, { target: "project" }) + expect(result).toBe("/test/path/.roo/mcp.json") + }) + }) + + describe("removeInstalledMarketplaceItem", () => { + it("should remove a mode item", async () => { + const item: MarketplaceItem = { + id: "test-mode", + name: "Test Mode", + description: "A test mode", + type: "mode", + content: "# Test Mode", + } + + // Mock the installer + jest.spyOn(manager["installer"], "removeItem").mockResolvedValue() + + await manager.removeInstalledMarketplaceItem(item) + + expect(manager["installer"].removeItem).toHaveBeenCalledWith(item, { target: "project" }) + }) + + it("should remove an MCP item", async () => { + const item: MarketplaceItem = { + id: "test-mcp", + name: "Test MCP", + description: "A test MCP", + type: "mcp", + content: '{"command": "node", "args": ["server.js"]}', + } + + // Mock the installer + jest.spyOn(manager["installer"], "removeItem").mockResolvedValue() + + await manager.removeInstalledMarketplaceItem(item) + + expect(manager["installer"].removeItem).toHaveBeenCalledWith(item, { target: "project" }) + }) + }) + + describe("cleanup", () => { + it("should clear API cache", async () => { + // Mock the clearCache method + jest.spyOn(manager["configLoader"], "clearCache") + + await manager.cleanup() + + expect(manager["configLoader"].clearCache).toHaveBeenCalled() + }) + }) +}) diff --git a/src/services/marketplace/__tests__/RemoteConfigLoader.test.ts b/src/services/marketplace/__tests__/RemoteConfigLoader.test.ts new file mode 100644 index 0000000000..8d78c78fcb --- /dev/null +++ b/src/services/marketplace/__tests__/RemoteConfigLoader.test.ts @@ -0,0 +1,333 @@ +import axios from "axios" +import { RemoteConfigLoader } from "../RemoteConfigLoader" +import { MarketplaceItemType } from "../types" + +// Mock axios +jest.mock("axios") +const mockedAxios = axios as jest.Mocked + +// Mock the cloud config +jest.mock("@roo-code/cloud", () => ({ + getRooCodeApiUrl: () => "https://test.api.com", +})) + +describe("RemoteConfigLoader", () => { + let loader: RemoteConfigLoader + + beforeEach(() => { + loader = new RemoteConfigLoader() + jest.clearAllMocks() + // Clear any existing cache + loader.clearCache() + }) + + describe("loadAllItems", () => { + it("should fetch and combine modes and MCPs from API", async () => { + const mockModesYaml = `items: + - id: "test-mode" + name: "Test Mode" + description: "A test mode" + content: "customModes:\\n - slug: test\\n name: Test"` + + const mockMcpsYaml = `items: + - id: "test-mcp" + name: "Test MCP" + description: "A test MCP" + url: "https://github.com/test/test-mcp" + content: '{"command": "test"}'` + + mockedAxios.get.mockImplementation((url) => { + if (url.includes("/modes")) { + return Promise.resolve({ data: mockModesYaml }) + } + if (url.includes("/mcps")) { + return Promise.resolve({ data: mockMcpsYaml }) + } + return Promise.reject(new Error("Unknown URL")) + }) + + const items = await loader.loadAllItems() + + expect(mockedAxios.get).toHaveBeenCalledTimes(2) + expect(mockedAxios.get).toHaveBeenCalledWith( + "https://test.api.com/api/marketplace/modes", + expect.objectContaining({ + timeout: 10000, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }), + ) + expect(mockedAxios.get).toHaveBeenCalledWith( + "https://test.api.com/api/marketplace/mcps", + expect.objectContaining({ + timeout: 10000, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }), + ) + + expect(items).toHaveLength(2) + expect(items[0]).toEqual({ + type: "mode", + id: "test-mode", + name: "Test Mode", + description: "A test mode", + content: "customModes:\n - slug: test\n name: Test", + }) + expect(items[1]).toEqual({ + type: "mcp", + id: "test-mcp", + name: "Test MCP", + description: "A test MCP", + url: "https://github.com/test/test-mcp", + content: '{"command": "test"}', + }) + }) + + it("should use cache on subsequent calls", async () => { + const mockModesYaml = `items: + - id: "test-mode" + name: "Test Mode" + description: "A test mode" + content: "test content"` + + const mockMcpsYaml = `items: + - id: "test-mcp" + name: "Test MCP" + description: "A test MCP" + url: "https://github.com/test/test-mcp" + content: "test content"` + + mockedAxios.get.mockImplementation((url) => { + if (url.includes("/modes")) { + return Promise.resolve({ data: mockModesYaml }) + } + if (url.includes("/mcps")) { + return Promise.resolve({ data: mockMcpsYaml }) + } + return Promise.reject(new Error("Unknown URL")) + }) + + // First call - should hit API + const items1 = await loader.loadAllItems() + expect(mockedAxios.get).toHaveBeenCalledTimes(2) + + // Second call - should use cache + const items2 = await loader.loadAllItems() + expect(mockedAxios.get).toHaveBeenCalledTimes(2) // Still 2, not 4 + + expect(items1).toEqual(items2) + }) + + it("should retry on network failures", async () => { + const mockModesYaml = `items: + - id: "test-mode" + name: "Test Mode" + description: "A test mode" + content: "test content"` + + const mockMcpsYaml = `items: []` + + // Mock modes endpoint to fail twice then succeed + let modesCallCount = 0 + mockedAxios.get.mockImplementation((url) => { + if (url.includes("/modes")) { + modesCallCount++ + if (modesCallCount <= 2) { + return Promise.reject(new Error("Network error")) + } + return Promise.resolve({ data: mockModesYaml }) + } + if (url.includes("/mcps")) { + return Promise.resolve({ data: mockMcpsYaml }) + } + return Promise.reject(new Error("Unknown URL")) + }) + + const items = await loader.loadAllItems() + + // Should have retried modes endpoint 3 times (2 failures + 1 success) + expect(modesCallCount).toBe(3) + expect(items).toHaveLength(1) + expect(items[0].type).toBe("mode") + }) + + it("should throw error after max retries", async () => { + mockedAxios.get.mockRejectedValue(new Error("Persistent network error")) + + await expect(loader.loadAllItems()).rejects.toThrow("Persistent network error") + + // Both endpoints will be called with retries since Promise.all starts both promises + // Each endpoint retries 3 times, but due to Promise.all behavior, one might fail faster + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining("/api/marketplace/"), + expect.any(Object), + ) + // Verify we got at least some retry attempts (should be at least 2 calls) + expect(mockedAxios.get.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + + it("should handle invalid data gracefully", async () => { + const invalidModesYaml = `items: + - id: "invalid-mode" + # Missing required fields like name and description` + + const validMcpsYaml = `items: + - id: "valid-mcp" + name: "Valid MCP" + description: "A valid MCP" + url: "https://github.com/test/test-mcp" + content: "test content"` + + mockedAxios.get.mockImplementation((url) => { + if (url.includes("/modes")) { + return Promise.resolve({ data: invalidModesYaml }) + } + if (url.includes("/mcps")) { + return Promise.resolve({ data: validMcpsYaml }) + } + return Promise.reject(new Error("Unknown URL")) + }) + + // Should throw validation error for invalid modes + await expect(loader.loadAllItems()).rejects.toThrow() + }) + }) + + describe("getItem", () => { + it("should find specific item by id and type", async () => { + const mockModesYaml = `items: + - id: "target-mode" + name: "Target Mode" + description: "The mode we want" + content: "test content"` + + const mockMcpsYaml = `items: + - id: "target-mcp" + name: "Target MCP" + description: "The MCP we want" + url: "https://github.com/test/test-mcp" + content: "test content"` + + mockedAxios.get.mockImplementation((url) => { + if (url.includes("/modes")) { + return Promise.resolve({ data: mockModesYaml }) + } + if (url.includes("/mcps")) { + return Promise.resolve({ data: mockMcpsYaml }) + } + return Promise.reject(new Error("Unknown URL")) + }) + + const modeItem = await loader.getItem("target-mode", "mode" as MarketplaceItemType) + const mcpItem = await loader.getItem("target-mcp", "mcp" as MarketplaceItemType) + const notFound = await loader.getItem("nonexistent", "mode" as MarketplaceItemType) + + expect(modeItem).toEqual({ + type: "mode", + id: "target-mode", + name: "Target Mode", + description: "The mode we want", + content: "test content", + }) + + expect(mcpItem).toEqual({ + type: "mcp", + id: "target-mcp", + name: "Target MCP", + description: "The MCP we want", + url: "https://github.com/test/test-mcp", + content: "test content", + }) + + expect(notFound).toBeNull() + }) + }) + + describe("clearCache", () => { + it("should clear cache and force fresh API calls", async () => { + const mockModesYaml = `items: + - id: "test-mode" + name: "Test Mode" + description: "A test mode" + content: "test content"` + + const mockMcpsYaml = `items: []` + + mockedAxios.get.mockImplementation((url) => { + if (url.includes("/modes")) { + return Promise.resolve({ data: mockModesYaml }) + } + if (url.includes("/mcps")) { + return Promise.resolve({ data: mockMcpsYaml }) + } + return Promise.reject(new Error("Unknown URL")) + }) + + // First call + await loader.loadAllItems() + expect(mockedAxios.get).toHaveBeenCalledTimes(2) + + // Second call - should use cache + await loader.loadAllItems() + expect(mockedAxios.get).toHaveBeenCalledTimes(2) + + // Clear cache + loader.clearCache() + + // Third call - should hit API again + await loader.loadAllItems() + expect(mockedAxios.get).toHaveBeenCalledTimes(4) + }) + }) + + describe("cache expiration", () => { + it("should expire cache after 5 minutes", async () => { + const mockModesYaml = `items: + - id: "test-mode" + name: "Test Mode" + description: "A test mode" + content: "test content"` + + const mockMcpsYaml = `items: []` + + mockedAxios.get.mockImplementation((url) => { + if (url.includes("/modes")) { + return Promise.resolve({ data: mockModesYaml }) + } + if (url.includes("/mcps")) { + return Promise.resolve({ data: mockMcpsYaml }) + } + return Promise.reject(new Error("Unknown URL")) + }) + + // Mock Date.now to control time + const originalDateNow = Date.now + let currentTime = 1000000 + + Date.now = jest.fn(() => currentTime) + + // First call + await loader.loadAllItems() + expect(mockedAxios.get).toHaveBeenCalledTimes(2) + + // Second call immediately - should use cache + await loader.loadAllItems() + expect(mockedAxios.get).toHaveBeenCalledTimes(2) + + // Advance time by 6 minutes (360,000 ms) + currentTime += 6 * 60 * 1000 + + // Third call - cache should be expired + await loader.loadAllItems() + expect(mockedAxios.get).toHaveBeenCalledTimes(4) + + // Restore original Date.now + Date.now = originalDateNow + }) + }) +}) diff --git a/src/services/marketplace/__tests__/SimpleInstaller.test.ts b/src/services/marketplace/__tests__/SimpleInstaller.test.ts new file mode 100644 index 0000000000..7ed90d14cb --- /dev/null +++ b/src/services/marketplace/__tests__/SimpleInstaller.test.ts @@ -0,0 +1,225 @@ +import { SimpleInstaller } from "../SimpleInstaller" +import * as fs from "fs/promises" +import * as yaml from "yaml" +import * as vscode from "vscode" +import { MarketplaceItem } from "../types" +import * as path from "path" + +jest.mock("fs/promises") +jest.mock("vscode", () => ({ + workspace: { + workspaceFolders: [ + { + uri: { fsPath: "/test/workspace" }, + name: "test", + index: 0, + }, + ], + }, +})) +jest.mock("../../../utils/globalContext") + +const mockFs = fs as jest.Mocked + +describe("SimpleInstaller", () => { + let installer: SimpleInstaller + let mockContext: vscode.ExtensionContext + + beforeEach(() => { + mockContext = {} as vscode.ExtensionContext + installer = new SimpleInstaller(mockContext) + jest.clearAllMocks() + + // Mock mkdir to always succeed + mockFs.mkdir.mockResolvedValue(undefined as any) + }) + + describe("installMode", () => { + const mockModeItem: MarketplaceItem = { + id: "test-mode", + name: "Test Mode", + description: "A test mode for testing", + type: "mode", + content: yaml.stringify({ + slug: "test", + name: "Test Mode", + roleDefinition: "Test role", + groups: ["read"], + }), + } + + it("should install mode when .roomodes file does not exist", async () => { + // Mock file not found error + const notFoundError = new Error("File not found") as any + notFoundError.code = "ENOENT" + mockFs.readFile.mockRejectedValueOnce(notFoundError) + mockFs.writeFile.mockResolvedValueOnce(undefined as any) + + const result = await installer.installItem(mockModeItem, { target: "project" }) + + expect(result.filePath).toBe(path.join("/test/workspace", ".roomodes")) + expect(mockFs.writeFile).toHaveBeenCalled() + + // Verify the written content contains the new mode + const writtenContent = mockFs.writeFile.mock.calls[0][1] as string + const writtenData = yaml.parse(writtenContent) + expect(writtenData.customModes).toHaveLength(1) + expect(writtenData.customModes[0].slug).toBe("test") + }) + + it("should install mode when .roomodes contains valid YAML", async () => { + const existingContent = yaml.stringify({ + customModes: [{ slug: "existing", name: "Existing Mode", roleDefinition: "Existing", groups: [] }], + }) + + mockFs.readFile.mockResolvedValueOnce(existingContent) + mockFs.writeFile.mockResolvedValueOnce(undefined as any) + + await installer.installItem(mockModeItem, { target: "project" }) + + expect(mockFs.writeFile).toHaveBeenCalled() + const writtenContent = mockFs.writeFile.mock.calls[0][1] as string + const writtenData = yaml.parse(writtenContent) + + // Should contain both existing and new mode + expect(writtenData.customModes).toHaveLength(2) + expect(writtenData.customModes.find((m: any) => m.slug === "existing")).toBeDefined() + expect(writtenData.customModes.find((m: any) => m.slug === "test")).toBeDefined() + }) + + it("should throw error when .roomodes contains invalid YAML", async () => { + const invalidYaml = "invalid: yaml: content: {" + + mockFs.readFile.mockResolvedValueOnce(invalidYaml) + + await expect(installer.installItem(mockModeItem, { target: "project" })).rejects.toThrow( + "Cannot install mode: The .roomodes file contains invalid YAML", + ) + + // Should NOT write to file + expect(mockFs.writeFile).not.toHaveBeenCalled() + }) + + it("should replace existing mode with same slug", async () => { + const existingContent = yaml.stringify({ + customModes: [{ slug: "test", name: "Old Test Mode", roleDefinition: "Old role", groups: [] }], + }) + + mockFs.readFile.mockResolvedValueOnce(existingContent) + mockFs.writeFile.mockResolvedValueOnce(undefined as any) + + await installer.installItem(mockModeItem, { target: "project" }) + + const writtenContent = mockFs.writeFile.mock.calls[0][1] as string + const writtenData = yaml.parse(writtenContent) + + // Should contain only one mode with updated content + expect(writtenData.customModes).toHaveLength(1) + expect(writtenData.customModes[0].slug).toBe("test") + expect(writtenData.customModes[0].name).toBe("Test Mode") // New name + }) + }) + + describe("installMcp", () => { + const mockMcpItem: MarketplaceItem = { + id: "test-mcp", + name: "Test MCP", + description: "A test MCP server for testing", + type: "mcp", + content: JSON.stringify({ + command: "test-server", + args: ["--test"], + }), + } + + it("should install MCP when mcp.json file does not exist", async () => { + const notFoundError = new Error("File not found") as any + notFoundError.code = "ENOENT" + mockFs.readFile.mockRejectedValueOnce(notFoundError) + mockFs.writeFile.mockResolvedValueOnce(undefined as any) + + const result = await installer.installItem(mockMcpItem, { target: "project" }) + + expect(result.filePath).toBe(path.join("/test/workspace", ".roo", "mcp.json")) + expect(mockFs.writeFile).toHaveBeenCalled() + + // Verify the written content contains the new server + const writtenContent = mockFs.writeFile.mock.calls[0][1] as string + const writtenData = JSON.parse(writtenContent) + expect(writtenData.mcpServers["test-mcp"]).toBeDefined() + }) + + it("should throw error when mcp.json contains invalid JSON", async () => { + const invalidJson = '{ "mcpServers": { invalid json' + + mockFs.readFile.mockResolvedValueOnce(invalidJson) + + await expect(installer.installItem(mockMcpItem, { target: "project" })).rejects.toThrow( + "Cannot install MCP server: The .roo/mcp.json file contains invalid JSON", + ) + + // Should NOT write to file + expect(mockFs.writeFile).not.toHaveBeenCalled() + }) + + it("should install MCP when mcp.json contains valid JSON", async () => { + const existingContent = JSON.stringify({ + mcpServers: { + "existing-server": { command: "existing", args: [] }, + }, + }) + + mockFs.readFile.mockResolvedValueOnce(existingContent) + mockFs.writeFile.mockResolvedValueOnce(undefined as any) + + await installer.installItem(mockMcpItem, { target: "project" }) + + const writtenContent = mockFs.writeFile.mock.calls[0][1] as string + const writtenData = JSON.parse(writtenContent) + + // Should contain both existing and new server + expect(Object.keys(writtenData.mcpServers)).toHaveLength(2) + expect(writtenData.mcpServers["existing-server"]).toBeDefined() + expect(writtenData.mcpServers["test-mcp"]).toBeDefined() + }) + }) + + describe("removeMode", () => { + const mockModeItem: MarketplaceItem = { + id: "test-mode", + name: "Test Mode", + description: "A test mode for testing", + type: "mode", + content: yaml.stringify({ + slug: "test", + name: "Test Mode", + roleDefinition: "Test role", + groups: ["read"], + }), + } + + it("should throw error when .roomodes contains invalid YAML during removal", async () => { + const invalidYaml = "invalid: yaml: content: {" + + mockFs.readFile.mockResolvedValueOnce(invalidYaml) + + await expect(installer.removeItem(mockModeItem, { target: "project" })).rejects.toThrow( + "Cannot remove mode: The .roomodes file contains invalid YAML", + ) + + // Should NOT write to file + expect(mockFs.writeFile).not.toHaveBeenCalled() + }) + + it("should do nothing when file does not exist", async () => { + const notFoundError = new Error("File not found") as any + notFoundError.code = "ENOENT" + mockFs.readFile.mockRejectedValueOnce(notFoundError) + + // Should not throw + await installer.removeItem(mockModeItem, { target: "project" }) + + expect(mockFs.writeFile).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/services/marketplace/__tests__/marketplace-setting-check.test.ts b/src/services/marketplace/__tests__/marketplace-setting-check.test.ts new file mode 100644 index 0000000000..8e2844a4ca --- /dev/null +++ b/src/services/marketplace/__tests__/marketplace-setting-check.test.ts @@ -0,0 +1,87 @@ +import { webviewMessageHandler } from "../../../core/webview/webviewMessageHandler" +import { MarketplaceManager } from "../MarketplaceManager" + +// Mock the provider and marketplace manager +const mockProvider = { + getState: jest.fn(), + postStateToWebview: jest.fn(), +} as any + +const mockMarketplaceManager = { + updateWithFilteredItems: jest.fn(), +} as any + +describe("Marketplace Setting Check", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should skip API calls when marketplace is disabled", async () => { + // Mock experiments with marketplace disabled + mockProvider.getState.mockResolvedValue({ + experiments: { marketplace: false }, + }) + + const message = { + type: "filterMarketplaceItems" as const, + filters: { type: "mcp", search: "", tags: [] }, + } + + await webviewMessageHandler(mockProvider, message, mockMarketplaceManager) + + // Should not call marketplace manager methods + expect(mockMarketplaceManager.updateWithFilteredItems).not.toHaveBeenCalled() + expect(mockProvider.postStateToWebview).not.toHaveBeenCalled() + }) + + it("should allow API calls when marketplace is enabled", async () => { + // Mock experiments with marketplace enabled + mockProvider.getState.mockResolvedValue({ + experiments: { marketplace: true }, + }) + + const message = { + type: "filterMarketplaceItems" as const, + filters: { type: "mcp", search: "", tags: [] }, + } + + await webviewMessageHandler(mockProvider, message, mockMarketplaceManager) + + // Should call marketplace manager methods + expect(mockMarketplaceManager.updateWithFilteredItems).toHaveBeenCalledWith({ + type: "mcp", + search: "", + tags: [], + }) + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("should skip installation when marketplace is disabled", async () => { + // Mock experiments with marketplace disabled + mockProvider.getState.mockResolvedValue({ + experiments: { marketplace: false }, + }) + + const mockInstallMarketplaceItem = jest.fn() + const mockMarketplaceManagerWithInstall = { + installMarketplaceItem: mockInstallMarketplaceItem, + } + + const message = { + type: "installMarketplaceItem" as const, + mpItem: { + id: "test-item", + name: "Test Item", + type: "mcp" as const, + description: "Test description", + content: "test content", + }, + mpInstallOptions: { target: "project" as const }, + } + + await webviewMessageHandler(mockProvider, message, mockMarketplaceManagerWithInstall as any) + + // Should not call install method + expect(mockInstallMarketplaceItem).not.toHaveBeenCalled() + }) +}) diff --git a/src/services/marketplace/__tests__/nested-parameters.spec.ts b/src/services/marketplace/__tests__/nested-parameters.spec.ts new file mode 100644 index 0000000000..67fc4267a3 --- /dev/null +++ b/src/services/marketplace/__tests__/nested-parameters.spec.ts @@ -0,0 +1,231 @@ +import { describe, it, expect } from "vitest" +import { mcpInstallationMethodSchema, mcpMarketplaceItemYamlSchema } from "../schemas" +import { McpInstallationMethod, McpMarketplaceItem } from "../types" + +describe("Nested Parameters", () => { + describe("McpInstallationMethod Schema", () => { + it("should validate installation method without parameters", () => { + const method = { + name: "Docker Installation", + content: '{"command": "docker", "args": ["run", "image"]}', + } + + const result = mcpInstallationMethodSchema.parse(method) + expect(result.parameters).toBeUndefined() + }) + + it("should validate installation method with parameters", () => { + const method = { + name: "Docker Installation", + content: '{"command": "docker", "args": ["run", "-p", "{{port}}:8080", "{{image}}"]}', + parameters: [ + { + name: "Port", + key: "port", + placeholder: "8080", + optional: true, + }, + { + name: "Docker Image", + key: "image", + placeholder: "latest", + }, + ], + } + + const result = mcpInstallationMethodSchema.parse(method) + expect(result.parameters).toHaveLength(2) + expect(result.parameters![0].key).toBe("port") + expect(result.parameters![0].optional).toBe(true) + expect(result.parameters![1].key).toBe("image") + expect(result.parameters![1].optional).toBe(false) + }) + + it("should validate installation method with empty parameters array", () => { + const method = { + name: "Simple Installation", + content: '{"command": "npm", "args": ["start"]}', + parameters: [], + } + + const result = mcpInstallationMethodSchema.parse(method) + expect(result.parameters).toEqual([]) + }) + }) + + describe("McpMarketplaceItem with Nested Parameters", () => { + it("should validate MCP item with global and method-specific parameters", () => { + const item = { + id: "multi-method-mcp", + name: "Multi-Method MCP", + description: "MCP with multiple installation methods", + url: "https://github.com/example/mcp", + parameters: [ + { + name: "API Key", + key: "api_key", + placeholder: "Enter your API key", + }, + ], + content: [ + { + name: "Docker Installation", + content: '{"command": "docker", "args": ["-e", "API_KEY={{api_key}}", "-p", "{{port}}:8080"]}', + parameters: [ + { + name: "Port", + key: "port", + placeholder: "8080", + optional: true, + }, + ], + }, + { + name: "NPM Installation", + content: '{"command": "npx", "args": ["package@{{version}}", "--api-key", "{{api_key}}"]}', + parameters: [ + { + name: "Package Version", + key: "version", + placeholder: "latest", + optional: true, + }, + ], + }, + ], + } + + const result = mcpMarketplaceItemYamlSchema.parse(item) + expect(result.parameters).toHaveLength(1) + expect(result.parameters![0].key).toBe("api_key") + + expect(Array.isArray(result.content)).toBe(true) + const methods = result.content as McpInstallationMethod[] + expect(methods).toHaveLength(2) + + expect(methods[0].parameters).toHaveLength(1) + expect(methods[0].parameters![0].key).toBe("port") + + expect(methods[1].parameters).toHaveLength(1) + expect(methods[1].parameters![0].key).toBe("version") + }) + + it("should validate MCP item with only global parameters", () => { + const item = { + id: "global-only-mcp", + name: "Global Only MCP", + description: "MCP with only global parameters", + url: "https://github.com/example/mcp", + parameters: [ + { + name: "API Key", + key: "api_key", + placeholder: "Enter your API key", + }, + ], + content: [ + { + name: "Installation", + content: '{"command": "npm", "args": ["--api-key", "{{api_key}}"]}', + }, + ], + } + + const result = mcpMarketplaceItemYamlSchema.parse(item) + expect(result.parameters).toHaveLength(1) + + const methods = result.content as McpInstallationMethod[] + expect(methods[0].parameters).toBeUndefined() + }) + + it("should validate MCP item with only method-specific parameters", () => { + const item = { + id: "method-only-mcp", + name: "Method Only MCP", + description: "MCP with only method-specific parameters", + url: "https://github.com/example/mcp", + content: [ + { + name: "Docker Installation", + content: '{"command": "docker", "args": ["-p", "{{port}}:8080"]}', + parameters: [ + { + name: "Port", + key: "port", + placeholder: "8080", + optional: true, + }, + ], + }, + ], + } + + const result = mcpMarketplaceItemYamlSchema.parse(item) + expect(result.parameters).toBeUndefined() + + const methods = result.content as McpInstallationMethod[] + expect(methods[0].parameters).toHaveLength(1) + expect(methods[0].parameters![0].key).toBe("port") + }) + + it("should validate MCP item with no parameters at all", () => { + const item = { + id: "no-params-mcp", + name: "No Parameters MCP", + description: "MCP with no parameters", + url: "https://github.com/example/mcp", + content: [ + { + name: "Simple Installation", + content: '{"command": "npm", "args": ["start"]}', + }, + ], + } + + const result = mcpMarketplaceItemYamlSchema.parse(item) + expect(result.parameters).toBeUndefined() + + const methods = result.content as McpInstallationMethod[] + expect(methods[0].parameters).toBeUndefined() + }) + }) + + describe("Parameter Key Conflicts", () => { + it("should allow same parameter key in global and method-specific parameters", () => { + const item = { + id: "conflict-mcp", + name: "Conflict MCP", + description: "MCP with parameter key conflicts", + url: "https://github.com/example/mcp", + parameters: [ + { + name: "Global Version", + key: "version", + placeholder: "1.0.0", + }, + ], + content: [ + { + name: "Method Installation", + content: '{"command": "npm", "args": ["package@{{version}}"]}', + parameters: [ + { + name: "Method Version", + key: "version", + placeholder: "latest", + optional: true, + }, + ], + }, + ], + } + + // This should validate successfully - the conflict resolution happens at runtime + const result = mcpMarketplaceItemYamlSchema.parse(item) + expect(result.parameters![0].key).toBe("version") + + const methods = result.content as McpInstallationMethod[] + expect(methods[0].parameters![0].key).toBe("version") + }) + }) +}) diff --git a/src/services/marketplace/__tests__/optional-parameters.spec.ts b/src/services/marketplace/__tests__/optional-parameters.spec.ts new file mode 100644 index 0000000000..1b73c15815 --- /dev/null +++ b/src/services/marketplace/__tests__/optional-parameters.spec.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest" +import { mcpParameterSchema } from "../schemas" +import { McpParameter } from "../types" + +describe("Optional Parameters", () => { + describe("McpParameter Schema", () => { + it("should validate parameter with optional field set to true", () => { + const param = { + name: "Test Parameter", + key: "test_key", + placeholder: "Enter value", + optional: true, + } + + const result = mcpParameterSchema.parse(param) + expect(result.optional).toBe(true) + }) + + it("should validate parameter with optional field set to false", () => { + const param = { + name: "Test Parameter", + key: "test_key", + placeholder: "Enter value", + optional: false, + } + + const result = mcpParameterSchema.parse(param) + expect(result.optional).toBe(false) + }) + + it("should default optional to false when not provided", () => { + const param = { + name: "Test Parameter", + key: "test_key", + placeholder: "Enter value", + } + + const result = mcpParameterSchema.parse(param) + expect(result.optional).toBe(false) + }) + + it("should validate parameter without placeholder", () => { + const param = { + name: "Test Parameter", + key: "test_key", + optional: true, + } + + const result = mcpParameterSchema.parse(param) + expect(result.optional).toBe(true) + expect(result.placeholder).toBeUndefined() + }) + + it("should require name and key fields", () => { + expect(() => { + mcpParameterSchema.parse({ + key: "test_key", + optional: true, + }) + }).toThrow() + + expect(() => { + mcpParameterSchema.parse({ + name: "Test Parameter", + optional: true, + }) + }).toThrow() + }) + }) + + describe("Type Definitions", () => { + it("should allow optional field in McpParameter interface", () => { + const requiredParam: McpParameter = { + name: "Required Param", + key: "required_key", + } + + const optionalParam: McpParameter = { + name: "Optional Param", + key: "optional_key", + optional: true, + } + + // These should compile without errors + expect(requiredParam.optional).toBeUndefined() + expect(optionalParam.optional).toBe(true) + }) + }) +}) diff --git a/src/services/marketplace/index.ts b/src/services/marketplace/index.ts new file mode 100644 index 0000000000..389d997706 --- /dev/null +++ b/src/services/marketplace/index.ts @@ -0,0 +1,4 @@ +export * from "./SimpleInstaller" +export * from "./MarketplaceManager" +export * from "./types" +export * from "./schemas" diff --git a/src/services/marketplace/schemas.ts b/src/services/marketplace/schemas.ts new file mode 100644 index 0000000000..42af243393 --- /dev/null +++ b/src/services/marketplace/schemas.ts @@ -0,0 +1,84 @@ +import { z } from "zod" + +/** + * Schema for MCP parameter definitions + */ +export const mcpParameterSchema = z.object({ + name: z.string().min(1), + key: z.string().min(1), + placeholder: z.string().optional(), + optional: z.boolean().optional().default(false), +}) + +/** + * Schema for MCP installation method with name + */ +export const mcpInstallationMethodSchema = z.object({ + name: z.string().min(1), + content: z.string().min(1), + parameters: z.array(mcpParameterSchema).optional(), + prerequisites: z.array(z.string()).optional(), +}) + +/** + * Component type validation + */ +export const marketplaceItemTypeSchema = z.enum(["mode", "mcp"] as const) + +/** + * Schema for a marketplace item (supports both mode and mcp types) + */ +export const marketplaceItemSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1, "Name is required"), + description: z.string(), + type: marketplaceItemTypeSchema, + author: z.string().optional(), + authorUrl: z.string().url("Author URL must be a valid URL").optional(), + tags: z.array(z.string()).optional(), + content: z.union([z.string().min(1), z.array(mcpInstallationMethodSchema)]), // Embedded content (YAML for modes, JSON for mcps, or named methods) + prerequisites: z.array(z.string()).optional(), +}) + +/** + * Local marketplace config schema (JSON format) + */ +export const marketplaceConfigSchema = z.object({ + items: z.record(z.string(), marketplaceItemSchema), +}) + +/** + * Local marketplace YAML config schema (uses any for items since they're validated separately by type) + */ +export const marketplaceYamlConfigSchema = z.object({ + items: z.array(z.any()), // Items are validated separately by type-specific schemas +}) + +// Schemas for YAML files (without type field, as type is added programmatically) +export const modeMarketplaceItemYamlSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + author: z.string().optional(), + authorUrl: z.string().url().optional(), + tags: z.array(z.string()).optional(), + content: z.string(), + prerequisites: z.array(z.string()).optional(), +}) + +export const mcpMarketplaceItemYamlSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + author: z.string().optional(), + authorUrl: z.string().url().optional(), + url: z.string().url(), // Required url field + tags: z.array(z.string()).optional(), + content: z.union([z.string(), z.array(mcpInstallationMethodSchema)]), + parameters: z.array(mcpParameterSchema).optional(), + prerequisites: z.array(z.string()).optional(), +}) + +// Export aliases for backward compatibility (these are the same as the YAML schemas) +export const modeMarketplaceItemSchema = modeMarketplaceItemYamlSchema +export const mcpMarketplaceItemSchema = mcpMarketplaceItemYamlSchema diff --git a/src/services/marketplace/types.ts b/src/services/marketplace/types.ts new file mode 100644 index 0000000000..52740141c7 --- /dev/null +++ b/src/services/marketplace/types.ts @@ -0,0 +1,92 @@ +/** + * Supported component types + */ +export type MarketplaceItemType = "mode" | "mcp" + +/** + * Local marketplace config types + */ +export interface MarketplaceConfig { + items: Record +} + +export interface MarketplaceYamlConfig { + items: T[] +} + +export interface ModeMarketplaceItem { + id: string + name: string + description: string + author?: string + authorUrl?: string + tags?: string[] + content: string // Embedded YAML content for .roomodes + prerequisites?: string[] +} + +export interface McpParameter { + name: string + key: string + placeholder?: string + optional?: boolean // Defaults to false if not provided +} + +export interface McpInstallationMethod { + name: string + content: string + parameters?: McpParameter[] + prerequisites?: string[] +} + +export interface McpMarketplaceItem { + id: string + name: string + description: string + author?: string + authorUrl?: string + url: string // Required url field + tags?: string[] + content: string | McpInstallationMethod[] // Can be a single config or array of named methods + parameters?: McpParameter[] + prerequisites?: string[] +} + +/** + * Unified marketplace item for UI + */ +export interface MarketplaceItem { + id: string + name: string + description: string + type: MarketplaceItemType + author?: string + authorUrl?: string + url?: string // Optional - only MCPs have url + tags?: string[] + content: string | McpInstallationMethod[] // Can be a single config or array of named methods + parameters?: McpParameter[] // Optional parameters for MCPs + prerequisites?: string[] +} + +export interface InstallMarketplaceItemOptions { + /** + * Specify the target scope + * + * @default 'project' + */ + target?: "global" | "project" + /** + * Parameters provided by the user for configurable marketplace items + */ + 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 59c024658b..739815be5a 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -16,6 +16,7 @@ import { GitCommit } from "../utils/git" import { McpServer } from "./mcp" import { Mode } from "./modes" import { RouterModels } from "./api" +import { MarketplaceItem } from "../services/marketplace/types" export interface LanguageModelChatSelector { vendor?: string @@ -73,16 +74,20 @@ export interface ExtensionMessage { | "indexingStatusUpdate" | "indexCleared" | "codebaseIndexConfig" + | "marketplaceInstallResult" text?: string + payload?: any // Add a generic payload for now, can refine later action?: | "chatButtonClicked" | "mcpButtonClicked" | "settingsButtonClicked" | "historyButtonClicked" | "promptsButtonClicked" + | "marketplaceButtonClicked" | "accountButtonClicked" | "didBecomeVisible" | "focusInput" + | "switchTab" invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage" state?: ExtensionState images?: string[] @@ -112,8 +117,10 @@ export interface ExtensionMessage { error?: string setting?: string value?: any + items?: MarketplaceItem[] userInfo?: CloudUserInfo organizationAllowList?: OrganizationAllowList + tab?: string } export type ExtensionState = Pick< @@ -225,6 +232,8 @@ export type ExtensionState = Pick< autoCondenseContext: boolean autoCondenseContextPercent: number + marketplaceItems?: MarketplaceItem[] + marketplaceInstalledMetadata?: { project: Record; global: Record } } export interface ClineSayTool { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 67ac3bc135..d27b931f10 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -1,6 +1,8 @@ import { z } from "zod" import type { ProviderSettings, PromptComponent, ModeConfig } from "@roo-code/types" +import { InstallMarketplaceItemOptions, MarketplaceItem } from "../services/marketplace/types" +import { marketplaceItemSchema } from "../services/marketplace/schemas" import { Mode } from "./modes" @@ -149,7 +151,18 @@ export interface WebviewMessage { | "indexingStatusUpdate" | "indexCleared" | "codebaseIndexConfig" + | "setHistoryPreviewCollapsed" + | "openExternal" + | "filterMarketplaceItems" + | "marketplaceButtonClicked" + | "installMarketplaceItem" + | "installMarketplaceItemWithParameters" + | "cancelMarketplaceInstall" + | "removeInstalledMarketplaceItem" + | "marketplaceInstallResult" + | "switchTab" text?: string + tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" disabled?: boolean askResponse?: ClineAskResponse apiConfiguration?: ProviderSettings @@ -178,6 +191,11 @@ export interface WebviewMessage { hasSystemPromptOverride?: boolean terminalOperation?: "continue" | "abort" historyPreviewCollapsed?: boolean + filters?: { type?: string; search?: string; tags?: string[] } + url?: string // For openExternal + mpItem?: MarketplaceItem + mpInstallOptions?: InstallMarketplaceItemOptions + config?: Record // Add config to the payload } export const checkoutDiffPayloadSchema = z.object({ @@ -207,8 +225,18 @@ export interface IndexClearedPayload { error?: string } +export const installMarketplaceItemWithParametersPayloadSchema = z.object({ + item: marketplaceItemSchema.strict(), + parameters: z.record(z.string(), z.any()), +}) + +export type InstallMarketplaceItemWithParametersPayload = z.infer< + typeof installMarketplaceItemWithParametersPayloadSchema +> + export type WebViewMessagePayload = | CheckpointDiffPayload | CheckpointRestorePayload | IndexingStatusPayload | IndexClearedPayload + | InstallMarketplaceItemWithParametersPayload diff --git a/src/shared/__tests__/experiments.test.ts b/src/shared/__tests__/experiments.test.ts index 741279d676..60a2f5e361 100644 --- a/src/shared/__tests__/experiments.test.ts +++ b/src/shared/__tests__/experiments.test.ts @@ -18,6 +18,7 @@ describe("experiments", () => { it("returns false when POWER_STEERING experiment is not enabled", () => { const experiments: Record = { powerSteering: false, + marketplace: false, concurrentFileReads: false, disableCompletionCommand: false, } @@ -27,6 +28,7 @@ describe("experiments", () => { it("returns true when experiment POWER_STEERING is enabled", () => { const experiments: Record = { powerSteering: true, + marketplace: false, concurrentFileReads: false, disableCompletionCommand: false, } @@ -36,10 +38,70 @@ describe("experiments", () => { it("returns false when experiment is not present", () => { const experiments: Record = { powerSteering: false, + marketplace: false, concurrentFileReads: false, disableCompletionCommand: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) + + it("returns false when CONCURRENT_FILE_READS experiment is not enabled", () => { + const experiments: Record = { + powerSteering: false, + marketplace: false, + concurrentFileReads: false, + disableCompletionCommand: false, + } + expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.CONCURRENT_FILE_READS)).toBe(false) + }) + + it("returns true when CONCURRENT_FILE_READS experiment is enabled", () => { + const experiments: Record = { + powerSteering: false, + marketplace: false, + concurrentFileReads: true, + disableCompletionCommand: false, + } + expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.CONCURRENT_FILE_READS)).toBe(true) + }) + }) + describe("MARKETPLACE", () => { + it("is configured correctly", () => { + expect(EXPERIMENT_IDS.MARKETPLACE).toBe("marketplace") + expect(experimentConfigsMap.MARKETPLACE).toMatchObject({ + enabled: false, + }) + }) + }) + + describe("isEnabled for MARKETPLACE", () => { + it("returns false when MARKETPLACE experiment is not enabled", () => { + const experiments: Record = { + powerSteering: false, + marketplace: false, + concurrentFileReads: false, + disableCompletionCommand: false, + } + expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.MARKETPLACE)).toBe(false) + }) + + it("returns true when MARKETPLACE experiment is enabled", () => { + const experiments: Record = { + powerSteering: false, + marketplace: true, + concurrentFileReads: false, + disableCompletionCommand: false, + } + expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.MARKETPLACE)).toBe(true) + }) + + it("returns false when MARKETPLACE experiment is not present", () => { + const experiments: Record = { + powerSteering: false, + concurrentFileReads: false, + // marketplace missing + } as any + expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.MARKETPLACE)).toBe(false) + }) }) }) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index 8e568ff3d3..bca295f498 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -1,9 +1,10 @@ import type { AssertEqual, Equals, Keys, Values, ExperimentId } from "@roo-code/types" export const EXPERIMENT_IDS = { - POWER_STEERING: "powerSteering", + MARKETPLACE: "marketplace", CONCURRENT_FILE_READS: "concurrentFileReads", DISABLE_COMPLETION_COMMAND: "disableCompletionCommand", + POWER_STEERING: "powerSteering", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -15,9 +16,10 @@ interface ExperimentConfig { } export const experimentConfigsMap: Record = { - POWER_STEERING: { enabled: false }, + MARKETPLACE: { enabled: false }, CONCURRENT_FILE_READS: { enabled: false }, DISABLE_COMPLETION_COMMAND: { enabled: false }, + POWER_STEERING: { enabled: false }, } export const experimentDefault = Object.fromEntries( diff --git a/src/utils/globalContext.ts b/src/utils/globalContext.ts new file mode 100644 index 0000000000..882501850d --- /dev/null +++ b/src/utils/globalContext.ts @@ -0,0 +1,13 @@ +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 }) + return settingsDir +} diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 30d9337914..505cb0b6ee 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useRef, useState, useMemo } from "react" import { useEvent } from "react-use" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { ExtensionMessage } from "@roo/ExtensionMessage" - import TranslationProvider from "./i18n/TranslationContext" +import { MarketplaceViewStateManager } from "./components/marketplace/MarketplaceViewStateManager" + import { vscode } from "./utils/vscode" import { telemetryClient } from "./utils/TelemetryClient" import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext" @@ -13,11 +14,12 @@ import HistoryView from "./components/history/HistoryView" import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView" import WelcomeView from "./components/welcome/WelcomeView" import McpView from "./components/mcp/McpView" +import { MarketplaceView } from "./components/marketplace/MarketplaceView" import ModesView from "./components/modes/ModesView" import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog" import { AccountView } from "./components/account/AccountView" -type Tab = "settings" | "history" | "mcp" | "modes" | "chat" | "account" +type Tab = "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" const tabsByMessageAction: Partial, Tab>> = { chatButtonClicked: "chat", @@ -25,6 +27,7 @@ const tabsByMessageAction: Partial { telemetrySetting, telemetryKey, machineId, + experiments, cloudUserInfo, cloudIsAuthenticated, } = useExtensionState() + // Create a persistent state manager + const marketplaceStateManager = useMemo(() => new MarketplaceViewStateManager(), []) + const [showAnnouncement, setShowAnnouncement] = useState(false) const [tab, setTab] = useState("chat") @@ -73,12 +80,28 @@ const App = () => { const message: ExtensionMessage = e.data if (message.type === "action" && message.action) { - const newTab = tabsByMessageAction[message.action] - const section = message.values?.section as string | undefined - - if (newTab) { - switchTab(newTab) - setCurrentSection(section) + // Handle switchTab action with tab parameter + if (message.action === "switchTab" && message.tab) { + const targetTab = message.tab as Tab + // Don't switch to marketplace tab if the experiment is disabled + if (targetTab === "marketplace" && !experiments.marketplace) { + return + } + switchTab(targetTab) + setCurrentSection(undefined) + } else { + // Handle other actions using the mapping + const newTab = tabsByMessageAction[message.action] + const section = message.values?.section as string | undefined + + if (newTab) { + // Don't switch to marketplace tab if the experiment is disabled + if (newTab === "marketplace" && !experiments.marketplace) { + return + } + switchTab(newTab) + setCurrentSection(section) + } } } @@ -91,7 +114,7 @@ const App = () => { chatViewRef.current?.acceptInput() } }, - [switchTab], + [switchTab, experiments], ) useEvent("message", onMessage) @@ -128,6 +151,9 @@ const App = () => { {tab === "settings" && ( setTab("chat")} targetSection={currentSection} /> )} + {tab === "marketplace" && ( + switchTab("chat")} /> + )} {tab === "account" && ( React.createElement("div") export const X = () => React.createElement("div") export const Edit = () => React.createElement("div") export const Database = (props: any) => React.createElement("span", { "data-testid": "database-icon", ...props }) +export const MoreVertical = () => React.createElement("div", {}, "VerticalMenu") +export const ExternalLink = () => React.createElement("div") +export const Download = () => React.createElement("div") diff --git a/webview-ui/src/__tests__/App.test.tsx b/webview-ui/src/__tests__/App.test.tsx index eeb173b206..5718e79e92 100644 --- a/webview-ui/src/__tests__/App.test.tsx +++ b/webview-ui/src/__tests__/App.test.tsx @@ -67,12 +67,30 @@ jest.mock("@src/components/modes/ModesView", () => ({ }, })) +jest.mock("@src/components/marketplace/MarketplaceView", () => ({ + MarketplaceView: function MarketplaceView({ onDone }: { onDone: () => void }) { + return ( +
+ Marketplace View +
+ ) + }, +})) + +jest.mock("@src/components/account/AccountView", () => ({ + AccountView: function AccountView({ onDone }: { onDone: () => void }) { + return ( +
+ Account View +
+ ) + }, +})) + +const mockUseExtensionState = jest.fn() + jest.mock("@src/context/ExtensionStateContext", () => ({ - useExtensionState: () => ({ - didHydrateState: true, - showWelcome: false, - shouldShowAnnouncement: false, - }), + useExtensionState: () => mockUseExtensionState(), ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })) @@ -80,6 +98,15 @@ describe("App", () => { beforeEach(() => { jest.clearAllMocks() window.removeEventListener("message", () => {}) + + // Set up default mock return value + mockUseExtensionState.mockReturnValue({ + didHydrateState: true, + showWelcome: false, + shouldShowAnnouncement: false, + experiments: { marketplace: false }, + language: "en", + }) }) afterEach(() => { @@ -196,4 +223,75 @@ describe("App", () => { expect(chatView.getAttribute("data-hidden")).toBe("false") expect(screen.queryByTestId(`${view}-view`)).not.toBeInTheDocument() }) + + describe("marketplace experiment", () => { + it("does not switch to marketplace tab when experiment is disabled", async () => { + mockUseExtensionState.mockReturnValue({ + didHydrateState: true, + showWelcome: false, + shouldShowAnnouncement: false, + experiments: { marketplace: false }, + language: "en", + }) + + render() + + act(() => { + triggerMessage("marketplaceButtonClicked") + }) + + // Should remain on chat view + const chatView = screen.getByTestId("chat-view") + expect(chatView.getAttribute("data-hidden")).toBe("false") + expect(screen.queryByTestId("marketplace-view")).not.toBeInTheDocument() + }) + + it("switches to marketplace tab when experiment is enabled", async () => { + mockUseExtensionState.mockReturnValue({ + didHydrateState: true, + showWelcome: false, + shouldShowAnnouncement: false, + experiments: { marketplace: true }, + language: "en", + }) + + render() + + act(() => { + triggerMessage("marketplaceButtonClicked") + }) + + const marketplaceView = await screen.findByTestId("marketplace-view") + expect(marketplaceView).toBeInTheDocument() + + const chatView = screen.getByTestId("chat-view") + expect(chatView.getAttribute("data-hidden")).toBe("true") + }) + + it("returns to chat view when clicking done in marketplace view", async () => { + mockUseExtensionState.mockReturnValue({ + didHydrateState: true, + showWelcome: false, + shouldShowAnnouncement: false, + experiments: { marketplace: true }, + language: "en", + }) + + render() + + act(() => { + triggerMessage("marketplaceButtonClicked") + }) + + const marketplaceView = await screen.findByTestId("marketplace-view") + + act(() => { + marketplaceView.click() + }) + + const chatView = screen.getByTestId("chat-view") + expect(chatView.getAttribute("data-hidden")).toBe("false") + expect(screen.queryByTestId("marketplace-view")).not.toBeInTheDocument() + }) + }) }) diff --git a/webview-ui/src/components/marketplace/MarketplaceListView.tsx b/webview-ui/src/components/marketplace/MarketplaceListView.tsx new file mode 100644 index 0000000000..fe81939fde --- /dev/null +++ b/webview-ui/src/components/marketplace/MarketplaceListView.tsx @@ -0,0 +1,217 @@ +import * as React from "react" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" +import { X, ChevronsUpDown } from "lucide-react" +import { MarketplaceItemCard } from "./components/MarketplaceItemCard" +import { MarketplaceViewStateManager } from "./MarketplaceViewStateManager" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { useStateManager } from "./useStateManager" +import { useExtensionState } from "@/context/ExtensionStateContext" + +export interface MarketplaceListViewProps { + stateManager: MarketplaceViewStateManager + allTags: string[] + filteredTags: string[] + filterByType?: "mcp" | "mode" +} + +export function MarketplaceListView({ stateManager, allTags, filteredTags, filterByType }: MarketplaceListViewProps) { + const [state, manager] = useStateManager(stateManager) + const { t } = useAppTranslation() + const { marketplaceInstalledMetadata } = useExtensionState() + const [isTagPopoverOpen, setIsTagPopoverOpen] = React.useState(false) + const [tagSearch, setTagSearch] = React.useState("") + const allItems = state.displayItems || [] + const items = filterByType ? allItems.filter((item) => item.type === filterByType) : allItems + const isEmpty = items.length === 0 + + return ( + <> +
+
+ + manager.transition({ + type: "UPDATE_FILTERS", + payload: { filters: { search: e.target.value } }, + }) + } + /> +
+ {allTags.length > 0 && ( +
+
+
+ +
+ {state.filters.tags.length > 0 && ( + + )} +
+ + setIsTagPopoverOpen(open)}> + + + + e.stopPropagation()}> + +
+ + {tagSearch && ( + + )} +
+ + + {t("marketplace:filters.tags.noResults")} + + + {filteredTags.map((tag: string) => ( + { + const isSelected = state.filters.tags.includes(tag) + manager.transition({ + type: "UPDATE_FILTERS", + payload: { + filters: { + tags: isSelected + ? state.filters.tags.filter((t) => t !== tag) + : [...state.filters.tags, tag], + }, + }, + }) + }} + data-selected={state.filters.tags.includes(tag)} + className="grid grid-cols-[1rem_1fr] gap-2 cursor-pointer text-sm capitalize" + onMouseDown={(e) => { + e.stopPropagation() + e.preventDefault() + }}> + {state.filters.tags.includes(tag) ? ( + + ) : ( + + )} + {tag} + + ))} + + +
+
+
+ {state.filters.tags.length > 0 && ( +
+ + {t("marketplace:filters.tags.selected")} +
+ )} +
+ )} +
+ + {state.isFetching && isEmpty && ( +
+
+ +
+

{t("marketplace:items.refresh.refreshing")}

+

{t("marketplace:items.refresh.mayTakeMoment")}

+
+ )} + + {!state.isFetching && isEmpty && ( +
+ +

{t("marketplace:items.empty.noItems")}

+

{t("marketplace:items.empty.adjustFilters")}

+ +
+ )} + + {!state.isFetching && !isEmpty && ( +
+ {items.map((item) => ( + + manager.transition({ + type: "UPDATE_FILTERS", + payload: { filters }, + }) + } + installed={{ + project: marketplaceInstalledMetadata?.project?.[item.id], + global: marketplaceInstalledMetadata?.global?.[item.id], + }} + /> + ))} +
+ )} + + ) +} diff --git a/webview-ui/src/components/marketplace/MarketplaceView.tsx b/webview-ui/src/components/marketplace/MarketplaceView.tsx new file mode 100644 index 0000000000..8d831d8674 --- /dev/null +++ b/webview-ui/src/components/marketplace/MarketplaceView.tsx @@ -0,0 +1,145 @@ +import { useState, useEffect, useMemo } from "react" +import { Button } from "@/components/ui/button" +import { Tab, TabContent, TabHeader } from "../common/Tab" +import { MarketplaceViewStateManager } from "./MarketplaceViewStateManager" +import { useStateManager } from "./useStateManager" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { vscode } from "@/utils/vscode" +import { MarketplaceListView } from "./MarketplaceListView" +import { cn } from "@/lib/utils" +import { TooltipProvider } from "@/components/ui/tooltip" + +interface MarketplaceViewProps { + onDone?: () => void + stateManager: MarketplaceViewStateManager +} +export function MarketplaceView({ stateManager, onDone }: MarketplaceViewProps) { + const { t } = useAppTranslation() + const [state, manager] = useStateManager(stateManager) + const [hasReceivedInitialState, setHasReceivedInitialState] = useState(false) + + // Track when we receive the initial state + useEffect(() => { + // Check if we already have items (state might have been received before mount) + if (state.allItems.length > 0 && !hasReceivedInitialState) { + setHasReceivedInitialState(true) + } + }, [state.allItems, hasReceivedInitialState]) + + // Ensure marketplace state manager processes messages when component mounts + useEffect(() => { + // When the marketplace view first mounts, we need to trigger a state update + // to ensure we get the current marketplace items. We do this by sending + // a filter message with empty filters, which will cause the extension to + // send back the full state including all marketplace items. + if (!hasReceivedInitialState && state.allItems.length === 0) { + // Send empty filter to trigger state update + vscode.postMessage({ + type: "filterMarketplaceItems", + filters: { + type: "", + search: "", + tags: [], + }, + }) + } + + // Listen for state changes to know when initial data arrives + const unsubscribe = manager.onStateChange((newState) => { + if (newState.allItems.length > 0 && !hasReceivedInitialState) { + setHasReceivedInitialState(true) + } + }) + + const handleVisibilityMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === "webviewVisible" && message.visible === true) { + // Data will be automatically fresh when panel becomes visible + // No manual fetching needed since we removed caching + } + } + + window.addEventListener("message", handleVisibilityMessage) + return () => { + window.removeEventListener("message", handleVisibilityMessage) + unsubscribe() + } + }, [manager, hasReceivedInitialState, state.allItems.length]) + + // Memoize all available tags + const allTags = useMemo( + () => Array.from(new Set(state.allItems.flatMap((item) => item.tags || []))).sort(), + [state.allItems], + ) + + // Memoize filtered tags + const filteredTags = useMemo(() => allTags, [allTags]) + + return ( + + + +
+

{t("marketplace:title")}

+
+ +
+
+ +
+
+
+
+
+ + +
+
+ + + + {state.activeTab === "mcp" && ( + + )} + {state.activeTab === "mode" && ( + + )} + + + + ) +} diff --git a/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts b/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts new file mode 100644 index 0000000000..8009a5b8d1 --- /dev/null +++ b/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts @@ -0,0 +1,345 @@ +/** + * MarketplaceViewStateManager + * + * This class manages the state for the marketplace view in the Roo Code extensions interface. + * + * IMPORTANT: Fixed issue where the marketplace feature was causing the Roo Code extensions interface + * to switch to the browse tab and redraw it every 30 seconds. The fix prevents unnecessary tab switching + * and redraws by: + * 1. Only updating the UI when necessary + * 2. Preserving the current tab when handling timeouts + * 3. Using minimal state updates to avoid resetting scroll position + */ + +import { MarketplaceItem } from "../../../../src/services/marketplace/types" +import { vscode } from "../../utils/vscode" +import { WebviewMessage } from "../../../../src/shared/WebviewMessage" + +export interface ViewState { + allItems: MarketplaceItem[] + displayItems?: MarketplaceItem[] // Items currently being displayed (filtered or all) + isFetching: boolean + activeTab: "mcp" | "mode" + filters: { + type: string + search: string + tags: string[] + } +} + +type TransitionPayloads = { + FETCH_ITEMS: undefined + FETCH_COMPLETE: { items: MarketplaceItem[] } + FETCH_ERROR: undefined + SET_ACTIVE_TAB: { tab: ViewState["activeTab"] } + UPDATE_FILTERS: { filters: Partial } +} + +export interface ViewStateTransition { + type: keyof TransitionPayloads + payload?: TransitionPayloads[keyof TransitionPayloads] +} + +export type StateChangeHandler = (state: ViewState) => void + +export class MarketplaceViewStateManager { + private state: ViewState = this.loadInitialState() + + private loadInitialState(): ViewState { + // Always start with default state - no sessionStorage caching + // This ensures fresh data from the extension is always used + return this.getDefaultState() + } + + private getDefaultState(): ViewState { + return { + allItems: [], + displayItems: [], // Always initialize as empty array, not undefined + isFetching: false, + activeTab: "mcp", + filters: { + type: "", + search: "", + tags: [], + }, + } + } + // Removed auto-polling timeout + private stateChangeHandlers: Set = new Set() + + // Empty constructor is required for test initialization + constructor() { + // Initialize is now handled by the loadInitialState call in the property initialization + } + + public initialize(): void { + // Set initial state + this.state = this.getDefaultState() + } + + public onStateChange(handler: StateChangeHandler): () => void { + this.stateChangeHandlers.add(handler) + return () => this.stateChangeHandlers.delete(handler) + } + + public cleanup(): void { + // Reset fetching state + if (this.state.isFetching) { + this.state.isFetching = false + this.notifyStateChange() + } + + // Clear handlers but preserve state + this.stateChangeHandlers.clear() + } + + public getState(): ViewState { + // Only create new arrays if they exist and have items + const allItems = this.state.allItems.length ? [...this.state.allItems] : [] + // Ensure displayItems is always an array, never undefined + const displayItems = this.state.displayItems ? [...this.state.displayItems] : [] + const tags = this.state.filters.tags.length ? [...this.state.filters.tags] : [] + + // Create minimal new state object + return { + ...this.state, + allItems, + displayItems, + filters: { + ...this.state.filters, + tags, + }, + } + } + + /** + * Notify all registered handlers of a state change + * @param preserveTab If true, ensures the active tab is not changed during notification + */ + private notifyStateChange(preserveTab: boolean = false): void { + const newState = this.getState() // Use getState to ensure proper copying + + if (preserveTab) { + // When preserveTab is true, we're careful not to cause tab switching + // This is used during timeout handling to prevent disrupting the user + this.stateChangeHandlers.forEach((handler) => { + // Store the current active tab + 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, + } + handler(safeState) + }) + } else { + // Normal state change notification + this.stateChangeHandlers.forEach((handler) => { + handler(newState) + }) + } + + // Removed sessionStorage caching to ensure fresh data from extension is always used + // This prevents old cached marketplace items from overriding fresh data + } + + public async transition(transition: ViewStateTransition): Promise { + switch (transition.type) { + case "FETCH_ITEMS": { + // Fetch functionality removed - data comes automatically from extension + // No manual fetching needed since we removed caching + break + } + + case "FETCH_COMPLETE": { + const { items } = transition.payload as TransitionPayloads["FETCH_COMPLETE"] + // No timeout to clear anymore + + // Compare with current state to avoid unnecessary updates + if (JSON.stringify(items) === JSON.stringify(this.state.allItems)) { + // No changes: update only isFetching flag and send minimal update + this.state.isFetching = false + this.stateChangeHandlers.forEach((handler) => { + handler({ + ...this.getState(), + isFetching: false, + }) + }) + break + } + + // Update allItems as source of truth + this.state = { + ...this.state, + allItems: [...items], + displayItems: this.isFilterActive() ? this.filterItems([...items]) : [...items], + isFetching: false, + } + + // Notify state change + this.notifyStateChange() + break + } + + case "FETCH_ERROR": { + // Preserve current filters and items + const { filters, activeTab, allItems, displayItems } = this.state + + // Reset state but preserve filters and items + this.state = { + ...this.getDefaultState(), + filters, + activeTab, + allItems, + displayItems, + isFetching: false, + } + this.notifyStateChange() + break + } + + case "SET_ACTIVE_TAB": { + const { tab } = transition.payload as TransitionPayloads["SET_ACTIVE_TAB"] + + // Update tab state + this.state = { + ...this.state, + activeTab: tab, + } + + // Tab switching no longer triggers fetch - data comes automatically from extension + + this.notifyStateChange() + break + } + + case "UPDATE_FILTERS": { + const { filters = {} } = (transition.payload as TransitionPayloads["UPDATE_FILTERS"]) || {} + + // Create new filters object preserving existing values for undefined fields + const updatedFilters = { + type: filters.type !== undefined ? filters.type : this.state.filters.type, + search: filters.search !== undefined ? filters.search : this.state.filters.search, + tags: filters.tags !== undefined ? filters.tags : this.state.filters.tags, + } + + // Update state + this.state = { + ...this.state, + filters: updatedFilters, + } + + // Send filter message + vscode.postMessage({ + type: "filterMarketplaceItems", + filters: updatedFilters, + } as WebviewMessage) + + this.notifyStateChange() + + break + } + } + } + + public isFilterActive(): boolean { + return !!(this.state.filters.type || this.state.filters.search || this.state.filters.tags.length > 0) + } + + public filterItems(items: MarketplaceItem[]): MarketplaceItem[] { + const { type, search, tags } = this.state.filters + + return items + .map((item) => { + // Create a copy of the item to modify + const itemCopy = { ...item } + + // Check specific match conditions for the main item + const typeMatch = !type || item.type === type + const nameMatch = search ? item.name.toLowerCase().includes(search.toLowerCase()) : false + const descriptionMatch = search + ? (item.description || "").toLowerCase().includes(search.toLowerCase()) + : false + const tagMatch = tags.length > 0 ? item.tags?.some((tag) => tags.includes(tag)) : false + + // Determine if the main item matches all filters + const mainItemMatches = + typeMatch && (!search || nameMatch || descriptionMatch) && (!tags.length || tagMatch) + + const hasMatchingSubcomponents = false + + // Return the item if it matches or has matching subcomponents + if (mainItemMatches || Boolean(hasMatchingSubcomponents)) { + return itemCopy + } + + return null + }) + .filter((item): item is MarketplaceItem => item !== null) + } + + public async handleMessage(message: any): Promise { + // Handle empty or invalid message + if (!message || !message.type || message.type === "invalidType") { + this.state = { + ...this.getDefaultState(), + } + this.notifyStateChange() + return + } + + // Handle state updates + if (message.type === "state") { + // Handle empty state + if (!message.state) { + this.state = { + ...this.getDefaultState(), + } + this.notifyStateChange() + return + } + + // Handle state updates for marketplace items + // The state.marketplaceItems come from ClineProvider, see the file src/core/webview/ClineProvider.ts + const marketplaceItems = message.state.marketplaceItems + + if (marketplaceItems !== undefined) { + // Always use the marketplace items from the extension when they're provided + // This ensures fresh data is always displayed + const items = [...marketplaceItems] + const newDisplayItems = this.isFilterActive() ? this.filterItems(items) : items + + // Update state in a single operation + this.state = { + ...this.state, + isFetching: false, + allItems: items, + displayItems: newDisplayItems, + } + // 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 isOnMcpTab = this.state.activeTab === "mcp" + const hasCurrentItems = (this.state.allItems || []).length > 0 + const preserveTab = !isOnMcpTab && hasCurrentItems + + this.notifyStateChange(preserveTab) + } + + // Handle marketplace button clicks + if (message.type === "marketplaceButtonClicked") { + if (message.text) { + // Error case + void this.transition({ type: "FETCH_ERROR" }) + } else { + // Refresh request + void this.transition({ type: "FETCH_ITEMS" }) + } + } + } +} diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.test.tsx b/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.test.tsx new file mode 100644 index 0000000000..e696f04495 --- /dev/null +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.test.tsx @@ -0,0 +1,146 @@ +import { render, screen, fireEvent } from "@testing-library/react" +import { MarketplaceListView } from "../MarketplaceListView" +import { ViewState } from "../MarketplaceViewStateManager" +import userEvent from "@testing-library/user-event" +import { TooltipProvider } from "@/components/ui/tooltip" +import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext" + +jest.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +class MockResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +global.ResizeObserver = MockResizeObserver + +const mockTransition = jest.fn() +const mockState: ViewState = { + allItems: [], + displayItems: [], + isFetching: false, + activeTab: "mcp", + filters: { + type: "", + search: "", + tags: [], + }, +} + +jest.mock("../useStateManager", () => ({ + useStateManager: () => [mockState, { transition: mockTransition }], +})) + +jest.mock("lucide-react", () => { + return new Proxy( + {}, + { + get: function (_obj, prop) { + if (prop === "__esModule") { + return true + } + return () =>
{String(prop)}
+ }, + }, + ) +}) + +const defaultProps = { + stateManager: {} as any, + allTags: ["tag1", "tag2"], + filteredTags: ["tag1", "tag2"], +} + +describe("MarketplaceListView", () => { + beforeEach(() => { + jest.clearAllMocks() + mockState.filters.tags = [] + mockState.isFetching = false + mockState.displayItems = [] + }) + + const renderWithProviders = (props = {}) => + render( + + + + + , + ) + + it("renders search input", () => { + renderWithProviders() + + const searchInput = screen.getByPlaceholderText("marketplace:filters.search.placeholder") + expect(searchInput).toBeInTheDocument() + }) + + it("does not render type filter (removed in simplified interface)", () => { + renderWithProviders() + + expect(screen.queryByText("marketplace:filters.type.label")).not.toBeInTheDocument() + expect(screen.queryByText("marketplace:filters.type.all")).not.toBeInTheDocument() + }) + + it("does not render sort options (removed in simplified interface)", () => { + renderWithProviders() + + expect(screen.queryByText("marketplace:filters.sort.label")).not.toBeInTheDocument() + expect(screen.queryByText("marketplace:filters.sort.name")).not.toBeInTheDocument() + }) + + it("renders tags section when tags are available", () => { + renderWithProviders() + + expect(screen.getByText("marketplace:filters.tags.label")).toBeInTheDocument() + }) + + it("shows loading state when fetching", () => { + mockState.isFetching = true + + renderWithProviders() + + expect(screen.getByText("marketplace:items.refresh.refreshing")).toBeInTheDocument() + expect(screen.getByText("marketplace:items.refresh.mayTakeMoment")).toBeInTheDocument() + }) + + it("shows empty state when no items and not fetching", () => { + renderWithProviders() + + expect(screen.getByText("marketplace:items.empty.noItems")).toBeInTheDocument() + expect(screen.getByText("marketplace:items.empty.adjustFilters")).toBeInTheDocument() + }) + + it("updates search filter when typing", () => { + renderWithProviders() + + const searchInput = screen.getByPlaceholderText("marketplace:filters.search.placeholder") + fireEvent.change(searchInput, { target: { value: "test" } }) + + expect(mockTransition).toHaveBeenCalledWith({ + type: "UPDATE_FILTERS", + payload: { filters: { search: "test" } }, + }) + }) + + it("shows clear tags button when tags are selected", async () => { + const user = userEvent.setup() + mockState.filters.tags = ["tag1"] + + renderWithProviders() + + const clearButton = screen.getByText("marketplace:filters.tags.clear") + expect(clearButton).toBeInTheDocument() + + await user.click(clearButton) + expect(mockTransition).toHaveBeenCalledWith({ + type: "UPDATE_FILTERS", + payload: { filters: { tags: [] } }, + }) + }) +}) diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceView.spec.tsx b/webview-ui/src/components/marketplace/__tests__/MarketplaceView.spec.tsx new file mode 100644 index 0000000000..f071cc98a8 --- /dev/null +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceView.spec.tsx @@ -0,0 +1,96 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { MarketplaceView } from "../MarketplaceView" +import { MarketplaceViewStateManager } from "../MarketplaceViewStateManager" + +// Mock all the dependencies to keep the test simple +jest.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: jest.fn(), + getState: jest.fn(() => ({})), + setState: jest.fn(), + }, +})) + +jest.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +jest.mock("../useStateManager", () => ({ + useStateManager: () => [ + { + allItems: [], + displayItems: [], + isFetching: false, + activeTab: "mcp", + filters: { type: "", search: "", tags: [] }, + }, + { + transition: jest.fn(), + onStateChange: jest.fn(() => jest.fn()), + }, + ], +})) + +jest.mock("../MarketplaceListView", () => ({ + MarketplaceListView: ({ filterByType }: { filterByType: string }) => ( +
MarketplaceListView - {filterByType}
+ ), +})) + +// Mock Tab components to avoid ExtensionStateContext dependency +jest.mock("@/components/common/Tab", () => ({ + Tab: ({ children, ...props }: any) =>
{children}
, + TabHeader: ({ children, ...props }: any) =>
{children}
, + TabContent: ({ children, ...props }: any) =>
{children}
, + TabList: ({ children, ...props }: any) =>
{children}
, + TabTrigger: ({ children, ...props }: any) => , +})) + +// Mock ResizeObserver +class MockResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +global.ResizeObserver = MockResizeObserver + +describe("MarketplaceView", () => { + const mockOnDone = jest.fn() + const mockStateManager = new MarketplaceViewStateManager() + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("renders without crashing", () => { + render() + + expect(screen.getByText("marketplace:title")).toBeInTheDocument() + expect(screen.getByText("marketplace:done")).toBeInTheDocument() + }) + + it("calls onDone when Done button is clicked", async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText("marketplace:done")) + expect(mockOnDone).toHaveBeenCalledTimes(1) + }) + + it("renders tab buttons", () => { + render() + + expect(screen.getByText("MCP")).toBeInTheDocument() + expect(screen.getByText("Modes")).toBeInTheDocument() + }) + + it("renders MarketplaceListView", () => { + render() + + expect(screen.getByTestId("marketplace-list-view")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/marketplace/components/MarketplaceInstallModal.tsx b/webview-ui/src/components/marketplace/components/MarketplaceInstallModal.tsx new file mode 100644 index 0000000000..e8ec5549eb --- /dev/null +++ b/webview-ui/src/components/marketplace/components/MarketplaceInstallModal.tsx @@ -0,0 +1,375 @@ +import React, { useState, useMemo, useEffect } from "react" +import { MarketplaceItem, McpParameter, McpInstallationMethod } from "../../../../../src/services/marketplace/types" +import { vscode } from "@/utils/vscode" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" + +interface MarketplaceInstallModalProps { + item: MarketplaceItem | null + isOpen: boolean + onClose: () => void + hasWorkspace: boolean +} + +export const MarketplaceInstallModal: React.FC = ({ + item, + isOpen, + onClose, + hasWorkspace, +}) => { + const { t } = useAppTranslation() + const [scope, setScope] = useState<"project" | "global">(hasWorkspace ? "project" : "global") + const [selectedMethodIndex, setSelectedMethodIndex] = useState(0) + const [parameterValues, setParameterValues] = useState>({}) + const [validationError, setValidationError] = useState(null) + const [installationComplete, setInstallationComplete] = useState(false) + + // Reset state when item changes + React.useEffect(() => { + if (item) { + setSelectedMethodIndex(0) + setParameterValues({}) + setValidationError(null) + setInstallationComplete(false) + } + }, [item]) + + // Check if item has multiple installation methods + const hasMultipleMethods = useMemo(() => { + return item && Array.isArray(item.content) && item.content.length > 1 + }, [item]) + + // Get installation method names (for display in dropdown) + const methodNames = useMemo(() => { + if (!item || !Array.isArray(item.content)) return [] + + // Content is an array of McpInstallationMethod objects + return (item.content as Array<{ name: string; content: string }>).map((method) => method.name) + }, [item]) + + // Get effective parameters for the selected method (global + method-specific) + const effectiveParameters = useMemo(() => { + if (!item) return [] + + const globalParams = item.parameters || [] + let methodParams: McpParameter[] = [] + + // Get method-specific parameters if content is an array + if (Array.isArray(item.content)) { + const selectedMethod = item.content[selectedMethodIndex] as McpInstallationMethod + methodParams = selectedMethod?.parameters || [] + } + + // Create map with global params first, then override with method-specific ones + const paramMap = new Map() + globalParams.forEach((p) => paramMap.set(p.key, p)) + methodParams.forEach((p) => paramMap.set(p.key, p)) + + return Array.from(paramMap.values()) + }, [item, selectedMethodIndex]) + + // Get effective prerequisites for the selected method (global + method-specific) + const effectivePrerequisites = useMemo(() => { + if (!item) return [] + + const globalPrereqs = item.prerequisites || [] + let methodPrereqs: string[] = [] + + // Get method-specific prerequisites if content is an array + if (Array.isArray(item.content)) { + const selectedMethod = item.content[selectedMethodIndex] as McpInstallationMethod + methodPrereqs = selectedMethod?.prerequisites || [] + } + + // Combine and deduplicate prerequisites + const allPrereqs = [...globalPrereqs, ...methodPrereqs] + return Array.from(new Set(allPrereqs)) + }, [item, selectedMethodIndex]) + + // Update parameter values when method changes + React.useEffect(() => { + if (item) { + // Get effective parameters for current method + const globalParams = item.parameters || [] + let methodParams: McpParameter[] = [] + + if (Array.isArray(item.content)) { + const selectedMethod = item.content[selectedMethodIndex] as McpInstallationMethod + methodParams = selectedMethod?.parameters || [] + } + + // Create map with global params first, then override with method-specific ones + const paramMap = new Map() + globalParams.forEach((p) => paramMap.set(p.key, p)) + methodParams.forEach((p) => paramMap.set(p.key, p)) + + const currentEffectiveParams = Array.from(paramMap.values()) + + // Initialize parameter values for effective parameters + setParameterValues((prev) => { + const newValues: Record = {} + currentEffectiveParams.forEach((param) => { + // Keep existing value if it exists, otherwise empty string + newValues[param.key] = prev[param.key] || "" + }) + return newValues + }) + } + }, [item, selectedMethodIndex]) + + // Listen for installation result messages + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === "marketplaceInstallResult" && message.slug === item?.id) { + if (message.success) { + // Installation succeeded - show success state + setInstallationComplete(true) + setValidationError(null) + } else { + // Installation failed - show error + setValidationError(message.error || "Installation failed") + setInstallationComplete(false) + } + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [item?.id]) + + const handleInstall = () => { + if (!item) return + + // Clear previous validation error + setValidationError(null) + + // Validate required parameters from effective parameters (global + method-specific) + for (const param of effectiveParameters) { + // Only validate if parameter is not optional (optional defaults to false) + if (!param.optional && !parameterValues[param.key]?.trim()) { + setValidationError(t("marketplace:install.validationRequired", { paramName: param.name })) + return + } + } + + // Prepare parameters - ensure optional parameters have empty string if not provided + const finalParameters: Record = { ...parameterValues } + for (const param of effectiveParameters) { + if (param.optional && !finalParameters[param.key]) { + finalParameters[param.key] = "" + } + } + + // Send install message with parameters + vscode.postMessage({ + type: "installMarketplaceItem", + mpItem: item, + mpInstallOptions: { + target: scope, + parameters: { + ...finalParameters, + _selectedIndex: hasMultipleMethods ? selectedMethodIndex : undefined, + }, + }, + }) + + // Don't show success immediately - wait for backend result + // The success state will be shown when installation actually succeeds + setValidationError(null) + } + + const handlePostInstallAction = (tab: "mcp" | "modes") => { + // Send message to switch to the appropriate tab + vscode.postMessage({ type: "switchTab", tab }) + // Close the modal + onClose() + } + + if (!item) return null + + return ( + + + + + {installationComplete + ? t("marketplace:install.successTitle", { name: item.name }) + : item.type === "mcp" + ? t("marketplace:install.titleMcp", { name: item.name }) + : t("marketplace:install.titleMode", { name: item.name })} + + + {installationComplete ? ( + t("marketplace:install.successDescription") + ) : item.type === "mcp" && item.url ? ( + + {t("marketplace:install.moreInfoMcp", { name: item.name })} + + ) : null} + + + + {installationComplete ? ( + // Post-installation options +
+
+
✓ {t("marketplace:install.installed")}
+

+ {item.type === "mcp" + ? t("marketplace:install.whatNextMcp") + : t("marketplace:install.whatNextMode")} +

+
+
+ ) : ( + // Installation configuration +
+ {/* Installation Scope */} +
+
{t("marketplace:install.scope")}
+
+ + +
+
+ + {/* Installation Method (if multiple) */} + {hasMultipleMethods && ( +
+
{t("marketplace:install.method")}
+ +
+ )} + + {/* Prerequisites */} + {effectivePrerequisites.length > 0 && ( +
+
{t("marketplace:install.prerequisites")}
+
    + {effectivePrerequisites.map((prereq, index) => ( +
  • + {prereq} +
  • + ))} +
+
+ )} + + {/* Parameters */} + {effectiveParameters.length > 0 && ( +
+
+
+ {t("marketplace:install.configuration")} +
+
+ {t("marketplace:install.configurationDescription")} +
+
+ {effectiveParameters.map((param) => ( +
+ + + setParameterValues((prev) => ({ + ...prev, + [param.key]: e.target.value, + })) + } + /> +
+ ))} +
+ )} + {/* Validation Error */} + {validationError && ( +
+ {validationError} +
+ )} +
+ )} + + + {installationComplete ? ( + <> + + + + ) : ( + <> + + + + )} + +
+
+ ) +} diff --git a/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx new file mode 100644 index 0000000000..c382b36fce --- /dev/null +++ b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx @@ -0,0 +1,197 @@ +import React, { useMemo, useState } from "react" +import { MarketplaceItem } from "../../../../../src/services/marketplace/types" +import { vscode } from "@/utils/vscode" +import { ViewState } from "../MarketplaceViewStateManager" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { isValidUrl } from "../../../utils/url" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { MarketplaceInstallModal } from "./MarketplaceInstallModal" +import { useExtensionState } from "@/context/ExtensionStateContext" + +interface ItemInstalledMetadata { + type: string +} + +interface MarketplaceItemCardProps { + item: MarketplaceItem + filters: ViewState["filters"] + setFilters: (filters: Partial) => void + installed: { + project: ItemInstalledMetadata | undefined + global: ItemInstalledMetadata | undefined + } +} + +export const MarketplaceItemCard: React.FC = ({ item, filters, setFilters, installed }) => { + const { t } = useAppTranslation() + const { cwd } = useExtensionState() + const [showInstallModal, setShowInstallModal] = useState(false) + + const typeLabel = useMemo(() => { + const labels: Partial> = { + mode: t("marketplace:filters.type.mode"), + mcp: t("marketplace:filters.type.mcpServer"), + } + return labels[item.type] ?? "N/A" + }, [item.type, t]) + + // Determine installation status + const isInstalledGlobally = !!installed.global + const isInstalledInProject = !!installed.project + const isInstalled = isInstalledGlobally || isInstalledInProject + + const handleInstallClick = () => { + // Show modal for all item types (MCP and modes) + setShowInstallModal(true) + } + + return ( + <> +
+
+
+
+

+ {item.url && isValidUrl(item.url) ? ( + + ) : ( + item.name + )} +

+ +
+
+
+ {isInstalled ? ( + /* Single Remove button when installed */ + + + + + + + + {isInstalledInProject + ? t("marketplace:items.card.removeProjectTooltip") + : t("marketplace:items.card.removeGlobalTooltip")} + + + ) : ( + /* Single Install button when not installed */ + + )} +
+
+ +

{item.description}

+ + {/* Installation status badges and tags in the same row */} + {(isInstalled || (item.tags && item.tags.length > 0)) && ( +
+ {/* Installation status badge on the left */} + {isInstalled && ( + + {t("marketplace:items.card.installed")} + + )} + + {/* Tags on the right */} + {item.tags && + item.tags.length > 0 && + item.tags.map((tag) => ( + + ))} +
+ )} +
+ + {/* Installation Modal - Outside the clickable card */} + setShowInstallModal(false)} + hasWorkspace={!!cwd} + /> + + ) +} + +interface AuthorInfoProps { + item: MarketplaceItem + typeLabel: string +} + +const AuthorInfo: React.FC = ({ item, typeLabel }) => { + const { t } = useAppTranslation() + + const handleOpenAuthorUrl = () => { + if (item.authorUrl && isValidUrl(item.authorUrl)) { + vscode.postMessage({ type: "openExternal", url: item.authorUrl }) + } + } + + if (item.author) { + return ( +

+ {typeLabel}{" "} + {item.authorUrl && isValidUrl(item.authorUrl) ? ( + + ) : ( + t("marketplace:items.card.by", { author: item.author }) + )} +

+ ) + } + return null +} diff --git a/webview-ui/src/components/marketplace/components/__tests__/MarketplaceInstallModal-optional-params.test.tsx b/webview-ui/src/components/marketplace/components/__tests__/MarketplaceInstallModal-optional-params.test.tsx new file mode 100644 index 0000000000..e769777068 --- /dev/null +++ b/webview-ui/src/components/marketplace/components/__tests__/MarketplaceInstallModal-optional-params.test.tsx @@ -0,0 +1,155 @@ +import React from "react" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { MarketplaceInstallModal } from "../MarketplaceInstallModal" +import { MarketplaceItem } from "../../../../../../src/services/marketplace/types" + +// Mock vscode +const mockPostMessage = jest.fn() +jest.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: mockPostMessage, + }, +})) + +// Mock translation +jest.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string, params?: any) => { + // Simple mock translation + if (key === "marketplace:install.configuration") return "Configuration" + if (key === "marketplace:install.button") return "Install" + if (key === "common:answers.cancel") return "Cancel" + if (key === "marketplace:install.validationRequired") { + return `Please provide a value for ${params?.paramName || "parameter"}` + } + return key + }, + }), +})) + +describe("MarketplaceInstallModal - Optional Parameters", () => { + const mockOnClose = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + const createMcpItemWithParams = (parameters: any[]): MarketplaceItem => ({ + id: "test-mcp", + name: "Test MCP", + description: "Test MCP with parameters", + type: "mcp", + content: '{"test-server": {"command": "test", "args": ["--key", "{{api_key}}", "--endpoint", "{{endpoint}}"]}}', + parameters, + }) + + it("should show (optional) label for optional parameters", () => { + const item = createMcpItemWithParams([ + { + name: "API Key", + key: "api_key", + placeholder: "Enter API key", + optional: false, + }, + { + name: "Custom Endpoint", + key: "endpoint", + placeholder: "Leave empty for default", + optional: true, + }, + ]) + + render() + + expect(screen.getByText("API Key")).toBeInTheDocument() + expect(screen.getByText("Custom Endpoint (optional)")).toBeInTheDocument() + }) + + it("should render input fields correctly for optional parameters", () => { + const item = createMcpItemWithParams([ + { + name: "API Key", + key: "api_key", + placeholder: "Enter API key", + optional: false, + }, + { + name: "Custom Endpoint", + key: "endpoint", + placeholder: "Leave empty for default", + optional: true, + }, + ]) + + render() + + // Check that input fields are rendered + const apiKeyInput = screen.getByPlaceholderText("Enter API key") + const endpointInput = screen.getByPlaceholderText("Leave empty for default") + + expect(apiKeyInput).toBeInTheDocument() + expect(endpointInput).toBeInTheDocument() + expect(endpointInput).toHaveValue("") + }) + + it("should require non-optional parameters", async () => { + const item = createMcpItemWithParams([ + { + name: "API Key", + key: "api_key", + placeholder: "Enter API key", + optional: false, + }, + { + name: "Custom Endpoint", + key: "endpoint", + placeholder: "Leave empty for default", + optional: true, + }, + ]) + + render() + + // Leave required parameter empty, fill optional one + const endpointInput = screen.getByPlaceholderText("Leave empty for default") + fireEvent.change(endpointInput, { target: { value: "https://custom.endpoint.com" } }) + + // Click install without filling required parameter + const installButton = screen.getByText("Install") + fireEvent.click(installButton) + + // Should show validation error + await waitFor(() => { + expect(screen.getByText("Please provide a value for API Key")).toBeInTheDocument() + }) + + // Should not call postMessage + expect(mockPostMessage).not.toHaveBeenCalled() + }) + + it("should handle parameters without optional field (defaults to required)", async () => { + const item = createMcpItemWithParams([ + { + name: "API Key", + key: "api_key", + placeholder: "Enter API key", + // No optional field - should default to required + }, + ]) + + render() + + // Should not show (optional) label + expect(screen.getByText("API Key")).toBeInTheDocument() + expect(screen.queryByText("API Key (optional)")).not.toBeInTheDocument() + + // Click install without filling parameter + const installButton = screen.getByText("Install") + fireEvent.click(installButton) + + // Should show validation error + await waitFor(() => { + expect(screen.getByText("Please provide a value for API Key")).toBeInTheDocument() + }) + }) +}) diff --git a/webview-ui/src/components/marketplace/components/__tests__/MarketplaceInstallModal.test.tsx b/webview-ui/src/components/marketplace/components/__tests__/MarketplaceInstallModal.test.tsx new file mode 100644 index 0000000000..d27ebd9811 --- /dev/null +++ b/webview-ui/src/components/marketplace/components/__tests__/MarketplaceInstallModal.test.tsx @@ -0,0 +1,217 @@ +import React from "react" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { MarketplaceInstallModal } from "../MarketplaceInstallModal" +import { MarketplaceItem } from "../../../../../../src/services/marketplace/types" + +// Mock the vscode module before importing the component +jest.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: jest.fn(), + }, +})) + +// Import the mocked vscode after setting up the mock +import { vscode } from "@/utils/vscode" +const mockedVscode = vscode as jest.Mocked + +// Mock the translation hook +jest.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string, params?: any) => { + // Simple mock translation that returns the key with params + if (key === "marketplace:install.validationRequired") { + return `Please provide a value for ${params?.paramName || "parameter"}` + } + if (params) { + return `${key}:${JSON.stringify(params)}` + } + return key + }, + }), +})) + +describe("MarketplaceInstallModal - Nested Parameters", () => { + const mockOnClose = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + // Reset the mock function + mockedVscode.postMessage.mockClear() + }) + + const createMockItem = (hasNestedParams = false): MarketplaceItem => ({ + id: "test-item", + name: "Test MCP Server", + description: "A test MCP server", + type: "mcp", + author: "Test Author", + tags: ["test"], + // Global parameters + parameters: [ + { + name: "Global API Key", + key: "apiKey", + placeholder: "Enter your API key", + optional: false, + }, + { + name: "Global Optional Setting", + key: "globalOptional", + placeholder: "Optional setting", + optional: true, + }, + ], + content: hasNestedParams + ? [ + { + name: "NPM Installation", + content: "npm install {{packageName}}", + parameters: [ + { + name: "Package Name", + key: "packageName", + placeholder: "Enter package name", + optional: false, + }, + // Override global parameter + { + name: "NPM API Key", + key: "apiKey", + placeholder: "Enter NPM API key", + optional: false, + }, + ], + }, + { + name: "Docker Installation", + content: "docker run {{imageName}}", + parameters: [ + { + name: "Docker Image", + key: "imageName", + placeholder: "Enter image name", + optional: false, + }, + ], + }, + ] + : "npm install test-package", + }) + + it("should display global parameters when no nested parameters exist", () => { + const item = createMockItem(false) + render() + + // Should show global parameters + expect(screen.getByPlaceholderText("Enter your API key")).toBeInTheDocument() + expect(screen.getByPlaceholderText("Optional setting")).toBeInTheDocument() + }) + + it("should display effective parameters for selected installation method", () => { + const item = createMockItem(true) + render() + + // Should show method dropdown for multiple methods + expect(screen.getByRole("combobox")).toBeInTheDocument() + + // Should show effective parameters (global + method-specific for NPM method) + expect(screen.getByPlaceholderText("Enter package name")).toBeInTheDocument() // Method-specific + expect(screen.getByPlaceholderText("Enter NPM API key")).toBeInTheDocument() // Overridden global + expect(screen.getByPlaceholderText("Optional setting")).toBeInTheDocument() // Global optional + }) + + it("should update parameters when switching installation methods", async () => { + const item = createMockItem(true) + render() + + // Initially should show NPM method parameters + expect(screen.getByPlaceholderText("Enter package name")).toBeInTheDocument() + expect(screen.getByPlaceholderText("Enter NPM API key")).toBeInTheDocument() + + // Switch to Docker method + const methodSelect = screen.getByRole("combobox") + fireEvent.click(methodSelect) + + // Find and click Docker option + await waitFor(() => { + const dockerOption = screen.getByText("Docker Installation") + fireEvent.click(dockerOption) + }) + + // Should now show Docker method parameters + await waitFor(() => { + expect(screen.getByPlaceholderText("Enter image name")).toBeInTheDocument() + // Should still show global API key (not overridden in Docker method) + expect(screen.getByPlaceholderText("Enter your API key")).toBeInTheDocument() + expect(screen.getByPlaceholderText("Optional setting")).toBeInTheDocument() + }) + + // Package name parameter should no longer be visible + expect(screen.queryByPlaceholderText("Enter package name")).not.toBeInTheDocument() + expect(screen.queryByPlaceholderText("Enter NPM API key")).not.toBeInTheDocument() + }) + + it("should validate required parameters from effective parameters", async () => { + const item = createMockItem(true) + render() + + // Try to install without filling required parameters + const installButton = screen.getByText("marketplace:install.button") + fireEvent.click(installButton) + + // Should show validation error for missing required parameter + await waitFor(() => { + expect(screen.getByText(/Please provide a value for/)).toBeInTheDocument() + }) + + // Fill in the required parameters + const packageNameInput = screen.getByPlaceholderText("Enter package name") + const apiKeyInput = screen.getByPlaceholderText("Enter NPM API key") + + fireEvent.change(packageNameInput, { target: { value: "test-package" } }) + fireEvent.change(apiKeyInput, { target: { value: "test-api-key" } }) + + // Now install should work + fireEvent.click(installButton) + + await waitFor(() => { + expect(mockedVscode.postMessage).toHaveBeenCalledWith({ + type: "installMarketplaceItem", + mpItem: item, + mpInstallOptions: { + target: "project", + parameters: { + packageName: "test-package", + apiKey: "test-api-key", // Overridden value + globalOptional: "", // Optional parameter with empty string + _selectedIndex: 0, + }, + }, + }) + }) + }) + + it("should preserve parameter values when switching methods if keys match", async () => { + const item = createMockItem(true) + render() + + // Fill in global optional parameter + const globalOptionalInput = screen.getByPlaceholderText("Optional setting") + fireEvent.change(globalOptionalInput, { target: { value: "test-value" } }) + + // Switch to Docker method + const methodSelect = screen.getByRole("combobox") + fireEvent.click(methodSelect) + + await waitFor(() => { + const dockerOption = screen.getByText("Docker Installation") + fireEvent.click(dockerOption) + }) + + // Global optional parameter value should be preserved + await waitFor(() => { + const preservedInput = screen.getByPlaceholderText("Optional setting") + expect(preservedInput).toHaveValue("test-value") + }) + }) +}) diff --git a/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.test.tsx b/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.test.tsx new file mode 100644 index 0000000000..fc44d8aca3 --- /dev/null +++ b/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.test.tsx @@ -0,0 +1,223 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { MarketplaceItemCard } from "../MarketplaceItemCard" +import { vscode } from "@/utils/vscode" +import { MarketplaceItem } from "../../../../../../src/services/marketplace/types" +import { TooltipProvider } from "@/components/ui/tooltip" +// Mock vscode API +jest.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: jest.fn(), + }, +})) + +// Mock ExtensionStateContext +jest.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + cwd: "/test/workspace", + filePaths: ["/test/workspace/file1.ts", "/test/workspace/file2.ts"], + }), +})) + +// Mock translation hook +jest.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string, params?: any) => { + if (key === "marketplace:items.card.by") { + return `by ${params.author}` + } + const translations: Record = { + "marketplace:filters.type.mode": "Mode", + "marketplace:filters.type.mcpServer": "MCP Server", + "marketplace:filters.tags.clear": "Remove filter", + "marketplace:filters.tags.clickToFilter": "Add filter", + "marketplace:items.components": "Components", // This should be a string for the title prop + "marketplace:items.card.install": "Install", + "marketplace:items.card.installed": "Installed", + "marketplace:items.card.installProject": "Install Project", + "marketplace:items.card.removeProject": "Remove Project", + "marketplace:items.card.remove": "Remove", + "marketplace:items.card.removeProjectTooltip": "Remove from current project", + "marketplace:items.card.removeGlobalTooltip": "Remove from global configuration", + "marketplace:items.card.noWorkspaceTooltip": "Open a workspace to install marketplace items", + "marketplace:items.matched": "matched", + } + // Special handling for "marketplace:items.components" when it's used as a badge with count + if (key === "marketplace:items.components" && params?.count !== undefined) { + return `${params.count} Components` + } + // Special handling for "marketplace:items.matched" when it's used as a badge with count + if (key === "marketplace:items.matched" && params?.count !== undefined) { + return `${params.count} matched` + } + return translations[key] || key + }, + }), +})) + +const renderWithProviders = (ui: React.ReactElement) => { + return render({ui}) +} + +describe("MarketplaceItemCard", () => { + const defaultItem: MarketplaceItem = { + id: "test-item", + name: "Test Item", + description: "Test Description", + type: "mode", + author: "Test Author", + authorUrl: "https://example.com", + tags: ["test", "example"], + content: "test content", + } + + const defaultProps = { + item: defaultItem, + filters: { + type: "", + search: "", + tags: [], + }, + setFilters: jest.fn(), + installed: { + project: undefined, + global: undefined, + }, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("renders basic item information", () => { + renderWithProviders() + + expect(screen.getByText("Test Item")).toBeInTheDocument() + expect(screen.getByText("Test Description")).toBeInTheDocument() + expect(screen.getByText("by Test Author")).toBeInTheDocument() + }) + + it("renders install button", () => { + renderWithProviders() + + // Should show install button + expect(screen.getByText("Install")).toBeInTheDocument() + }) + + it("renders tags and handles tag clicks", async () => { + const user = userEvent.setup() + const setFilters = jest.fn() + + renderWithProviders() + + const tagButton = screen.getByText("test") + await user.click(tagButton) + + expect(setFilters).toHaveBeenCalledWith({ tags: ["test"] }) + }) + + it("handles author link click", async () => { + const user = userEvent.setup() + renderWithProviders() + + const authorLink = screen.getByText("by Test Author") + await user.click(authorLink) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "openExternal", + url: "https://example.com", + }) + }) + + it("does not render invalid author URLs", () => { + const itemWithInvalidUrl: MarketplaceItem = { + ...defaultItem, + authorUrl: "invalid-url", + } + + renderWithProviders() + + const authorText = screen.getByText(/by Test Author/) // Changed to regex + expect(authorText.tagName).not.toBe("BUTTON") + }) + + describe("MarketplaceItemCard install button", () => { + it("renders install button", () => { + const setFilters = jest.fn() + const item: MarketplaceItem = { + id: "test-item", + name: "Test Item", + description: "Test Description", + type: "mode", + author: "Test Author", + authorUrl: "https://example.com", + tags: ["test", "example"], + content: "test content", + } + renderWithProviders( + , + ) + + expect(screen.getByText("Install")).toBeInTheDocument() + }) + }) + + it("shows install button when no workspace is open", async () => { + // Mock useExtensionState to simulate no workspace + // eslint-disable-next-line @typescript-eslint/no-require-imports + jest.spyOn(require("@/context/ExtensionStateContext"), "useExtensionState").mockReturnValue({ + cwd: undefined, + filePaths: [], + } as any) + + renderWithProviders() + + // Should still show the Install button (dropdown behavior is handled by MarketplaceItemActionsMenu) + expect(screen.getByText("Install")).toBeInTheDocument() + }) + + it("shows single Installed badge when item is installed", () => { + const installedProps = { + ...defaultProps, + installed: { + project: { type: "mode" }, + global: undefined, + }, + } + + renderWithProviders() + + // Should show single "Installed" badge + expect(screen.getByText("Installed")).toBeInTheDocument() + // Should show Remove button instead of Install + expect(screen.getByText("Remove")).toBeInTheDocument() + // Should not show Install button + expect(screen.queryByText("Install")).not.toBeInTheDocument() + }) + + it("shows single Installed badge even when installed in both locations", () => { + const installedProps = { + ...defaultProps, + installed: { + project: { type: "mode" }, + global: { type: "mode" }, + }, + } + + renderWithProviders() + + // Should show only one "Installed" badge + const installedBadges = screen.getAllByText("Installed") + expect(installedBadges).toHaveLength(1) + // Should show Remove button + expect(screen.getByText("Remove")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/marketplace/useStateManager.ts b/webview-ui/src/components/marketplace/useStateManager.ts new file mode 100644 index 0000000000..697c015cbd --- /dev/null +++ b/webview-ui/src/components/marketplace/useStateManager.ts @@ -0,0 +1,47 @@ +import { useState, useEffect } from "react" +import { MarketplaceViewStateManager, ViewState } from "./MarketplaceViewStateManager" + +export function useStateManager(existingManager?: MarketplaceViewStateManager) { + const [manager] = useState(() => existingManager || new MarketplaceViewStateManager()) + const [state, setState] = useState(() => manager.getState()) + + useEffect(() => { + const handleStateChange = (newState: ViewState) => { + setState((prevState) => { + // Compare specific state properties that matter for rendering + const hasChanged = + prevState.isFetching !== newState.isFetching || + prevState.activeTab !== newState.activeTab || + JSON.stringify(prevState.allItems) !== JSON.stringify(newState.allItems) || + JSON.stringify(prevState.displayItems) !== JSON.stringify(newState.displayItems) || + JSON.stringify(prevState.filters) !== JSON.stringify(newState.filters) + + return hasChanged ? newState : prevState + }) + } + + const handleMessage = (event: MessageEvent) => { + manager.handleMessage(event.data) + } + + // Register message handler immediately + window.addEventListener("message", handleMessage) + + // Register state change handler + const unsubscribe = manager.onStateChange(handleStateChange) + + // Force initial state sync + handleStateChange(manager.getState()) + + return () => { + window.removeEventListener("message", handleMessage) + unsubscribe() + // Don't cleanup the manager if it was provided externally + if (!existingManager) { + manager.cleanup() + } + } + }, [manager, existingManager]) + + return [state, manager] as const +} diff --git a/webview-ui/src/components/marketplace/utils/__tests__/grouping.test.ts b/webview-ui/src/components/marketplace/utils/__tests__/grouping.test.ts new file mode 100644 index 0000000000..aa38f46261 --- /dev/null +++ b/webview-ui/src/components/marketplace/utils/__tests__/grouping.test.ts @@ -0,0 +1,120 @@ +import { groupItemsByType, formatItemText, getTotalItemCount, getUniqueTypes } from "../grouping" +import { MarketplaceItem } from "../../../../../../src/services/marketplace/types" + +describe("grouping utilities", () => { + const mockItems: MarketplaceItem[] = [ + { + id: "test-server", + name: "Test Server", + description: "A test MCP server", + type: "mcp", + content: "test content", + }, + { + id: "test-mode", + name: "Test Mode", + description: "A test mode", + type: "mode", + content: "test content", + }, + { + id: "another-server", + name: "Another Server", + description: "Another test MCP server", + type: "mcp", + content: "test content", + }, + ] + + describe("groupItemsByType", () => { + it("should group items by type correctly", () => { + const result = groupItemsByType(mockItems) + + expect(Object.keys(result)).toHaveLength(2) + expect(result["mcp"].items).toHaveLength(2) + expect(result["mode"].items).toHaveLength(1) + + expect(result["mcp"].items[0].name).toBe("Test Server") + expect(result["mode"].items[0].name).toBe("Test Mode") + }) + + it("should handle empty items array", () => { + expect(groupItemsByType([])).toEqual({}) + expect(groupItemsByType(undefined)).toEqual({}) + }) + + it("should handle items with missing metadata", () => { + const itemsWithMissingData: MarketplaceItem[] = [ + { + id: "test-item", + name: "", + description: "", + type: "mcp", + content: "test content", + }, + ] + + const result = groupItemsByType(itemsWithMissingData) + expect(result["mcp"].items[0].name).toBe("Unnamed item") + }) + + it("should preserve item order within groups", () => { + const result = groupItemsByType(mockItems) + const servers = result["mcp"].items + + expect(servers[0].name).toBe("Test Server") + expect(servers[1].name).toBe("Another Server") + }) + + it("should skip items without type", () => { + const itemsWithoutType = [ + { + id: "test-item", + name: "Test Item", + description: "Test description", + type: undefined as any, // Force undefined type to test the skip logic + content: "test content", + }, + ] as MarketplaceItem[] + + const result = groupItemsByType(itemsWithoutType) + expect(Object.keys(result)).toHaveLength(0) + }) + }) + + describe("formatItemText", () => { + it("should format item with name and description", () => { + const item = { name: "Test", description: "Description" } + expect(formatItemText(item)).toBe("Test - Description") + }) + + it("should handle items without description", () => { + const item = { name: "Test" } + expect(formatItemText(item)).toBe("Test") + }) + }) + + describe("getTotalItemCount", () => { + it("should count total items across all groups", () => { + const groups = groupItemsByType(mockItems) + expect(getTotalItemCount(groups)).toBe(3) + }) + + it("should handle empty groups", () => { + expect(getTotalItemCount({})).toBe(0) + }) + }) + + describe("getUniqueTypes", () => { + it("should return sorted array of unique types", () => { + const groups = groupItemsByType(mockItems) + const types = getUniqueTypes(groups) + + expect(types).toEqual(["mcp", "mode"]) + }) + + it("should handle empty groups", () => { + expect(getUniqueTypes({})).toEqual([]) + }) + }) +}) diff --git a/webview-ui/src/components/marketplace/utils/grouping.ts b/webview-ui/src/components/marketplace/utils/grouping.ts new file mode 100644 index 0000000000..30ec361372 --- /dev/null +++ b/webview-ui/src/components/marketplace/utils/grouping.ts @@ -0,0 +1,90 @@ +import { MarketplaceItem } from "../../../../../src/services/marketplace/types" + +export interface GroupedItems { + [type: string]: { + type: string + items: Array<{ + name: string + description?: string + metadata?: any + path?: string + matchInfo?: { + matched: boolean + matchReason?: Record + } + }> + } +} + +/** + * Groups package items by their type + * @param items Array of items to group + * @returns Object with items grouped by type + */ +export function groupItemsByType(items: MarketplaceItem[] = []): GroupedItems { + if (!items?.length) { + return {} + } + + const groups: GroupedItems = {} + + for (const item of items) { + if (!item.type) continue + + if (!groups[item.type]) { + groups[item.type] = { + type: item.type, + items: [], + } + } + + groups[item.type].items.push({ + name: item.name || "Unnamed item", + description: item.description, + metadata: undefined, + path: item.id, // Use id as path since MarketplaceItem doesn't have path + matchInfo: undefined, + }) + } + + return groups +} + +/** + * Gets a formatted string representation of an item + * @param item The item to format + * @returns Formatted string with name and description + */ +export function formatItemText(item: { name: string; description?: string }): string { + if (!item.description) { + return item.name + } + + const maxLength = 100 + const result = + item.name + + " - " + + (item.description.length > maxLength ? item.description.substring(0, maxLength) + "..." : item.description) + + return result +} + +/** + * Gets the total number of items across all groups + * @param groups Grouped items object + * @returns Total number of items + */ +export function getTotalItemCount(groups: GroupedItems): number { + return Object.values(groups).reduce((total, group) => total + group.items.length, 0) +} + +/** + * Gets an array of unique types from the grouped items + * @param groups Grouped items object + * @returns Array of type strings + */ +export function getUniqueTypes(groups: GroupedItems): string[] { + const types = Object.keys(groups) + types.sort() + return types +} diff --git a/webview-ui/src/components/settings/ExperimentalSettings.tsx b/webview-ui/src/components/settings/ExperimentalSettings.tsx index 5525a842b2..d8bc6691d5 100644 --- a/webview-ui/src/components/settings/ExperimentalSettings.tsx +++ b/webview-ui/src/components/settings/ExperimentalSettings.tsx @@ -78,7 +78,10 @@ export const ExperimentalSettings = ({ experimentKey={config[0]} enabled={experiments[EXPERIMENT_IDS[config[0] as keyof typeof EXPERIMENT_IDS]] ?? false} onChange={(enabled) => - setExperimentEnabled(EXPERIMENT_IDS[config[0] as keyof typeof EXPERIMENT_IDS], enabled) + setExperimentEnabled( + EXPERIMENT_IDS[config[0] as keyof typeof EXPERIMENT_IDS], + enabled, + ) } /> ) diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx index 58d823d338..80b4dcd6ec 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx @@ -222,7 +222,7 @@ describe("mergeExtensionState", () => { apiConfiguration: { modelMaxThinkingTokens: 456, modelTemperature: 0.3 }, experiments: { powerSteering: true, - autoCondenseContext: true, + marketplace: false, concurrentFileReads: true, disableCompletionCommand: false, } as Record, @@ -237,7 +237,7 @@ describe("mergeExtensionState", () => { expect(result.experiments).toEqual({ powerSteering: true, - autoCondenseContext: true, + marketplace: false, concurrentFileReads: true, disableCompletionCommand: false, }) diff --git a/webview-ui/src/i18n/locales/ca/common.json b/webview-ui/src/i18n/locales/ca/common.json index 8e95450cc4..3043e6d3c3 100644 --- a/webview-ui/src/i18n/locales/ca/common.json +++ b/webview-ui/src/i18n/locales/ca/common.json @@ -1,4 +1,11 @@ { + "answers": { + "yes": "Sí", + "no": "No", + "cancel": "Cancel·lar", + "remove": "Eliminar", + "keep": "Mantenir" + }, "number_format": { "thousand_suffix": "k", "million_suffix": "m", diff --git a/webview-ui/src/i18n/locales/ca/marketplace.json b/webview-ui/src/i18n/locales/ca/marketplace.json new file mode 100644 index 0000000000..9783a68c25 --- /dev/null +++ b/webview-ui/src/i18n/locales/ca/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "Instal·lat", + "settings": "Configuració", + "browse": "Navegar" + }, + "done": "Fet", + "refresh": "Actualitzar", + "filters": { + "search": { + "placeholder": "Cercar elements del marketplace...", + "placeholderMcp": "Cercar MCPs...", + "placeholderMode": "Cercar modes..." + }, + "type": { + "label": "Filtrar per tipus:", + "all": "Tots els tipus", + "mode": "Mode", + "mcpServer": "Servidor MCP" + }, + "sort": { + "label": "Ordenar per:", + "name": "Nom", + "author": "Autor", + "lastUpdated": "Última actualització" + }, + "tags": { + "label": "Filtrar per etiquetes:", + "clear": "Netejar etiquetes", + "placeholder": "Escriu per cercar i seleccionar etiquetes...", + "noResults": "No s'han trobat etiquetes coincidents", + "selected": "Mostrant elements amb qualsevol de les etiquetes seleccionades", + "clickToFilter": "Feu clic a les etiquetes per filtrar elements" + }, + "none": "Cap" + }, + "type-group": { + "modes": "Modes", + "mcps": "Servidors MCP" + }, + "items": { + "empty": { + "noItems": "No s'han trobat elements del marketplace", + "withFilters": "Prova d'ajustar els filtres", + "noSources": "Prova d'afegir una font a la pestanya Fonts", + "adjustFilters": "Prova d'ajustar els filtres o termes de cerca", + "clearAllFilters": "Netejar tots els filtres" + }, + "count": "{{count}} elements trobats", + "components": "{{count}} components", + "matched": "{{count}} coincidents", + "refresh": { + "button": "Actualitzar", + "refreshing": "Actualitzant...", + "mayTakeMoment": "Això pot trigar un moment." + }, + "card": { + "by": "per {{author}}", + "from": "de {{source}}", + "install": "Instal·lar", + "installProject": "Instal·lar", + "installGlobal": "Instal·lar (Global)", + "remove": "Eliminar", + "removeProject": "Eliminar", + "removeGlobal": "Eliminar (Global)", + "viewSource": "Veure", + "viewOnSource": "Veure a {{source}}", + "noWorkspaceTooltip": "Obre un espai de treball per instal·lar elements del marketplace", + "installed": "Instal·lat", + "removeProjectTooltip": "Eliminar del projecte actual", + "removeGlobalTooltip": "Eliminar de la configuració global", + "actionsMenuLabel": "Més accions" + } + }, + "install": { + "title": "Instal·lar {{name}}", + "titleMode": "Instal·lar mode {{name}}", + "titleMcp": "Instal·lar MCP {{name}}", + "scope": "Àmbit d'instal·lació", + "project": "Projecte (espai de treball actual)", + "global": "Global (tots els espais de treball)", + "method": "Mètode d'instal·lació", + "configuration": "Configuració", + "configurationDescription": "Configura els paràmetres necessaris per a aquest servidor MCP", + "button": "Instal·lar", + "successTitle": "{{name}} instal·lat", + "successDescription": "Instal·lació completada amb èxit", + "installed": "Instal·lat amb èxit!", + "whatNextMcp": "Ara pots configurar i utilitzar aquest servidor MCP. Feu clic a la icona MCP de la barra lateral per canviar de pestanya.", + "whatNextMode": "Ara pots utilitzar aquest mode. Feu clic a la icona Modes de la barra lateral per canviar de pestanya.", + "done": "Fet", + "goToMcp": "Anar a la pestanya MCP", + "goToModes": "Anar a la pestanya Modes", + "moreInfoMcp": "Veure documentació MCP de {{name}}", + "validationRequired": "Si us plau, proporciona un valor per a {{paramName}}", + "prerequisites": "Prerequisits" + }, + "sources": { + "title": "Configurar fonts del marketplace", + "description": "Afegeix repositoris Git que continguin elements del marketplace. Aquests repositoris es recuperaran quan navegueu pel marketplace.", + "add": { + "title": "Afegir nova font", + "urlPlaceholder": "URL del repositori Git (p. ex., https://github.com/username/repo)", + "urlFormats": "Formats compatibles: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git), o protocol Git (git://github.com/username/repo.git)", + "namePlaceholder": "Nom de visualització (màx. 20 caràcters)", + "button": "Afegir font" + }, + "current": { + "title": "Fonts actuals", + "empty": "No hi ha fonts configurades. Afegeix una font per començar.", + "refresh": "Actualitzar aquesta font", + "remove": "Eliminar font" + }, + "errors": { + "emptyUrl": "La URL no pot estar buida", + "invalidUrl": "Format d'URL no vàlid", + "nonVisibleChars": "La URL conté caràcters no visibles a part dels espais", + "invalidGitUrl": "La URL ha de ser una URL de repositori Git vàlida (p. ex., https://github.com/username/repo)", + "duplicateUrl": "Aquesta URL ja és a la llista (coincidència insensible a majúscules i espais)", + "nameTooLong": "El nom ha de tenir 20 caràcters o menys", + "nonVisibleCharsName": "El nom conté caràcters no visibles a part dels espais", + "duplicateName": "Aquest nom ja s'està utilitzant (coincidència insensible a majúscules i espais)", + "emojiName": "Els caràcters emoji poden causar problemes de visualització", + "maxSources": "Màxim de {{max}} fonts permeses" + } + } +} diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index e6f2ff9dc1..c5a03cdc7f 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -493,6 +493,10 @@ "name": "Habilitar lectura concurrent de fitxers", "description": "Quan està habilitat, Roo pot llegir múltiples fitxers en una sola sol·licitud. Quan està deshabilitat, Roo ha de llegir fitxers un per un. Deshabilitar-ho pot ajudar quan es treballa amb models menys capaços o quan voleu més control sobre l'accés als fitxers." }, + "MARKETPLACE": { + "name": "Habilitar Marketplace", + "description": "Quan està habilitat, pots instal·lar MCP i modes personalitzats del Marketplace." + }, "DISABLE_COMPLETION_COMMAND": { "name": "Desactivar l'execució de comandes a attempt_completion", "description": "Quan està activat, l'eina attempt_completion no executarà comandes. Aquesta és una característica experimental per preparar la futura eliminació de l'execució de comandes en la finalització de tasques." diff --git a/webview-ui/src/i18n/locales/de/common.json b/webview-ui/src/i18n/locales/de/common.json index c5b7172efe..49a7fd4018 100644 --- a/webview-ui/src/i18n/locales/de/common.json +++ b/webview-ui/src/i18n/locales/de/common.json @@ -1,4 +1,11 @@ { + "answers": { + "yes": "Ja", + "no": "Nein", + "cancel": "Abbrechen", + "remove": "Entfernen", + "keep": "Behalten" + }, "number_format": { "thousand_suffix": "k", "million_suffix": "m", diff --git a/webview-ui/src/i18n/locales/de/marketplace.json b/webview-ui/src/i18n/locales/de/marketplace.json new file mode 100644 index 0000000000..41d70d4dc4 --- /dev/null +++ b/webview-ui/src/i18n/locales/de/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "Installiert", + "settings": "Einstellungen", + "browse": "Durchsuchen" + }, + "done": "Fertig", + "refresh": "Aktualisieren", + "filters": { + "search": { + "placeholder": "Marketplace-Elemente durchsuchen...", + "placeholderMcp": "MCPs durchsuchen...", + "placeholderMode": "Modi durchsuchen..." + }, + "type": { + "label": "Nach Typ filtern:", + "all": "Alle Typen", + "mode": "Modus", + "mcpServer": "MCP-Server" + }, + "sort": { + "label": "Sortieren nach:", + "name": "Name", + "author": "Autor", + "lastUpdated": "Zuletzt aktualisiert" + }, + "tags": { + "label": "Nach Tags filtern:", + "clear": "Tags löschen", + "placeholder": "Zum Suchen und Auswählen von Tags eingeben...", + "noResults": "Keine passenden Tags gefunden", + "selected": "Zeige Elemente mit einem der ausgewählten Tags", + "clickToFilter": "Klicke auf Tags, um Elemente zu filtern" + }, + "none": "Keine" + }, + "type-group": { + "modes": "Modi", + "mcps": "MCP-Server" + }, + "items": { + "empty": { + "noItems": "Keine Marketplace-Elemente gefunden", + "withFilters": "Versuche deine Filter anzupassen", + "noSources": "Versuche eine Quelle im Quellen-Tab hinzuzufügen", + "adjustFilters": "Versuche deine Filter oder Suchbegriffe anzupassen", + "clearAllFilters": "Alle Filter löschen" + }, + "count": "{{count}} Elemente gefunden", + "components": "{{count}} Komponenten", + "matched": "{{count}} gefunden", + "refresh": { + "button": "Aktualisieren", + "refreshing": "Wird aktualisiert...", + "mayTakeMoment": "Dies kann einen Moment dauern." + }, + "card": { + "by": "von {{author}}", + "from": "von {{source}}", + "install": "Installieren", + "installProject": "Installieren", + "installGlobal": "Installieren (Global)", + "remove": "Entfernen", + "removeProject": "Entfernen", + "removeGlobal": "Entfernen (Global)", + "viewSource": "Anzeigen", + "viewOnSource": "Auf {{source}} anzeigen", + "noWorkspaceTooltip": "Öffne einen Arbeitsbereich, um Marketplace-Elemente zu installieren", + "installed": "Installiert", + "removeProjectTooltip": "Aus aktuellem Projekt entfernen", + "removeGlobalTooltip": "Aus globaler Konfiguration entfernen", + "actionsMenuLabel": "Weitere Aktionen" + } + }, + "install": { + "title": "{{name}} installieren", + "titleMode": "{{name}} Modus installieren", + "titleMcp": "{{name}} MCP installieren", + "scope": "Installationsbereich", + "project": "Projekt (aktueller Arbeitsbereich)", + "global": "Global (alle Arbeitsbereiche)", + "method": "Installationsmethode", + "configuration": "Konfiguration", + "configurationDescription": "Konfiguriere die für diesen MCP-Server erforderlichen Parameter", + "button": "Installieren", + "successTitle": "{{name}} installiert", + "successDescription": "Installation erfolgreich abgeschlossen", + "installed": "Erfolgreich installiert!", + "whatNextMcp": "Du kannst diesen MCP-Server jetzt konfigurieren und verwenden. Klicke auf das MCP-Symbol in der Seitenleiste, um die Tabs zu wechseln.", + "whatNextMode": "Du kannst diesen Modus jetzt verwenden. Klicke auf das Modi-Symbol in der Seitenleiste, um die Tabs zu wechseln.", + "done": "Fertig", + "goToMcp": "Zum MCP-Tab gehen", + "goToModes": "Zum Modi-Tab gehen", + "moreInfoMcp": "{{name}} MCP-Dokumentation anzeigen", + "validationRequired": "Bitte gib einen Wert für {{paramName}} an", + "prerequisites": "Voraussetzungen" + }, + "sources": { + "title": "Marketplace-Quellen konfigurieren", + "description": "Füge Git-Repositories hinzu, die Marketplace-Elemente enthalten. Diese Repositories werden beim Durchsuchen des Marketplace abgerufen.", + "add": { + "title": "Neue Quelle hinzufügen", + "urlPlaceholder": "Git-Repository-URL (z.B. https://github.com/username/repo)", + "urlFormats": "Unterstützte Formate: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git) oder Git-Protokoll (git://github.com/username/repo.git)", + "namePlaceholder": "Anzeigename (max. 20 Zeichen)", + "button": "Quelle hinzufügen" + }, + "current": { + "title": "Aktuelle Quellen", + "empty": "Keine Quellen konfiguriert. Füge eine Quelle hinzu, um zu beginnen.", + "refresh": "Diese Quelle aktualisieren", + "remove": "Quelle entfernen" + }, + "errors": { + "emptyUrl": "URL darf nicht leer sein", + "invalidUrl": "Ungültiges URL-Format", + "nonVisibleChars": "URL enthält nicht sichtbare Zeichen außer Leerzeichen", + "invalidGitUrl": "URL muss eine gültige Git-Repository-URL sein (z.B. https://github.com/username/repo)", + "duplicateUrl": "Diese URL ist bereits in der Liste (Groß-/Kleinschreibung und Leerzeichen werden ignoriert)", + "nameTooLong": "Name muss 20 Zeichen oder weniger haben", + "nonVisibleCharsName": "Name enthält nicht sichtbare Zeichen außer Leerzeichen", + "duplicateName": "Dieser Name wird bereits verwendet (Groß-/Kleinschreibung und Leerzeichen werden ignoriert)", + "emojiName": "Emoji-Zeichen können Anzeigefehler verursachen", + "maxSources": "Maximal {{max}} Quellen erlaubt" + } + } +} diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index daa58cb73d..47b77cd888 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -493,6 +493,10 @@ "name": "Gleichzeitiges Lesen von Dateien aktivieren", "description": "Wenn aktiviert, kann Roo mehrere Dateien in einer einzigen Anfrage lesen. Wenn deaktiviert, muss Roo Dateien nacheinander lesen. Das Deaktivieren kann helfen, wenn du mit weniger leistungsfähigen Modellen arbeitest oder mehr Kontrolle über den Dateizugriff möchtest." }, + "MARKETPLACE": { + "name": "Marketplace aktivieren", + "description": "Wenn aktiviert, kannst du MCP und benutzerdefinierte Modi aus dem Marketplace installieren und verwalten." + }, "DISABLE_COMPLETION_COMMAND": { "name": "Befehlsausführung in attempt_completion deaktivieren", "description": "Wenn aktiviert, führt das Tool attempt_completion keine Befehle aus. Dies ist eine experimentelle Funktion, um die Abschaffung der Befehlsausführung bei Aufgabenabschluss vorzubereiten." diff --git a/webview-ui/src/i18n/locales/en/common.json b/webview-ui/src/i18n/locales/en/common.json index 70a3c4a2d7..c68e64484e 100644 --- a/webview-ui/src/i18n/locales/en/common.json +++ b/webview-ui/src/i18n/locales/en/common.json @@ -1,4 +1,11 @@ { + "answers": { + "yes": "Yes", + "no": "No", + "cancel": "Cancel", + "remove": "Remove", + "keep": "Keep" + }, "number_format": { "thousand_suffix": "k", "million_suffix": "m", diff --git a/webview-ui/src/i18n/locales/en/marketplace.json b/webview-ui/src/i18n/locales/en/marketplace.json new file mode 100644 index 0000000000..bb7d29eba1 --- /dev/null +++ b/webview-ui/src/i18n/locales/en/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "Installed", + "settings": "Settings", + "browse": "Browse" + }, + "done": "Done", + "refresh": "Refresh", + "filters": { + "search": { + "placeholder": "Search marketplace items...", + "placeholderMcp": "Search MCPs...", + "placeholderMode": "Search Modes..." + }, + "type": { + "label": "Filter by type:", + "all": "All types", + "mode": "Mode", + "mcpServer": "MCP Server" + }, + "sort": { + "label": "Sort by:", + "name": "Name", + "author": "Author", + "lastUpdated": "Last Updated" + }, + "tags": { + "label": "Filter by tags:", + "clear": "Clear tags", + "placeholder": "Type to search and select tags...", + "noResults": "No matching tags found", + "selected": "Showing items with any of the selected tags", + "clickToFilter": "Click tags to filter items" + }, + "none": "None" + }, + "type-group": { + "modes": "Modes", + "mcps": "MCP Servers" + }, + "items": { + "empty": { + "noItems": "No marketplace items found", + "withFilters": "Try adjusting your filters", + "noSources": "Try adding a source in the Sources tab", + "adjustFilters": "Try adjusting your filters or search terms", + "clearAllFilters": "Clear all filters" + }, + "count": "{{count}} items found", + "components": "{{count}} components", + "matched": "{{count}} matched", + "refresh": { + "button": "Refresh", + "refreshing": "Refreshing...", + "mayTakeMoment": "This may take a moment." + }, + "card": { + "by": "by {{author}}", + "from": "from {{source}}", + "install": "Install", + "installProject": "Install", + "installGlobal": "Install (Global)", + "remove": "Remove", + "removeProject": "Remove", + "removeGlobal": "Remove (Global)", + "viewSource": "View", + "viewOnSource": "View on {{source}}", + "noWorkspaceTooltip": "Open a workspace to install marketplace items", + "installed": "Installed", + "removeProjectTooltip": "Remove from current project", + "removeGlobalTooltip": "Remove from global configuration", + "actionsMenuLabel": "More actions" + } + }, + "install": { + "title": "Install {{name}}", + "titleMode": "Install {{name}} Mode", + "titleMcp": "Install {{name}} MCP", + "scope": "Installation Scope", + "project": "Project (current workspace)", + "global": "Global (all workspaces)", + "method": "Installation Method", + "prerequisites": "Prerequisites", + "configuration": "Configuration", + "configurationDescription": "Configure the parameters required for this MCP server", + "button": "Install", + "successTitle": "{{name}} Installed", + "successDescription": "Installation completed successfully", + "installed": "Successfully installed!", + "whatNextMcp": "You can now configure and use this MCP server. Click the MCP icon in the sidebar to switch tabs.", + "whatNextMode": "You can now use this mode. Click the Modes icon in the sidebar to switch tabs.", + "done": "Done", + "goToMcp": "Go to MCP Tab", + "goToModes": "Go to Modes Tab", + "moreInfoMcp": "View {{name}} MCP documentation", + "validationRequired": "Please provide a value for {{paramName}}" + }, + "sources": { + "title": "Configure Marketplace Sources", + "description": "Add Git repositories that contain marketplace items. These repositories will be fetched when browsing the marketplace.", + "add": { + "title": "Add New Source", + "urlPlaceholder": "Git repository URL (e.g., https://github.com/username/repo)", + "urlFormats": "Supported formats: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git), or Git protocol (git://github.com/username/repo.git)", + "namePlaceholder": "Display name (max 20 chars)", + "button": "Add Source" + }, + "current": { + "title": "Current Sources", + "empty": "No sources configured. Add a source to get started.", + "refresh": "Refresh this source", + "remove": "Remove source" + }, + "errors": { + "emptyUrl": "URL cannot be empty", + "invalidUrl": "Invalid URL format", + "nonVisibleChars": "URL contains non-visible characters other than spaces", + "invalidGitUrl": "URL must be a valid Git repository URL (e.g., https://github.com/username/repo)", + "duplicateUrl": "This URL is already in the list (case and whitespace insensitive match)", + "nameTooLong": "Name must be 20 characters or less", + "nonVisibleCharsName": "Name contains non-visible characters other than spaces", + "duplicateName": "This name is already in use (case and whitespace insensitive match)", + "emojiName": "Emoji characters may cause display issues", + "maxSources": "Maximum of {{max}} sources allowed" + } + } +} diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index ef559763db..37a22bdca5 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -493,6 +493,10 @@ "name": "Use experimental multi block diff tool", "description": "When enabled, Roo will use multi block diff tool. This will try to update multiple code blocks in the file in one request." }, + "MARKETPLACE": { + "name": "Enable Marketplace", + "description": "When enabled, you can install MCPs and custom modes from the Marketplace." + }, "DISABLE_COMPLETION_COMMAND": { "name": "Disable command execution in attempt_completion", "description": "When enabled, the attempt_completion tool will not execute commands. This is an experimental feature to prepare for deprecating command execution in task completion." diff --git a/webview-ui/src/i18n/locales/es/common.json b/webview-ui/src/i18n/locales/es/common.json index 6d649eb926..cca7966e12 100644 --- a/webview-ui/src/i18n/locales/es/common.json +++ b/webview-ui/src/i18n/locales/es/common.json @@ -1,4 +1,11 @@ { + "answers": { + "yes": "Sí", + "no": "No", + "cancel": "Cancelar", + "remove": "Eliminar", + "keep": "Mantener" + }, "number_format": { "thousand_suffix": "k", "million_suffix": "m", diff --git a/webview-ui/src/i18n/locales/es/marketplace.json b/webview-ui/src/i18n/locales/es/marketplace.json new file mode 100644 index 0000000000..326b88045c --- /dev/null +++ b/webview-ui/src/i18n/locales/es/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "Instalado", + "settings": "Configuración", + "browse": "Explorar" + }, + "done": "Hecho", + "refresh": "Actualizar", + "filters": { + "search": { + "placeholder": "Buscar elementos del marketplace...", + "placeholderMcp": "Buscar MCPs...", + "placeholderMode": "Buscar modos..." + }, + "type": { + "label": "Filtrar por tipo:", + "all": "Todos los tipos", + "mode": "Modo", + "mcpServer": "Servidor MCP" + }, + "sort": { + "label": "Ordenar por:", + "name": "Nombre", + "author": "Autor", + "lastUpdated": "Última actualización" + }, + "tags": { + "label": "Filtrar por etiquetas:", + "clear": "Limpiar etiquetas", + "placeholder": "Escribe para buscar y seleccionar etiquetas...", + "noResults": "No se encontraron etiquetas coincidentes", + "selected": "Mostrando elementos con cualquiera de las etiquetas seleccionadas", + "clickToFilter": "Haz clic en las etiquetas para filtrar elementos" + }, + "none": "Ninguno" + }, + "type-group": { + "modes": "Modos", + "mcps": "Servidores MCP" + }, + "items": { + "empty": { + "noItems": "No se encontraron elementos del marketplace", + "withFilters": "Intenta ajustar tus filtros", + "noSources": "Intenta agregar una fuente en la pestaña Fuentes", + "adjustFilters": "Intenta ajustar tus filtros o términos de búsqueda", + "clearAllFilters": "Limpiar todos los filtros" + }, + "count": "{{count}} elementos encontrados", + "components": "{{count}} componentes", + "matched": "{{count}} coincidentes", + "refresh": { + "button": "Actualizar", + "refreshing": "Actualizando...", + "mayTakeMoment": "Esto puede tomar un momento." + }, + "card": { + "by": "por {{author}}", + "from": "de {{source}}", + "install": "Instalar", + "installProject": "Instalar", + "installGlobal": "Instalar (Global)", + "remove": "Eliminar", + "removeProject": "Eliminar", + "removeGlobal": "Eliminar (Global)", + "viewSource": "Ver", + "viewOnSource": "Ver en {{source}}", + "noWorkspaceTooltip": "Abre un espacio de trabajo para instalar elementos del marketplace", + "installed": "Instalado", + "removeProjectTooltip": "Eliminar del proyecto actual", + "removeGlobalTooltip": "Eliminar de la configuración global", + "actionsMenuLabel": "Más acciones" + } + }, + "install": { + "title": "Instalar {{name}}", + "titleMode": "Instalar modo {{name}}", + "titleMcp": "Instalar MCP {{name}}", + "scope": "Ámbito de instalación", + "project": "Proyecto (espacio de trabajo actual)", + "global": "Global (todos los espacios de trabajo)", + "method": "Método de instalación", + "configuration": "Configuración", + "configurationDescription": "Configura los parámetros requeridos para este servidor MCP", + "button": "Instalar", + "successTitle": "{{name}} instalado", + "successDescription": "Instalación completada exitosamente", + "installed": "¡Instalado exitosamente!", + "whatNextMcp": "Ahora puedes configurar y usar este servidor MCP. Haz clic en el icono MCP en la barra lateral para cambiar de pestaña.", + "whatNextMode": "Ahora puedes usar este modo. Haz clic en el icono Modos en la barra lateral para cambiar de pestaña.", + "done": "Hecho", + "goToMcp": "Ir a la pestaña MCP", + "goToModes": "Ir a la pestaña Modos", + "moreInfoMcp": "Ver documentación MCP de {{name}}", + "validationRequired": "Por favor proporciona un valor para {{paramName}}", + "prerequisites": "Requisitos previos" + }, + "sources": { + "title": "Configurar fuentes del marketplace", + "description": "Agrega repositorios Git que contengan elementos del marketplace. Estos repositorios se obtendrán al navegar por el marketplace.", + "add": { + "title": "Agregar nueva fuente", + "urlPlaceholder": "URL del repositorio Git (ej., https://github.com/username/repo)", + "urlFormats": "Formatos soportados: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git), o protocolo Git (git://github.com/username/repo.git)", + "namePlaceholder": "Nombre para mostrar (máx. 20 caracteres)", + "button": "Agregar fuente" + }, + "current": { + "title": "Fuentes actuales", + "empty": "No hay fuentes configuradas. Agrega una fuente para comenzar.", + "refresh": "Actualizar esta fuente", + "remove": "Eliminar fuente" + }, + "errors": { + "emptyUrl": "La URL no puede estar vacía", + "invalidUrl": "Formato de URL inválido", + "nonVisibleChars": "La URL contiene caracteres no visibles además de espacios", + "invalidGitUrl": "La URL debe ser una URL de repositorio Git válida (ej., https://github.com/username/repo)", + "duplicateUrl": "Esta URL ya está en la lista (coincidencia insensible a mayúsculas y espacios)", + "nameTooLong": "El nombre debe tener 20 caracteres o menos", + "nonVisibleCharsName": "El nombre contiene caracteres no visibles además de espacios", + "duplicateName": "Este nombre ya está en uso (coincidencia insensible a mayúsculas y espacios)", + "emojiName": "Los caracteres emoji pueden causar problemas de visualización", + "maxSources": "Máximo de {{max}} fuentes permitidas" + } + } +} diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index e11cb6efa7..3c9d423b8f 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -493,6 +493,10 @@ "name": "Habilitar lectura concurrente de archivos", "description": "Cuando está habilitado, Roo puede leer múltiples archivos en una sola solicitud. Cuando está deshabilitado, Roo debe leer archivos uno a la vez. Deshabilitarlo puede ayudar cuando se trabaja con modelos menos capaces o cuando deseas más control sobre el acceso a archivos." }, + "MARKETPLACE": { + "name": "Habilitar Marketplace", + "description": "Cuando está habilitado, puedes instalar MCP y modos personalizados del Marketplace." + }, "DISABLE_COMPLETION_COMMAND": { "name": "Desactivar la ejecución de comandos en attempt_completion", "description": "Cuando está activado, la herramienta attempt_completion no ejecutará comandos. Esta es una función experimental para preparar la futura eliminación de la ejecución de comandos en la finalización de tareas." diff --git a/webview-ui/src/i18n/locales/fr/common.json b/webview-ui/src/i18n/locales/fr/common.json index caeb10851f..b453214988 100644 --- a/webview-ui/src/i18n/locales/fr/common.json +++ b/webview-ui/src/i18n/locales/fr/common.json @@ -1,4 +1,11 @@ { + "answers": { + "yes": "Oui", + "no": "Non", + "cancel": "Annuler", + "remove": "Supprimer", + "keep": "Conserver" + }, "number_format": { "thousand_suffix": "k", "million_suffix": "m", diff --git a/webview-ui/src/i18n/locales/fr/marketplace.json b/webview-ui/src/i18n/locales/fr/marketplace.json new file mode 100644 index 0000000000..50faed130a --- /dev/null +++ b/webview-ui/src/i18n/locales/fr/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "Installé", + "settings": "Paramètres", + "browse": "Parcourir" + }, + "done": "Terminé", + "refresh": "Actualiser", + "filters": { + "search": { + "placeholder": "Rechercher des éléments du marketplace...", + "placeholderMcp": "Rechercher des MCPs...", + "placeholderMode": "Rechercher des modes..." + }, + "type": { + "label": "Filtrer par type :", + "all": "Tous les types", + "mode": "Mode", + "mcpServer": "Serveur MCP" + }, + "sort": { + "label": "Trier par :", + "name": "Nom", + "author": "Auteur", + "lastUpdated": "Dernière mise à jour" + }, + "tags": { + "label": "Filtrer par étiquettes :", + "clear": "Effacer les étiquettes", + "placeholder": "Tapez pour rechercher et sélectionner des étiquettes...", + "noResults": "Aucune étiquette correspondante trouvée", + "selected": "Affichage des éléments avec l'une des étiquettes sélectionnées", + "clickToFilter": "Cliquez sur les étiquettes pour filtrer les éléments" + }, + "none": "Aucun" + }, + "type-group": { + "modes": "Modes", + "mcps": "Serveurs MCP" + }, + "items": { + "empty": { + "noItems": "Aucun élément du marketplace trouvé", + "withFilters": "Essayez d'ajuster vos filtres", + "noSources": "Essayez d'ajouter une source dans l'onglet Sources", + "adjustFilters": "Essayez d'ajuster vos filtres ou termes de recherche", + "clearAllFilters": "Effacer tous les filtres" + }, + "count": "{{count}} éléments trouvés", + "components": "{{count}} composants", + "matched": "{{count}} correspondants", + "refresh": { + "button": "Actualiser", + "refreshing": "Actualisation...", + "mayTakeMoment": "Cela peut prendre un moment." + }, + "card": { + "by": "par {{author}}", + "from": "de {{source}}", + "install": "Installer", + "installProject": "Installer", + "installGlobal": "Installer (Global)", + "remove": "Supprimer", + "removeProject": "Supprimer", + "removeGlobal": "Supprimer (Global)", + "viewSource": "Voir", + "viewOnSource": "Voir sur {{source}}", + "noWorkspaceTooltip": "Ouvrez un espace de travail pour installer des éléments du marketplace", + "installed": "Installé", + "removeProjectTooltip": "Supprimer du projet actuel", + "removeGlobalTooltip": "Supprimer de la configuration globale", + "actionsMenuLabel": "Plus d'actions" + } + }, + "install": { + "title": "Installer {{name}}", + "titleMode": "Installer le mode {{name}}", + "titleMcp": "Installer le MCP {{name}}", + "scope": "Portée d'installation", + "project": "Projet (espace de travail actuel)", + "global": "Global (tous les espaces de travail)", + "method": "Méthode d'installation", + "configuration": "Configuration", + "configurationDescription": "Configurez les paramètres requis pour ce serveur MCP", + "button": "Installer", + "successTitle": "{{name}} installé", + "successDescription": "Installation terminée avec succès", + "installed": "Installé avec succès !", + "whatNextMcp": "Vous pouvez maintenant configurer et utiliser ce serveur MCP. Cliquez sur l'icône MCP dans la barre latérale pour changer d'onglet.", + "whatNextMode": "Vous pouvez maintenant utiliser ce mode. Cliquez sur l'icône Modes dans la barre latérale pour changer d'onglet.", + "done": "Terminé", + "goToMcp": "Aller à l'onglet MCP", + "goToModes": "Aller à l'onglet Modes", + "moreInfoMcp": "Voir la documentation MCP de {{name}}", + "validationRequired": "Veuillez fournir une valeur pour {{paramName}}", + "prerequisites": "Prérequis" + }, + "sources": { + "title": "Configurer les sources du marketplace", + "description": "Ajoutez des dépôts Git qui contiennent des éléments du marketplace. Ces dépôts seront récupérés lors de la navigation dans le marketplace.", + "add": { + "title": "Ajouter une nouvelle source", + "urlPlaceholder": "URL du dépôt Git (ex., https://github.com/username/repo)", + "urlFormats": "Formats pris en charge : HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git), ou protocole Git (git://github.com/username/repo.git)", + "namePlaceholder": "Nom d'affichage (max. 20 caractères)", + "button": "Ajouter une source" + }, + "current": { + "title": "Sources actuelles", + "empty": "Aucune source configurée. Ajoutez une source pour commencer.", + "refresh": "Actualiser cette source", + "remove": "Supprimer la source" + }, + "errors": { + "emptyUrl": "L'URL ne peut pas être vide", + "invalidUrl": "Format d'URL invalide", + "nonVisibleChars": "L'URL contient des caractères non visibles autres que des espaces", + "invalidGitUrl": "L'URL doit être une URL de dépôt Git valide (ex., https://github.com/username/repo)", + "duplicateUrl": "Cette URL est déjà dans la liste (correspondance insensible à la casse et aux espaces)", + "nameTooLong": "Le nom doit faire 20 caractères ou moins", + "nonVisibleCharsName": "Le nom contient des caractères non visibles autres que des espaces", + "duplicateName": "Ce nom est déjà utilisé (correspondance insensible à la casse et aux espaces)", + "emojiName": "Les caractères emoji peuvent causer des problèmes d'affichage", + "maxSources": "Maximum de {{max}} sources autorisées" + } + } +} diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index c0c1b75b43..01d8980986 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -493,6 +493,10 @@ "name": "Activer la lecture simultanée de fichiers", "description": "Lorsqu'activé, Roo peut lire plusieurs fichiers dans une seule requête. Lorsque désactivé, Roo doit lire les fichiers un par un. La désactivation peut aider lors du travail avec des modèles moins performants ou lorsque tu souhaites plus de contrôle sur l'accès aux fichiers." }, + "MARKETPLACE": { + "name": "Activer le Marketplace", + "description": "Lorsque cette option est activée, tu peux installer des MCP et des modes personnalisés depuis le Marketplace." + }, "DISABLE_COMPLETION_COMMAND": { "name": "Désactiver l'exécution des commandes dans attempt_completion", "description": "Lorsque cette option est activée, l'outil attempt_completion n'exécutera pas de commandes. Il s'agit d'une fonctionnalité expérimentale visant à préparer la dépréciation de l'exécution des commandes lors de la finalisation des tâches." diff --git a/webview-ui/src/i18n/locales/hi/common.json b/webview-ui/src/i18n/locales/hi/common.json index de95d083b5..250af8d47e 100644 --- a/webview-ui/src/i18n/locales/hi/common.json +++ b/webview-ui/src/i18n/locales/hi/common.json @@ -1,4 +1,11 @@ { + "answers": { + "yes": "हाँ", + "no": "नहीं", + "cancel": "रद्द करें", + "remove": "हटाएं", + "keep": "रखें" + }, "number_format": { "thousand_suffix": "k", "million_suffix": "m", diff --git a/webview-ui/src/i18n/locales/hi/marketplace.json b/webview-ui/src/i18n/locales/hi/marketplace.json new file mode 100644 index 0000000000..ec87a6e49d --- /dev/null +++ b/webview-ui/src/i18n/locales/hi/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "इंस्टॉल किया गया", + "settings": "सेटिंग्स", + "browse": "ब्राउज़ करें" + }, + "done": "पूर्ण", + "refresh": "रिफ्रेश करें", + "filters": { + "search": { + "placeholder": "Marketplace आइटम खोजें...", + "placeholderMcp": "MCP खोजें...", + "placeholderMode": "मोड खोजें..." + }, + "type": { + "label": "प्रकार के अनुसार फ़िल्टर करें:", + "all": "सभी प्रकार", + "mode": "मोड", + "mcpServer": "MCP सर्वर" + }, + "sort": { + "label": "इसके अनुसार क्रमबद्ध करें:", + "name": "नाम", + "author": "लेखक", + "lastUpdated": "अंतिम अपडेट" + }, + "tags": { + "label": "टैग के अनुसार फ़िल्टर करें:", + "clear": "टैग साफ़ करें", + "placeholder": "टैग खोजने और चुनने के लिए टाइप करें...", + "noResults": "कोई मिलान टैग नहीं मिला", + "selected": "चयनित टैग में से किसी एक के साथ आइटम दिखाएं", + "clickToFilter": "आइटम फ़िल्टर करने के लिए टैग पर क्लिक करें" + }, + "none": "कोई नहीं" + }, + "type-group": { + "modes": "मोड", + "mcps": "MCP सर्वर" + }, + "items": { + "empty": { + "noItems": "कोई Marketplace आइटम नहीं मिला", + "withFilters": "अपने फ़िल्टर समायोजित करने का प्रयास करें", + "noSources": "स्रोत टैब में एक स्रोत जोड़ने का प्रयास करें", + "adjustFilters": "अपने फ़िल्टर या खोज शब्दों को समायोजित करने का प्रयास करें", + "clearAllFilters": "सभी फ़िल्टर साफ़ करें" + }, + "count": "{{count}} आइटम मिले", + "components": "{{count}} घटक", + "matched": "{{count}} मिले", + "refresh": { + "button": "रिफ्रेश करें", + "refreshing": "रिफ्रेश हो रहा है...", + "mayTakeMoment": "इसमें कुछ समय लग सकता है।" + }, + "card": { + "by": "{{author}} द्वारा", + "from": "{{source}} से", + "install": "इंस्टॉल करें", + "installProject": "इंस्टॉल करें", + "installGlobal": "इंस्टॉल करें (ग्लोबल)", + "remove": "हटाएं", + "removeProject": "हटाएं", + "removeGlobal": "हटाएं (ग्लोबल)", + "viewSource": "देखें", + "viewOnSource": "{{source}} पर देखें", + "noWorkspaceTooltip": "Marketplace आइटम इंस्टॉल करने के लिए एक वर्कस्पेस खोलें", + "installed": "इंस्टॉल किया गया", + "removeProjectTooltip": "वर्तमान प्रोजेक्ट से हटाएं", + "removeGlobalTooltip": "ग्लोबल कॉन्फ़िगरेशन से हटाएं", + "actionsMenuLabel": "अधिक क्रियाएं" + } + }, + "install": { + "title": "{{name}} इंस्टॉल करें", + "titleMode": "{{name}} मोड इंस्टॉल करें", + "titleMcp": "{{name}} MCP इंस्टॉल करें", + "scope": "इंस्टॉलेशन स्कोप", + "project": "प्रोजेक्ट (वर्तमान वर्कस्पेस)", + "global": "ग्लोबल (सभी वर्कस्पेस)", + "method": "इंस्टॉलेशन विधि", + "configuration": "कॉन्फ़िगरेशन", + "configurationDescription": "इस MCP सर्वर के लिए आवश्यक पैरामीटर कॉन्फ़िगर करें", + "button": "इंस्टॉल करें", + "successTitle": "{{name}} इंस्टॉल किया गया", + "successDescription": "इंस्टॉलेशन सफलतापूर्वक पूर्ण", + "installed": "सफलतापूर्वक इंस्टॉल किया गया!", + "whatNextMcp": "अब आप इस MCP सर्वर को कॉन्फ़िगर और उपयोग कर सकते हैं। टैब स्विच करने के लिए साइडबार में MCP आइकन पर क्लिक करें।", + "whatNextMode": "अब आप इस मोड का उपयोग कर सकते हैं। टैब स्विच करने के लिए साइडबार में मोड आइकन पर क्लिक करें।", + "done": "पूर्ण", + "goToMcp": "MCP टैब पर जाएं", + "goToModes": "मोड टैब पर जाएं", + "moreInfoMcp": "{{name}} MCP दस्तावेज़ देखें", + "validationRequired": "कृपया {{paramName}} के लिए एक मान प्रदान करें", + "prerequisites": "आवश्यकताएं" + }, + "sources": { + "title": "Marketplace स्रोत कॉन्फ़िगर करें", + "description": "Git रिपॉजिटरी जोड़ें जिनमें Marketplace आइटम हैं। Marketplace ब्राउज़ करते समय इन रिपॉजिटरी को प्राप्त किया जाएगा।", + "add": { + "title": "नया स्रोत जोड़ें", + "urlPlaceholder": "Git रिपॉजिटरी URL (जैसे https://github.com/username/repo)", + "urlFormats": "समर्थित प्रारूप: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git) या Git प्रोटोकॉल (git://github.com/username/repo.git)", + "namePlaceholder": "प्रदर्शन नाम (अधिकतम 20 वर्ण)", + "button": "स्रोत जोड़ें" + }, + "current": { + "title": "वर्तमान स्रोत", + "empty": "कोई स्रोत कॉन्फ़िगर नहीं किया गया। शुरू करने के लिए एक स्रोत जोड़ें।", + "refresh": "इस स्रोत को रिफ्रेश करें", + "remove": "स्रोत हटाएं" + }, + "errors": { + "emptyUrl": "URL खाली नहीं हो सकता", + "invalidUrl": "अमान्य URL प्रारूप", + "nonVisibleChars": "URL में स्पेस के अलावा अदृश्य वर्ण हैं", + "invalidGitUrl": "URL एक वैध Git रिपॉजिटरी URL होना चाहिए (जैसे https://github.com/username/repo)", + "duplicateUrl": "यह URL पहले से सूची में है (केस और स्पेस को नजरअंदाज किया गया)", + "nameTooLong": "नाम 20 वर्ण या उससे कम होना चाहिए", + "nonVisibleCharsName": "नाम में स्पेस के अलावा अदृश्य वर्ण हैं", + "duplicateName": "यह नाम पहले से उपयोग में है (केस और स्पेस को नजरअंदाज किया गया)", + "emojiName": "इमोजी वर्ण प्रदर्शन समस्याएं पैदा कर सकते हैं", + "maxSources": "अधिकतम {{max}} स्रोतों की अनुमति है" + } + } +} diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 35532c7df6..c0fd1e9efb 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -493,6 +493,10 @@ "name": "समवर्ती फ़ाइल पढ़ना सक्षम करें", "description": "सक्षम होने पर, Roo एक ही अनुरोध में कई फ़ाइलें पढ़ सकता है अक्षम होने पर, Roo को एक बार में एक फ़ाइल पढ़नी होगी। कम सक्षम मॉडल के साथ काम करते समय या जब आप फ़ाइल एक्सेस पर अधिक नियंत्रण चाहते हैं तो इसे अक्षम करना मददगार हो सकता है।" }, + "MARKETPLACE": { + "name": "Marketplace सक्षम करें", + "description": "जब सक्षम होता है, तो आप Marketplace से MCP और कस्टम मोड इंस्टॉल कर सकते हैं।" + }, "DISABLE_COMPLETION_COMMAND": { "name": "attempt_completion में कमांड निष्पादन अक्षम करें", "description": "जब सक्षम किया जाता है, तो attempt_completion टूल कमांड निष्पादित नहीं करेगा। यह कार्य पूर्ण होने पर कमांड निष्पादन को पदावनत करने की तैयारी के लिए एक प्रयोगात्मक सुविधा है।" diff --git a/webview-ui/src/i18n/locales/it/common.json b/webview-ui/src/i18n/locales/it/common.json index c07023796a..06915332cd 100644 --- a/webview-ui/src/i18n/locales/it/common.json +++ b/webview-ui/src/i18n/locales/it/common.json @@ -1,4 +1,11 @@ { + "answers": { + "yes": "Sì", + "no": "No", + "cancel": "Annulla", + "remove": "Rimuovi", + "keep": "Mantieni" + }, "number_format": { "thousand_suffix": "k", "million_suffix": "m", diff --git a/webview-ui/src/i18n/locales/it/marketplace.json b/webview-ui/src/i18n/locales/it/marketplace.json new file mode 100644 index 0000000000..5a68a4a40e --- /dev/null +++ b/webview-ui/src/i18n/locales/it/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "Installati", + "settings": "Impostazioni", + "browse": "Sfoglia" + }, + "done": "Fatto", + "refresh": "Aggiorna", + "filters": { + "search": { + "placeholder": "Cerca elementi del Marketplace...", + "placeholderMcp": "Cerca MCP...", + "placeholderMode": "Cerca modalità..." + }, + "type": { + "label": "Filtra per tipo:", + "all": "Tutti i tipi", + "mode": "Modalità", + "mcpServer": "Server MCP" + }, + "sort": { + "label": "Ordina per:", + "name": "Nome", + "author": "Autore", + "lastUpdated": "Ultimo aggiornamento" + }, + "tags": { + "label": "Filtra per tag:", + "clear": "Cancella tag", + "placeholder": "Digita per cercare e selezionare tag...", + "noResults": "Nessun tag corrispondente trovato", + "selected": "Mostra elementi con uno qualsiasi dei tag selezionati", + "clickToFilter": "Clicca sui tag per filtrare gli elementi" + }, + "none": "Nessuno" + }, + "type-group": { + "modes": "Modalità", + "mcps": "Server MCP" + }, + "items": { + "empty": { + "noItems": "Nessun elemento del Marketplace trovato", + "withFilters": "Prova ad aggiustare i tuoi filtri", + "noSources": "Prova ad aggiungere una fonte nella scheda Fonti", + "adjustFilters": "Prova ad aggiustare i tuoi filtri o termini di ricerca", + "clearAllFilters": "Cancella tutti i filtri" + }, + "count": "{{count}} elementi trovati", + "components": "{{count}} componenti", + "matched": "{{count}} trovati", + "refresh": { + "button": "Aggiorna", + "refreshing": "Aggiornamento in corso...", + "mayTakeMoment": "Questo potrebbe richiedere un momento." + }, + "card": { + "by": "di {{author}}", + "from": "da {{source}}", + "install": "Installa", + "installProject": "Installa", + "installGlobal": "Installa (Globale)", + "remove": "Rimuovi", + "removeProject": "Rimuovi", + "removeGlobal": "Rimuovi (Globale)", + "viewSource": "Visualizza", + "viewOnSource": "Visualizza su {{source}}", + "noWorkspaceTooltip": "Apri un'area di lavoro per installare elementi del Marketplace", + "installed": "Installato", + "removeProjectTooltip": "Rimuovi dal progetto corrente", + "removeGlobalTooltip": "Rimuovi dalla configurazione globale", + "actionsMenuLabel": "Altre azioni" + } + }, + "install": { + "title": "Installa {{name}}", + "titleMode": "Installa modalità {{name}}", + "titleMcp": "Installa MCP {{name}}", + "scope": "Ambito di installazione", + "project": "Progetto (area di lavoro corrente)", + "global": "Globale (tutte le aree di lavoro)", + "method": "Metodo di installazione", + "configuration": "Configurazione", + "configurationDescription": "Configura i parametri richiesti per questo server MCP", + "button": "Installa", + "successTitle": "{{name}} installato", + "successDescription": "Installazione completata con successo", + "installed": "Installato con successo!", + "whatNextMcp": "Ora puoi configurare e utilizzare questo server MCP. Clicca sull'icona MCP nella barra laterale per cambiare scheda.", + "whatNextMode": "Ora puoi utilizzare questa modalità. Clicca sull'icona delle modalità nella barra laterale per cambiare scheda.", + "done": "Fatto", + "goToMcp": "Vai alla scheda MCP", + "goToModes": "Vai alla scheda Modalità", + "moreInfoMcp": "Visualizza documentazione MCP {{name}}", + "validationRequired": "Fornisci un valore per {{paramName}}", + "prerequisites": "Prerequisiti" + }, + "sources": { + "title": "Configura fonti del Marketplace", + "description": "Aggiungi repository Git che contengono elementi del Marketplace. Questi repository verranno recuperati quando si naviga nel Marketplace.", + "add": { + "title": "Aggiungi nuova fonte", + "urlPlaceholder": "URL del repository Git (es. https://github.com/username/repo)", + "urlFormats": "Formati supportati: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git) o protocollo Git (git://github.com/username/repo.git)", + "namePlaceholder": "Nome visualizzato (max 20 caratteri)", + "button": "Aggiungi fonte" + }, + "current": { + "title": "Fonti correnti", + "empty": "Nessuna fonte configurata. Aggiungi una fonte per iniziare.", + "refresh": "Aggiorna questa fonte", + "remove": "Rimuovi fonte" + }, + "errors": { + "emptyUrl": "L'URL non può essere vuoto", + "invalidUrl": "Formato URL non valido", + "nonVisibleChars": "L'URL contiene caratteri non visibili oltre agli spazi", + "invalidGitUrl": "L'URL deve essere un URL di repository Git valido (es. https://github.com/username/repo)", + "duplicateUrl": "Questo URL è già nell'elenco (maiuscole/minuscole e spazi ignorati)", + "nameTooLong": "Il nome deve essere di 20 caratteri o meno", + "nonVisibleCharsName": "Il nome contiene caratteri non visibili oltre agli spazi", + "duplicateName": "Questo nome è già in uso (maiuscole/minuscole e spazi ignorati)", + "emojiName": "I caratteri emoji possono causare problemi di visualizzazione", + "maxSources": "Massimo {{max}} fonti consentite" + } + } +} diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 4dc8b6b2b0..d920cddb6b 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -493,6 +493,10 @@ "name": "Abilita lettura simultanea dei file", "description": "Quando abilitato, Roo può leggere più file in una singola richiesta. Quando disabilitato, Roo deve leggere i file uno alla volta. Disabilitarlo può aiutare quando si lavora con modelli meno capaci o quando si desidera maggiore controllo sull'accesso ai file." }, + "MARKETPLACE": { + "name": "Abilita Marketplace", + "description": "Quando abilitato, puoi installare MCP e modalità personalizzate dal Marketplace." + }, "DISABLE_COMPLETION_COMMAND": { "name": "Disabilita l'esecuzione dei comandi in attempt_completion", "description": "Se abilitato, lo strumento attempt_completion non eseguirà comandi. Questa è una funzionalità sperimentale per preparare la futura deprecazione dell'esecuzione dei comandi al completamento dell'attività." diff --git a/webview-ui/src/i18n/locales/ja/common.json b/webview-ui/src/i18n/locales/ja/common.json index c890f8baed..b92b037690 100644 --- a/webview-ui/src/i18n/locales/ja/common.json +++ b/webview-ui/src/i18n/locales/ja/common.json @@ -1,4 +1,11 @@ { + "answers": { + "yes": "はい", + "no": "いいえ", + "cancel": "キャンセル", + "remove": "削除", + "keep": "保持" + }, "number_format": { "thousand_suffix": "k", "million_suffix": "m", diff --git a/webview-ui/src/i18n/locales/ja/marketplace.json b/webview-ui/src/i18n/locales/ja/marketplace.json new file mode 100644 index 0000000000..c7448ca08f --- /dev/null +++ b/webview-ui/src/i18n/locales/ja/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "インストール済み", + "settings": "設定", + "browse": "参照" + }, + "done": "完了", + "refresh": "更新", + "filters": { + "search": { + "placeholder": "Marketplaceアイテムを検索...", + "placeholderMcp": "MCPを検索...", + "placeholderMode": "モードを検索..." + }, + "type": { + "label": "タイプでフィルター:", + "all": "すべてのタイプ", + "mode": "モード", + "mcpServer": "MCPサーバー" + }, + "sort": { + "label": "並び順:", + "name": "名前", + "author": "作成者", + "lastUpdated": "最終更新" + }, + "tags": { + "label": "タグでフィルター:", + "clear": "タグをクリア", + "placeholder": "タグを検索して選択するために入力...", + "noResults": "一致するタグが見つかりません", + "selected": "選択されたタグのいずれかを持つアイテムを表示", + "clickToFilter": "タグをクリックしてアイテムをフィルター" + }, + "none": "なし" + }, + "type-group": { + "modes": "モード", + "mcps": "MCPサーバー" + }, + "items": { + "empty": { + "noItems": "Marketplaceアイテムが見つかりません", + "withFilters": "フィルターを調整してみてください", + "noSources": "ソースタブでソースを追加してみてください", + "adjustFilters": "フィルターや検索語を調整してみてください", + "clearAllFilters": "すべてのフィルターをクリア" + }, + "count": "{{count}}個のアイテムが見つかりました", + "components": "{{count}}個のコンポーネント", + "matched": "{{count}}個が一致", + "refresh": { + "button": "更新", + "refreshing": "更新中...", + "mayTakeMoment": "しばらく時間がかかる場合があります。" + }, + "card": { + "by": "{{author}}による", + "from": "{{source}}から", + "install": "インストール", + "installProject": "インストール", + "installGlobal": "インストール(グローバル)", + "remove": "削除", + "removeProject": "削除", + "removeGlobal": "削除(グローバル)", + "viewSource": "表示", + "viewOnSource": "{{source}}で表示", + "noWorkspaceTooltip": "Marketplaceアイテムをインストールするにはワークスペースを開いてください", + "installed": "インストール済み", + "removeProjectTooltip": "現在のプロジェクトから削除", + "removeGlobalTooltip": "グローバル設定から削除", + "actionsMenuLabel": "その他のアクション" + } + }, + "install": { + "title": "{{name}}をインストール", + "titleMode": "{{name}}モードをインストール", + "titleMcp": "{{name}} MCPをインストール", + "scope": "インストール範囲", + "project": "プロジェクト(現在のワークスペース)", + "global": "グローバル(すべてのワークスペース)", + "method": "インストール方法", + "configuration": "設定", + "configurationDescription": "このMCPサーバーに必要なパラメーターを設定", + "button": "インストール", + "successTitle": "{{name}}がインストールされました", + "successDescription": "インストールが正常に完了しました", + "installed": "正常にインストールされました!", + "whatNextMcp": "このMCPサーバーを設定して使用できるようになりました。サイドバーのMCPアイコンをクリックしてタブを切り替えてください。", + "whatNextMode": "このモードを使用できるようになりました。サイドバーのモードアイコンをクリックしてタブを切り替えてください。", + "done": "完了", + "goToMcp": "MCPタブに移動", + "goToModes": "モードタブに移動", + "moreInfoMcp": "{{name}} MCPドキュメントを表示", + "validationRequired": "{{paramName}}の値を入力してください", + "prerequisites": "前提条件" + }, + "sources": { + "title": "Marketplaceソースを設定", + "description": "Marketplaceアイテムを含むGitリポジトリを追加します。Marketplaceを閲覧する際にこれらのリポジトリが取得されます。", + "add": { + "title": "新しいソースを追加", + "urlPlaceholder": "GitリポジトリURL(例:https://github.com/username/repo)", + "urlFormats": "サポートされている形式:HTTPS(https://github.com/username/repo)、SSH(git@github.com:username/repo.git)、またはGitプロトコル(git://github.com/username/repo.git)", + "namePlaceholder": "表示名(最大20文字)", + "button": "ソースを追加" + }, + "current": { + "title": "現在のソース", + "empty": "ソースが設定されていません。開始するにはソースを追加してください。", + "refresh": "このソースを更新", + "remove": "ソースを削除" + }, + "errors": { + "emptyUrl": "URLを空にすることはできません", + "invalidUrl": "無効なURL形式", + "nonVisibleChars": "URLにスペース以外の見えない文字が含まれています", + "invalidGitUrl": "URLは有効なGitリポジトリURLである必要があります(例:https://github.com/username/repo)", + "duplicateUrl": "このURLは既にリストにあります(大文字小文字とスペースは無視されます)", + "nameTooLong": "名前は20文字以下である必要があります", + "nonVisibleCharsName": "名前にスペース以外の見えない文字が含まれています", + "duplicateName": "この名前は既に使用されています(大文字小文字とスペースは無視されます)", + "emojiName": "絵文字文字は表示の問題を引き起こす可能性があります", + "maxSources": "最大{{max}}個のソースが許可されています" + } + } +} diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 1a4bceeb5e..f164063fac 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -493,6 +493,10 @@ "name": "並行ファイル読み取りを有効にする", "description": "有効にすると、Rooは1回のリクエストで複数のファイル を読み取ることができます。無効にすると、Rooはファイルを1つずつ読み取る必要があります。能力の低いモデルで作業する場合や、ファイルアクセスをより細かく制御したい場合は、無効にすると役立ちます。" }, + "MARKETPLACE": { + "name": "Marketplaceを有効にする", + "description": "有効にすると、MarketplaceからMCPとカスタムモードをインストールできます。" + }, "DISABLE_COMPLETION_COMMAND": { "name": "attempt_completionでのコマンド実行を無効にする", "description": "有効にすると、attempt_completionツールはコマンドを実行しません。これは、タスク完了時のコマンド実行の非推奨化に備えるための実験的な機能です。" diff --git a/webview-ui/src/i18n/locales/ko/common.json b/webview-ui/src/i18n/locales/ko/common.json index 6996f6c387..01b01360e0 100644 --- a/webview-ui/src/i18n/locales/ko/common.json +++ b/webview-ui/src/i18n/locales/ko/common.json @@ -1,4 +1,11 @@ { + "answers": { + "yes": "예", + "no": "아니오", + "cancel": "취소", + "remove": "삭제", + "keep": "유지" + }, "number_format": { "thousand_suffix": "k", "million_suffix": "m", diff --git a/webview-ui/src/i18n/locales/ko/marketplace.json b/webview-ui/src/i18n/locales/ko/marketplace.json new file mode 100644 index 0000000000..36c4f30eba --- /dev/null +++ b/webview-ui/src/i18n/locales/ko/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "설치됨", + "settings": "설정", + "browse": "찾아보기" + }, + "done": "완료", + "refresh": "새로고침", + "filters": { + "search": { + "placeholder": "Marketplace 아이템 검색...", + "placeholderMcp": "MCP 검색...", + "placeholderMode": "모드 검색..." + }, + "type": { + "label": "유형별 필터:", + "all": "모든 유형", + "mode": "모드", + "mcpServer": "MCP 서버" + }, + "sort": { + "label": "정렬 기준:", + "name": "이름", + "author": "작성자", + "lastUpdated": "마지막 업데이트" + }, + "tags": { + "label": "태그별 필터:", + "clear": "태그 지우기", + "placeholder": "태그를 검색하고 선택하려면 입력하세요...", + "noResults": "일치하는 태그가 없습니다", + "selected": "선택된 태그 중 하나를 가진 아이템 표시", + "clickToFilter": "태그를 클릭하여 아이템 필터링" + }, + "none": "없음" + }, + "type-group": { + "modes": "모드", + "mcps": "MCP 서버" + }, + "items": { + "empty": { + "noItems": "Marketplace 아이템을 찾을 수 없습니다", + "withFilters": "필터를 조정해 보세요", + "noSources": "소스 탭에서 소스를 추가해 보세요", + "adjustFilters": "필터나 검색어를 조정해 보세요", + "clearAllFilters": "모든 필터 지우기" + }, + "count": "{{count}}개 아이템 발견", + "components": "{{count}}개 구성 요소", + "matched": "{{count}}개 일치", + "refresh": { + "button": "새로고침", + "refreshing": "새로고침 중...", + "mayTakeMoment": "잠시 시간이 걸릴 수 있습니다." + }, + "card": { + "by": "{{author}} 작성", + "from": "{{source}}에서", + "install": "설치", + "installProject": "설치", + "installGlobal": "설치 (전역)", + "remove": "삭제", + "removeProject": "삭제", + "removeGlobal": "삭제 (전역)", + "viewSource": "보기", + "viewOnSource": "{{source}}에서 보기", + "noWorkspaceTooltip": "Marketplace 아이템을 설치하려면 워크스페이스를 열어주세요", + "installed": "설치됨", + "removeProjectTooltip": "현재 프로젝트에서 삭제", + "removeGlobalTooltip": "전역 설정에서 삭제", + "actionsMenuLabel": "추가 작업" + } + }, + "install": { + "title": "{{name}} 설치", + "titleMode": "{{name}} 모드 설치", + "titleMcp": "{{name}} MCP 설치", + "scope": "설치 범위", + "project": "프로젝트 (현재 워크스페이스)", + "global": "전역 (모든 워크스페이스)", + "method": "설치 방법", + "configuration": "설정", + "configurationDescription": "이 MCP 서버에 필요한 매개변수를 설정하세요", + "button": "설치", + "successTitle": "{{name}} 설치됨", + "successDescription": "설치가 성공적으로 완료되었습니다", + "installed": "성공적으로 설치되었습니다!", + "whatNextMcp": "이제 이 MCP 서버를 설정하고 사용할 수 있습니다. 사이드바의 MCP 아이콘을 클릭하여 탭을 전환하세요.", + "whatNextMode": "이제 이 모드를 사용할 수 있습니다. 사이드바의 모드 아이콘을 클릭하여 탭을 전환하세요.", + "done": "완료", + "goToMcp": "MCP 탭으로 이동", + "goToModes": "모드 탭으로 이동", + "moreInfoMcp": "{{name}} MCP 문서 보기", + "validationRequired": "{{paramName}}에 대한 값을 입력해주세요", + "prerequisites": "전제 조건" + }, + "sources": { + "title": "Marketplace 소스 설정", + "description": "Marketplace 아이템이 포함된 Git 저장소를 추가하세요. Marketplace를 탐색할 때 이러한 저장소가 가져와집니다.", + "add": { + "title": "새 소스 추가", + "urlPlaceholder": "Git 저장소 URL (예: https://github.com/username/repo)", + "urlFormats": "지원되는 형식: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git) 또는 Git 프로토콜 (git://github.com/username/repo.git)", + "namePlaceholder": "표시 이름 (최대 20자)", + "button": "소스 추가" + }, + "current": { + "title": "현재 소스", + "empty": "설정된 소스가 없습니다. 시작하려면 소스를 추가하세요.", + "refresh": "이 소스 새로고침", + "remove": "소스 삭제" + }, + "errors": { + "emptyUrl": "URL은 비워둘 수 없습니다", + "invalidUrl": "잘못된 URL 형식", + "nonVisibleChars": "URL에 공백 외의 보이지 않는 문자가 포함되어 있습니다", + "invalidGitUrl": "URL은 유효한 Git 저장소 URL이어야 합니다 (예: https://github.com/username/repo)", + "duplicateUrl": "이 URL은 이미 목록에 있습니다 (대소문자 및 공백 무시됨)", + "nameTooLong": "이름은 20자 이하여야 합니다", + "nonVisibleCharsName": "이름에 공백 외의 보이지 않는 문자가 포함되어 있습니다", + "duplicateName": "이 이름은 이미 사용 중입니다 (대소문자 및 공백 무시됨)", + "emojiName": "이모지 문자는 표시 문제를 일으킬 수 있습니다", + "maxSources": "최대 {{max}}개의 소스가 허용됩니다" + } + } +} diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 7d06f801d2..8d311f8fe6 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -493,6 +493,10 @@ "name": "동시 파일 읽기 활성화", "description": "활성화하면 Roo가 한 번의 요청으로 여러 파일 을 읽을 수 있습니다. 비활성화하면 Roo는 파일을 하나씩 읽어야 합니다. 성능이 낮은 모델로 작업하거나 파일 액세스를 더 제어하려는 경우 비활성화하면 도움이 될 수 있습니다." }, + "MARKETPLACE": { + "name": "Marketplace 활성화", + "description": "활성화하면 Marketplace에서 MCP와 사용자 정의 모드를 설치할 수 있습니다." + }, "DISABLE_COMPLETION_COMMAND": { "name": "attempt_completion에서 명령 실행 비활성화", "description": "활성화하면 attempt_completion 도구가 명령을 실행하지 않습니다. 이는 작업 완료 시 명령 실행을 더 이상 사용하지 않도록 준비하기 위한 실험적 기능입니다." diff --git a/webview-ui/src/i18n/locales/nl/common.json b/webview-ui/src/i18n/locales/nl/common.json index 38259b02ff..59c175150b 100644 --- a/webview-ui/src/i18n/locales/nl/common.json +++ b/webview-ui/src/i18n/locales/nl/common.json @@ -1,4 +1,11 @@ { + "answers": { + "yes": "Ja", + "no": "Nee", + "cancel": "Annuleren", + "remove": "Verwijderen", + "keep": "Behouden" + }, "number_format": { "thousand_suffix": "k", "million_suffix": "m", diff --git a/webview-ui/src/i18n/locales/nl/marketplace.json b/webview-ui/src/i18n/locales/nl/marketplace.json new file mode 100644 index 0000000000..c61c084cc1 --- /dev/null +++ b/webview-ui/src/i18n/locales/nl/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "Geïnstalleerd", + "settings": "Instellingen", + "browse": "Bladeren" + }, + "done": "Gereed", + "refresh": "Vernieuwen", + "filters": { + "search": { + "placeholder": "Marketplace-items zoeken...", + "placeholderMcp": "MCP's zoeken...", + "placeholderMode": "Modi zoeken..." + }, + "type": { + "label": "Filteren op type:", + "all": "Alle types", + "mode": "Modus", + "mcpServer": "MCP-server" + }, + "sort": { + "label": "Sorteren op:", + "name": "Naam", + "author": "Auteur", + "lastUpdated": "Laatst bijgewerkt" + }, + "tags": { + "label": "Filteren op tags:", + "clear": "Tags wissen", + "placeholder": "Type om tags te zoeken en selecteren...", + "noResults": "Geen overeenkomende tags gevonden", + "selected": "Items tonen met een van de geselecteerde tags", + "clickToFilter": "Klik op tags om items te filteren" + }, + "none": "Geen" + }, + "type-group": { + "modes": "Modi", + "mcps": "MCP-servers" + }, + "items": { + "empty": { + "noItems": "Geen Marketplace-items gevonden", + "withFilters": "Probeer je filters aan te passen", + "noSources": "Probeer een bron toe te voegen in het Bronnen-tabblad", + "adjustFilters": "Probeer je filters of zoektermen aan te passen", + "clearAllFilters": "Alle filters wissen" + }, + "count": "{{count}} items gevonden", + "components": "{{count}} componenten", + "matched": "{{count}} gevonden", + "refresh": { + "button": "Vernieuwen", + "refreshing": "Vernieuwen...", + "mayTakeMoment": "Dit kan even duren." + }, + "card": { + "by": "door {{author}}", + "from": "van {{source}}", + "install": "Installeren", + "installProject": "Installeren", + "installGlobal": "Installeren (Globaal)", + "remove": "Verwijderen", + "removeProject": "Verwijderen", + "removeGlobal": "Verwijderen (Globaal)", + "viewSource": "Bekijken", + "viewOnSource": "Bekijken op {{source}}", + "noWorkspaceTooltip": "Open een werkruimte om Marketplace-items te installeren", + "installed": "Geïnstalleerd", + "removeProjectTooltip": "Verwijderen uit huidig project", + "removeGlobalTooltip": "Verwijderen uit globale configuratie", + "actionsMenuLabel": "Meer acties" + } + }, + "install": { + "title": "{{name}} installeren", + "titleMode": "{{name}} modus installeren", + "titleMcp": "{{name}} MCP installeren", + "scope": "Installatiebereik", + "project": "Project (huidige werkruimte)", + "global": "Globaal (alle werkruimtes)", + "method": "Installatiemethode", + "configuration": "Configuratie", + "configurationDescription": "Configureer de vereiste parameters voor deze MCP-server", + "button": "Installeren", + "successTitle": "{{name}} geïnstalleerd", + "successDescription": "Installatie succesvol voltooid", + "installed": "Succesvol geïnstalleerd!", + "whatNextMcp": "Je kunt deze MCP-server nu configureren en gebruiken. Klik op het MCP-pictogram in de zijbalk om van tabblad te wisselen.", + "whatNextMode": "Je kunt deze modus nu gebruiken. Klik op het modi-pictogram in de zijbalk om van tabblad te wisselen.", + "done": "Gereed", + "goToMcp": "Ga naar MCP-tabblad", + "goToModes": "Ga naar Modi-tabblad", + "moreInfoMcp": "{{name}} MCP-documentatie bekijken", + "validationRequired": "Geef een waarde op voor {{paramName}}", + "prerequisites": "Vereisten" + }, + "sources": { + "title": "Marketplace-bronnen configureren", + "description": "Voeg Git-repositories toe die Marketplace-items bevatten. Deze repositories worden opgehaald bij het bladeren door de Marketplace.", + "add": { + "title": "Nieuwe bron toevoegen", + "urlPlaceholder": "Git-repository URL (bijv. https://github.com/username/repo)", + "urlFormats": "Ondersteunde formaten: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git) of Git-protocol (git://github.com/username/repo.git)", + "namePlaceholder": "Weergavenaam (max 20 tekens)", + "button": "Bron toevoegen" + }, + "current": { + "title": "Huidige bronnen", + "empty": "Geen bronnen geconfigureerd. Voeg een bron toe om te beginnen.", + "refresh": "Deze bron vernieuwen", + "remove": "Bron verwijderen" + }, + "errors": { + "emptyUrl": "URL mag niet leeg zijn", + "invalidUrl": "Ongeldig URL-formaat", + "nonVisibleChars": "URL bevat onzichtbare tekens anders dan spaties", + "invalidGitUrl": "URL moet een geldige Git-repository URL zijn (bijv. https://github.com/username/repo)", + "duplicateUrl": "Deze URL staat al in de lijst (hoofdletters en spaties genegeerd)", + "nameTooLong": "Naam moet 20 tekens of minder zijn", + "nonVisibleCharsName": "Naam bevat onzichtbare tekens anders dan spaties", + "duplicateName": "Deze naam is al in gebruik (hoofdletters en spaties genegeerd)", + "emojiName": "Emoji-tekens kunnen weergaveproblemen veroorzaken", + "maxSources": "Maximaal {{max}} bronnen toegestaan" + } + } +} diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index b2283618c1..d234ff7e11 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -493,6 +493,10 @@ "name": "Gelijktijdig lezen van bestanden inschakelen", "description": "Wanneer ingeschakeld, kan Roo meerdere bestanden in één verzoek lezen. Wanneer uitgeschakeld, moet Roo bestanden één voor één lezen. Uitschakelen kan helpen bij het werken met minder capabele modellen of wanneer u meer controle over bestandstoegang wilt." }, + "MARKETPLACE": { + "name": "Marketplace inschakelen", + "description": "Wanneer ingeschakeld kun je MCP's en aangepaste modi uit de Marketplace installeren." + }, "DISABLE_COMPLETION_COMMAND": { "name": "Commando-uitvoering in attempt_completion uitschakelen", "description": "Indien ingeschakeld, zal de attempt_completion tool geen commando's uitvoeren. Dit is een experimentele functie ter voorbereiding op het afschaffen van commando-uitvoering bij taakvoltooiing." diff --git a/webview-ui/src/i18n/locales/pl/common.json b/webview-ui/src/i18n/locales/pl/common.json index cf2da432f8..a1b8bd645d 100644 --- a/webview-ui/src/i18n/locales/pl/common.json +++ b/webview-ui/src/i18n/locales/pl/common.json @@ -4,6 +4,13 @@ "million_suffix": "m", "billion_suffix": "b" }, + "answers": { + "yes": "Tak", + "no": "Nie", + "cancel": "Anuluj", + "remove": "Usuń", + "keep": "Zachowaj" + }, "ui": { "search_placeholder": "Szukaj..." }, diff --git a/webview-ui/src/i18n/locales/pl/marketplace.json b/webview-ui/src/i18n/locales/pl/marketplace.json new file mode 100644 index 0000000000..4f52fc7e18 --- /dev/null +++ b/webview-ui/src/i18n/locales/pl/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "Zainstalowane", + "settings": "Ustawienia", + "browse": "Przeglądaj" + }, + "done": "Gotowe", + "refresh": "Odśwież", + "filters": { + "search": { + "placeholder": "Szukaj elementów marketplace...", + "placeholderMcp": "Szukaj MCPs...", + "placeholderMode": "Szukaj trybów..." + }, + "type": { + "label": "Filtruj według typu:", + "all": "Wszystkie typy", + "mode": "Tryb", + "mcpServer": "Serwer MCP" + }, + "sort": { + "label": "Sortuj według:", + "name": "Nazwa", + "author": "Autor", + "lastUpdated": "Ostatnia aktualizacja" + }, + "tags": { + "label": "Filtruj według tagów:", + "clear": "Wyczyść tagi", + "placeholder": "Wpisz, aby wyszukać i wybrać tagi...", + "noResults": "Nie znaleziono pasujących tagów", + "selected": "Pokazywanie elementów z dowolnym z wybranych tagów", + "clickToFilter": "Kliknij tagi, aby filtrować elementy" + }, + "none": "Brak" + }, + "type-group": { + "modes": "Tryby", + "mcps": "Serwery MCP" + }, + "items": { + "empty": { + "noItems": "Nie znaleziono elementów marketplace", + "withFilters": "Spróbuj dostosować swoje filtry", + "noSources": "Spróbuj dodać źródło w zakładce Źródła", + "adjustFilters": "Spróbuj dostosować swoje filtry lub terminy wyszukiwania", + "clearAllFilters": "Wyczyść wszystkie filtry" + }, + "count": "Znaleziono {{count}} elementów", + "components": "{{count}} komponentów", + "matched": "{{count}} dopasowanych", + "refresh": { + "button": "Odśwież", + "refreshing": "Odświeżanie...", + "mayTakeMoment": "To może chwilę potrwać." + }, + "card": { + "by": "przez {{author}}", + "from": "z {{source}}", + "install": "Zainstaluj", + "installProject": "Zainstaluj", + "installGlobal": "Zainstaluj (Globalnie)", + "remove": "Usuń", + "removeProject": "Usuń", + "removeGlobal": "Usuń (Globalnie)", + "viewSource": "Zobacz", + "viewOnSource": "Zobacz na {{source}}", + "noWorkspaceTooltip": "Otwórz obszar roboczy, aby zainstalować elementy marketplace", + "installed": "Zainstalowane", + "removeProjectTooltip": "Usuń z bieżącego projektu", + "removeGlobalTooltip": "Usuń z konfiguracji globalnej", + "actionsMenuLabel": "Więcej akcji" + } + }, + "install": { + "title": "Zainstaluj {{name}}", + "titleMode": "Zainstaluj tryb {{name}}", + "titleMcp": "Zainstaluj MCP {{name}}", + "scope": "Zakres instalacji", + "project": "Projekt (bieżący obszar roboczy)", + "global": "Globalnie (wszystkie obszary robocze)", + "method": "Metoda instalacji", + "configuration": "Konfiguracja", + "configurationDescription": "Skonfiguruj parametry wymagane dla tego serwera MCP", + "button": "Zainstaluj", + "successTitle": "{{name}} zainstalowane", + "successDescription": "Instalacja zakończona pomyślnie", + "installed": "Zainstalowano pomyślnie!", + "whatNextMcp": "Możesz teraz skonfigurować i używać tego serwera MCP. Kliknij ikonę MCP na pasku bocznym, aby przełączyć zakładki.", + "whatNextMode": "Możesz teraz używać tego trybu. Kliknij ikonę Tryby na pasku bocznym, aby przełączyć zakładki.", + "done": "Gotowe", + "goToMcp": "Przejdź do zakładki MCP", + "goToModes": "Przejdź do zakładki Tryby", + "moreInfoMcp": "Zobacz dokumentację MCP {{name}}", + "validationRequired": "Podaj wartość dla {{paramName}}", + "prerequisites": "Wymagania wstępne" + }, + "sources": { + "title": "Konfiguruj źródła marketplace", + "description": "Dodaj repozytoria Git zawierające elementy marketplace. Te repozytoria będą pobierane podczas przeglądania marketplace.", + "add": { + "title": "Dodaj nowe źródło", + "urlPlaceholder": "URL repozytorium Git (np. https://github.com/username/repo)", + "urlFormats": "Obsługiwane formaty: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git) lub protokół Git (git://github.com/username/repo.git)", + "namePlaceholder": "Nazwa wyświetlana (maks. 20 znaków)", + "button": "Dodaj źródło" + }, + "current": { + "title": "Bieżące źródła", + "empty": "Brak skonfigurowanych źródeł. Dodaj źródło, aby rozpocząć.", + "refresh": "Odśwież to źródło", + "remove": "Usuń źródło" + }, + "errors": { + "emptyUrl": "URL nie może być pusty", + "invalidUrl": "Nieprawidłowy format URL", + "nonVisibleChars": "URL zawiera niewidoczne znaki inne niż spacje", + "invalidGitUrl": "URL musi być prawidłowym URL repozytorium Git (np. https://github.com/username/repo)", + "duplicateUrl": "Ten URL jest już na liście (dopasowanie bez uwzględniania wielkości liter i spacji)", + "nameTooLong": "Nazwa musi mieć 20 znaków lub mniej", + "nonVisibleCharsName": "Nazwa zawiera niewidoczne znaki inne niż spacje", + "duplicateName": "Ta nazwa jest już używana (dopasowanie bez uwzględniania wielkości liter i spacji)", + "emojiName": "Znaki emoji mogą powodować problemy z wyświetlaniem", + "maxSources": "Maksymalnie {{max}} źródeł dozwolonych" + } + } +} diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 7b926e0282..c9bc4ac1ab 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -496,6 +496,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "Wyłącz wykonywanie poleceń w attempt_completion", "description": "Gdy włączone, narzędzie attempt_completion nie będzie wykonywać poleceń. Jest to funkcja eksperymentalna przygotowująca do przyszłego wycofania wykonywania poleceń po zakończeniu zadania." + }, + "MARKETPLACE": { + "name": "Włącz Marketplace", + "description": "Gdy włączone, możesz instalować MCP i niestandardowe tryby z Marketplace." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pt-BR/common.json b/webview-ui/src/i18n/locales/pt-BR/common.json index dd89eee333..02bb8f1be6 100644 --- a/webview-ui/src/i18n/locales/pt-BR/common.json +++ b/webview-ui/src/i18n/locales/pt-BR/common.json @@ -4,6 +4,13 @@ "million_suffix": "m", "billion_suffix": "b" }, + "answers": { + "yes": "Sim", + "no": "Não", + "cancel": "Cancelar", + "remove": "Remover", + "keep": "Manter" + }, "ui": { "search_placeholder": "Pesquisar..." }, diff --git a/webview-ui/src/i18n/locales/pt-BR/marketplace.json b/webview-ui/src/i18n/locales/pt-BR/marketplace.json new file mode 100644 index 0000000000..e2aab3f06f --- /dev/null +++ b/webview-ui/src/i18n/locales/pt-BR/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "Instalado", + "settings": "Configurações", + "browse": "Navegar" + }, + "done": "Concluído", + "refresh": "Atualizar", + "filters": { + "search": { + "placeholder": "Buscar itens do marketplace...", + "placeholderMcp": "Buscar MCPs...", + "placeholderMode": "Buscar modos..." + }, + "type": { + "label": "Filtrar por tipo:", + "all": "Todos os tipos", + "mode": "Modo", + "mcpServer": "Servidor MCP" + }, + "sort": { + "label": "Ordenar por:", + "name": "Nome", + "author": "Autor", + "lastUpdated": "Última atualização" + }, + "tags": { + "label": "Filtrar por tags:", + "clear": "Limpar tags", + "placeholder": "Digite para buscar e selecionar tags...", + "noResults": "Nenhuma tag correspondente encontrada", + "selected": "Mostrando itens com qualquer uma das tags selecionadas", + "clickToFilter": "Clique nas tags para filtrar itens" + }, + "none": "Nenhum" + }, + "type-group": { + "modes": "Modos", + "mcps": "Servidores MCP" + }, + "items": { + "empty": { + "noItems": "Nenhum item do marketplace encontrado", + "withFilters": "Tente ajustar seus filtros", + "noSources": "Tente adicionar uma fonte na aba Fontes", + "adjustFilters": "Tente ajustar seus filtros ou termos de busca", + "clearAllFilters": "Limpar todos os filtros" + }, + "count": "{{count}} itens encontrados", + "components": "{{count}} componentes", + "matched": "{{count}} correspondentes", + "refresh": { + "button": "Atualizar", + "refreshing": "Atualizando...", + "mayTakeMoment": "Isso pode levar um momento." + }, + "card": { + "by": "por {{author}}", + "from": "de {{source}}", + "install": "Instalar", + "installProject": "Instalar", + "installGlobal": "Instalar (Global)", + "remove": "Remover", + "removeProject": "Remover", + "removeGlobal": "Remover (Global)", + "viewSource": "Ver", + "viewOnSource": "Ver em {{source}}", + "noWorkspaceTooltip": "Abra um workspace para instalar itens do marketplace", + "installed": "Instalado", + "removeProjectTooltip": "Remover do projeto atual", + "removeGlobalTooltip": "Remover da configuração global", + "actionsMenuLabel": "Mais ações" + } + }, + "install": { + "title": "Instalar {{name}}", + "titleMode": "Instalar modo {{name}}", + "titleMcp": "Instalar MCP {{name}}", + "scope": "Escopo da instalação", + "project": "Projeto (workspace atual)", + "global": "Global (todos os workspaces)", + "method": "Método de instalação", + "configuration": "Configuração", + "configurationDescription": "Configure os parâmetros necessários para este servidor MCP", + "button": "Instalar", + "successTitle": "{{name}} instalado", + "successDescription": "Instalação concluída com sucesso", + "installed": "Instalado com sucesso!", + "whatNextMcp": "Agora você pode configurar e usar este servidor MCP. Clique no ícone MCP na barra lateral para trocar de aba.", + "whatNextMode": "Agora você pode usar este modo. Clique no ícone Modos na barra lateral para trocar de aba.", + "done": "Concluído", + "goToMcp": "Ir para aba MCP", + "goToModes": "Ir para aba Modos", + "moreInfoMcp": "Ver documentação MCP do {{name}}", + "validationRequired": "Por favor, forneça um valor para {{paramName}}", + "prerequisites": "Pré-requisitos" + }, + "sources": { + "title": "Configurar fontes do marketplace", + "description": "Adicione repositórios Git que contenham itens do marketplace. Estes repositórios serão buscados ao navegar pelo marketplace.", + "add": { + "title": "Adicionar nova fonte", + "urlPlaceholder": "URL do repositório Git (ex: https://github.com/username/repo)", + "urlFormats": "Formatos suportados: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git), ou protocolo Git (git://github.com/username/repo.git)", + "namePlaceholder": "Nome de exibição (máx. 20 caracteres)", + "button": "Adicionar fonte" + }, + "current": { + "title": "Fontes atuais", + "empty": "Nenhuma fonte configurada. Adicione uma fonte para começar.", + "refresh": "Atualizar esta fonte", + "remove": "Remover fonte" + }, + "errors": { + "emptyUrl": "URL não pode estar vazia", + "invalidUrl": "Formato de URL inválido", + "nonVisibleChars": "URL contém caracteres não visíveis além de espaços", + "invalidGitUrl": "URL deve ser uma URL de repositório Git válida (ex: https://github.com/username/repo)", + "duplicateUrl": "Esta URL já está na lista (correspondência insensível a maiúsculas e espaços)", + "nameTooLong": "Nome deve ter 20 caracteres ou menos", + "nonVisibleCharsName": "Nome contém caracteres não visíveis além de espaços", + "duplicateName": "Este nome já está em uso (correspondência insensível a maiúsculas e espaços)", + "emojiName": "Caracteres emoji podem causar problemas de exibição", + "maxSources": "Máximo de {{max}} fontes permitidas" + } + } +} diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index b6755dd9a4..b2ab1756c3 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -496,6 +496,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "Desativar execução de comando em attempt_completion", "description": "Quando ativado, a ferramenta attempt_completion não executará comandos. Este é um recurso experimental para preparar a futura descontinuação da execução de comandos na conclusão da tarefa." + }, + "MARKETPLACE": { + "name": "Ativar Marketplace", + "description": "Quando ativado, você pode instalar MCPs e modos personalizados do Marketplace." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ru/common.json b/webview-ui/src/i18n/locales/ru/common.json index 8327762467..87f2adcbfb 100644 --- a/webview-ui/src/i18n/locales/ru/common.json +++ b/webview-ui/src/i18n/locales/ru/common.json @@ -4,6 +4,13 @@ "million_suffix": "млн", "billion_suffix": "млрд" }, + "answers": { + "yes": "Да", + "no": "Нет", + "cancel": "Отмена", + "remove": "Удалить", + "keep": "Оставить" + }, "ui": { "search_placeholder": "Поиск..." }, diff --git a/webview-ui/src/i18n/locales/ru/marketplace.json b/webview-ui/src/i18n/locales/ru/marketplace.json new file mode 100644 index 0000000000..f68fe9c23b --- /dev/null +++ b/webview-ui/src/i18n/locales/ru/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "Установлено", + "settings": "Настройки", + "browse": "Обзор" + }, + "done": "Готово", + "refresh": "Обновить", + "filters": { + "search": { + "placeholder": "Поиск элементов marketplace...", + "placeholderMcp": "Поиск MCPs...", + "placeholderMode": "Поиск режимов..." + }, + "type": { + "label": "Фильтр по типу:", + "all": "Все типы", + "mode": "Режим", + "mcpServer": "MCP сервер" + }, + "sort": { + "label": "Сортировать по:", + "name": "Название", + "author": "Автор", + "lastUpdated": "Последнее обновление" + }, + "tags": { + "label": "Фильтр по тегам:", + "clear": "Очистить теги", + "placeholder": "Введите для поиска и выбора тегов...", + "noResults": "Подходящие теги не найдены", + "selected": "Показаны элементы с любым из выбранных тегов", + "clickToFilter": "Нажмите на теги для фильтрации элементов" + }, + "none": "Нет" + }, + "type-group": { + "modes": "Режимы", + "mcps": "MCP серверы" + }, + "items": { + "empty": { + "noItems": "Элементы marketplace не найдены", + "withFilters": "Попробуйте настроить фильтры", + "noSources": "Попробуйте добавить источник во вкладке Источники", + "adjustFilters": "Попробуйте настроить фильтры или поисковые запросы", + "clearAllFilters": "Очистить все фильтры" + }, + "count": "Найдено {{count}} элементов", + "components": "{{count}} компонентов", + "matched": "{{count}} совпадений", + "refresh": { + "button": "Обновить", + "refreshing": "Обновление...", + "mayTakeMoment": "Это может занять некоторое время." + }, + "card": { + "by": "от {{author}}", + "from": "из {{source}}", + "install": "Установить", + "installProject": "Установить", + "installGlobal": "Установить (Глобально)", + "remove": "Удалить", + "removeProject": "Удалить", + "removeGlobal": "Удалить (Глобально)", + "viewSource": "Просмотр", + "viewOnSource": "Просмотр на {{source}}", + "noWorkspaceTooltip": "Откройте рабочую область для установки элементов marketplace", + "installed": "Установлено", + "removeProjectTooltip": "Удалить из текущего проекта", + "removeGlobalTooltip": "Удалить из глобальной конфигурации", + "actionsMenuLabel": "Дополнительные действия" + } + }, + "install": { + "title": "Установить {{name}}", + "titleMode": "Установить режим {{name}}", + "titleMcp": "Установить MCP {{name}}", + "scope": "Область установки", + "project": "Проект (текущая рабочая область)", + "global": "Глобально (все рабочие области)", + "method": "Метод установки", + "configuration": "Конфигурация", + "configurationDescription": "Настройте параметры, необходимые для этого MCP сервера", + "button": "Установить", + "successTitle": "{{name}} установлен", + "successDescription": "Установка успешно завершена", + "installed": "Успешно установлено!", + "whatNextMcp": "Теперь вы можете настроить и использовать этот MCP сервер. Нажмите на иконку MCP в боковой панели для переключения вкладок.", + "whatNextMode": "Теперь вы можете использовать этот режим. Нажмите на иконку Режимы в боковой панели для переключения вкладок.", + "done": "Готово", + "goToMcp": "Перейти во вкладку MCP", + "goToModes": "Перейти во вкладку Режимы", + "moreInfoMcp": "Просмотреть документацию MCP {{name}}", + "validationRequired": "Пожалуйста, укажите значение для {{paramName}}", + "prerequisites": "Предварительные требования" + }, + "sources": { + "title": "Настроить источники marketplace", + "description": "Добавьте Git репозитории, содержащие элементы marketplace. Эти репозитории будут загружены при просмотре marketplace.", + "add": { + "title": "Добавить новый источник", + "urlPlaceholder": "URL Git репозитория (например, https://github.com/username/repo)", + "urlFormats": "Поддерживаемые форматы: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git), или Git протокол (git://github.com/username/repo.git)", + "namePlaceholder": "Отображаемое имя (макс. 20 символов)", + "button": "Добавить источник" + }, + "current": { + "title": "Текущие источники", + "empty": "Источники не настроены. Добавьте источник для начала работы.", + "refresh": "Обновить этот источник", + "remove": "Удалить источник" + }, + "errors": { + "emptyUrl": "URL не может быть пустым", + "invalidUrl": "Неверный формат URL", + "nonVisibleChars": "URL содержит невидимые символы кроме пробелов", + "invalidGitUrl": "URL должен быть действительным URL Git репозитория (например, https://github.com/username/repo)", + "duplicateUrl": "Этот URL уже есть в списке (сравнение без учета регистра и пробелов)", + "nameTooLong": "Имя должно содержать 20 символов или меньше", + "nonVisibleCharsName": "Имя содержит невидимые символы кроме пробелов", + "duplicateName": "Это имя уже используется (сравнение без учета регистра и пробелов)", + "emojiName": "Символы эмодзи могут вызвать проблемы с отображением", + "maxSources": "Максимум {{max}} источников разрешено" + } + } +} diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index d0bc0e33db..1daf624a71 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -496,6 +496,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "Отключить выполнение команд в attempt_completion", "description": "Если включено, инструмент attempt_completion не будет выполнять команды. Это экспериментальная функция для подготовки к будущему прекращению поддержки выполнения команд при завершении задачи." + }, + "MARKETPLACE": { + "name": "Включить Marketplace", + "description": "Когда включено, вы можете устанавливать MCP и пользовательские режимы из Marketplace." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/tr/common.json b/webview-ui/src/i18n/locales/tr/common.json index a66d24e02f..e3180989cd 100644 --- a/webview-ui/src/i18n/locales/tr/common.json +++ b/webview-ui/src/i18n/locales/tr/common.json @@ -1,4 +1,11 @@ { + "answers": { + "yes": "Evet", + "no": "Hayır", + "cancel": "İptal", + "remove": "Kaldır", + "keep": "Tut" + }, "number_format": { "thousand_suffix": "k", "million_suffix": "m", diff --git a/webview-ui/src/i18n/locales/tr/marketplace.json b/webview-ui/src/i18n/locales/tr/marketplace.json new file mode 100644 index 0000000000..d4d7d6d1c9 --- /dev/null +++ b/webview-ui/src/i18n/locales/tr/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "Yüklü", + "settings": "Ayarlar", + "browse": "Gözat" + }, + "done": "Tamamlandı", + "refresh": "Yenile", + "filters": { + "search": { + "placeholder": "Marketplace öğelerini ara...", + "placeholderMcp": "MCP'leri ara...", + "placeholderMode": "Modları ara..." + }, + "type": { + "label": "Türe göre filtrele:", + "all": "Tüm türler", + "mode": "Mod", + "mcpServer": "MCP Sunucusu" + }, + "sort": { + "label": "Sırala:", + "name": "İsim", + "author": "Yazar", + "lastUpdated": "Son Güncelleme" + }, + "tags": { + "label": "Etiketlere göre filtrele:", + "clear": "Etiketleri temizle", + "placeholder": "Etiket aramak ve seçmek için yazın...", + "noResults": "Eşleşen etiket bulunamadı", + "selected": "Seçilen etiketlerden herhangi birine sahip öğeler gösteriliyor", + "clickToFilter": "Öğeleri filtrelemek için etiketlere tıklayın" + }, + "none": "Hiçbiri" + }, + "type-group": { + "modes": "Modlar", + "mcps": "MCP Sunucuları" + }, + "items": { + "empty": { + "noItems": "Marketplace öğesi bulunamadı", + "withFilters": "Filtrelerinizi ayarlamayı deneyin", + "noSources": "Kaynaklar sekmesinde kaynak eklemeyi deneyin", + "adjustFilters": "Filtrelerinizi veya arama terimlerinizi ayarlamayı deneyin", + "clearAllFilters": "Tüm filtreleri temizle" + }, + "count": "{{count}} öğe bulundu", + "components": "{{count}} bileşen", + "matched": "{{count}} eşleşti", + "refresh": { + "button": "Yenile", + "refreshing": "Yenileniyor...", + "mayTakeMoment": "Bu biraz zaman alabilir." + }, + "card": { + "by": "{{author}} tarafından", + "from": "{{source}} kaynağından", + "install": "Yükle", + "installProject": "Yükle", + "installGlobal": "Yükle (Global)", + "remove": "Kaldır", + "removeProject": "Kaldır", + "removeGlobal": "Kaldır (Global)", + "viewSource": "Görüntüle", + "viewOnSource": "{{source}} üzerinde görüntüle", + "noWorkspaceTooltip": "Marketplace öğelerini yüklemek için bir çalışma alanı açın", + "installed": "Yüklü", + "removeProjectTooltip": "Mevcut projeden kaldır", + "removeGlobalTooltip": "Global yapılandırmadan kaldır", + "actionsMenuLabel": "Daha fazla eylem" + } + }, + "install": { + "title": "{{name}} Yükle", + "titleMode": "{{name}} Modunu Yükle", + "titleMcp": "{{name}} MCP'sini Yükle", + "scope": "Yükleme Kapsamı", + "project": "Proje (mevcut çalışma alanı)", + "global": "Global (tüm çalışma alanları)", + "method": "Yükleme Yöntemi", + "configuration": "Yapılandırma", + "configurationDescription": "Bu MCP sunucusu için gerekli parametreleri yapılandırın", + "button": "Yükle", + "successTitle": "{{name}} Yüklendi", + "successDescription": "Yükleme başarıyla tamamlandı", + "installed": "Başarıyla yüklendi!", + "whatNextMcp": "Artık bu MCP sunucusunu yapılandırabilir ve kullanabilirsiniz. Sekmeleri değiştirmek için kenar çubuğundaki MCP simgesine tıklayın.", + "whatNextMode": "Artık bu modu kullanabilirsiniz. Sekmeleri değiştirmek için kenar çubuğundaki Modlar simgesine tıklayın.", + "done": "Tamamlandı", + "goToMcp": "MCP Sekmesine Git", + "goToModes": "Modlar Sekmesine Git", + "moreInfoMcp": "{{name}} MCP belgelerini görüntüle", + "validationRequired": "Lütfen {{paramName}} için bir değer sağlayın", + "prerequisites": "Ön koşullar" + }, + "sources": { + "title": "Marketplace Kaynaklarını Yapılandır", + "description": "Marketplace öğeleri içeren Git depolarını ekleyin. Bu depolar marketplace'e göz atarken getirilecektir.", + "add": { + "title": "Yeni Kaynak Ekle", + "urlPlaceholder": "Git deposu URL'si (örn., https://github.com/username/repo)", + "urlFormats": "Desteklenen formatlar: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git), veya Git protokolü (git://github.com/username/repo.git)", + "namePlaceholder": "Görüntüleme adı (maks 20 karakter)", + "button": "Kaynak Ekle" + }, + "current": { + "title": "Mevcut Kaynaklar", + "empty": "Yapılandırılmış kaynak yok. Başlamak için bir kaynak ekleyin.", + "refresh": "Bu kaynağı yenile", + "remove": "Kaynağı kaldır" + }, + "errors": { + "emptyUrl": "URL boş olamaz", + "invalidUrl": "Geçersiz URL formatı", + "nonVisibleChars": "URL boşluklar dışında görünmeyen karakterler içeriyor", + "invalidGitUrl": "URL geçerli bir Git deposu URL'si olmalıdır (örn., https://github.com/username/repo)", + "duplicateUrl": "Bu URL zaten listede var (büyük/küçük harf ve boşluk duyarsız eşleşme)", + "nameTooLong": "İsim 20 karakter veya daha az olmalıdır", + "nonVisibleCharsName": "İsim boşluklar dışında görünmeyen karakterler içeriyor", + "duplicateName": "Bu isim zaten kullanılıyor (büyük/küçük harf ve boşluk duyarsız eşleşme)", + "emojiName": "Emoji karakterleri görüntüleme sorunlarına neden olabilir", + "maxSources": "Maksimum {{max}} kaynak izin verilir" + } + } +} diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 84db07f03d..8638045511 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -493,6 +493,10 @@ "name": "Eşzamanlı dosya okumayı etkinleştir", "description": "Etkinleştirildiğinde, Roo tek bir istekte birden fazla dosya okuyabilir. Devre dışı bırakıldığında, Roo dosyaları birer birer okumalıdır. Daha az yetenekli modellerle çalışırken veya dosya erişimi üzerinde daha fazla kontrol istediğinizde devre dışı bırakmak yardımcı olabilir." }, + "MARKETPLACE": { + "name": "Marketplace'i Etkinleştir", + "description": "Etkinleştirildiğinde, Marketplace'ten MCP'leri ve özel modları yükleyebilirsiniz." + }, "DISABLE_COMPLETION_COMMAND": { "name": "attempt_completion'da komut yürütmeyi devre dışı bırak", "description": "Etkinleştirildiğinde, attempt_completion aracı komutları yürütmez. Bu, görev tamamlandığında komut yürütmenin kullanımdan kaldırılmasına hazırlanmak için deneysel bir özelliktir." diff --git a/webview-ui/src/i18n/locales/vi/common.json b/webview-ui/src/i18n/locales/vi/common.json index 19f8b8b801..cce43466d6 100644 --- a/webview-ui/src/i18n/locales/vi/common.json +++ b/webview-ui/src/i18n/locales/vi/common.json @@ -1,4 +1,11 @@ { + "answers": { + "yes": "Có", + "no": "Không", + "cancel": "Hủy", + "remove": "Xóa", + "keep": "Giữ" + }, "number_format": { "thousand_suffix": "k", "million_suffix": "m", diff --git a/webview-ui/src/i18n/locales/vi/marketplace.json b/webview-ui/src/i18n/locales/vi/marketplace.json new file mode 100644 index 0000000000..ea5abe9bd1 --- /dev/null +++ b/webview-ui/src/i18n/locales/vi/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "Đã cài đặt", + "settings": "Cài đặt", + "browse": "Duyệt" + }, + "done": "Hoàn thành", + "refresh": "Làm mới", + "filters": { + "search": { + "placeholder": "Tìm kiếm các mục marketplace...", + "placeholderMcp": "Tìm kiếm MCP...", + "placeholderMode": "Tìm kiếm Chế độ..." + }, + "type": { + "label": "Lọc theo loại:", + "all": "Tất cả loại", + "mode": "Chế độ", + "mcpServer": "Máy chủ MCP" + }, + "sort": { + "label": "Sắp xếp theo:", + "name": "Tên", + "author": "Tác giả", + "lastUpdated": "Cập nhật lần cuối" + }, + "tags": { + "label": "Lọc theo thẻ:", + "clear": "Xóa thẻ", + "placeholder": "Nhập để tìm kiếm và chọn thẻ...", + "noResults": "Không tìm thấy thẻ phù hợp", + "selected": "Hiển thị các mục có bất kỳ thẻ nào được chọn", + "clickToFilter": "Nhấp vào thẻ để lọc mục" + }, + "none": "Không có" + }, + "type-group": { + "modes": "Chế độ", + "mcps": "Máy chủ MCP" + }, + "items": { + "empty": { + "noItems": "Không tìm thấy mục marketplace nào", + "withFilters": "Thử điều chỉnh bộ lọc của bạn", + "noSources": "Thử thêm nguồn trong tab Nguồn", + "adjustFilters": "Thử điều chỉnh bộ lọc hoặc từ khóa tìm kiếm của bạn", + "clearAllFilters": "Xóa tất cả bộ lọc" + }, + "count": "Tìm thấy {{count}} mục", + "components": "{{count}} thành phần", + "matched": "{{count}} khớp", + "refresh": { + "button": "Làm mới", + "refreshing": "Đang làm mới...", + "mayTakeMoment": "Điều này có thể mất một chút thời gian." + }, + "card": { + "by": "bởi {{author}}", + "from": "từ {{source}}", + "install": "Cài đặt", + "installProject": "Cài đặt", + "installGlobal": "Cài đặt (Toàn cục)", + "remove": "Xóa", + "removeProject": "Xóa", + "removeGlobal": "Xóa (Toàn cục)", + "viewSource": "Xem", + "viewOnSource": "Xem trên {{source}}", + "noWorkspaceTooltip": "Mở không gian làm việc để cài đặt các mục marketplace", + "installed": "Đã cài đặt", + "removeProjectTooltip": "Xóa khỏi dự án hiện tại", + "removeGlobalTooltip": "Xóa khỏi cấu hình toàn cục", + "actionsMenuLabel": "Thêm hành động" + } + }, + "install": { + "title": "Cài đặt {{name}}", + "titleMode": "Cài đặt Chế độ {{name}}", + "titleMcp": "Cài đặt MCP {{name}}", + "scope": "Phạm vi cài đặt", + "project": "Dự án (không gian làm việc hiện tại)", + "global": "Toàn cục (tất cả không gian làm việc)", + "method": "Phương thức cài đặt", + "configuration": "Cấu hình", + "configurationDescription": "Cấu hình các tham số cần thiết cho máy chủ MCP này", + "button": "Cài đặt", + "successTitle": "{{name}} đã được cài đặt", + "successDescription": "Cài đặt hoàn tất thành công", + "installed": "Cài đặt thành công!", + "whatNextMcp": "Bây giờ bạn có thể cấu hình và sử dụng máy chủ MCP này. Nhấp vào biểu tượng MCP trong thanh bên để chuyển tab.", + "whatNextMode": "Bây giờ bạn có thể sử dụng chế độ này. Nhấp vào biểu tượng Chế độ trong thanh bên để chuyển tab.", + "done": "Hoàn thành", + "goToMcp": "Đi đến Tab MCP", + "goToModes": "Đi đến Tab Chế độ", + "moreInfoMcp": "Xem tài liệu MCP {{name}}", + "validationRequired": "Vui lòng cung cấp giá trị cho {{paramName}}", + "prerequisites": "Điều kiện tiên quyết" + }, + "sources": { + "title": "Cấu hình Nguồn Marketplace", + "description": "Thêm các kho Git chứa các mục marketplace. Các kho này sẽ được tải khi duyệt marketplace.", + "add": { + "title": "Thêm Nguồn Mới", + "urlPlaceholder": "URL kho Git (ví dụ: https://github.com/username/repo)", + "urlFormats": "Định dạng được hỗ trợ: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git), hoặc giao thức Git (git://github.com/username/repo.git)", + "namePlaceholder": "Tên hiển thị (tối đa 20 ký tự)", + "button": "Thêm Nguồn" + }, + "current": { + "title": "Nguồn Hiện tại", + "empty": "Chưa có nguồn nào được cấu hình. Thêm nguồn để bắt đầu.", + "refresh": "Làm mới nguồn này", + "remove": "Xóa nguồn" + }, + "errors": { + "emptyUrl": "URL không được để trống", + "invalidUrl": "Định dạng URL không hợp lệ", + "nonVisibleChars": "URL chứa các ký tự không hiển thị khác ngoài khoảng trắng", + "invalidGitUrl": "URL phải là URL kho Git hợp lệ (ví dụ: https://github.com/username/repo)", + "duplicateUrl": "URL này đã có trong danh sách (khớp không phân biệt chữ hoa thường và khoảng trắng)", + "nameTooLong": "Tên phải có 20 ký tự hoặc ít hơn", + "nonVisibleCharsName": "Tên chứa các ký tự không hiển thị khác ngoài khoảng trắng", + "duplicateName": "Tên này đã được sử dụng (khớp không phân biệt chữ hoa thường và khoảng trắng)", + "emojiName": "Ký tự emoji có thể gây ra vấn đề hiển thị", + "maxSources": "Tối đa {{max}} nguồn được phép" + } + } +} diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index a04a11fb0b..edc85033fb 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -493,6 +493,10 @@ "name": "Bật đọc tệp đồng thời", "description": "Khi bật, Roo có thể đọc nhiều tệp trong một yêu cầu duy nhất. Khi tắt, Roo phải đọc từng tệp một. Việc tắt có thể hữu ích khi làm việc với các mô hình ít khả năng hơn hoặc khi bạn muốn kiểm soát nhiều hơn quyền truy cập tệp." }, + "MARKETPLACE": { + "name": "Bật Marketplace", + "description": "Khi được bật, bạn có thể cài đặt MCP và chế độ tùy chỉnh từ Marketplace." + }, "DISABLE_COMPLETION_COMMAND": { "name": "Tắt thực thi lệnh trong attempt_completion", "description": "Khi được bật, công cụ attempt_completion sẽ không thực thi lệnh. Đây là một tính năng thử nghiệm để chuẩn bị cho việc ngừng hỗ trợ thực thi lệnh khi hoàn thành tác vụ trong tương lai." diff --git a/webview-ui/src/i18n/locales/zh-CN/common.json b/webview-ui/src/i18n/locales/zh-CN/common.json index adaa86ec70..61a201d680 100644 --- a/webview-ui/src/i18n/locales/zh-CN/common.json +++ b/webview-ui/src/i18n/locales/zh-CN/common.json @@ -1,4 +1,11 @@ { + "answers": { + "yes": "是", + "no": "否", + "cancel": "取消", + "remove": "移除", + "keep": "保留" + }, "number_format": { "thousand_suffix": "k", "million_suffix": "m", diff --git a/webview-ui/src/i18n/locales/zh-CN/marketplace.json b/webview-ui/src/i18n/locales/zh-CN/marketplace.json new file mode 100644 index 0000000000..470486ac21 --- /dev/null +++ b/webview-ui/src/i18n/locales/zh-CN/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "已安装", + "settings": "设置", + "browse": "浏览" + }, + "done": "完成", + "refresh": "刷新", + "filters": { + "search": { + "placeholder": "搜索 Marketplace 项目...", + "placeholderMcp": "搜索 MCP...", + "placeholderMode": "搜索模式..." + }, + "type": { + "label": "按类型筛选:", + "all": "所有类型", + "mode": "模式", + "mcpServer": "MCP 服务" + }, + "sort": { + "label": "排序方式:", + "name": "名称", + "author": "作者", + "lastUpdated": "最后更新" + }, + "tags": { + "label": "按标签筛选:", + "clear": "清除标签", + "placeholder": "输入搜索和选择标签...", + "noResults": "未找到匹配的标签", + "selected": "显示包含任何选定标签的项目", + "clickToFilter": "点击标签筛选项目" + }, + "none": "无" + }, + "type-group": { + "modes": "模式", + "mcps": "MCP 服务" + }, + "items": { + "empty": { + "noItems": "未找到 Marketplace 项目", + "withFilters": "尝试调整筛选条件", + "noSources": "尝试在源标签页中添加源", + "adjustFilters": "尝试调整筛选条件或搜索词", + "clearAllFilters": "清除所有筛选条件" + }, + "count": "找到 {{count}} 个项目", + "components": "{{count}} 个组件", + "matched": "{{count}} 个匹配", + "refresh": { + "button": "刷新", + "refreshing": "刷新中...", + "mayTakeMoment": "这可能需要一些时间。" + }, + "card": { + "by": "作者:{{author}}", + "from": "来源:{{source}}", + "install": "安装", + "installProject": "安装", + "installGlobal": "安装(全局)", + "remove": "移除", + "removeProject": "移除", + "removeGlobal": "移除(全局)", + "viewSource": "查看", + "viewOnSource": "在 {{source}} 上查看", + "noWorkspaceTooltip": "打开工作区以安装 Marketplace 项目", + "installed": "已安装", + "removeProjectTooltip": "从当前项目中移除", + "removeGlobalTooltip": "从全局配置中移除", + "actionsMenuLabel": "更多操作" + } + }, + "install": { + "title": "安装 {{name}}", + "titleMode": "安装 {{name}} 模式", + "titleMcp": "安装 {{name}} MCP", + "scope": "安装范围", + "project": "项目(当前工作区)", + "global": "全局(所有工作区)", + "method": "安装方法", + "configuration": "配置", + "configurationDescription": "配置此 MCP 服务所需的参数", + "button": "安装", + "successTitle": "{{name}} 已安装", + "successDescription": "安装成功完成", + "installed": "安装成功!", + "whatNextMcp": "现在您可以配置和使用此 MCP 服务。点击侧边栏中的 MCP 图标切换标签页。", + "whatNextMode": "现在您可以使用此模式。点击侧边栏中的模式图标切换标签页。", + "done": "完成", + "goToMcp": "转到 MCP 标签页", + "goToModes": "转到模式标签页", + "moreInfoMcp": "查看 {{name}} MCP 文档", + "validationRequired": "请为 {{paramName}} 提供值", + "prerequisites": "前置条件" + }, + "sources": { + "title": "配置 Marketplace 源", + "description": "添加包含 Marketplace 项目的 Git 仓库。浏览 Marketplace 时将获取这些仓库。", + "add": { + "title": "添加新源", + "urlPlaceholder": "Git 仓库 URL(例如:https://github.com/username/repo)", + "urlFormats": "支持的格式:HTTPS(https://github.com/username/repo)、SSH(git@github.com:username/repo.git)或 Git 协议(git://github.com/username/repo.git)", + "namePlaceholder": "显示名称(最多 20 个字符)", + "button": "添加源" + }, + "current": { + "title": "当前源", + "empty": "未配置源。添加源以开始使用。", + "refresh": "刷新此源", + "remove": "移除源" + }, + "errors": { + "emptyUrl": "URL 不能为空", + "invalidUrl": "无效的 URL 格式", + "nonVisibleChars": "URL 包含除空格外的不可见字符", + "invalidGitUrl": "URL 必须是有效的 Git 仓库 URL(例如:https://github.com/username/repo)", + "duplicateUrl": "此 URL 已在列表中(不区分大小写和空格的匹配)", + "nameTooLong": "名称必须为 20 个字符或更少", + "nonVisibleCharsName": "名称包含除空格外的不可见字符", + "duplicateName": "此名称已被使用(不区分大小写和空格的匹配)", + "emojiName": "表情符号字符可能导致显示问题", + "maxSources": "最多允许 {{max}} 个源" + } + } +} diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 6ea9512be3..9e422f9a58 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -493,6 +493,10 @@ "name": "启用并发文件读取", "description": "启用后,Roo 可以在单个请求中读取多个文件。禁用后,Roo 必须逐个读取文件。在使用能力较弱的模型或希望对文件访问有更多控制时,禁用此功能可能会有所帮助。" }, + "MARKETPLACE": { + "name": "启用 Marketplace", + "description": "启用后,你可以从 Marketplace 安装 MCP 和自定义模式。" + }, "DISABLE_COMPLETION_COMMAND": { "name": "禁用 attempt_completion 中的命令执行", "description": "启用后,attempt_completion 工具将不会执行命令。这是一项实验性功能,旨在为将来弃用任务完成时的命令执行做准备。" diff --git a/webview-ui/src/i18n/locales/zh-TW/common.json b/webview-ui/src/i18n/locales/zh-TW/common.json index 12c42220dd..4daad76112 100644 --- a/webview-ui/src/i18n/locales/zh-TW/common.json +++ b/webview-ui/src/i18n/locales/zh-TW/common.json @@ -1,4 +1,11 @@ { + "answers": { + "yes": "是", + "no": "否", + "cancel": "取消", + "remove": "移除", + "keep": "保留" + }, "number_format": { "thousand_suffix": "k", "million_suffix": "m", diff --git a/webview-ui/src/i18n/locales/zh-TW/marketplace.json b/webview-ui/src/i18n/locales/zh-TW/marketplace.json new file mode 100644 index 0000000000..cffceb732a --- /dev/null +++ b/webview-ui/src/i18n/locales/zh-TW/marketplace.json @@ -0,0 +1,128 @@ +{ + "title": "Marketplace", + "tabs": { + "installed": "已安裝", + "settings": "設定", + "browse": "瀏覽" + }, + "done": "完成", + "refresh": "重新整理", + "filters": { + "search": { + "placeholder": "搜尋 Marketplace 項目...", + "placeholderMcp": "搜尋 MCP...", + "placeholderMode": "搜尋模式..." + }, + "type": { + "label": "依類型篩選:", + "all": "所有類型", + "mode": "模式", + "mcpServer": "MCP 伺服器" + }, + "sort": { + "label": "排序方式:", + "name": "名稱", + "author": "作者", + "lastUpdated": "最後更新" + }, + "tags": { + "label": "依標籤篩選:", + "clear": "清除標籤", + "placeholder": "輸入以搜尋和選擇標籤...", + "noResults": "找不到符合的標籤", + "selected": "顯示包含任何選定標籤的項目", + "clickToFilter": "點擊標籤以篩選項目" + }, + "none": "無" + }, + "type-group": { + "modes": "模式", + "mcps": "MCP 伺服器" + }, + "items": { + "empty": { + "noItems": "找不到 Marketplace 項目", + "withFilters": "嘗試調整您的篩選條件", + "noSources": "嘗試在來源標籤頁中新增來源", + "adjustFilters": "嘗試調整您的篩選條件或搜尋詞", + "clearAllFilters": "清除所有篩選條件" + }, + "count": "找到 {{count}} 個項目", + "components": "{{count}} 個元件", + "matched": "{{count}} 個符合", + "refresh": { + "button": "重新整理", + "refreshing": "重新整理中...", + "mayTakeMoment": "這可能需要一些時間。" + }, + "card": { + "by": "作者:{{author}}", + "from": "來源:{{source}}", + "install": "安裝", + "installProject": "安裝", + "installGlobal": "安裝(全域)", + "remove": "移除", + "removeProject": "移除", + "removeGlobal": "移除(全域)", + "viewSource": "檢視", + "viewOnSource": "在 {{source}} 上檢視", + "noWorkspaceTooltip": "開啟工作區以安裝 Marketplace 項目", + "installed": "已安裝", + "removeProjectTooltip": "從目前專案中移除", + "removeGlobalTooltip": "從全域設定中移除", + "actionsMenuLabel": "更多動作" + } + }, + "install": { + "title": "安裝 {{name}}", + "titleMode": "安裝 {{name}} 模式", + "titleMcp": "安裝 {{name}} MCP", + "scope": "安裝範圍", + "project": "專案(目前工作區)", + "global": "全域(所有工作區)", + "method": "安裝方法", + "configuration": "設定", + "configurationDescription": "設定此 MCP 伺服器所需的參數", + "button": "安裝", + "successTitle": "{{name}} 已安裝", + "successDescription": "安裝成功完成", + "installed": "安裝成功!", + "whatNextMcp": "現在您可以設定和使用此 MCP 伺服器。點擊側邊欄中的 MCP 圖示以切換標籤頁。", + "whatNextMode": "現在您可以使用此模式。點擊側邊欄中的模式圖示以切換標籤頁。", + "done": "完成", + "goToMcp": "前往 MCP 標籤頁", + "goToModes": "前往模式標籤頁", + "moreInfoMcp": "檢視 {{name}} MCP 文件", + "validationRequired": "請為 {{paramName}} 提供值", + "prerequisites": "前置條件" + }, + "sources": { + "title": "設定 Marketplace 來源", + "description": "新增包含 Marketplace 項目的 Git 儲存庫。瀏覽 Marketplace 時將會擷取這些儲存庫。", + "add": { + "title": "新增來源", + "urlPlaceholder": "Git 儲存庫 URL(例如:https://github.com/username/repo)", + "urlFormats": "支援的格式:HTTPS(https://github.com/username/repo)、SSH(git@github.com:username/repo.git)或 Git 協定(git://github.com/username/repo.git)", + "namePlaceholder": "顯示名稱(最多 20 個字元)", + "button": "新增來源" + }, + "current": { + "title": "目前來源", + "empty": "尚未設定來源。新增來源以開始使用。", + "refresh": "重新整理此來源", + "remove": "移除來源" + }, + "errors": { + "emptyUrl": "URL 不能為空", + "invalidUrl": "無效的 URL 格式", + "nonVisibleChars": "URL 包含除空格外的不可見字元", + "invalidGitUrl": "URL 必須是有效的 Git 儲存庫 URL(例如:https://github.com/username/repo)", + "duplicateUrl": "此 URL 已在清單中(不區分大小寫和空格的比對)", + "nameTooLong": "名稱必須為 20 個字元或更少", + "nonVisibleCharsName": "名稱包含除空格外的不可見字元", + "duplicateName": "此名稱已被使用(不區分大小寫和空格的比對)", + "emojiName": "表情符號字元可能導致顯示問題", + "maxSources": "最多允許 {{max}} 個來源" + } + } +} diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 1c830fde29..04b26b7310 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -493,6 +493,10 @@ "name": "啟用並行檔案讀取", "description": "啟用後,Roo 可以在單一請求中讀取多個檔案(最多 15 個檔案)。停用後,Roo 必須逐一讀取檔案。在使用能力較弱的模型或希望對檔案存取有更多控制時,停用此功能可能會有所幫助。" }, + "MARKETPLACE": { + "name": "啟用 Marketplace", + "description": "啟用後,您可以從 Marketplace 安裝 MCP 和自訂模式。" + }, "DISABLE_COMPLETION_COMMAND": { "name": "停用 attempt_completion 中的指令執行", "description": "啟用後,attempt_completion 工具將不會執行指令。這是一項實驗性功能,旨在為未來停用工作完成時的指令執行做準備。" diff --git a/webview-ui/src/i18n/test-utils.ts b/webview-ui/src/i18n/test-utils.ts index 9abd4d9e06..daad16bdea 100644 --- a/webview-ui/src/i18n/test-utils.ts +++ b/webview-ui/src/i18n/test-utils.ts @@ -29,6 +29,25 @@ export const setupI18nForTests = () => { chat: { test: "Test", }, + marketplace: { + items: { + card: { + by: "by {{author}}", + viewSource: "View", + externalComponents: "Contains {{count}} external component", + externalComponents_plural: "Contains {{count}} external components", + }, + }, + filters: { + type: { + package: "Package", + mode: "Mode", + }, + tags: { + clickToFilter: "Click tags to filter items", + }, + }, + }, }, }, }) diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index 32702562a7..0eee0359d8 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -419,10 +419,59 @@ input[cmdk-input]:focus { text-rendering: geometricPrecision !important; } -/* - * Fix the color of in ChatView +/** + * Custom animations for UI elements */ -a:focus { - outline: 1px solid var(--vscode-focusBorder); +@keyframes slide-in-right { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +.animate-slide-in-right { + animation: slide-in-right 0.3s ease-out; +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.animate-fade-in { + animation: fade-in 0.2s ease-out; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +.animate-pulse { + animation: pulse 1.5s ease-in-out infinite; +} + +/* Transition utilities */ +.transition-all { + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-colors { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; } diff --git a/webview-ui/src/utils/url.ts b/webview-ui/src/utils/url.ts new file mode 100644 index 0000000000..94f32902e8 --- /dev/null +++ b/webview-ui/src/utils/url.ts @@ -0,0 +1,8 @@ +export const isValidUrl = (urlString: string): boolean => { + try { + new URL(urlString) + return true + } catch { + return false + } +} diff --git a/webview-ui/vite.config.ts b/webview-ui/vite.config.ts index 6657d1e131..938ce5c4c6 100644 --- a/webview-ui/vite.config.ts +++ b/webview-ui/vite.config.ts @@ -124,7 +124,9 @@ export default defineConfig(({ mode }) => { if (moduleInfo?.importers.some((importer) => importer.includes("node_modules/mermaid"))) { return "mermaid-bundle" } - if (moduleInfo?.dynamicImporters.some((importer) => importer.includes("node_modules/mermaid"))) { + if ( + moduleInfo?.dynamicImporters.some((importer) => importer.includes("node_modules/mermaid")) + ) { return "mermaid-bundle" } },