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
This commit is contained in:
Roo Code 2025-09-12 08:12:06 +00:00
parent 08d7f80e22
commit ba458f4659
2 changed files with 420 additions and 12 deletions

View file

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

View file

@ -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,
})
}
/**