Skip to content

Commit c486de8

Browse files
committed
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
1 parent 8fee312 commit c486de8

File tree

10 files changed

+280
-4
lines changed

10 files changed

+280
-4
lines changed

src/core/config/ProviderSettingsManager.ts

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as vscode from "vscode"
12
import { ExtensionContext } from "vscode"
23
import { z, ZodError } from "zod"
34
import deepEqual from "fast-deep-equal"
@@ -14,6 +15,7 @@ import {
1415
import { TelemetryService } from "@roo-code/telemetry"
1516

1617
import { Mode, modes } from "../../shared/modes"
18+
import { getWorkspacePath } from "../../utils/path"
1719

1820
export interface SyncCloudProfilesResult {
1921
hasChanges: boolean
@@ -41,6 +43,7 @@ export type ProviderProfiles = z.infer<typeof providerProfilesSchema>
4143

4244
export class ProviderSettingsManager {
4345
private static readonly SCOPE_PREFIX = "roo_cline_config_"
46+
private static readonly WORKSPACE_PREFIX = "workspace_"
4447
private readonly defaultConfigId = this.generateId()
4548

4649
private readonly defaultModeApiConfigs: Record<string, string> = Object.fromEntries(
@@ -61,14 +64,105 @@ export class ProviderSettingsManager {
6164
}
6265

6366
private readonly context: ExtensionContext
67+
private useWorkspaceSettings: boolean = false
6468

6569
constructor(context: ExtensionContext) {
6670
this.context = context
6771

72+
// Check if workspace settings should be used
73+
this.checkWorkspaceSettingsPreference()
74+
6875
// TODO: We really shouldn't have async methods in the constructor.
6976
this.initialize().catch(console.error)
7077
}
7178

79+
/**
80+
* Check if the user prefers workspace-specific settings
81+
*/
82+
private checkWorkspaceSettingsPreference(): void {
83+
const config = vscode.workspace.getConfiguration("roo-cline")
84+
this.useWorkspaceSettings = config.get<boolean>("useWorkspaceProviderSettings", false)
85+
}
86+
87+
/**
88+
* Get the appropriate storage key based on workspace preference
89+
*/
90+
private getStorageKey(): string {
91+
if (this.useWorkspaceSettings) {
92+
const workspacePath = getWorkspacePath()
93+
if (workspacePath) {
94+
// Create a unique key based on workspace path
95+
const workspaceId = Buffer.from(workspacePath)
96+
.toString("base64")
97+
.replace(/[^a-zA-Z0-9]/g, "")
98+
.substring(0, 20)
99+
return `${ProviderSettingsManager.SCOPE_PREFIX}${ProviderSettingsManager.WORKSPACE_PREFIX}${workspaceId}`
100+
}
101+
}
102+
// Fall back to global settings
103+
return `${ProviderSettingsManager.SCOPE_PREFIX}api_config`
104+
}
105+
106+
/**
107+
* Set whether to use workspace-specific settings
108+
*/
109+
public async setUseWorkspaceSettings(useWorkspace: boolean): Promise<void> {
110+
const wasUsingWorkspace = this.useWorkspaceSettings
111+
this.useWorkspaceSettings = useWorkspace
112+
113+
// Update VSCode configuration
114+
const config = vscode.workspace.getConfiguration("roo-cline")
115+
await config.update("useWorkspaceProviderSettings", useWorkspace, vscode.ConfigurationTarget.Global)
116+
117+
// If switching from global to workspace or vice versa, optionally migrate settings
118+
if (wasUsingWorkspace !== useWorkspace) {
119+
await this.migrateSettingsBetweenScopes(wasUsingWorkspace, useWorkspace)
120+
}
121+
}
122+
123+
/**
124+
* Migrate settings between global and workspace scopes
125+
*/
126+
private async migrateSettingsBetweenScopes(fromWorkspace: boolean, toWorkspace: boolean): Promise<void> {
127+
try {
128+
// Get settings from the source scope
129+
const sourceKey = fromWorkspace ? this.getStorageKey() : `${ProviderSettingsManager.SCOPE_PREFIX}api_config`
130+
const targetKey = toWorkspace ? this.getStorageKey() : `${ProviderSettingsManager.SCOPE_PREFIX}api_config`
131+
132+
if (sourceKey === targetKey) {
133+
return // No migration needed
134+
}
135+
136+
const sourceContent = await this.context.secrets.get(sourceKey)
137+
if (!sourceContent) {
138+
return // No settings to migrate
139+
}
140+
141+
// Check if target already has settings
142+
const targetContent = await this.context.secrets.get(targetKey)
143+
if (targetContent) {
144+
// Target already has settings, don't overwrite
145+
console.log(`Target scope already has settings, skipping migration`)
146+
return
147+
}
148+
149+
// Migrate the settings
150+
await this.context.secrets.store(targetKey, sourceContent)
151+
console.log(
152+
`Successfully migrated provider settings from ${fromWorkspace ? "workspace" : "global"} to ${toWorkspace ? "workspace" : "global"} scope`,
153+
)
154+
} catch (error) {
155+
console.error(`Failed to migrate settings between scopes: ${error}`)
156+
}
157+
}
158+
159+
/**
160+
* Check if current scope is workspace-specific
161+
*/
162+
public isUsingWorkspaceSettings(): boolean {
163+
return this.useWorkspaceSettings && !!getWorkspacePath()
164+
}
165+
72166
public generateId() {
73167
return Math.random().toString(36).substring(2, 15)
74168
}
@@ -372,6 +466,40 @@ export class ProviderSettingsManager {
372466
}
373467
}
374468

469+
/**
470+
* Get a configuration from global storage regardless of current workspace setting
471+
*/
472+
public async getConfigFromGlobal(name: string): Promise<ProviderSettingsWithId | null> {
473+
try {
474+
return await this.lock(async () => {
475+
// Temporarily get the global storage key
476+
const globalKey = `${ProviderSettingsManager.SCOPE_PREFIX}api_config`
477+
const content = await this.context.secrets.get(globalKey)
478+
479+
if (!content) {
480+
return null
481+
}
482+
483+
const providerProfiles = providerProfilesSchema
484+
.extend({
485+
apiConfigs: z.record(z.string(), z.any()),
486+
})
487+
.parse(JSON.parse(content))
488+
489+
const config = providerProfiles.apiConfigs[name]
490+
if (!config) {
491+
return null
492+
}
493+
494+
const result = providerSettingsWithIdSchema.safeParse(config)
495+
return result.success ? result.data : null
496+
})
497+
} catch (error) {
498+
console.error(`Failed to get global config for ${name}:`, error)
499+
return null
500+
}
501+
}
502+
375503
/**
376504
* Activate a profile by name or ID.
377505
*/
@@ -493,12 +621,12 @@ export class ProviderSettingsManager {
493621
*/
494622
public async resetAllConfigs() {
495623
return await this.lock(async () => {
496-
await this.context.secrets.delete(this.secretsKey)
624+
await this.context.secrets.delete(this.getStorageKey())
497625
})
498626
}
499627

500628
private get secretsKey() {
501-
return `${ProviderSettingsManager.SCOPE_PREFIX}api_config`
629+
return this.getStorageKey()
502630
}
503631

504632
private async load(): Promise<ProviderProfiles> {

src/core/config/__tests__/importExport.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ vi.mock("vscode", () => ({
2626
Uri: {
2727
file: vi.fn((filePath) => ({ fsPath: filePath })),
2828
},
29+
workspace: {
30+
workspaceFolders: undefined,
31+
getConfiguration: vi.fn(() => ({
32+
get: vi.fn((key) => {
33+
if (key === "useWorkspaceProviderSettings") {
34+
return false
35+
}
36+
return undefined
37+
}),
38+
update: vi.fn(),
39+
})),
40+
},
41+
ConfigurationTarget: {
42+
Global: 1,
43+
Workspace: 2,
44+
WorkspaceFolder: 3,
45+
},
2946
}))
3047

3148
vi.mock("fs/promises", () => ({

src/core/webview/ClineProvider.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1795,6 +1795,9 @@ export class ClineProvider
17951795
const currentMode = mode ?? defaultModeSlug
17961796
const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode)
17971797

1798+
// Check if using workspace-specific provider settings
1799+
const isUsingWorkspaceSettings = this.providerSettingsManager.isUsingWorkspaceSettings()
1800+
17981801
return {
17991802
version: this.context.extension?.packageJSON?.version ?? "",
18001803
apiConfiguration,
@@ -1920,6 +1923,7 @@ export class ClineProvider
19201923
openRouterImageGenerationSelectedModel,
19211924
openRouterUseMiddleOutTransform,
19221925
featureRoomoteControlEnabled,
1926+
isUsingWorkspaceSettings,
19231927
}
19241928
}
19251929

src/core/webview/__tests__/ClineProvider.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,7 @@ describe("ClineProvider", () => {
889889
listConfig: vi.fn().mockResolvedValue([profile]),
890890
activateProfile: vi.fn().mockResolvedValue(profile),
891891
setModeConfig: vi.fn(),
892+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
892893
} as any
893894

894895
// Switch to architect mode
@@ -910,6 +911,7 @@ describe("ClineProvider", () => {
910911
.fn()
911912
.mockResolvedValue([{ name: "current-config", id: "current-id", apiProvider: "anthropic" }]),
912913
setModeConfig: vi.fn(),
914+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
913915
} as any
914916

915917
provider.setValue("currentApiConfigName", "current-config")
@@ -932,6 +934,7 @@ describe("ClineProvider", () => {
932934
listConfig: vi.fn().mockResolvedValue([profile]),
933935
setModeConfig: vi.fn(),
934936
getModeConfigId: vi.fn().mockResolvedValue(undefined),
937+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
935938
} as any
936939

937940
// First set the mode
@@ -959,6 +962,7 @@ describe("ClineProvider", () => {
959962
listConfig: vi.fn().mockResolvedValue([profile]),
960963
setModeConfig: vi.fn(),
961964
getModeConfigId: vi.fn().mockResolvedValue(undefined),
965+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
962966
} as any
963967

964968
// First set the mode
@@ -1159,6 +1163,7 @@ describe("ClineProvider", () => {
11591163
listConfig: vi.fn().mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]),
11601164
saveConfig: vi.fn().mockResolvedValue("test-id"),
11611165
setModeConfig: vi.fn(),
1166+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
11621167
} as any
11631168

11641169
// Update API configuration
@@ -1626,6 +1631,7 @@ describe("ClineProvider", () => {
16261631
listConfig: vi.fn().mockResolvedValue([profile]),
16271632
activateProfile: vi.fn().mockResolvedValue(profile),
16281633
setModeConfig: vi.fn(),
1634+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
16291635
} as any
16301636

16311637
// Switch to architect mode
@@ -1650,6 +1656,7 @@ describe("ClineProvider", () => {
16501656
.fn()
16511657
.mockResolvedValue([{ name: "current-config", id: "current-id", apiProvider: "anthropic" }]),
16521658
setModeConfig: vi.fn(),
1659+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
16531660
} as any
16541661

16551662
// Mock the ContextProxy's getValue method to return the current config name
@@ -1707,6 +1714,7 @@ describe("ClineProvider", () => {
17071714
;(provider as any).providerSettingsManager = {
17081715
getModeConfigId: vi.fn().mockResolvedValue(undefined),
17091716
listConfig: vi.fn().mockResolvedValue([]),
1717+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
17101718
}
17111719

17121720
// Spy on log method to verify warning was logged
@@ -1776,6 +1784,7 @@ describe("ClineProvider", () => {
17761784
activateProfile: vi
17771785
.fn()
17781786
.mockResolvedValue({ name: "test-config", id: "config-id", apiProvider: "anthropic" }),
1787+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
17791788
}
17801789

17811790
// Spy on log method to verify no warning was logged
@@ -1831,6 +1840,7 @@ describe("ClineProvider", () => {
18311840
;(provider as any).providerSettingsManager = {
18321841
getModeConfigId: vi.fn().mockResolvedValue(undefined),
18331842
listConfig: vi.fn().mockResolvedValue([]),
1843+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
18341844
}
18351845

18361846
// Create history item with built-in mode
@@ -1862,6 +1872,7 @@ describe("ClineProvider", () => {
18621872
;(provider as any).providerSettingsManager = {
18631873
getModeConfigId: vi.fn().mockResolvedValue(undefined),
18641874
listConfig: vi.fn().mockResolvedValue([]),
1875+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
18651876
}
18661877

18671878
// Create history item without mode
@@ -1909,6 +1920,7 @@ describe("ClineProvider", () => {
19091920
.fn()
19101921
.mockResolvedValue([{ name: "test-config", id: "config-id", apiProvider: "anthropic" }]),
19111922
activateProfile: vi.fn().mockRejectedValue(new Error("Failed to load config")),
1923+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
19121924
}
19131925

19141926
// Spy on log method
@@ -2008,6 +2020,7 @@ describe("ClineProvider", () => {
20082020
listConfig: vi
20092021
.fn()
20102022
.mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]),
2023+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
20112024
} as any
20122025

20132026
// Mock getState to provide necessary data
@@ -2040,6 +2053,7 @@ describe("ClineProvider", () => {
20402053
listConfig: vi
20412054
.fn()
20422055
.mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]),
2056+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
20432057
} as any
20442058

20452059
const testApiConfig = {
@@ -2083,6 +2097,7 @@ describe("ClineProvider", () => {
20832097
listConfig: vi
20842098
.fn()
20852099
.mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]),
2100+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
20862101
} as any
20872102

20882103
// Setup Task instance with auto-mock from the top of the file
@@ -2124,6 +2139,7 @@ describe("ClineProvider", () => {
21242139
listConfig: vi
21252140
.fn()
21262141
.mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]),
2142+
isUsingWorkspaceSettings: vi.fn().mockReturnValue(false),
21272143
} as any
21282144

21292145
const testApiConfig = {

0 commit comments

Comments
 (0)