Skip to content

Commit 1472c19

Browse files
shivamd1810claudedaniel-lxs
authored
feat: register importSettings as VSCode command (#5095)
Co-authored-by: Claude <[email protected]> Co-authored-by: Daniel Riccio <[email protected]>
1 parent 8455909 commit 1472c19

26 files changed

+614
-21
lines changed

packages/types/src/vscode.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const commandIds = [
4848
"newTask",
4949

5050
"setCustomStoragePath",
51+
"importSettings",
5152

5253
"focusInput",
5354
"acceptInput",

src/activate/registerCommands.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { focusPanel } from "../utils/focusPanel"
1313
import { registerHumanRelayCallback, unregisterHumanRelayCallback, handleHumanRelayResponse } from "./humanRelay"
1414
import { handleNewTask } from "./handleTask"
1515
import { CodeIndexManager } from "../services/code-index/manager"
16+
import { importSettingsWithFeedback } from "../core/config/importExport"
17+
import { t } from "../i18n"
1618

1719
/**
1820
* Helper to get the visible ClineProvider instance or log if not found.
@@ -171,6 +173,22 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
171173
const { promptForCustomStoragePath } = await import("../utils/storage")
172174
await promptForCustomStoragePath()
173175
},
176+
importSettings: async (filePath?: string) => {
177+
const visibleProvider = getVisibleProviderOrLog(outputChannel)
178+
if (!visibleProvider) {
179+
return
180+
}
181+
182+
await importSettingsWithFeedback(
183+
{
184+
providerSettingsManager: visibleProvider.providerSettingsManager,
185+
contextProxy: visibleProvider.contextProxy,
186+
customModesManager: visibleProvider.customModesManager,
187+
provider: visibleProvider,
188+
},
189+
filePath,
190+
)
191+
},
174192
focusInput: async () => {
175193
try {
176194
await focusPanel(tabPanel, sidebarPanel)

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

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import * as vscode from "vscode"
88
import type { ProviderName } from "@roo-code/types"
99
import { TelemetryService } from "@roo-code/telemetry"
1010

11-
import { importSettings, exportSettings } from "../importExport"
11+
import { importSettings, importSettingsFromFile, importSettingsWithFeedback, exportSettings } from "../importExport"
1212
import { ProviderSettingsManager } from "../ProviderSettingsManager"
1313
import { ContextProxy } from "../ContextProxy"
1414
import { CustomModesManager } from "../CustomModesManager"
@@ -20,6 +20,8 @@ vi.mock("vscode", () => ({
2020
window: {
2121
showOpenDialog: vi.fn(),
2222
showSaveDialog: vi.fn(),
23+
showErrorMessage: vi.fn(),
24+
showInformationMessage: vi.fn(),
2325
},
2426
Uri: {
2527
file: vi.fn((filePath) => ({ fsPath: filePath })),
@@ -31,10 +33,20 @@ vi.mock("fs/promises", () => ({
3133
readFile: vi.fn(),
3234
mkdir: vi.fn(),
3335
writeFile: vi.fn(),
36+
access: vi.fn(),
37+
constants: {
38+
F_OK: 0,
39+
R_OK: 4,
40+
},
3441
},
3542
readFile: vi.fn(),
3643
mkdir: vi.fn(),
3744
writeFile: vi.fn(),
45+
access: vi.fn(),
46+
constants: {
47+
F_OK: 0,
48+
R_OK: 4,
49+
},
3850
}))
3951

4052
vi.mock("os", () => ({
@@ -96,7 +108,7 @@ describe("importExport", () => {
96108
customModesManager: mockCustomModesManager,
97109
})
98110

99-
expect(result).toEqual({ success: false })
111+
expect(result).toEqual({ success: false, error: "User cancelled file selection" })
100112

101113
expect(vscode.window.showOpenDialog).toHaveBeenCalledWith({
102114
filters: { JSON: ["json"] },
@@ -146,9 +158,12 @@ describe("importExport", () => {
146158
expect(mockProviderSettingsManager.export).toHaveBeenCalled()
147159

148160
expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({
149-
...previousProviderProfiles,
150161
currentApiConfigName: "test",
151-
apiConfigs: { test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" } },
162+
apiConfigs: {
163+
default: { apiProvider: "anthropic" as ProviderName, id: "default-id" },
164+
test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" },
165+
},
166+
modeApiConfigs: {},
152167
})
153168

154169
expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code", autoApprovalEnabled: true })
@@ -219,11 +234,12 @@ describe("importExport", () => {
219234
expect(fs.readFile).toHaveBeenCalledWith("/mock/path/settings.json", "utf-8")
220235
expect(mockProviderSettingsManager.export).toHaveBeenCalled()
221236
expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({
222-
...previousProviderProfiles,
223237
currentApiConfigName: "test",
224238
apiConfigs: {
239+
default: { apiProvider: "anthropic" as ProviderName, id: "default-id" },
225240
test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" },
226241
},
242+
modeApiConfigs: {},
227243
})
228244

229245
// Should call setValues with an empty object since globalSettings is missing.
@@ -297,9 +313,11 @@ describe("importExport", () => {
297313
})
298314

299315
expect(result.success).toBe(true)
300-
expect(result.providerProfiles?.apiConfigs["openai"]).toBeDefined()
301-
expect(result.providerProfiles?.apiConfigs["default"]).toBeDefined()
302-
expect(result.providerProfiles?.apiConfigs["default"].apiProvider).toBe("anthropic")
316+
if (result.success && "providerProfiles" in result) {
317+
expect(result.providerProfiles?.apiConfigs["openai"]).toBeDefined()
318+
expect(result.providerProfiles?.apiConfigs["default"]).toBeDefined()
319+
expect(result.providerProfiles?.apiConfigs["default"].apiProvider).toBe("anthropic")
320+
}
303321
})
304322

305323
it("should call updateCustomMode for each custom mode in config", async () => {
@@ -337,6 +355,87 @@ describe("importExport", () => {
337355
expect(mockCustomModesManager.updateCustomMode).toHaveBeenCalledWith(mode.slug, mode)
338356
})
339357
})
358+
359+
it("should import settings from provided file path without showing dialog", async () => {
360+
const filePath = "/mock/path/settings.json"
361+
const mockFileContent = JSON.stringify({
362+
providerProfiles: {
363+
currentApiConfigName: "test",
364+
apiConfigs: { test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" } },
365+
},
366+
globalSettings: { mode: "code", autoApprovalEnabled: true },
367+
})
368+
369+
;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
370+
;(fs.access as Mock).mockResolvedValue(undefined) // File exists and is readable
371+
372+
const previousProviderProfiles = {
373+
currentApiConfigName: "default",
374+
apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } },
375+
}
376+
377+
mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles)
378+
mockProviderSettingsManager.listConfig.mockResolvedValue([
379+
{ name: "test", id: "test-id", apiProvider: "openai" as ProviderName },
380+
{ name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName },
381+
])
382+
mockContextProxy.export.mockResolvedValue({ mode: "code" })
383+
384+
const result = await importSettingsFromFile(
385+
{
386+
providerSettingsManager: mockProviderSettingsManager,
387+
contextProxy: mockContextProxy,
388+
customModesManager: mockCustomModesManager,
389+
},
390+
vscode.Uri.file(filePath),
391+
)
392+
393+
expect(vscode.window.showOpenDialog).not.toHaveBeenCalled()
394+
expect(fs.readFile).toHaveBeenCalledWith(filePath, "utf-8")
395+
expect(result.success).toBe(true)
396+
expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({
397+
currentApiConfigName: "test",
398+
apiConfigs: {
399+
default: { apiProvider: "anthropic" as ProviderName, id: "default-id" },
400+
test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" },
401+
},
402+
modeApiConfigs: {},
403+
})
404+
expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code", autoApprovalEnabled: true })
405+
})
406+
407+
it("should return error when provided file path does not exist", async () => {
408+
const filePath = "/nonexistent/path/settings.json"
409+
const accessError = new Error("ENOENT: no such file or directory")
410+
411+
;(fs.access as Mock).mockRejectedValue(accessError)
412+
413+
// Create a mock provider for the test
414+
const mockProvider = {
415+
settingsImportedAt: 0,
416+
postStateToWebview: vi.fn().mockResolvedValue(undefined),
417+
}
418+
419+
// Mock the showErrorMessage to capture the error
420+
const showErrorMessageSpy = vi.spyOn(vscode.window, "showErrorMessage").mockResolvedValue(undefined)
421+
422+
await importSettingsWithFeedback(
423+
{
424+
providerSettingsManager: mockProviderSettingsManager,
425+
contextProxy: mockContextProxy,
426+
customModesManager: mockCustomModesManager,
427+
provider: mockProvider,
428+
},
429+
filePath,
430+
)
431+
432+
expect(vscode.window.showOpenDialog).not.toHaveBeenCalled()
433+
expect(fs.access).toHaveBeenCalledWith(filePath, fs.constants.F_OK | fs.constants.R_OK)
434+
expect(fs.readFile).not.toHaveBeenCalled()
435+
expect(showErrorMessageSpy).toHaveBeenCalledWith(expect.stringContaining("errors.settings_import_failed"))
436+
437+
showErrorMessageSpy.mockRestore()
438+
})
340439
})
341440

342441
describe("exportSettings", () => {

src/core/config/importExport.ts

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { TelemetryService } from "@roo-code/telemetry"
1212
import { ProviderSettingsManager, providerProfilesSchema } from "./ProviderSettingsManager"
1313
import { ContextProxy } from "./ContextProxy"
1414
import { CustomModesManager } from "./CustomModesManager"
15+
import { t } from "../../i18n"
1516

1617
type ImportOptions = {
1718
providerSettingsManager: ProviderSettingsManager
@@ -24,16 +25,41 @@ type ExportOptions = {
2425
contextProxy: ContextProxy
2526
}
2627

28+
type ImportWithProviderOptions = ImportOptions & {
29+
provider: {
30+
settingsImportedAt?: number
31+
postStateToWebview: () => Promise<void>
32+
}
33+
}
34+
35+
/**
36+
* Import settings from a file using a file dialog
37+
* @param options - Import options containing managers and proxy
38+
* @returns Promise resolving to import result
39+
*/
2740
export const importSettings = async ({ providerSettingsManager, contextProxy, customModesManager }: ImportOptions) => {
2841
const uris = await vscode.window.showOpenDialog({
2942
filters: { JSON: ["json"] },
3043
canSelectMany: false,
3144
})
3245

3346
if (!uris) {
34-
return { success: false }
47+
return { success: false, error: "User cancelled file selection" }
3548
}
3649

50+
return await importSettingsFromFile({ providerSettingsManager, contextProxy, customModesManager }, uris[0])
51+
}
52+
53+
/**
54+
* Import settings from a specific file
55+
* @param options - Import options containing managers and proxy
56+
* @param fileUri - URI of the file to import from
57+
* @returns Promise resolving to import result
58+
*/
59+
export const importSettingsFromFile = async (
60+
{ providerSettingsManager, contextProxy, customModesManager }: ImportOptions,
61+
fileUri: vscode.Uri,
62+
) => {
3763
const schema = z.object({
3864
providerProfiles: providerProfilesSchema,
3965
globalSettings: globalSettingsSchema.optional(),
@@ -42,7 +68,7 @@ export const importSettings = async ({ providerSettingsManager, contextProxy, cu
4268
try {
4369
const previousProviderProfiles = await providerSettingsManager.export()
4470

45-
const data = JSON.parse(await fs.readFile(uris[0].fsPath, "utf-8"))
71+
const data = JSON.parse(await fs.readFile(fileUri.fsPath, "utf-8"))
4672
const { providerProfiles: newProviderProfiles, globalSettings = {} } = schema.parse(data)
4773

4874
const providerProfiles = {
@@ -61,7 +87,7 @@ export const importSettings = async ({ providerSettingsManager, contextProxy, cu
6187
(globalSettings.customModes ?? []).map((mode) => customModesManager.updateCustomMode(mode.slug, mode)),
6288
)
6389

64-
await providerSettingsManager.import(newProviderProfiles)
90+
await providerSettingsManager.import(providerProfiles)
6591
await contextProxy.setValues(globalSettings)
6692

6793
// Set the current provider.
@@ -120,3 +146,44 @@ export const exportSettings = async ({ providerSettingsManager, contextProxy }:
120146
await safeWriteJson(uri.fsPath, { providerProfiles, globalSettings })
121147
} catch (e) {}
122148
}
149+
150+
/**
151+
* Import settings with complete UI feedback and provider state updates
152+
* @param options - Import options with provider instance
153+
* @param filePath - Optional file path to import from. If not provided, a file dialog will be shown.
154+
* @returns Promise that resolves when import is complete
155+
*/
156+
export const importSettingsWithFeedback = async (
157+
{ providerSettingsManager, contextProxy, customModesManager, provider }: ImportWithProviderOptions,
158+
filePath?: string,
159+
) => {
160+
let result
161+
162+
if (filePath) {
163+
// Validate file path and check if file exists
164+
try {
165+
const fileUri = vscode.Uri.file(filePath)
166+
// Check if file exists and is readable
167+
await fs.access(fileUri.fsPath, fs.constants.F_OK | fs.constants.R_OK)
168+
result = await importSettingsFromFile(
169+
{ providerSettingsManager, contextProxy, customModesManager },
170+
fileUri,
171+
)
172+
} catch (error) {
173+
result = {
174+
success: false,
175+
error: `Cannot access file at path "${filePath}": ${error instanceof Error ? error.message : "Unknown error"}`,
176+
}
177+
}
178+
} else {
179+
result = await importSettings({ providerSettingsManager, contextProxy, customModesManager })
180+
}
181+
182+
if (result.success) {
183+
provider.settingsImportedAt = Date.now()
184+
await provider.postStateToWebview()
185+
await vscode.window.showInformationMessage(t("common:info.settings_imported"))
186+
} else if (result.error) {
187+
await vscode.window.showErrorMessage(t("common:errors.settings_import_failed", { error: result.error }))
188+
}
189+
}

src/core/webview/webviewMessageHandler.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { fileExistsAtPath } from "../../utils/fs"
2828
import { playTts, setTtsEnabled, setTtsSpeed, stopTts } from "../../utils/tts"
2929
import { singleCompletionHandler } from "../../utils/single-completion-handler"
3030
import { searchCommits } from "../../utils/git"
31-
import { exportSettings, importSettings } from "../config/importExport"
31+
import { exportSettings, importSettingsWithFeedback } from "../config/importExport"
3232
import { getOpenAiModels } from "../../api/providers/openai"
3333
import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
3434
import { openMention } from "../mentions"
@@ -325,20 +325,13 @@ export const webviewMessageHandler = async (
325325
provider.exportTaskWithId(message.text!)
326326
break
327327
case "importSettings": {
328-
const result = await importSettings({
328+
await importSettingsWithFeedback({
329329
providerSettingsManager: provider.providerSettingsManager,
330330
contextProxy: provider.contextProxy,
331331
customModesManager: provider.customModesManager,
332+
provider: provider,
332333
})
333334

334-
if (result.success) {
335-
provider.settingsImportedAt = Date.now()
336-
await provider.postStateToWebview()
337-
await vscode.window.showInformationMessage(t("common:info.settings_imported"))
338-
} else if (result.error) {
339-
await vscode.window.showErrorMessage(t("common:errors.settings_import_failed", { error: result.error }))
340-
}
341-
342335
break
343336
}
344337
case "exportSettings":

src/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,11 @@
155155
"title": "%command.setCustomStoragePath.title%",
156156
"category": "%configuration.title%"
157157
},
158+
{
159+
"command": "roo-cline.importSettings",
160+
"title": "%command.importSettings.title%",
161+
"category": "%configuration.title%"
162+
},
158163
{
159164
"command": "roo-cline.focusInput",
160165
"title": "%command.focusInput.title%",

src/package.nls.ca.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"command.openInNewTab.title": "Obrir en una Nova Pestanya",
1010
"command.focusInput.title": "Enfocar Camp d'Entrada",
1111
"command.setCustomStoragePath.title": "Establir Ruta d'Emmagatzematge Personalitzada",
12+
"command.importSettings.title": "Importar Configuració",
1213
"command.terminal.addToContext.title": "Afegir Contingut del Terminal al Context",
1314
"command.terminal.fixCommand.title": "Corregir Aquesta Ordre",
1415
"command.terminal.explainCommand.title": "Explicar Aquesta Ordre",

src/package.nls.de.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"command.openInNewTab.title": "In Neuem Tab Öffnen",
1010
"command.focusInput.title": "Eingabefeld Fokussieren",
1111
"command.setCustomStoragePath.title": "Benutzerdefinierten Speicherpfad Festlegen",
12+
"command.importSettings.title": "Einstellungen Importieren",
1213
"command.terminal.addToContext.title": "Terminal-Inhalt zum Kontext Hinzufügen",
1314
"command.terminal.fixCommand.title": "Diesen Befehl Reparieren",
1415
"command.terminal.explainCommand.title": "Diesen Befehl Erklären",

0 commit comments

Comments
 (0)