feat: register importSettings as VSCode command (#5095)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
This commit is contained in:
shivamd1810 2025-06-25 13:07:08 -07:00 committed by GitHub
parent 8455909809
commit 1472c19f8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 614 additions and 21 deletions

View file

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

View file

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

View file

@ -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", () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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