diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index e6640e9bb6..00f6bbbcba 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -48,6 +48,7 @@ export const commandIds = [ "newTask", "setCustomStoragePath", + "importSettings", "focusInput", "acceptInput", diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index fc30878c7b..8e84981d8a 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -13,6 +13,8 @@ import { focusPanel } from "../utils/focusPanel" import { registerHumanRelayCallback, unregisterHumanRelayCallback, handleHumanRelayResponse } from "./humanRelay" import { handleNewTask } from "./handleTask" import { CodeIndexManager } from "../services/code-index/manager" +import { importSettingsWithFeedback } from "../core/config/importExport" +import { t } from "../i18n" /** * Helper to get the visible ClineProvider instance or log if not found. @@ -171,6 +173,22 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt const { promptForCustomStoragePath } = await import("../utils/storage") await promptForCustomStoragePath() }, + importSettings: async (filePath?: string) => { + const visibleProvider = getVisibleProviderOrLog(outputChannel) + if (!visibleProvider) { + return + } + + await importSettingsWithFeedback( + { + providerSettingsManager: visibleProvider.providerSettingsManager, + contextProxy: visibleProvider.contextProxy, + customModesManager: visibleProvider.customModesManager, + provider: visibleProvider, + }, + filePath, + ) + }, focusInput: async () => { try { await focusPanel(tabPanel, sidebarPanel) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 4a525c3e63..1f6bd5f28e 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -8,7 +8,7 @@ import * as vscode from "vscode" import type { ProviderName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { importSettings, exportSettings } from "../importExport" +import { importSettings, importSettingsFromFile, importSettingsWithFeedback, exportSettings } from "../importExport" import { ProviderSettingsManager } from "../ProviderSettingsManager" import { ContextProxy } from "../ContextProxy" import { CustomModesManager } from "../CustomModesManager" @@ -20,6 +20,8 @@ vi.mock("vscode", () => ({ window: { showOpenDialog: vi.fn(), showSaveDialog: vi.fn(), + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), }, Uri: { file: vi.fn((filePath) => ({ fsPath: filePath })), @@ -31,10 +33,20 @@ vi.mock("fs/promises", () => ({ readFile: vi.fn(), mkdir: vi.fn(), writeFile: vi.fn(), + access: vi.fn(), + constants: { + F_OK: 0, + R_OK: 4, + }, }, readFile: vi.fn(), mkdir: vi.fn(), writeFile: vi.fn(), + access: vi.fn(), + constants: { + F_OK: 0, + R_OK: 4, + }, })) vi.mock("os", () => ({ @@ -96,7 +108,7 @@ describe("importExport", () => { customModesManager: mockCustomModesManager, }) - expect(result).toEqual({ success: false }) + expect(result).toEqual({ success: false, error: "User cancelled file selection" }) expect(vscode.window.showOpenDialog).toHaveBeenCalledWith({ filters: { JSON: ["json"] }, @@ -146,9 +158,12 @@ describe("importExport", () => { expect(mockProviderSettingsManager.export).toHaveBeenCalled() expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({ - ...previousProviderProfiles, currentApiConfigName: "test", - apiConfigs: { test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" } }, + apiConfigs: { + default: { apiProvider: "anthropic" as ProviderName, id: "default-id" }, + test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" }, + }, + modeApiConfigs: {}, }) expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code", autoApprovalEnabled: true }) @@ -219,11 +234,12 @@ describe("importExport", () => { expect(fs.readFile).toHaveBeenCalledWith("/mock/path/settings.json", "utf-8") expect(mockProviderSettingsManager.export).toHaveBeenCalled() expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({ - ...previousProviderProfiles, currentApiConfigName: "test", apiConfigs: { + default: { apiProvider: "anthropic" as ProviderName, id: "default-id" }, test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" }, }, + modeApiConfigs: {}, }) // Should call setValues with an empty object since globalSettings is missing. @@ -297,9 +313,11 @@ describe("importExport", () => { }) expect(result.success).toBe(true) - expect(result.providerProfiles?.apiConfigs["openai"]).toBeDefined() - expect(result.providerProfiles?.apiConfigs["default"]).toBeDefined() - expect(result.providerProfiles?.apiConfigs["default"].apiProvider).toBe("anthropic") + if (result.success && "providerProfiles" in result) { + expect(result.providerProfiles?.apiConfigs["openai"]).toBeDefined() + expect(result.providerProfiles?.apiConfigs["default"]).toBeDefined() + expect(result.providerProfiles?.apiConfigs["default"].apiProvider).toBe("anthropic") + } }) it("should call updateCustomMode for each custom mode in config", async () => { @@ -337,6 +355,87 @@ describe("importExport", () => { expect(mockCustomModesManager.updateCustomMode).toHaveBeenCalledWith(mode.slug, mode) }) }) + + it("should import settings from provided file path without showing dialog", async () => { + const filePath = "/mock/path/settings.json" + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "test", + apiConfigs: { test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" } }, + }, + globalSettings: { mode: "code", autoApprovalEnabled: true }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) + ;(fs.access as Mock).mockResolvedValue(undefined) // File exists and is readable + + const previousProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + } + + mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "test", id: "test-id", apiProvider: "openai" as ProviderName }, + { name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName }, + ]) + mockContextProxy.export.mockResolvedValue({ mode: "code" }) + + const result = await importSettingsFromFile( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }, + vscode.Uri.file(filePath), + ) + + expect(vscode.window.showOpenDialog).not.toHaveBeenCalled() + expect(fs.readFile).toHaveBeenCalledWith(filePath, "utf-8") + expect(result.success).toBe(true) + expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({ + currentApiConfigName: "test", + apiConfigs: { + default: { apiProvider: "anthropic" as ProviderName, id: "default-id" }, + test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" }, + }, + modeApiConfigs: {}, + }) + expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code", autoApprovalEnabled: true }) + }) + + it("should return error when provided file path does not exist", async () => { + const filePath = "/nonexistent/path/settings.json" + const accessError = new Error("ENOENT: no such file or directory") + + ;(fs.access as Mock).mockRejectedValue(accessError) + + // Create a mock provider for the test + const mockProvider = { + settingsImportedAt: 0, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + } + + // Mock the showErrorMessage to capture the error + const showErrorMessageSpy = vi.spyOn(vscode.window, "showErrorMessage").mockResolvedValue(undefined) + + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) + + expect(vscode.window.showOpenDialog).not.toHaveBeenCalled() + expect(fs.access).toHaveBeenCalledWith(filePath, fs.constants.F_OK | fs.constants.R_OK) + expect(fs.readFile).not.toHaveBeenCalled() + expect(showErrorMessageSpy).toHaveBeenCalledWith(expect.stringContaining("errors.settings_import_failed")) + + showErrorMessageSpy.mockRestore() + }) }) describe("exportSettings", () => { diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index cd92a81ff6..65638ab95e 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -12,6 +12,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { ProviderSettingsManager, providerProfilesSchema } from "./ProviderSettingsManager" import { ContextProxy } from "./ContextProxy" import { CustomModesManager } from "./CustomModesManager" +import { t } from "../../i18n" type ImportOptions = { providerSettingsManager: ProviderSettingsManager @@ -24,6 +25,18 @@ type ExportOptions = { contextProxy: ContextProxy } +type ImportWithProviderOptions = ImportOptions & { + provider: { + settingsImportedAt?: number + postStateToWebview: () => Promise + } +} + +/** + * Import settings from a file using a file dialog + * @param options - Import options containing managers and proxy + * @returns Promise resolving to import result + */ export const importSettings = async ({ providerSettingsManager, contextProxy, customModesManager }: ImportOptions) => { const uris = await vscode.window.showOpenDialog({ filters: { JSON: ["json"] }, @@ -31,9 +44,22 @@ export const importSettings = async ({ providerSettingsManager, contextProxy, cu }) if (!uris) { - return { success: false } + return { success: false, error: "User cancelled file selection" } } + return await importSettingsFromFile({ providerSettingsManager, contextProxy, customModesManager }, uris[0]) +} + +/** + * Import settings from a specific file + * @param options - Import options containing managers and proxy + * @param fileUri - URI of the file to import from + * @returns Promise resolving to import result + */ +export const importSettingsFromFile = async ( + { providerSettingsManager, contextProxy, customModesManager }: ImportOptions, + fileUri: vscode.Uri, +) => { const schema = z.object({ providerProfiles: providerProfilesSchema, globalSettings: globalSettingsSchema.optional(), @@ -42,7 +68,7 @@ export const importSettings = async ({ providerSettingsManager, contextProxy, cu try { const previousProviderProfiles = await providerSettingsManager.export() - const data = JSON.parse(await fs.readFile(uris[0].fsPath, "utf-8")) + const data = JSON.parse(await fs.readFile(fileUri.fsPath, "utf-8")) const { providerProfiles: newProviderProfiles, globalSettings = {} } = schema.parse(data) const providerProfiles = { @@ -61,7 +87,7 @@ export const importSettings = async ({ providerSettingsManager, contextProxy, cu (globalSettings.customModes ?? []).map((mode) => customModesManager.updateCustomMode(mode.slug, mode)), ) - await providerSettingsManager.import(newProviderProfiles) + await providerSettingsManager.import(providerProfiles) await contextProxy.setValues(globalSettings) // Set the current provider. @@ -120,3 +146,44 @@ export const exportSettings = async ({ providerSettingsManager, contextProxy }: await safeWriteJson(uri.fsPath, { providerProfiles, globalSettings }) } catch (e) {} } + +/** + * Import settings with complete UI feedback and provider state updates + * @param options - Import options with provider instance + * @param filePath - Optional file path to import from. If not provided, a file dialog will be shown. + * @returns Promise that resolves when import is complete + */ +export const importSettingsWithFeedback = async ( + { providerSettingsManager, contextProxy, customModesManager, provider }: ImportWithProviderOptions, + filePath?: string, +) => { + let result + + if (filePath) { + // Validate file path and check if file exists + try { + const fileUri = vscode.Uri.file(filePath) + // Check if file exists and is readable + await fs.access(fileUri.fsPath, fs.constants.F_OK | fs.constants.R_OK) + result = await importSettingsFromFile( + { providerSettingsManager, contextProxy, customModesManager }, + fileUri, + ) + } catch (error) { + result = { + success: false, + error: `Cannot access file at path "${filePath}": ${error instanceof Error ? error.message : "Unknown error"}`, + } + } + } else { + result = await importSettings({ providerSettingsManager, contextProxy, customModesManager }) + } + + if (result.success) { + provider.settingsImportedAt = Date.now() + await provider.postStateToWebview() + await vscode.window.showInformationMessage(t("common:info.settings_imported")) + } else if (result.error) { + await vscode.window.showErrorMessage(t("common:errors.settings_import_failed", { error: result.error })) + } +} diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index a45a6a34fc..cac94aa0ce 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -28,7 +28,7 @@ import { fileExistsAtPath } from "../../utils/fs" import { playTts, setTtsEnabled, setTtsSpeed, stopTts } from "../../utils/tts" import { singleCompletionHandler } from "../../utils/single-completion-handler" import { searchCommits } from "../../utils/git" -import { exportSettings, importSettings } from "../config/importExport" +import { exportSettings, importSettingsWithFeedback } from "../config/importExport" import { getOpenAiModels } from "../../api/providers/openai" import { getVsCodeLmModels } from "../../api/providers/vscode-lm" import { openMention } from "../mentions" @@ -325,20 +325,13 @@ export const webviewMessageHandler = async ( provider.exportTaskWithId(message.text!) break case "importSettings": { - const result = await importSettings({ + await importSettingsWithFeedback({ providerSettingsManager: provider.providerSettingsManager, contextProxy: provider.contextProxy, customModesManager: provider.customModesManager, + provider: provider, }) - if (result.success) { - provider.settingsImportedAt = Date.now() - await provider.postStateToWebview() - await vscode.window.showInformationMessage(t("common:info.settings_imported")) - } else if (result.error) { - await vscode.window.showErrorMessage(t("common:errors.settings_import_failed", { error: result.error })) - } - break } case "exportSettings": diff --git a/src/package.json b/src/package.json index 0266a694b4..df87ced36c 100644 --- a/src/package.json +++ b/src/package.json @@ -155,6 +155,11 @@ "title": "%command.setCustomStoragePath.title%", "category": "%configuration.title%" }, + { + "command": "roo-cline.importSettings", + "title": "%command.importSettings.title%", + "category": "%configuration.title%" + }, { "command": "roo-cline.focusInput", "title": "%command.focusInput.title%", diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json index b20c814724..f20f269e20 100644 --- a/src/package.nls.ca.json +++ b/src/package.nls.ca.json @@ -9,6 +9,7 @@ "command.openInNewTab.title": "Obrir en una Nova Pestanya", "command.focusInput.title": "Enfocar Camp d'Entrada", "command.setCustomStoragePath.title": "Establir Ruta d'Emmagatzematge Personalitzada", + "command.importSettings.title": "Importar Configuració", "command.terminal.addToContext.title": "Afegir Contingut del Terminal al Context", "command.terminal.fixCommand.title": "Corregir Aquesta Ordre", "command.terminal.explainCommand.title": "Explicar Aquesta Ordre", diff --git a/src/package.nls.de.json b/src/package.nls.de.json index c31c8b1629..781b310668 100644 --- a/src/package.nls.de.json +++ b/src/package.nls.de.json @@ -9,6 +9,7 @@ "command.openInNewTab.title": "In Neuem Tab Öffnen", "command.focusInput.title": "Eingabefeld Fokussieren", "command.setCustomStoragePath.title": "Benutzerdefinierten Speicherpfad Festlegen", + "command.importSettings.title": "Einstellungen Importieren", "command.terminal.addToContext.title": "Terminal-Inhalt zum Kontext Hinzufügen", "command.terminal.fixCommand.title": "Diesen Befehl Reparieren", "command.terminal.explainCommand.title": "Diesen Befehl Erklären", diff --git a/src/package.nls.es.json b/src/package.nls.es.json index 3bf575427f..4938f5ea64 100644 --- a/src/package.nls.es.json +++ b/src/package.nls.es.json @@ -9,6 +9,7 @@ "command.openInNewTab.title": "Abrir en Nueva Pestaña", "command.focusInput.title": "Enfocar Campo de Entrada", "command.setCustomStoragePath.title": "Establecer Ruta de Almacenamiento Personalizada", + "command.importSettings.title": "Importar Configuración", "command.terminal.addToContext.title": "Añadir Contenido de Terminal al Contexto", "command.terminal.fixCommand.title": "Corregir Este Comando", "command.terminal.explainCommand.title": "Explicar Este Comando", diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json index 62103db8b0..ada2502e4f 100644 --- a/src/package.nls.fr.json +++ b/src/package.nls.fr.json @@ -9,6 +9,7 @@ "command.openInNewTab.title": "Ouvrir dans un Nouvel Onglet", "command.focusInput.title": "Focus sur le Champ de Saisie", "command.setCustomStoragePath.title": "Définir le Chemin de Stockage Personnalisé", + "command.importSettings.title": "Importer les Paramètres", "command.terminal.addToContext.title": "Ajouter le Contenu du Terminal au Contexte", "command.terminal.fixCommand.title": "Corriger cette Commande", "command.terminal.explainCommand.title": "Expliquer cette Commande", diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json index 022ca3d50d..f06e21cf06 100644 --- a/src/package.nls.hi.json +++ b/src/package.nls.hi.json @@ -9,6 +9,7 @@ "command.openInNewTab.title": "नए टैब में खोलें", "command.focusInput.title": "इनपुट फ़ील्ड पर फोकस करें", "command.setCustomStoragePath.title": "कस्टम स्टोरेज पाथ सेट करें", + "command.importSettings.title": "सेटिंग्स इम्पोर्ट करें", "command.terminal.addToContext.title": "टर्मिनल सामग्री को संदर्भ में जोड़ें", "command.terminal.fixCommand.title": "यह कमांड ठीक करें", "command.terminal.explainCommand.title": "यह कमांड समझाएं", diff --git a/src/package.nls.id.json b/src/package.nls.id.json index 0e9ca6a7e4..c7461aab6b 100644 --- a/src/package.nls.id.json +++ b/src/package.nls.id.json @@ -20,6 +20,7 @@ "command.addToContext.title": "Tambahkan ke Konteks", "command.focusInput.title": "Fokus ke Field Input", "command.setCustomStoragePath.title": "Atur Path Penyimpanan Kustom", + "command.importSettings.title": "Impor Pengaturan", "command.terminal.addToContext.title": "Tambahkan Konten Terminal ke Konteks", "command.terminal.fixCommand.title": "Perbaiki Perintah Ini", "command.terminal.explainCommand.title": "Jelaskan Perintah Ini", diff --git a/src/package.nls.it.json b/src/package.nls.it.json index 1ee2ecc7eb..cc63935e60 100644 --- a/src/package.nls.it.json +++ b/src/package.nls.it.json @@ -9,6 +9,7 @@ "command.openInNewTab.title": "Apri in Nuova Scheda", "command.focusInput.title": "Focalizza Campo di Input", "command.setCustomStoragePath.title": "Imposta Percorso di Archiviazione Personalizzato", + "command.importSettings.title": "Importa Impostazioni", "command.terminal.addToContext.title": "Aggiungi Contenuto del Terminale al Contesto", "command.terminal.fixCommand.title": "Correggi Questo Comando", "command.terminal.explainCommand.title": "Spiega Questo Comando", diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json index a7514d0d6f..7e601d3c78 100644 --- a/src/package.nls.ja.json +++ b/src/package.nls.ja.json @@ -20,6 +20,7 @@ "command.addToContext.title": "コンテキストに追加", "command.focusInput.title": "入力フィールドにフォーカス", "command.setCustomStoragePath.title": "カスタムストレージパスの設定", + "command.importSettings.title": "設定をインポート", "command.terminal.addToContext.title": "ターミナルの内容をコンテキストに追加", "command.terminal.fixCommand.title": "このコマンドを修正", "command.terminal.explainCommand.title": "このコマンドを説明", diff --git a/src/package.nls.json b/src/package.nls.json index b4ccbb0e2e..b6880b8bfe 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -20,6 +20,7 @@ "command.addToContext.title": "Add To Context", "command.focusInput.title": "Focus Input Field", "command.setCustomStoragePath.title": "Set Custom Storage Path", + "command.importSettings.title": "Import Settings", "command.terminal.addToContext.title": "Add Terminal Content to Context", "command.terminal.fixCommand.title": "Fix This Command", "command.terminal.explainCommand.title": "Explain This Command", diff --git a/src/package.nls.ko.json b/src/package.nls.ko.json index 2dae8aedcb..e305da4c4c 100644 --- a/src/package.nls.ko.json +++ b/src/package.nls.ko.json @@ -9,6 +9,7 @@ "command.openInNewTab.title": "새 탭에서 열기", "command.focusInput.title": "입력 필드 포커스", "command.setCustomStoragePath.title": "사용자 지정 저장소 경로 설정", + "command.importSettings.title": "설정 가져오기", "command.terminal.addToContext.title": "터미널 내용을 컨텍스트에 추가", "command.terminal.fixCommand.title": "이 명령어 수정", "command.terminal.explainCommand.title": "이 명령어 설명", diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json index e9a00f1f3f..3cd1880941 100644 --- a/src/package.nls.nl.json +++ b/src/package.nls.nl.json @@ -20,6 +20,7 @@ "command.addToContext.title": "Toevoegen aan Context", "command.focusInput.title": "Focus op Invoerveld", "command.setCustomStoragePath.title": "Aangepast Opslagpad Instellen", + "command.importSettings.title": "Instellingen Importeren", "command.terminal.addToContext.title": "Terminalinhoud aan Context Toevoegen", "command.terminal.fixCommand.title": "Repareer Dit Commando", "command.terminal.explainCommand.title": "Leg Dit Commando Uit", diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json index d1c5bfa39c..275c404d06 100644 --- a/src/package.nls.pl.json +++ b/src/package.nls.pl.json @@ -9,6 +9,7 @@ "command.openInNewTab.title": "Otwórz w Nowej Karcie", "command.focusInput.title": "Fokus na Pole Wprowadzania", "command.setCustomStoragePath.title": "Ustaw Niestandardową Ścieżkę Przechowywania", + "command.importSettings.title": "Importuj Ustawienia", "command.terminal.addToContext.title": "Dodaj Zawartość Terminala do Kontekstu", "command.terminal.fixCommand.title": "Napraw tę Komendę", "command.terminal.explainCommand.title": "Wyjaśnij tę Komendę", diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json index 5b7b5d8a10..057f255c44 100644 --- a/src/package.nls.pt-BR.json +++ b/src/package.nls.pt-BR.json @@ -9,6 +9,7 @@ "command.openInNewTab.title": "Abrir em Nova Aba", "command.focusInput.title": "Focar Campo de Entrada", "command.setCustomStoragePath.title": "Definir Caminho de Armazenamento Personalizado", + "command.importSettings.title": "Importar Configurações", "command.terminal.addToContext.title": "Adicionar Conteúdo do Terminal ao Contexto", "command.terminal.fixCommand.title": "Corrigir Este Comando", "command.terminal.explainCommand.title": "Explicar Este Comando", diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json index d33c05ed00..02a5bcf93d 100644 --- a/src/package.nls.ru.json +++ b/src/package.nls.ru.json @@ -20,6 +20,7 @@ "command.addToContext.title": "Добавить в контекст", "command.focusInput.title": "Фокус на поле ввода", "command.setCustomStoragePath.title": "Указать путь хранения", + "command.importSettings.title": "Импортировать настройки", "command.terminal.addToContext.title": "Добавить содержимое терминала в контекст", "command.terminal.fixCommand.title": "Исправить эту команду", "command.terminal.explainCommand.title": "Объяснить эту команду", diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json index 08a7e2ccca..dda6e9e8d1 100644 --- a/src/package.nls.tr.json +++ b/src/package.nls.tr.json @@ -9,6 +9,7 @@ "command.openInNewTab.title": "Yeni Sekmede Aç", "command.focusInput.title": "Giriş Alanına Odaklan", "command.setCustomStoragePath.title": "Özel Depolama Yolunu Ayarla", + "command.importSettings.title": "Ayarları İçe Aktar", "command.terminal.addToContext.title": "Terminal İçeriğini Bağlama Ekle", "command.terminal.fixCommand.title": "Bu Komutu Düzelt", "command.terminal.explainCommand.title": "Bu Komutu Açıkla", diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json index cc8561b817..985465acb7 100644 --- a/src/package.nls.vi.json +++ b/src/package.nls.vi.json @@ -9,6 +9,7 @@ "command.openInNewTab.title": "Mở trong Tab Mới", "command.focusInput.title": "Tập Trung vào Trường Nhập", "command.setCustomStoragePath.title": "Đặt Đường Dẫn Lưu Trữ Tùy Chỉnh", + "command.importSettings.title": "Nhập Cài Đặt", "command.terminal.addToContext.title": "Thêm Nội Dung Terminal vào Ngữ Cảnh", "command.terminal.fixCommand.title": "Sửa Lệnh Này", "command.terminal.explainCommand.title": "Giải Thích Lệnh Này", diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index fb0cf02b88..25d4e15c0a 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -9,6 +9,7 @@ "command.openInNewTab.title": "在新标签页中打开", "command.focusInput.title": "聚焦输入框", "command.setCustomStoragePath.title": "设置自定义存储路径", + "command.importSettings.title": "导入设置", "command.terminal.addToContext.title": "将终端内容添加到上下文", "command.terminal.fixCommand.title": "修复此命令", "command.terminal.explainCommand.title": "解释此命令", diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index f9679fbb2e..918b4bff33 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -9,6 +9,7 @@ "command.openInNewTab.title": "在新分頁中開啟", "command.focusInput.title": "聚焦輸入框", "command.setCustomStoragePath.title": "設定自訂儲存路徑", + "command.importSettings.title": "匯入設定", "command.terminal.addToContext.title": "將終端內容新增到上下文", "command.terminal.fixCommand.title": "修復此命令", "command.terminal.explainCommand.title": "解釋此命令", diff --git a/src/utils/__tests__/autoImportSettings.spec.ts b/src/utils/__tests__/autoImportSettings.spec.ts new file mode 100644 index 0000000000..04d2a6a6c7 --- /dev/null +++ b/src/utils/__tests__/autoImportSettings.spec.ts @@ -0,0 +1,295 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" + +// Mock dependencies +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(), + }, + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + }, + Uri: { + file: vi.fn((path: string) => ({ fsPath: path })), + }, +})) + +vi.mock("fs/promises", () => ({ + __esModule: true, + default: { + readFile: vi.fn(), + }, + readFile: vi.fn(), +})) + +vi.mock("path", () => ({ + join: vi.fn((...args: string[]) => args.join("/")), + isAbsolute: vi.fn((p: string) => p.startsWith("/")), + basename: vi.fn((p: string) => p.split("/").pop() || ""), +})) + +vi.mock("os", () => ({ + homedir: vi.fn(() => "/home/user"), +})) + +vi.mock("../fs", () => ({ + fileExistsAtPath: vi.fn(), +})) + +vi.mock("../../core/config/ProviderSettingsManager", async (importOriginal) => { + const originalModule = await importOriginal() + return { + __esModule: true, + // We need to mock the class constructor and its methods, + // but keep other exports (like schemas) as their original values. + ...(originalModule || {}), // Spread original exports + ProviderSettingsManager: vi.fn().mockImplementation(() => ({ + // Mock the class + export: vi.fn().mockResolvedValue({ + apiConfigs: {}, + modeApiConfigs: {}, + currentApiConfigName: "default", + }), + import: vi.fn().mockResolvedValue({ success: true }), + listConfig: vi.fn().mockResolvedValue([]), + })), + } +}) +vi.mock("../../core/config/ContextProxy") +vi.mock("../../core/config/CustomModesManager") + +import { autoImportSettings } from "../autoImportSettings" +import * as vscode from "vscode" +import fsPromises from "fs/promises" +import { fileExistsAtPath } from "../fs" + +describe("autoImportSettings", () => { + let mockProviderSettingsManager: any + let mockContextProxy: any + let mockCustomModesManager: any + let mockOutputChannel: any + let mockProvider: any + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks() + + // Mock output channel + mockOutputChannel = { + appendLine: vi.fn(), + } + + // Mock provider settings manager + mockProviderSettingsManager = { + export: vi.fn().mockResolvedValue({ + apiConfigs: {}, + modeApiConfigs: {}, + currentApiConfigName: "default", + }), + import: vi.fn().mockResolvedValue({ success: true }), + listConfig: vi.fn().mockResolvedValue([]), + } + + // Mock context proxy + mockContextProxy = { + setValues: vi.fn().mockResolvedValue(undefined), + setValue: vi.fn().mockResolvedValue(undefined), + setProviderSettings: vi.fn().mockResolvedValue(undefined), + } + + // Mock custom modes manager + mockCustomModesManager = { + updateCustomMode: vi.fn().mockResolvedValue(undefined), + } + + // mockProvider must be initialized AFTER its dependencies + mockProvider = { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + upsertProviderProfile: vi.fn().mockResolvedValue({ success: true }), + postStateToWebview: vi.fn().mockResolvedValue({ success: true }), + } + + // Reset fs mock + vi.mocked(fsPromises.readFile).mockReset() + vi.mocked(fileExistsAtPath).mockReset() + vi.mocked(vscode.workspace.getConfiguration).mockReset() + vi.mocked(vscode.window.showInformationMessage).mockReset() + vi.mocked(vscode.window.showWarningMessage).mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should skip auto-import when no settings path is specified", async () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(""), + } as any) + + await autoImportSettings(mockOutputChannel, { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[AutoImport] No auto-import settings path specified, skipping auto-import", + ) + expect(mockProviderSettingsManager.import).not.toHaveBeenCalled() + }) + + it("should skip auto-import when settings file does not exist", async () => { + const settingsPath = "~/Documents/roo-config.json" + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(settingsPath), + } as any) + + // Mock fileExistsAtPath to return false + vi.mocked(fileExistsAtPath).mockResolvedValue(false) + + await autoImportSettings(mockOutputChannel, { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[AutoImport] Checking for settings file at: /home/user/Documents/roo-config.json", + ) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[AutoImport] Settings file not found at /home/user/Documents/roo-config.json, skipping auto-import", + ) + expect(mockProviderSettingsManager.import).not.toHaveBeenCalled() + }) + + it("should successfully import settings when file exists and is valid", async () => { + const settingsPath = "/absolute/path/to/config.json" + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(settingsPath), + } as any) + + // Mock fileExistsAtPath to return true + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + + // Mock fs.readFile to return valid config + const mockSettings = { + providerProfiles: { + currentApiConfigName: "test-config", + apiConfigs: { + "test-config": { + apiProvider: "anthropic", + anthropicApiKey: "test-key", + }, + }, + }, + globalSettings: { + customInstructions: "Test instructions", + }, + } + + vi.mocked(fsPromises.readFile).mockResolvedValue(JSON.stringify(mockSettings) as any) + + await autoImportSettings(mockOutputChannel, { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[AutoImport] Checking for settings file at: /absolute/path/to/config.json", + ) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[AutoImport] Successfully imported settings from /absolute/path/to/config.json", + ) + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("info.auto_import_success") + expect(mockProviderSettingsManager.import).toHaveBeenCalled() + expect(mockContextProxy.setValues).toHaveBeenCalled() + }) + + it("should handle invalid JSON gracefully", async () => { + const settingsPath = "~/config.json" + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(settingsPath), + } as any) + + // Mock fileExistsAtPath to return true + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + + // Mock fs.readFile to return invalid JSON + vi.mocked(fsPromises.readFile).mockResolvedValue("invalid json" as any) + + await autoImportSettings(mockOutputChannel, { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("[AutoImport] Failed to import settings:"), + ) + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith("warnings.auto_import_failed") + expect(mockProviderSettingsManager.import).not.toHaveBeenCalled() + }) + + it("should resolve home directory paths correctly", async () => { + const settingsPath = "~/Documents/config.json" + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(settingsPath), + } as any) + + // Mock fileExistsAtPath to return false (so we can check the resolved path) + vi.mocked(fileExistsAtPath).mockResolvedValue(false) + + await autoImportSettings(mockOutputChannel, { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[AutoImport] Checking for settings file at: /home/user/Documents/config.json", + ) + }) + + it("should handle relative paths by resolving them to home directory", async () => { + const settingsPath = "Documents/config.json" + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(settingsPath), + } as any) + + // Mock fileExistsAtPath to return false (so we can check the resolved path) + vi.mocked(fileExistsAtPath).mockResolvedValue(false) + + await autoImportSettings(mockOutputChannel, { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[AutoImport] Checking for settings file at: /home/user/Documents/config.json", + ) + }) + + it("should handle file system errors gracefully", async () => { + const settingsPath = "~/config.json" + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(settingsPath), + } as any) + + // Mock fileExistsAtPath to throw an error + vi.mocked(fileExistsAtPath).mockRejectedValue(new Error("File system error")) + + await autoImportSettings(mockOutputChannel, { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("[AutoImport] Unexpected error during auto-import:"), + ) + expect(mockProviderSettingsManager.import).not.toHaveBeenCalled() + }) +}) diff --git a/src/utils/autoImportSettings.ts b/src/utils/autoImportSettings.ts new file mode 100644 index 0000000000..d5871f3224 --- /dev/null +++ b/src/utils/autoImportSettings.ts @@ -0,0 +1,97 @@ +import * as vscode from "vscode" +import * as path from "path" +import * as os from "os" + +import { Package } from "../shared/package" +import { fileExistsAtPath } from "./fs" +import { t } from "../i18n" + +import { importSettingsFromFile } from "../core/config/importExport" +import { ProviderSettingsManager } from "../core/config/ProviderSettingsManager" +import { ContextProxy } from "../core/config/ContextProxy" +import { CustomModesManager } from "../core/config/CustomModesManager" + +type ImportOptions = { + providerSettingsManager: ProviderSettingsManager + contextProxy: ContextProxy + customModesManager: CustomModesManager +} + +/** + * Automatically imports RooCode settings from a specified path if it exists. + * This function is called during extension activation to allow users to pre-configure + * their settings by placing a settings file at a predefined location. + */ +export async function autoImportSettings( + outputChannel: vscode.OutputChannel, + { providerSettingsManager, contextProxy, customModesManager }: ImportOptions, +): Promise { + try { + // Get the auto-import settings path from VSCode settings + const settingsPath = vscode.workspace.getConfiguration(Package.name).get("autoImportSettingsPath") + + if (!settingsPath || settingsPath.trim() === "") { + outputChannel.appendLine("[AutoImport] No auto-import settings path specified, skipping auto-import") + return + } + + // Resolve the path (handle ~ for home directory and relative paths) + const resolvedPath = resolvePath(settingsPath.trim()) + outputChannel.appendLine(`[AutoImport] Checking for settings file at: ${resolvedPath}`) + + // Check if the file exists + if (!(await fileExistsAtPath(resolvedPath))) { + outputChannel.appendLine(`[AutoImport] Settings file not found at ${resolvedPath}, skipping auto-import`) + return + } + + // Attempt to import the configuration + const fileUri = vscode.Uri.file(resolvedPath) + const result = await importSettingsFromFile( + { + providerSettingsManager, + contextProxy, + customModesManager, + }, + fileUri, + ) + + if (result.success) { + outputChannel.appendLine(`[AutoImport] Successfully imported settings from ${resolvedPath}`) + + // Show a notification to the user + vscode.window.showInformationMessage( + t("common:info.auto_import_success", { filename: path.basename(resolvedPath) }), + ) + } else { + outputChannel.appendLine(`[AutoImport] Failed to import settings: ${result.error}`) + + // Show a warning but don't fail the extension activation + vscode.window.showWarningMessage(t("common:warnings.auto_import_failed", { error: result.error })) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + outputChannel.appendLine(`[AutoImport] Unexpected error during auto-import: ${errorMessage}`) + + // Log error but don't fail extension activation + console.warn("Auto-import settings error:", error) + } +} + +/** + * Resolves a file path, handling home directory expansion and relative paths + */ +function resolvePath(settingsPath: string): string { + // Handle home directory expansion + if (settingsPath.startsWith("~/")) { + return path.join(os.homedir(), settingsPath.slice(2)) + } + + // Handle absolute paths + if (path.isAbsolute(settingsPath)) { + return settingsPath + } + + // Handle relative paths (relative to home directory for safety) + return path.join(os.homedir(), settingsPath) +}