Browse Source

feat: register importSettings as VSCode command (#5095)

Co-authored-by: Claude <[email protected]>
Co-authored-by: Daniel Riccio <[email protected]>
shivamd1810 8 months ago
parent
commit
1472c19f8f

+ 1 - 0
packages/types/src/vscode.ts

@@ -48,6 +48,7 @@ export const commandIds = [
 	"newTask",
 
 	"setCustomStoragePath",
+	"importSettings",
 
 	"focusInput",
 	"acceptInput",

+ 18 - 0
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)

+ 107 - 8
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", () => {

+ 70 - 3
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<void>
+	}
+}
+
+/**
+ * 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 }))
+	}
+}

+ 3 - 10
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":

+ 5 - 0
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%",

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
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": "यह कमांड समझाएं",

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
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": "このコマンドを説明",

+ 1 - 0
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",

+ 1 - 0
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": "이 명령어 설명",

+ 1 - 0
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",

+ 1 - 0
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ę",

+ 1 - 0
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",

+ 1 - 0
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": "Объяснить эту команду",

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
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": "解释此命令",

+ 1 - 0
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": "解釋此命令",

+ 295 - 0
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()
+	})
+})

+ 97 - 0
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<void> {
+	try {
+		// Get the auto-import settings path from VSCode settings
+		const settingsPath = vscode.workspace.getConfiguration(Package.name).get<string>("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)
+}