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
This commit is contained in:
Roo Code 2025-09-10 22:27:40 +00:00
parent 8fee3127ff
commit c486de84da
10 changed files with 280 additions and 4 deletions

View file

@ -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<typeof providerProfilesSchema>
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<string, string> = 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<boolean>("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<void> {
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<void> {
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<ProviderSettingsWithId | null> {
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<ProviderProfiles> {

View file

@ -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", () => ({

View file

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

View file

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

View file

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

View file

@ -407,6 +407,11 @@
"minimum": 1,
"maximum": 200,
"description": "%settings.codeIndex.embeddingBatchSize.description%"
},
"roo-cline.useWorkspaceProviderSettings": {
"type": "boolean",
"default": false,
"description": "%settings.useWorkspaceProviderSettings.description%"
}
}
}

View file

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

View file

@ -347,6 +347,7 @@ export type ExtensionState = Pick<
remoteControlEnabled: boolean
taskSyncEnabled: boolean
featureRoomoteControlEnabled: boolean
isUsingWorkspaceSettings?: boolean
}
export interface ClineSayTool {

View file

@ -37,6 +37,7 @@ export interface WebviewMessage {
| "loadApiConfigurationById"
| "renameApiConfiguration"
| "getListApiConfiguration"
| "toggleWorkspaceProviderSettings"
| "customInstructions"
| "allowedCommands"
| "deniedCommands"

View file

@ -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 = ({
</>
)}
</div>
{/* Workspace vs Global Settings Toggle */}
<div className="flex items-center justify-between mt-3 p-2 rounded bg-vscode-editor-background border border-vscode-panel-border">
<div className="flex items-center gap-2">
{isUsingWorkspaceSettings ? (
<FolderOpen size={16} className="text-vscode-foreground" />
) : (
<Globe size={16} className="text-vscode-foreground" />
)}
<div className="flex flex-col">
<span className="text-sm font-medium">
{isUsingWorkspaceSettings
? t("settings:providers.workspaceSettings") || "Workspace Settings"
: t("settings:providers.globalSettings") || "Global Settings"}
</span>
<span className="text-xs text-vscode-descriptionForeground">
{isUsingWorkspaceSettings
? t("settings:providers.workspaceSettingsDesc") ||
"Settings apply only to this workspace"
: t("settings:providers.globalSettingsDesc") ||
"Settings apply to all workspaces"}
</span>
</div>
</div>
<StandardTooltip
content={
isUsingWorkspaceSettings
? t("settings:providers.switchToGlobal") || "Switch to global settings"
: t("settings:providers.switchToWorkspace") || "Switch to workspace settings"
}>
<ToggleSwitch
checked={isUsingWorkspaceSettings || false}
onChange={() => {
vscode.postMessage({
type: "toggleWorkspaceProviderSettings",
bool: !isUsingWorkspaceSettings,
values: { migrateSettings: false },
})
}}
aria-label={
isUsingWorkspaceSettings ? "Using workspace settings" : "Using global settings"
}
disabled={false}
/>
</StandardTooltip>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:providers.description")}
</div>