From c486de84dab57b4d786d3665d2509dbd17e97b34 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 10 Sep 2025 22:27:40 +0000 Subject: [PATCH] feat: implement workspace-specific provider settings - Add workspace-scoped storage support to ProviderSettingsManager - Add toggle between workspace and global settings in UI - Add visual indicators (Globe/Folder icons) for current scope - Add VSCode configuration option for workspace provider settings - Support migration of settings between global and workspace scopes - Update tests to support new workspace settings functionality Fixes #7865 --- src/core/config/ProviderSettingsManager.ts | 132 +++++++++++++++++- .../config/__tests__/importExport.spec.ts | 17 +++ src/core/webview/ClineProvider.ts | 4 + .../webview/__tests__/ClineProvider.spec.ts | 16 +++ src/core/webview/webviewMessageHandler.ts | 52 +++++++ src/package.json | 5 + src/package.nls.json | 3 +- src/shared/ExtensionMessage.ts | 1 + src/shared/WebviewMessage.ts | 1 + .../components/settings/ApiConfigManager.tsx | 53 ++++++- 10 files changed, 280 insertions(+), 4 deletions(-) diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 21a7a060c1..9967d68012 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -1,3 +1,4 @@ +import * as vscode from "vscode" import { ExtensionContext } from "vscode" import { z, ZodError } from "zod" import deepEqual from "fast-deep-equal" @@ -14,6 +15,7 @@ import { import { TelemetryService } from "@roo-code/telemetry" import { Mode, modes } from "../../shared/modes" +import { getWorkspacePath } from "../../utils/path" export interface SyncCloudProfilesResult { hasChanges: boolean @@ -41,6 +43,7 @@ export type ProviderProfiles = z.infer export class ProviderSettingsManager { private static readonly SCOPE_PREFIX = "roo_cline_config_" + private static readonly WORKSPACE_PREFIX = "workspace_" private readonly defaultConfigId = this.generateId() private readonly defaultModeApiConfigs: Record = Object.fromEntries( @@ -61,14 +64,105 @@ export class ProviderSettingsManager { } private readonly context: ExtensionContext + private useWorkspaceSettings: boolean = false constructor(context: ExtensionContext) { this.context = context + // Check if workspace settings should be used + this.checkWorkspaceSettingsPreference() + // TODO: We really shouldn't have async methods in the constructor. this.initialize().catch(console.error) } + /** + * Check if the user prefers workspace-specific settings + */ + private checkWorkspaceSettingsPreference(): void { + const config = vscode.workspace.getConfiguration("roo-cline") + this.useWorkspaceSettings = config.get("useWorkspaceProviderSettings", false) + } + + /** + * Get the appropriate storage key based on workspace preference + */ + private getStorageKey(): string { + if (this.useWorkspaceSettings) { + const workspacePath = getWorkspacePath() + if (workspacePath) { + // Create a unique key based on workspace path + const workspaceId = Buffer.from(workspacePath) + .toString("base64") + .replace(/[^a-zA-Z0-9]/g, "") + .substring(0, 20) + return `${ProviderSettingsManager.SCOPE_PREFIX}${ProviderSettingsManager.WORKSPACE_PREFIX}${workspaceId}` + } + } + // Fall back to global settings + return `${ProviderSettingsManager.SCOPE_PREFIX}api_config` + } + + /** + * Set whether to use workspace-specific settings + */ + public async setUseWorkspaceSettings(useWorkspace: boolean): Promise { + const wasUsingWorkspace = this.useWorkspaceSettings + this.useWorkspaceSettings = useWorkspace + + // Update VSCode configuration + const config = vscode.workspace.getConfiguration("roo-cline") + await config.update("useWorkspaceProviderSettings", useWorkspace, vscode.ConfigurationTarget.Global) + + // If switching from global to workspace or vice versa, optionally migrate settings + if (wasUsingWorkspace !== useWorkspace) { + await this.migrateSettingsBetweenScopes(wasUsingWorkspace, useWorkspace) + } + } + + /** + * Migrate settings between global and workspace scopes + */ + private async migrateSettingsBetweenScopes(fromWorkspace: boolean, toWorkspace: boolean): Promise { + try { + // Get settings from the source scope + const sourceKey = fromWorkspace ? this.getStorageKey() : `${ProviderSettingsManager.SCOPE_PREFIX}api_config` + const targetKey = toWorkspace ? this.getStorageKey() : `${ProviderSettingsManager.SCOPE_PREFIX}api_config` + + if (sourceKey === targetKey) { + return // No migration needed + } + + const sourceContent = await this.context.secrets.get(sourceKey) + if (!sourceContent) { + return // No settings to migrate + } + + // Check if target already has settings + const targetContent = await this.context.secrets.get(targetKey) + if (targetContent) { + // Target already has settings, don't overwrite + console.log(`Target scope already has settings, skipping migration`) + return + } + + // Migrate the settings + await this.context.secrets.store(targetKey, sourceContent) + console.log( + `Successfully migrated provider settings from ${fromWorkspace ? "workspace" : "global"} to ${toWorkspace ? "workspace" : "global"} scope`, + ) + } catch (error) { + console.error(`Failed to migrate settings between scopes: ${error}`) + } + } + + /** + * Check if current scope is workspace-specific + */ + public isUsingWorkspaceSettings(): boolean { + return this.useWorkspaceSettings && !!getWorkspacePath() + } + public generateId() { return Math.random().toString(36).substring(2, 15) } @@ -372,6 +466,40 @@ export class ProviderSettingsManager { } } + /** + * Get a configuration from global storage regardless of current workspace setting + */ + public async getConfigFromGlobal(name: string): Promise { + try { + return await this.lock(async () => { + // Temporarily get the global storage key + const globalKey = `${ProviderSettingsManager.SCOPE_PREFIX}api_config` + const content = await this.context.secrets.get(globalKey) + + if (!content) { + return null + } + + const providerProfiles = providerProfilesSchema + .extend({ + apiConfigs: z.record(z.string(), z.any()), + }) + .parse(JSON.parse(content)) + + const config = providerProfiles.apiConfigs[name] + if (!config) { + return null + } + + const result = providerSettingsWithIdSchema.safeParse(config) + return result.success ? result.data : null + }) + } catch (error) { + console.error(`Failed to get global config for ${name}:`, error) + return null + } + } + /** * Activate a profile by name or ID. */ @@ -493,12 +621,12 @@ export class ProviderSettingsManager { */ public async resetAllConfigs() { return await this.lock(async () => { - await this.context.secrets.delete(this.secretsKey) + await this.context.secrets.delete(this.getStorageKey()) }) } private get secretsKey() { - return `${ProviderSettingsManager.SCOPE_PREFIX}api_config` + return this.getStorageKey() } private async load(): Promise { diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 361d6b23b0..fdf01c6de6 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -26,6 +26,23 @@ vi.mock("vscode", () => ({ Uri: { file: vi.fn((filePath) => ({ fsPath: filePath })), }, + workspace: { + workspaceFolders: undefined, + getConfiguration: vi.fn(() => ({ + get: vi.fn((key) => { + if (key === "useWorkspaceProviderSettings") { + return false + } + return undefined + }), + update: vi.fn(), + })), + }, + ConfigurationTarget: { + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, + }, })) vi.mock("fs/promises", () => ({ diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f453b57dba..7c1e06ee00 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1795,6 +1795,9 @@ export class ClineProvider const currentMode = mode ?? defaultModeSlug const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode) + // Check if using workspace-specific provider settings + const isUsingWorkspaceSettings = this.providerSettingsManager.isUsingWorkspaceSettings() + return { version: this.context.extension?.packageJSON?.version ?? "", apiConfiguration, @@ -1920,6 +1923,7 @@ export class ClineProvider openRouterImageGenerationSelectedModel, openRouterUseMiddleOutTransform, featureRoomoteControlEnabled, + isUsingWorkspaceSettings, } } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bd4608c6eb..212c3492c6 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -889,6 +889,7 @@ describe("ClineProvider", () => { listConfig: vi.fn().mockResolvedValue([profile]), activateProfile: vi.fn().mockResolvedValue(profile), setModeConfig: vi.fn(), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } as any // Switch to architect mode @@ -910,6 +911,7 @@ describe("ClineProvider", () => { .fn() .mockResolvedValue([{ name: "current-config", id: "current-id", apiProvider: "anthropic" }]), setModeConfig: vi.fn(), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } as any provider.setValue("currentApiConfigName", "current-config") @@ -932,6 +934,7 @@ describe("ClineProvider", () => { listConfig: vi.fn().mockResolvedValue([profile]), setModeConfig: vi.fn(), getModeConfigId: vi.fn().mockResolvedValue(undefined), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } as any // First set the mode @@ -959,6 +962,7 @@ describe("ClineProvider", () => { listConfig: vi.fn().mockResolvedValue([profile]), setModeConfig: vi.fn(), getModeConfigId: vi.fn().mockResolvedValue(undefined), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } as any // First set the mode @@ -1159,6 +1163,7 @@ describe("ClineProvider", () => { listConfig: vi.fn().mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), saveConfig: vi.fn().mockResolvedValue("test-id"), setModeConfig: vi.fn(), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } as any // Update API configuration @@ -1626,6 +1631,7 @@ describe("ClineProvider", () => { listConfig: vi.fn().mockResolvedValue([profile]), activateProfile: vi.fn().mockResolvedValue(profile), setModeConfig: vi.fn(), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } as any // Switch to architect mode @@ -1650,6 +1656,7 @@ describe("ClineProvider", () => { .fn() .mockResolvedValue([{ name: "current-config", id: "current-id", apiProvider: "anthropic" }]), setModeConfig: vi.fn(), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } as any // Mock the ContextProxy's getValue method to return the current config name @@ -1707,6 +1714,7 @@ describe("ClineProvider", () => { ;(provider as any).providerSettingsManager = { getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi.fn().mockResolvedValue([]), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } // Spy on log method to verify warning was logged @@ -1776,6 +1784,7 @@ describe("ClineProvider", () => { activateProfile: vi .fn() .mockResolvedValue({ name: "test-config", id: "config-id", apiProvider: "anthropic" }), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } // Spy on log method to verify no warning was logged @@ -1831,6 +1840,7 @@ describe("ClineProvider", () => { ;(provider as any).providerSettingsManager = { getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi.fn().mockResolvedValue([]), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } // Create history item with built-in mode @@ -1862,6 +1872,7 @@ describe("ClineProvider", () => { ;(provider as any).providerSettingsManager = { getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi.fn().mockResolvedValue([]), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } // Create history item without mode @@ -1909,6 +1920,7 @@ describe("ClineProvider", () => { .fn() .mockResolvedValue([{ name: "test-config", id: "config-id", apiProvider: "anthropic" }]), activateProfile: vi.fn().mockRejectedValue(new Error("Failed to load config")), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } // Spy on log method @@ -2008,6 +2020,7 @@ describe("ClineProvider", () => { listConfig: vi .fn() .mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } as any // Mock getState to provide necessary data @@ -2040,6 +2053,7 @@ describe("ClineProvider", () => { listConfig: vi .fn() .mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } as any const testApiConfig = { @@ -2083,6 +2097,7 @@ describe("ClineProvider", () => { listConfig: vi .fn() .mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } as any // Setup Task instance with auto-mock from the top of the file @@ -2124,6 +2139,7 @@ describe("ClineProvider", () => { listConfig: vi .fn() .mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), + isUsingWorkspaceSettings: vi.fn().mockReturnValue(false), } as any const testApiConfig = { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index a6e8e73a6a..2f10539d0d 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1809,6 +1809,58 @@ export const webviewMessageHandler = async ( } } break + case "toggleWorkspaceProviderSettings": + // Toggle between workspace and global provider settings + try { + const useWorkspace = message.bool ?? false + const migrateSettings = message.values?.migrateSettings ?? false + + // Update the configuration setting + await vscode.workspace + .getConfiguration(Package.name) + .update("useWorkspaceProviderSettings", useWorkspace, vscode.ConfigurationTarget.Global) + + // Update the provider settings manager (pass migrateSettings as second parameter) + await provider.providerSettingsManager.setUseWorkspaceSettings(useWorkspace) + + // If migration is requested, handle it separately + if (migrateSettings && useWorkspace) { + // When switching to workspace, optionally copy global settings + const globalConfig = await provider.providerSettingsManager.getConfigFromGlobal( + getGlobalState("currentApiConfigName") || "default", + ) + if (globalConfig) { + await provider.providerSettingsManager.saveConfig( + getGlobalState("currentApiConfigName") || "default", + globalConfig, + ) + } + } + + // Reload the current configuration + const currentConfigName = getGlobalState("currentApiConfigName") + if (currentConfigName) { + await provider.activateProviderProfile({ name: currentConfigName }) + } + + // Post updated state to webview (isUsingWorkspaceSettings is already part of ExtensionState) + await provider.postStateToWebview() + + // Show confirmation message + const scope = useWorkspace ? "workspace" : "global" + vscode.window.showInformationMessage( + t(`common:info.provider_settings_switched_to_${scope}`) || + `Provider settings switched to ${scope} scope`, + ) + } catch (error) { + provider.log( + `Error toggling workspace provider settings: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + vscode.window.showErrorMessage( + t("common:errors.toggle_workspace_settings") || "Failed to toggle workspace provider settings", + ) + } + break case "upsertApiConfiguration": if (message.text && message.apiConfiguration) { await provider.upsertProviderProfile(message.text, message.apiConfiguration) diff --git a/src/package.json b/src/package.json index 8e1319a924..d43bc8cb21 100644 --- a/src/package.json +++ b/src/package.json @@ -407,6 +407,11 @@ "minimum": 1, "maximum": 200, "description": "%settings.codeIndex.embeddingBatchSize.description%" + }, + "roo-cline.useWorkspaceProviderSettings": { + "type": "boolean", + "default": false, + "description": "%settings.useWorkspaceProviderSettings.description%" } } } diff --git a/src/package.nls.json b/src/package.nls.json index b0b7f401f8..a53db319e8 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -41,5 +41,6 @@ "settings.useAgentRules.description": "Enable loading of AGENTS.md files for agent-specific rules (see https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Maximum time in seconds to wait for API responses (0 = no timeout, 1-3600s, default: 600s). Higher values are recommended for local providers like LM Studio and Ollama that may need more processing time.", "settings.newTaskRequireTodos.description": "Require todos parameter when creating new tasks with the new_task tool", - "settings.codeIndex.embeddingBatchSize.description": "The batch size for embedding operations during code indexing. Adjust this based on your API provider's limits. Default is 60." + "settings.codeIndex.embeddingBatchSize.description": "The batch size for embedding operations during code indexing. Adjust this based on your API provider's limits. Default is 60.", + "settings.useWorkspaceProviderSettings.description": "Save AI provider configurations per workspace instead of globally. When enabled, each workspace will have its own provider settings." } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index aaddc520cb..83604120b2 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -347,6 +347,7 @@ export type ExtensionState = Pick< remoteControlEnabled: boolean taskSyncEnabled: boolean featureRoomoteControlEnabled: boolean + isUsingWorkspaceSettings?: boolean } export interface ClineSayTool { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 93d0b9bc45..bb73b52947 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -37,6 +37,7 @@ export interface WebviewMessage { | "loadApiConfigurationById" | "renameApiConfiguration" | "getListApiConfiguration" + | "toggleWorkspaceProviderSettings" | "customInstructions" | "allowedCommands" | "deniedCommands" diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx index 366f7a81e7..f360a8ef8c 100644 --- a/webview-ui/src/components/settings/ApiConfigManager.tsx +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -1,10 +1,12 @@ import { memo, useEffect, useRef, useState } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { AlertTriangle } from "lucide-react" +import { AlertTriangle, Globe, FolderOpen } from "lucide-react" import type { ProviderSettingsEntry, OrganizationAllowList } from "@roo-code/types" import { useAppTranslation } from "@/i18n/TranslationContext" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { vscode } from "@/utils/vscode" import { type SearchableSelectOption, Button, @@ -14,6 +16,7 @@ import { DialogTitle, StandardTooltip, SearchableSelect, + ToggleSwitch, } from "@/components/ui" interface ApiConfigManagerProps { @@ -36,6 +39,7 @@ const ApiConfigManager = ({ onUpsertConfig, }: ApiConfigManagerProps) => { const { t } = useAppTranslation() + const { isUsingWorkspaceSettings } = useExtensionState() const [isRenaming, setIsRenaming] = useState(false) const [isCreating, setIsCreating] = useState(false) @@ -292,6 +296,53 @@ const ApiConfigManager = ({ )} + + {/* Workspace vs Global Settings Toggle */} +
+
+ {isUsingWorkspaceSettings ? ( + + ) : ( + + )} +
+ + {isUsingWorkspaceSettings + ? t("settings:providers.workspaceSettings") || "Workspace Settings" + : t("settings:providers.globalSettings") || "Global Settings"} + + + {isUsingWorkspaceSettings + ? t("settings:providers.workspaceSettingsDesc") || + "Settings apply only to this workspace" + : t("settings:providers.globalSettingsDesc") || + "Settings apply to all workspaces"} + +
+
+ + { + vscode.postMessage({ + type: "toggleWorkspaceProviderSettings", + bool: !isUsingWorkspaceSettings, + values: { migrateSettings: false }, + }) + }} + aria-label={ + isUsingWorkspaceSettings ? "Using workspace settings" : "Using global settings" + } + disabled={false} + /> + +
+
{t("settings:providers.description")}