From ba458f4659b1de28c657c2c830bfdd643422da16 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 12 Sep 2025 08:12:06 +0000 Subject: [PATCH] feat: enable local file selection for settings import in Remote SSH environments - Add detection for remote SSH environments using vscode.env.remoteName - Provide user choice between local and remote file selection in remote environments - Support pasting JSON content directly for local files in remote SSH sessions - Add comprehensive tests for remote SSH scenarios - Maintain backward compatibility for local environments Fixes #7930 --- .../config/__tests__/importExport.spec.ts | 262 ++++++++++++++++++ src/core/config/importExport.ts | 170 +++++++++++- 2 files changed, 420 insertions(+), 12 deletions(-) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 361d6b23b0..f09d4e3e32 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -22,10 +22,13 @@ vi.mock("vscode", () => ({ showSaveDialog: vi.fn(), showErrorMessage: vi.fn(), showInformationMessage: vi.fn(), + showQuickPick: vi.fn(), + showInputBox: vi.fn(), }, Uri: { file: vi.fn((filePath) => ({ fsPath: filePath })), }, + env: {}, })) vi.mock("fs/promises", () => ({ @@ -34,6 +37,7 @@ vi.mock("fs/promises", () => ({ mkdir: vi.fn(), writeFile: vi.fn(), access: vi.fn(), + unlink: vi.fn(), constants: { F_OK: 0, R_OK: 4, @@ -43,6 +47,7 @@ vi.mock("fs/promises", () => ({ mkdir: vi.fn(), writeFile: vi.fn(), access: vi.fn(), + unlink: vi.fn(), constants: { F_OK: 0, R_OK: 4, @@ -52,8 +57,10 @@ vi.mock("fs/promises", () => ({ vi.mock("os", () => ({ default: { homedir: vi.fn(() => "/mock/home"), + tmpdir: vi.fn(() => "/tmp"), }, homedir: vi.fn(() => "/mock/home"), + tmpdir: vi.fn(() => "/tmp"), })) vi.mock("../../../utils/safeWriteJson") @@ -436,6 +443,261 @@ describe("importExport", () => { showErrorMessageSpy.mockRestore() }) + + describe("remote SSH environment", () => { + beforeEach(() => { + // Mock remote environment + ;(vscode.env as any).remoteName = "ssh-remote" + }) + + afterEach(() => { + // Reset to local environment + delete (vscode.env as any).remoteName + }) + + it("should show quick pick for local vs remote file selection in remote environment", async () => { + ;(vscode.window.showQuickPick as Mock).mockResolvedValue(undefined) + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(vscode.window.showQuickPick).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ value: "local" }), + expect.objectContaining({ value: "remote" }), + ]), + expect.objectContaining({ + placeHolder: "Choose where to import settings from", + title: "Import Settings", + }), + ) + + expect(result).toEqual({ success: false, error: "User cancelled import" }) + }) + + it("should handle paste option for local file in remote environment", async () => { + ;(vscode.window.showQuickPick as Mock) + .mockResolvedValueOnce({ value: "local" }) + .mockResolvedValueOnce({ value: "paste" }) + + const mockSettings = { + providerProfiles: { + currentApiConfigName: "test", + apiConfigs: { + test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" }, + }, + }, + globalSettings: { mode: "code" }, + } + + ;(vscode.window.showInputBox as Mock).mockResolvedValue(JSON.stringify(mockSettings)) + ;(fs.writeFile as Mock).mockResolvedValue(undefined) + ;(fs.unlink as Mock).mockResolvedValue(undefined) + ;(fs.readFile as Mock).mockResolvedValue(JSON.stringify(mockSettings)) + + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: {}, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([]) + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(vscode.window.showInputBox).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Paste your settings JSON content here", + ignoreFocusOut: true, + }), + ) + + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining("roo-settings-import-"), + JSON.stringify(mockSettings), + "utf-8", + ) + + expect(result.success).toBe(true) + }) + + it("should validate JSON when pasting content", async () => { + ;(vscode.window.showQuickPick as Mock) + .mockResolvedValueOnce({ value: "local" }) + .mockResolvedValueOnce({ value: "paste" }) + ;(vscode.window.showInputBox as Mock).mockImplementation(async (options) => { + // Test the validation function + const validateResult = options.validateInput('{"invalid": json}') + expect(validateResult).toBe("Invalid JSON format") + + const validResult = options.validateInput('{"valid": "json"}') + expect(validResult).toBeUndefined() + + const emptyResult = options.validateInput("") + expect(emptyResult).toBe("Please paste the settings content") + + return undefined // User cancels + }) + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(result).toEqual({ success: false, error: "User cancelled import" }) + }) + + it("should show info message when local file path is entered in remote environment", async () => { + ;(vscode.window.showQuickPick as Mock) + .mockResolvedValueOnce({ value: "local" }) + .mockResolvedValueOnce({ value: "path" }) + ;(vscode.window.showInputBox as Mock).mockResolvedValue("~/Documents/settings.json") + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("To import from a local file in a remote SSH session"), + "OK", + ) + + expect(result).toEqual({ + success: false, + error: "Cannot directly access local files from remote environment", + }) + }) + + it("should use standard dialog for remote file selection in remote environment", async () => { + ;(vscode.window.showQuickPick as Mock).mockResolvedValueOnce({ + label: "$(remote) Import from remote file", + value: "remote", + }) + ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/remote/path/settings.json" }]) + + const mockSettings = { + providerProfiles: { + currentApiConfigName: "test", + apiConfigs: { test: { apiProvider: "openai" as ProviderName, id: "test-id" } }, + }, + } + + ;(fs.readFile as Mock).mockResolvedValue(JSON.stringify(mockSettings)) + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: {}, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([]) + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(vscode.window.showOpenDialog).toHaveBeenCalledWith({ + filters: { JSON: ["json"] }, + canSelectMany: false, + }) + + expect(result.success).toBe(true) + }) + + it("should clean up temp file even if import fails", async () => { + ;(vscode.window.showQuickPick as Mock) + .mockResolvedValueOnce({ value: "local" }) + .mockResolvedValueOnce({ value: "paste" }) + + const invalidSettings = '{"invalid": "no provider profiles"}' + ;(vscode.window.showInputBox as Mock).mockResolvedValue(invalidSettings) + ;(fs.writeFile as Mock).mockResolvedValue(undefined) + ;(fs.unlink as Mock).mockResolvedValue(undefined) + ;(fs.readFile as Mock).mockResolvedValue(invalidSettings) + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(result.success).toBe(false) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("roo-settings-import-")) + }) + + it("should handle file write errors when pasting content", async () => { + ;(vscode.window.showQuickPick as Mock) + .mockResolvedValueOnce({ value: "local" }) + .mockResolvedValueOnce({ value: "paste" }) + + const mockSettings = { + providerProfiles: { + currentApiConfigName: "test", + apiConfigs: { test: { apiProvider: "openai" as ProviderName, id: "test-id" } }, + }, + } + + ;(vscode.window.showInputBox as Mock).mockResolvedValue(JSON.stringify(mockSettings)) + ;(fs.writeFile as Mock).mockRejectedValue(new Error("Disk full")) + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(result).toEqual({ success: false, error: "Failed to process settings: Error: Disk full" }) + }) + }) + + describe("local environment", () => { + beforeEach(() => { + // Ensure we're in local environment + delete (vscode.env as any).remoteName + }) + + it("should use standard dialog in local environment", async () => { + ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/local/path/settings.json" }]) + + const mockSettings = { + providerProfiles: { + currentApiConfigName: "test", + apiConfigs: { test: { apiProvider: "openai" as ProviderName, id: "test-id" } }, + }, + } + + ;(fs.readFile as Mock).mockResolvedValue(JSON.stringify(mockSettings)) + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: {}, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([]) + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + // Should NOT show quick pick in local environment + expect(vscode.window.showQuickPick).not.toHaveBeenCalled() + + // Should use standard dialog + expect(vscode.window.showOpenDialog).toHaveBeenCalledWith({ + filters: { JSON: ["json"] }, + canSelectMany: false, + }) + + expect(result.success).toBe(true) + }) + }) }) describe("exportSettings", () => { diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index c3d6f9c215..189f31a93a 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -14,6 +14,15 @@ import { ContextProxy } from "./ContextProxy" import { CustomModesManager } from "./CustomModesManager" import { t } from "../../i18n" +/** + * Check if the extension is running in a remote environment + */ +function isRemoteEnvironment(): boolean { + // Check if we're in a remote environment by looking at the extension context + // In remote environments, vscode.env.remoteName will be set + return typeof (vscode.env as any).remoteName !== "undefined" && (vscode.env as any).remoteName !== null +} + export type ImportOptions = { providerSettingsManager: ProviderSettingsManager contextProxy: ContextProxy @@ -109,20 +118,157 @@ export async function importSettingsFromPath( * @returns Promise resolving to import result */ export const importSettings = async ({ providerSettingsManager, contextProxy, customModesManager }: ImportOptions) => { - const uris = await vscode.window.showOpenDialog({ - filters: { JSON: ["json"] }, - canSelectMany: false, - }) + // Check if we're in a remote environment + if (isRemoteEnvironment()) { + // In remote environments, we need to handle file selection differently + // to ensure the user can select files from their local machine - if (!uris) { - return { success: false, error: "User cancelled file selection" } + // Show a quick pick to let user choose between local file or remote file + const choice = await vscode.window.showQuickPick( + [ + { + label: "$(file) Import from local file", + description: "Select a file from your local machine", + value: "local", + }, + { + label: "$(remote) Import from remote file", + description: "Select a file from the remote server", + value: "remote", + }, + ], + { + placeHolder: "Choose where to import settings from", + title: "Import Settings", + }, + ) + + if (!choice) { + return { success: false, error: "User cancelled import" } + } + + if (choice.value === "local") { + // For local file selection in remote environment, we need to: + // 1. Ask user to paste the content or provide a path + const inputChoice = await vscode.window.showQuickPick( + [ + { + label: "$(paste) Paste settings content", + description: "Paste the JSON content directly", + value: "paste", + }, + { + label: "$(file-text) Enter local file path", + description: "Provide the path to a local file", + value: "path", + }, + ], + { + placeHolder: "How would you like to provide the settings?", + title: "Import Local Settings", + }, + ) + + if (!inputChoice) { + return { success: false, error: "User cancelled import" } + } + + if (inputChoice.value === "paste") { + // Ask user to paste the JSON content + const jsonContent = await vscode.window.showInputBox({ + prompt: "Paste your settings JSON content here", + placeHolder: '{"providerProfiles": {...}, "globalSettings": {...}}', + ignoreFocusOut: true, + validateInput: (value) => { + if (!value) return "Please paste the settings content" + try { + JSON.parse(value) + return undefined + } catch { + return "Invalid JSON format" + } + }, + }) + + if (!jsonContent) { + return { success: false, error: "User cancelled import" } + } + + // Create a temporary file with the content + const tempDir = os.tmpdir() + const tempFile = path.join(tempDir, `roo-settings-import-${Date.now()}.json`) + + try { + await fs.writeFile(tempFile, jsonContent, "utf-8") + const result = await importSettingsFromPath(tempFile, { + providerSettingsManager, + contextProxy, + customModesManager, + }) + // Clean up temp file + await fs.unlink(tempFile).catch(() => {}) // Ignore errors + return result + } catch (error) { + return { success: false, error: `Failed to process settings: ${error}` } + } + } else { + // Ask user to enter the local file path + const localPath = await vscode.window.showInputBox({ + prompt: "Enter the path to your local settings file", + placeHolder: "~/Documents/roo-code-settings.json", + ignoreFocusOut: true, + }) + + if (!localPath) { + return { success: false, error: "User cancelled import" } + } + + // Note: We can't directly access the local file from remote environment + // So we'll show instructions to the user + await vscode.window.showInformationMessage( + "To import from a local file in a remote SSH session, please use one of these methods:\n" + + "1. Copy the file to the remote server first using scp/sftp\n" + + "2. Use the 'Paste settings content' option instead\n" + + "3. Open the file locally and copy its content to paste", + "OK", + ) + + return { success: false, error: "Cannot directly access local files from remote environment" } + } + } else { + // Remote file selection - use the standard dialog + const uris = await vscode.window.showOpenDialog({ + filters: { JSON: ["json"] }, + canSelectMany: false, + }) + + if (!uris) { + return { success: false, error: "User cancelled file selection" } + } + + return importSettingsFromPath(uris[0].fsPath, { + providerSettingsManager, + contextProxy, + customModesManager, + }) + } + } else { + // Standard local environment - use the normal file dialog + const uris = await vscode.window.showOpenDialog({ + filters: { JSON: ["json"] }, + canSelectMany: false, + }) + + if (!uris) { + return { success: false, error: "User cancelled file selection" } + } + + return importSettingsFromPath(uris[0].fsPath, { + providerSettingsManager, + contextProxy, + customModesManager, + }) } - - return importSettingsFromPath(uris[0].fsPath, { - providerSettingsManager, - contextProxy, - customModesManager, - }) } /**