Import / export

This commit is contained in:
cte 2025-03-25 02:30:10 -07:00
parent 8f64109894
commit edafcf04ba
12 changed files with 485 additions and 73 deletions

View file

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

View file

@ -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<ConfigurationValues>,
),
...API_CONFIG_KEYS.filter((key) => isGlobalStateKey(key))
.filter((key) => !!this.stateCache.get(key))
.reduce((acc, key) => ({ ...acc, [key]: undefined }), {} as Partial<ConfigurationValues>),
...apiConfiguration,
})
}
private getAllGlobalStateValues() {
const values: Partial<Record<GlobalStateKey, any>> = {}
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<Record<SecretKey, string>> = {}
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<string>([...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<string>([...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()
}
}

View file

@ -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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> implements
language,
renderContext: this.renderContext,
maxReadFileLine: maxReadFileLine ?? 500,
settingsImportedAt: this.settingsImportedAt,
}
}
@ -2671,7 +2709,9 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> 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,
}

View file

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

View file

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

View file

@ -34,6 +34,8 @@ export interface WebviewMessage {
| "showTaskWithId"
| "deleteTaskWithId"
| "exportTaskWithId"
| "importSettings"
| "exportSettings"
| "resetState"
| "requestOllamaModels"
| "requestLmStudioModels"

View file

@ -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<Equals<ModelInfo, z.infer<typeof modelInfoSchema>>>
// 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<ApiHandlerOptions, z.infer<typeof apiHandlerOptionsSchema>>
>
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

View file

@ -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<SecretKey, (typeof SECRET_KEYS)[number]> 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<GlobalStateKey, (typeof GLOBAL_STATE_KEYS)[number]> 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())

3
src/utils/type-fu.ts Normal file
View file

@ -0,0 +1,3 @@
export type Equals<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false
export type AssertEqual<T extends true> = T

View file

@ -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
<Section>
<div>
<VSCodeCheckbox
style={{ marginBottom: "5px" }}
checked={telemetrySetting === "enabled"}
onChange={(e: any) => {
const checked = e.target.checked === true
@ -42,12 +42,7 @@ export const About = ({ version, telemetrySetting, setTelemetrySetting, classNam
}}>
{t("settings:footer.telemetry.label")}
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
<p className="text-vscode-descriptionForeground text-sm mt-0">
{t("settings:footer.telemetry.description")}
</p>
</div>
@ -63,15 +58,47 @@ export const About = ({ version, telemetrySetting, setTelemetrySetting, classNam
/>
</div>
<div className="flex justify-between items-center gap-3">
<p>{t("settings:footer.reset.description")}</p>
<VSCodeButton
onClick={() => vscode.postMessage({ type: "resetState" })}
appearance="secondary"
className="shrink-0">
<span className="codicon codicon-warning text-vscode-errorForeground mr-1" />
<div className="flex items-center gap-2 mt-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
<Download className="p-0.5" />
Import
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => vscode.postMessage({ type: "importSettings", text: "provider" })}>
Current Provider Settings
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => vscode.postMessage({ type: "importSettings", text: "global" })}>
Global Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
<Upload className="p-0.5" />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => vscode.postMessage({ type: "exportSettings", text: "provider" })}>
Current Provider Settings
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => vscode.postMessage({ type: "exportSettings", text: "global" })}>
Global Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="destructive" onClick={() => vscode.postMessage({ type: "resetState" })}>
<TriangleAlert className="p-0.5" />
{t("settings:footer.reset.button")}
</VSCodeButton>
</Button>
</div>
</Section>
</div>

View file

@ -85,7 +85,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ 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<SettingsViewRef, SettingsViewProps>(({ 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<SettingsViewRef, SettingsViewProps>(({ onDone },
setChangeDetected(false)
}, [currentApiConfigName, extensionState, isChangeDetected])
useEffect(() => {
if (settingsImportedAt) {
setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState }))
setChangeDetected(false)
}
}, [settingsImportedAt, extensionState])
const setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType> = useCallback((field, value) => {
setCachedState((prevState) => {
if (prevState[field] === value) {
@ -180,11 +188,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
}
setChangeDetected(true)
return {
...prevState,
experiments: { ...prevState.experiments, [id]: enabled },
}
return { ...prevState, experiments: { ...prevState.experiments, [id]: enabled } }
})
}, [])
@ -193,11 +197,9 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
if (prevState.telemetrySetting === setting) {
return prevState
}
setChangeDetected(true)
return {
...prevState,
telemetrySetting: setting,
}
return { ...prevState, telemetrySetting: setting }
})
}, [])

View file

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