Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 130 additions & 2 deletions src/core/config/ProviderSettingsManager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as vscode from "vscode"
import { ExtensionContext } from "vscode"
import { z, ZodError } from "zod"
import deepEqual from "fast-deep-equal"
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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)
}
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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> {
Expand Down
17 changes: 17 additions & 0 deletions src/core/config/__tests__/importExport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down
4 changes: 4 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1920,6 +1923,7 @@ export class ClineProvider
openRouterImageGenerationSelectedModel,
openRouterUseMiddleOutTransform,
featureRoomoteControlEnabled,
isUsingWorkspaceSettings,
}
}

Expand Down
16 changes: 16 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading