mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
08d7f80e22
commit
ba458f4659
2 changed files with 420 additions and 12 deletions
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue