diff --git a/src/core/__tests__/contextProxy.test.ts b/src/core/__tests__/contextProxy.test.ts index e2d6c4ad12..c62bd1d3cc 100644 --- a/src/core/__tests__/contextProxy.test.ts +++ b/src/core/__tests__/contextProxy.test.ts @@ -1,5 +1,7 @@ // npx jest src/core/__tests__/contextProxy.test.ts +import fs from "fs/promises" + import * as vscode from "vscode" import { ContextProxy } from "../contextProxy" @@ -416,4 +418,76 @@ describe("ContextProxy", () => { expect(initializeSpy).toHaveBeenCalledTimes(1) }) }) + + describe("exportGlobalConfiguration", () => { + it("should write configuration to a file when filePath is provided", async () => { + await proxy.updateGlobalState("apiModelId", "gpt-4") + await proxy.updateGlobalState("apiProvider", "openai") + await proxy.storeSecret("openAiApiKey", "test-api-key") + + const filePath = `/tmp/roo-global-config-${Date.now()}.json` + const result = await proxy.exportGlobalConfiguration(filePath) + expect(result).toEqual({ apiProvider: "openai" }) + const fileContent = await fs.readFile(filePath, "utf-8") + expect(fileContent).toContain('"apiProvider": "openai"') + + await proxy.updateGlobalState("apiProvider", "openrouter") + + const importedConfig = await proxy.importGlobalConfiguration(filePath) + expect(importedConfig).toEqual({ apiProvider: "openai" }) + + await fs.unlink(filePath) + }) + }) + + describe("exportApiConfiguration", () => { + it("should write configuration to a file when filePath is provided", async () => { + await proxy.updateGlobalState("apiModelId", "gpt-4") + await proxy.updateGlobalState("apiProvider", "openai") + await proxy.storeSecret("openAiApiKey", "test-api-key") + + const filePath = `/tmp/roo-api-config-${Date.now()}.json` + const result = await proxy.exportApiConfiguration(filePath) + expect(result).toEqual({ + apiModelId: "gpt-4", + openAiApiKey: "test-api-key", + apiKey: "test-secret", + awsAccessKey: "test-secret", + awsSecretKey: "test-secret", + awsSessionToken: "test-secret", + deepSeekApiKey: "test-secret", + geminiApiKey: "test-secret", + glamaApiKey: "test-secret", + mistralApiKey: "test-secret", + openAiNativeApiKey: "test-secret", + openRouterApiKey: "test-secret", + requestyApiKey: "test-secret", + unboundApiKey: "test-secret", + }) + const fileContent = await fs.readFile(filePath, "utf-8") + expect(fileContent).toContain('"openAiApiKey": "test-api-key"') + + await proxy.storeSecret("openAiApiKey", "new-text-api-key") + + const importedConfig = await proxy.importApiConfiguration(filePath) + expect(importedConfig).toEqual({ + apiModelId: "gpt-4", + openAiApiKey: "test-api-key", + apiKey: "test-secret", + awsAccessKey: "test-secret", + awsSecretKey: "test-secret", + awsSessionToken: "test-secret", + deepSeekApiKey: "test-secret", + geminiApiKey: "test-secret", + glamaApiKey: "test-secret", + mistralApiKey: "test-secret", + openAiNativeApiKey: "test-secret", + openRouterApiKey: "test-secret", + requestyApiKey: "test-secret", + unboundApiKey: "test-secret", + }) + + await fs.unlink(filePath) + }) + }) }) diff --git a/src/core/contextProxy.ts b/src/core/contextProxy.ts index 698713179d..ebe67ed69f 100644 --- a/src/core/contextProxy.ts +++ b/src/core/contextProxy.ts @@ -1,4 +1,6 @@ import * as vscode from "vscode" +import * as fs from "fs/promises" +import * as path from "path" import { logger } from "../utils/logging" import { @@ -11,8 +13,22 @@ import { isSecretKey, isGlobalStateKey, isPassThroughStateKey, + globalStateSchema, } from "../shared/globalState" -import { API_CONFIG_KEYS, ApiConfiguration } from "../shared/api" +import { API_CONFIG_KEYS, ApiConfiguration, apiHandlerOptionsSchema, ApiHandlerOptionsKey } from "../shared/api" + +const NON_EXPORTABLE_GLOBAL_CONFIGURATION: GlobalStateKey[] = [ + "taskHistory", + "listApiConfigMeta", + "currentApiConfigName", +] + +const NON_EXPORTABLE_API_CONFIGURATION: ApiHandlerOptionsKey[] = [ + "glamaModelInfo", + "openRouterModelInfo", + "unboundModelInfo", + "requestyModelInfo", +] export class ContextProxy { private readonly originalContext: vscode.ExtensionContext @@ -155,14 +171,125 @@ export class ContextProxy { // that the setting's value should be `undefined` and therefore we // need to remove it from the state cache if it exists. await this.setValues({ - ...API_CONFIG_KEYS.filter((key) => !!this.stateCache.get(key)).reduce( - (acc, key) => ({ ...acc, [key]: undefined }), - {} as Partial, - ), + ...API_CONFIG_KEYS.filter((key) => isGlobalStateKey(key)) + .filter((key) => !!this.stateCache.get(key)) + .reduce((acc, key) => ({ ...acc, [key]: undefined }), {} as Partial), ...apiConfiguration, }) } + private getAllGlobalStateValues() { + const values: Partial> = {} + + for (const key of GLOBAL_STATE_KEYS) { + const value = this.getGlobalState(key) + + if (value !== undefined) { + values[key] = value + } + } + + return values + } + + private getAllSecretValues() { + const values: Partial> = {} + + for (const key of SECRET_KEYS) { + const value = this.getSecret(key) + + if (value !== undefined) { + values[key] = value + } + } + + return values + } + + async exportGlobalConfiguration(filePath: string) { + try { + const values = this.getAllGlobalStateValues() + const configuration = globalStateSchema.parse(values) + const omit = new Set([...API_CONFIG_KEYS, ...NON_EXPORTABLE_GLOBAL_CONFIGURATION]) + const entries = Object.entries(configuration).filter(([key]) => !omit.has(key)) + + if (entries.length === 0) { + throw new Error("No configuration values to export.") + } + + const globalConfiguration = Object.fromEntries(entries) + + const dirname = path.dirname(filePath) + await fs.mkdir(dirname, { recursive: true }) + await fs.writeFile(filePath, JSON.stringify(globalConfiguration, null, 2), "utf-8") + return globalConfiguration + } catch (error) { + console.log(error.message) + logger.error( + `Error exporting global configuration to ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined + } + } + + async importGlobalConfiguration(filePath: string) { + try { + const configuration = globalStateSchema.parse(JSON.parse(await fs.readFile(filePath, "utf-8"))) + const omit = new Set([...API_CONFIG_KEYS, ...NON_EXPORTABLE_GLOBAL_CONFIGURATION]) + const entries = Object.entries(configuration).filter(([key]) => !omit.has(key)) + + if (entries.length === 0) { + throw new Error("No configuration values to import.") + } + + const globalConfiguration = Object.fromEntries(entries) + await this.setValues(globalConfiguration) + return globalConfiguration + } catch (error) { + logger.error( + `Error importing global configuration from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined + } + } + + async exportApiConfiguration(filePath: string) { + try { + const apiConfiguration = apiHandlerOptionsSchema + .omit(NON_EXPORTABLE_API_CONFIGURATION.reduce((acc, key) => ({ ...acc, [key]: true }), {})) + .parse({ + ...this.getAllGlobalStateValues(), + ...this.getAllSecretValues(), + }) + + const dirname = path.dirname(filePath) + await fs.mkdir(dirname, { recursive: true }) + await fs.writeFile(filePath, JSON.stringify(apiConfiguration, null, 2), "utf-8") + return apiConfiguration + } catch (error) { + logger.error( + `Error exporting API configuration to ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined + } + } + + async importApiConfiguration(filePath: string) { + try { + const apiConfiguration = apiHandlerOptionsSchema + .omit(NON_EXPORTABLE_API_CONFIGURATION.reduce((acc, key) => ({ ...acc, [key]: true }), {})) + .parse(JSON.parse(await fs.readFile(filePath, "utf-8"))) + + await this.setApiConfiguration(apiConfiguration) + return apiConfiguration + } catch (error) { + logger.error( + `Error importing API configuration from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined + } + } + /** * Resets all global state, secrets, and in-memory caches. * This clears all data from both the in-memory caches and the VSCode storage. @@ -184,6 +311,6 @@ export class ContextProxy { // Wait for all reset operations to complete. await Promise.all([...stateResetPromises, ...secretResetPromises]) - this.initialize() + await this.initialize() } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 53c142706d..8613548059 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -31,6 +31,7 @@ import { SECRET_KEYS, GLOBAL_STATE_KEYS, ConfigurationValues, + isGlobalStateKey, } from "../../shared/globalState" import { HistoryItem } from "../../shared/HistoryItem" import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage" @@ -99,6 +100,7 @@ export class ClineProvider extends EventEmitter implements private workspaceTracker?: WorkspaceTracker protected mcpHub?: McpHub // Change from private to protected private latestAnnouncementId = "mar-20-2025-3-10" // update to some unique identifier when we add a new announcement + private settingsImportedAt?: number private contextProxy: ContextProxy configManager: ConfigManager customModesManager: CustomModesManager @@ -1070,6 +1072,41 @@ export class ClineProvider extends EventEmitter implements } case "exportTaskWithId": this.exportTaskWithId(message.text!) + break + case "importSettings": + const uris = await vscode.window.showOpenDialog({ + filters: { JSON: ["json"] }, + canSelectMany: false, + }) + + if (uris) { + if (message.text === "global") { + await this.contextProxy.importGlobalConfiguration(uris[0].fsPath) + } else { + await this.contextProxy.importApiConfiguration(uris[0].fsPath) + } + + this.settingsImportedAt = Date.now() + await this.postStateToWebview() + await vscode.window.showInformationMessage(t("common:info.settings_imported")) + } + break + case "exportSettings": + const uri = await vscode.window.showSaveDialog({ + filters: { JSON: ["json"] }, + defaultUri: vscode.Uri.file( + path.join(os.homedir(), "Documents", `roo-code-${message.text}.json`), + ), + }) + + if (uri) { + if (message.text === "global") { + await this.contextProxy.exportGlobalConfiguration(uri.fsPath) + } else { + await this.contextProxy.exportApiConfiguration(uri.fsPath) + } + } + break case "resetState": await this.resetState() @@ -2582,6 +2619,7 @@ export class ClineProvider extends EventEmitter implements language, renderContext: this.renderContext, maxReadFileLine: maxReadFileLine ?? 500, + settingsImportedAt: this.settingsImportedAt, } } @@ -2671,7 +2709,9 @@ export class ClineProvider extends EventEmitter implements // Using the dynamic approach with API_CONFIG_KEYS const apiConfiguration: ApiConfiguration = { // Dynamically add all API-related keys from stateValues - ...Object.fromEntries(API_CONFIG_KEYS.map((key) => [key, stateValues[key]])), + ...Object.fromEntries( + API_CONFIG_KEYS.filter((key) => isGlobalStateKey(key)).map((key) => [key, stateValues[key]]), + ), // Add all secrets ...secretValues, } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index f3a2e86a96..0a7b95093a 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -61,7 +61,8 @@ "mcp_server_restarting": "Restarting {{serverName}} MCP server...", "mcp_server_connected": "{{serverName}} MCP server connected", "mcp_server_deleted": "Deleted MCP server: {{serverName}}", - "mcp_server_not_found": "Server \"{{serverName}}\" not found in configuration" + "mcp_server_not_found": "Server \"{{serverName}}\" not found in configuration", + "settings_imported": "Settings imported successfully." }, "answers": { "yes": "Yes", diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index b7219de2f8..0072412238 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -168,6 +168,7 @@ export interface ExtensionState { showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings renderContext: "sidebar" | "editor" maxReadFileLine: number // Maximum number of lines to read from a file before truncating + settingsImportedAt?: number } export type { ClineMessage, ClineAsk, ClineSay } diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index d87be2a716..148a5e60e4 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -34,6 +34,8 @@ export interface WebviewMessage { | "showTaskWithId" | "deleteTaskWithId" | "exportTaskWithId" + | "importSettings" + | "exportSettings" | "resetState" | "requestOllamaModels" | "requestLmStudioModels" diff --git a/src/shared/api.ts b/src/shared/api.ts index 8586dfc027..2981ff3184 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -1,4 +1,45 @@ import * as vscode from "vscode" +import { z } from "zod" + +import { AssertEqual, Equals } from "../utils/type-fu" + +// Models + +export const modelInfoSchema = z.object({ + maxTokens: z.number().optional(), + contextWindow: z.number(), + supportsImages: z.boolean().optional(), + supportsComputerUse: z.boolean().optional(), + supportsPromptCache: z.boolean(), + inputPrice: z.number().optional(), + outputPrice: z.number().optional(), + cacheWritesPrice: z.number().optional(), + cacheReadsPrice: z.number().optional(), + description: z.string().optional(), + reasoningEffort: z.enum(["low", "medium", "high"]).optional(), + thinking: z.boolean().optional(), +}) + +export interface ModelInfo { + maxTokens?: number + contextWindow: number + supportsImages?: boolean + supportsComputerUse?: boolean + supportsPromptCache: boolean // This value is hardcoded for now. + inputPrice?: number + outputPrice?: number + cacheWritesPrice?: number + cacheReadsPrice?: number + description?: string + reasoningEffort?: "low" | "medium" | "high" + thinking?: boolean +} + +// Throws a type error if the inferred type of the schema is not equal to the +// type of the ModelInfo. +type _AssertModelInfoMatchesSchema = AssertEqual>> + +// Providers export type ApiProvider = | "anthropic" @@ -34,7 +75,7 @@ export interface ApiHandlerOptions { openRouterModelInfo?: ModelInfo openRouterBaseUrl?: string openRouterSpecificProvider?: string - // AWS Bedrok + // AWS Bedrock awsAccessKey?: string awsSecretKey?: string awsSessionToken?: string @@ -74,7 +115,7 @@ export interface ApiHandlerOptions { openAiNativeApiKey?: string // Mistral mistralApiKey?: string - mistralCodestralUrl?: string // New option for Codestral URL + mistralCodestralUrl?: string // New option for Codestral URL. // Azure azureApiVersion?: string // OpenRouter @@ -100,18 +141,109 @@ export interface ApiHandlerOptions { fakeAi?: unknown } +export type ApiHandlerOptionsKey = keyof ApiHandlerOptions + +export const apiHandlerOptionsSchema = z.object({ + // Anthropic + apiModelId: z.string().optional(), + apiKey: z.string().optional(), + anthropicBaseUrl: z.string().optional(), + // Glama + glamaModelId: z.string().optional(), + glamaModelInfo: modelInfoSchema.optional(), + glamaApiKey: z.string().optional(), + // OpenRouter + openRouterApiKey: z.string().optional(), + openRouterModelId: z.string().optional(), + openRouterModelInfo: modelInfoSchema.optional(), + openRouterBaseUrl: z.string().optional(), + openRouterSpecificProvider: z.string().optional(), + // AWS Bedrock + awsAccessKey: z.string().optional(), + awsSecretKey: z.string().optional(), + awsSessionToken: z.string().optional(), + awsRegion: z.string().optional(), + awsUseCrossRegionInference: z.boolean().optional(), + awsUsePromptCache: z.boolean().optional(), + awspromptCacheId: z.string().optional(), + awsProfile: z.string().optional(), + awsUseProfile: z.boolean().optional(), + awsCustomArn: z.string().optional(), + // Google Vertex + vertexKeyFile: z.string().optional(), + vertexJsonCredentials: z.string().optional(), + vertexProjectId: z.string().optional(), + vertexRegion: z.string().optional(), + // OpenAI + openAiBaseUrl: z.string().optional(), + openAiApiKey: z.string().optional(), + openAiR1FormatEnabled: z.boolean().optional(), + openAiModelId: z.string().optional(), + openAiCustomModelInfo: modelInfoSchema.optional(), + openAiUseAzure: z.boolean().optional(), + // Ollama + ollamaModelId: z.string().optional(), + ollamaBaseUrl: z.string().optional(), + // VS Code LM + vsCodeLmModelSelector: z + .object({ + vendor: z.string().optional(), + family: z.string().optional(), + version: z.string().optional(), + id: z.string().optional(), + }) + .optional(), + // LM Studio + lmStudioModelId: z.string().optional(), + lmStudioBaseUrl: z.string().optional(), + lmStudioDraftModelId: z.string().optional(), + lmStudioSpeculativeDecodingEnabled: z.boolean().optional(), + // Gemini + geminiApiKey: z.string().optional(), + googleGeminiBaseUrl: z.string().optional(), + // OpenAI Native + openAiNativeApiKey: z.string().optional(), + // Mistral + mistralApiKey: z.string().optional(), + mistralCodestralUrl: z.string().optional(), + // Azure + azureApiVersion: z.string().optional(), + // OpenRouter + openRouterUseMiddleOutTransform: z.boolean().optional(), + openAiStreamingEnabled: z.boolean().optional(), + // DeepSeek + deepSeekBaseUrl: z.string().optional(), + deepSeekApiKey: z.string().optional(), + includeMaxTokens: z.boolean().optional(), + // Unbound + unboundApiKey: z.string().optional(), + unboundModelId: z.string().optional(), + unboundModelInfo: modelInfoSchema.optional(), + // Requesty + requestyApiKey: z.string().optional(), + requestyModelId: z.string().optional(), + requestyModelInfo: modelInfoSchema.optional(), + // Claude 3.7 Sonnet Thinking + modelTemperature: z.number().nullish(), + modelMaxTokens: z.number().optional(), + modelMaxThinkingTokens: z.number().optional(), + // Fake AI + fakeAi: z.unknown().optional(), +}) + +// Throws a type error if the inferred type of the schema is not equal to the +// type of the ApiHandlerOptions. +type _AssertApiHandlerOptionsMatchesSchema = AssertEqual< + Equals> +> + export type ApiConfiguration = ApiHandlerOptions & { apiProvider?: ApiProvider - id?: string // stable unique identifier + id?: string // Stable unique identifier. } -// Import GlobalStateKey type from globalState.ts -import { GlobalStateKey } from "./globalState" - // Define API configuration keys for dynamic object building. -// TODO: This needs actual type safety; a type error should be thrown if -// this is not an exhaustive list of all `GlobalStateKey` values. -export const API_CONFIG_KEYS: GlobalStateKey[] = [ +export const API_CONFIG_KEYS: ApiHandlerOptionsKey[] = [ "apiModelId", "anthropicBaseUrl", "vsCodeLmModelSelector", @@ -160,23 +292,6 @@ export const API_CONFIG_KEYS: GlobalStateKey[] = [ "fakeAi", ] -// Models - -export interface ModelInfo { - maxTokens?: number - contextWindow: number - supportsImages?: boolean - supportsComputerUse?: boolean - supportsPromptCache: boolean // this value is hardcoded for now - inputPrice?: number - outputPrice?: number - cacheWritesPrice?: number - cacheReadsPrice?: number - description?: string - reasoningEffort?: "low" | "medium" | "high" - thinking?: boolean -} - // Anthropic // https://docs.anthropic.com/en/docs/about-claude/models export type AnthropicModelId = keyof typeof anthropicModels diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 5513fe7134..c614615672 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -1,3 +1,5 @@ +import { z } from "zod" + import type { SecretKey, GlobalStateKey, ConfigurationKey, ConfigurationValues } from "../exports/roo-code" export type { SecretKey, GlobalStateKey, ConfigurationKey, ConfigurationValues } @@ -12,6 +14,10 @@ export type { SecretKey, GlobalStateKey, ConfigurationKey, ConfigurationValues } * keys or a type error will be thrown. */ +/** + * Secret keys. + */ + export const SECRET_KEYS = [ "apiKey", "glamaApiKey", @@ -28,10 +34,19 @@ export const SECRET_KEYS = [ "requestyApiKey", ] as const +// Throws a type error we're missing a key. type CheckSecretKeysExhaustiveness = Exclude extends never ? true : false - const _checkSecretKeysExhaustiveness: CheckSecretKeysExhaustiveness = true +export const isSecretKey = (key: string): key is SecretKey => SECRET_KEYS.includes(key as SecretKey) + +// TODO: Replace `z.unknown()` with the actual types. +export const secretStateSchema = z.record(z.enum(SECRET_KEYS), z.unknown()) + +/** + * Global state keys. + */ + export const GLOBAL_STATE_KEYS = [ "apiProvider", "apiModelId", @@ -127,17 +142,27 @@ export const GLOBAL_STATE_KEYS = [ "fakeAi", ] as const -export const PASS_THROUGH_STATE_KEYS = ["taskHistory"] as const - +// Throws a type error we're missing a key. type CheckGlobalStateKeysExhaustiveness = Exclude extends never ? true : false const _checkGlobalStateKeysExhaustiveness: CheckGlobalStateKeysExhaustiveness = true -export const isSecretKey = (key: string): key is SecretKey => SECRET_KEYS.includes(key as SecretKey) - export const isGlobalStateKey = (key: string): key is GlobalStateKey => GLOBAL_STATE_KEYS.includes(key as GlobalStateKey) +// TODO: Replace `z.unknown()` with the actual types. +export const globalStateSchema = z.record(z.enum(GLOBAL_STATE_KEYS), z.unknown()) + +/** + * Pass-through state keys. + * TODO: What are these? + */ + +export const PASS_THROUGH_STATE_KEYS = ["taskHistory"] as const + export const isPassThroughStateKey = (key: string): key is (typeof PASS_THROUGH_STATE_KEYS)[number] => PASS_THROUGH_STATE_KEYS.includes(key as (typeof PASS_THROUGH_STATE_KEYS)[number]) + +// TODO: Replace `z.unknown()` with the actual types. +export const passThroughStateSchema = z.record(z.enum(PASS_THROUGH_STATE_KEYS), z.unknown()) diff --git a/src/utils/type-fu.ts b/src/utils/type-fu.ts new file mode 100644 index 0000000000..53145310f9 --- /dev/null +++ b/src/utils/type-fu.ts @@ -0,0 +1,3 @@ +export type Equals = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false + +export type AssertEqual = T diff --git a/webview-ui/src/components/settings/About.tsx b/webview-ui/src/components/settings/About.tsx index d196ec037d..c3f20f1196 100644 --- a/webview-ui/src/components/settings/About.tsx +++ b/webview-ui/src/components/settings/About.tsx @@ -1,14 +1,15 @@ import { HTMLAttributes } from "react" import { useAppTranslation } from "@/i18n/TranslationContext" import { Trans } from "react-i18next" -import { Info } from "lucide-react" +import { Info, Download, Upload, TriangleAlert } from "lucide-react" -import { VSCodeButton, VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react" +import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { TelemetrySetting } from "../../../../src/shared/TelemetrySetting" import { vscode } from "@/utils/vscode" import { cn } from "@/lib/utils" +import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" @@ -34,7 +35,6 @@ export const About = ({ version, telemetrySetting, setTelemetrySetting, classNam
{ const checked = e.target.checked === true @@ -42,12 +42,7 @@ export const About = ({ version, telemetrySetting, setTelemetrySetting, classNam }}> {t("settings:footer.telemetry.label")} -

+

{t("settings:footer.telemetry.description")}

@@ -63,15 +58,47 @@ export const About = ({ version, telemetrySetting, setTelemetrySetting, classNam /> -
-

{t("settings:footer.reset.description")}

- vscode.postMessage({ type: "resetState" })} - appearance="secondary" - className="shrink-0"> - +
+ + + + + + vscode.postMessage({ type: "importSettings", text: "provider" })}> + Current Provider Settings + + vscode.postMessage({ type: "importSettings", text: "global" })}> + Global Settings + + + + + + + + + vscode.postMessage({ type: "exportSettings", text: "provider" })}> + Current Provider Settings + + vscode.postMessage({ type: "exportSettings", text: "global" })}> + Global Settings + + + +
diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 79a080a65b..db1722d2e8 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -85,7 +85,7 @@ const SettingsView = forwardRef(({ onDone }, const { t } = useAppTranslation() const extensionState = useExtensionState() - const { currentApiConfigName, listApiConfigMeta, uriScheme, version } = extensionState + const { currentApiConfigName, listApiConfigMeta, uriScheme, version, settingsImportedAt } = extensionState const [isDiscardDialogShow, setDiscardDialogShow] = useState(false) const [isChangeDetected, setChangeDetected] = useState(false) @@ -136,6 +136,7 @@ const SettingsView = forwardRef(({ onDone }, // Make sure apiConfiguration is initialized and managed by SettingsView. const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) + useEffect(() => { // Update only when currentApiConfigName is changed. // Expected to be triggered by loadApiConfiguration/upsertApiConfiguration. @@ -148,6 +149,13 @@ const SettingsView = forwardRef(({ onDone }, setChangeDetected(false) }, [currentApiConfigName, extensionState, isChangeDetected]) + useEffect(() => { + if (settingsImportedAt) { + setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) + setChangeDetected(false) + } + }, [settingsImportedAt, extensionState]) + const setCachedStateField: SetCachedStateField = useCallback((field, value) => { setCachedState((prevState) => { if (prevState[field] === value) { @@ -180,11 +188,7 @@ const SettingsView = forwardRef(({ onDone }, } setChangeDetected(true) - - return { - ...prevState, - experiments: { ...prevState.experiments, [id]: enabled }, - } + return { ...prevState, experiments: { ...prevState.experiments, [id]: enabled } } }) }, []) @@ -193,11 +197,9 @@ const SettingsView = forwardRef(({ onDone }, if (prevState.telemetrySetting === setting) { return prevState } + setChangeDetected(true) - return { - ...prevState, - telemetrySetting: setting, - } + return { ...prevState, telemetrySetting: setting } }) }, []) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 389592fa27..de3aed071f 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -171,6 +171,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode (value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), [], ) + const handleMessage = useCallback( (event: MessageEvent) => { const message: ExtensionMessage = event.data @@ -250,13 +251,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setExperimentEnabled: (id, enabled) => setState((prevState) => ({ ...prevState, experiments: { ...prevState.experiments, [id]: enabled } })), setApiConfiguration: (value) => - setState((prevState) => ({ - ...prevState, - apiConfiguration: { - ...prevState.apiConfiguration, - ...value, - }, - })), + setState((prevState) => ({ ...prevState, apiConfiguration: { ...prevState.apiConfiguration, ...value } })), setCustomInstructions: (value) => setState((prevState) => ({ ...prevState, customInstructions: value })), setAlwaysAllowReadOnly: (value) => setState((prevState) => ({ ...prevState, alwaysAllowReadOnly: value })), setAlwaysAllowWrite: (value) => setState((prevState) => ({ ...prevState, alwaysAllowWrite: value })),