Skip to content

Commit ba458f4

Browse files
committed
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
1 parent 08d7f80 commit ba458f4

File tree

2 files changed

+420
-12
lines changed

2 files changed

+420
-12
lines changed

src/core/config/__tests__/importExport.spec.ts

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,13 @@ vi.mock("vscode", () => ({
2222
showSaveDialog: vi.fn(),
2323
showErrorMessage: vi.fn(),
2424
showInformationMessage: vi.fn(),
25+
showQuickPick: vi.fn(),
26+
showInputBox: vi.fn(),
2527
},
2628
Uri: {
2729
file: vi.fn((filePath) => ({ fsPath: filePath })),
2830
},
31+
env: {},
2932
}))
3033

3134
vi.mock("fs/promises", () => ({
@@ -34,6 +37,7 @@ vi.mock("fs/promises", () => ({
3437
mkdir: vi.fn(),
3538
writeFile: vi.fn(),
3639
access: vi.fn(),
40+
unlink: vi.fn(),
3741
constants: {
3842
F_OK: 0,
3943
R_OK: 4,
@@ -43,6 +47,7 @@ vi.mock("fs/promises", () => ({
4347
mkdir: vi.fn(),
4448
writeFile: vi.fn(),
4549
access: vi.fn(),
50+
unlink: vi.fn(),
4651
constants: {
4752
F_OK: 0,
4853
R_OK: 4,
@@ -52,8 +57,10 @@ vi.mock("fs/promises", () => ({
5257
vi.mock("os", () => ({
5358
default: {
5459
homedir: vi.fn(() => "/mock/home"),
60+
tmpdir: vi.fn(() => "/tmp"),
5561
},
5662
homedir: vi.fn(() => "/mock/home"),
63+
tmpdir: vi.fn(() => "/tmp"),
5764
}))
5865

5966
vi.mock("../../../utils/safeWriteJson")
@@ -436,6 +443,261 @@ describe("importExport", () => {
436443

437444
showErrorMessageSpy.mockRestore()
438445
})
446+
447+
describe("remote SSH environment", () => {
448+
beforeEach(() => {
449+
// Mock remote environment
450+
;(vscode.env as any).remoteName = "ssh-remote"
451+
})
452+
453+
afterEach(() => {
454+
// Reset to local environment
455+
delete (vscode.env as any).remoteName
456+
})
457+
458+
it("should show quick pick for local vs remote file selection in remote environment", async () => {
459+
;(vscode.window.showQuickPick as Mock).mockResolvedValue(undefined)
460+
461+
const result = await importSettings({
462+
providerSettingsManager: mockProviderSettingsManager,
463+
contextProxy: mockContextProxy,
464+
customModesManager: mockCustomModesManager,
465+
})
466+
467+
expect(vscode.window.showQuickPick).toHaveBeenCalledWith(
468+
expect.arrayContaining([
469+
expect.objectContaining({ value: "local" }),
470+
expect.objectContaining({ value: "remote" }),
471+
]),
472+
expect.objectContaining({
473+
placeHolder: "Choose where to import settings from",
474+
title: "Import Settings",
475+
}),
476+
)
477+
478+
expect(result).toEqual({ success: false, error: "User cancelled import" })
479+
})
480+
481+
it("should handle paste option for local file in remote environment", async () => {
482+
;(vscode.window.showQuickPick as Mock)
483+
.mockResolvedValueOnce({ value: "local" })
484+
.mockResolvedValueOnce({ value: "paste" })
485+
486+
const mockSettings = {
487+
providerProfiles: {
488+
currentApiConfigName: "test",
489+
apiConfigs: {
490+
test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" },
491+
},
492+
},
493+
globalSettings: { mode: "code" },
494+
}
495+
496+
;(vscode.window.showInputBox as Mock).mockResolvedValue(JSON.stringify(mockSettings))
497+
;(fs.writeFile as Mock).mockResolvedValue(undefined)
498+
;(fs.unlink as Mock).mockResolvedValue(undefined)
499+
;(fs.readFile as Mock).mockResolvedValue(JSON.stringify(mockSettings))
500+
501+
mockProviderSettingsManager.export.mockResolvedValue({
502+
currentApiConfigName: "default",
503+
apiConfigs: {},
504+
})
505+
mockProviderSettingsManager.listConfig.mockResolvedValue([])
506+
507+
const result = await importSettings({
508+
providerSettingsManager: mockProviderSettingsManager,
509+
contextProxy: mockContextProxy,
510+
customModesManager: mockCustomModesManager,
511+
})
512+
513+
expect(vscode.window.showInputBox).toHaveBeenCalledWith(
514+
expect.objectContaining({
515+
prompt: "Paste your settings JSON content here",
516+
ignoreFocusOut: true,
517+
}),
518+
)
519+
520+
expect(fs.writeFile).toHaveBeenCalledWith(
521+
expect.stringContaining("roo-settings-import-"),
522+
JSON.stringify(mockSettings),
523+
"utf-8",
524+
)
525+
526+
expect(result.success).toBe(true)
527+
})
528+
529+
it("should validate JSON when pasting content", async () => {
530+
;(vscode.window.showQuickPick as Mock)
531+
.mockResolvedValueOnce({ value: "local" })
532+
.mockResolvedValueOnce({ value: "paste" })
533+
;(vscode.window.showInputBox as Mock).mockImplementation(async (options) => {
534+
// Test the validation function
535+
const validateResult = options.validateInput('{"invalid": json}')
536+
expect(validateResult).toBe("Invalid JSON format")
537+
538+
const validResult = options.validateInput('{"valid": "json"}')
539+
expect(validResult).toBeUndefined()
540+
541+
const emptyResult = options.validateInput("")
542+
expect(emptyResult).toBe("Please paste the settings content")
543+
544+
return undefined // User cancels
545+
})
546+
547+
const result = await importSettings({
548+
providerSettingsManager: mockProviderSettingsManager,
549+
contextProxy: mockContextProxy,
550+
customModesManager: mockCustomModesManager,
551+
})
552+
553+
expect(result).toEqual({ success: false, error: "User cancelled import" })
554+
})
555+
556+
it("should show info message when local file path is entered in remote environment", async () => {
557+
;(vscode.window.showQuickPick as Mock)
558+
.mockResolvedValueOnce({ value: "local" })
559+
.mockResolvedValueOnce({ value: "path" })
560+
;(vscode.window.showInputBox as Mock).mockResolvedValue("~/Documents/settings.json")
561+
562+
const result = await importSettings({
563+
providerSettingsManager: mockProviderSettingsManager,
564+
contextProxy: mockContextProxy,
565+
customModesManager: mockCustomModesManager,
566+
})
567+
568+
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
569+
expect.stringContaining("To import from a local file in a remote SSH session"),
570+
"OK",
571+
)
572+
573+
expect(result).toEqual({
574+
success: false,
575+
error: "Cannot directly access local files from remote environment",
576+
})
577+
})
578+
579+
it("should use standard dialog for remote file selection in remote environment", async () => {
580+
;(vscode.window.showQuickPick as Mock).mockResolvedValueOnce({
581+
label: "$(remote) Import from remote file",
582+
value: "remote",
583+
})
584+
;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/remote/path/settings.json" }])
585+
586+
const mockSettings = {
587+
providerProfiles: {
588+
currentApiConfigName: "test",
589+
apiConfigs: { test: { apiProvider: "openai" as ProviderName, id: "test-id" } },
590+
},
591+
}
592+
593+
;(fs.readFile as Mock).mockResolvedValue(JSON.stringify(mockSettings))
594+
mockProviderSettingsManager.export.mockResolvedValue({
595+
currentApiConfigName: "default",
596+
apiConfigs: {},
597+
})
598+
mockProviderSettingsManager.listConfig.mockResolvedValue([])
599+
600+
const result = await importSettings({
601+
providerSettingsManager: mockProviderSettingsManager,
602+
contextProxy: mockContextProxy,
603+
customModesManager: mockCustomModesManager,
604+
})
605+
606+
expect(vscode.window.showOpenDialog).toHaveBeenCalledWith({
607+
filters: { JSON: ["json"] },
608+
canSelectMany: false,
609+
})
610+
611+
expect(result.success).toBe(true)
612+
})
613+
614+
it("should clean up temp file even if import fails", async () => {
615+
;(vscode.window.showQuickPick as Mock)
616+
.mockResolvedValueOnce({ value: "local" })
617+
.mockResolvedValueOnce({ value: "paste" })
618+
619+
const invalidSettings = '{"invalid": "no provider profiles"}'
620+
;(vscode.window.showInputBox as Mock).mockResolvedValue(invalidSettings)
621+
;(fs.writeFile as Mock).mockResolvedValue(undefined)
622+
;(fs.unlink as Mock).mockResolvedValue(undefined)
623+
;(fs.readFile as Mock).mockResolvedValue(invalidSettings)
624+
625+
const result = await importSettings({
626+
providerSettingsManager: mockProviderSettingsManager,
627+
contextProxy: mockContextProxy,
628+
customModesManager: mockCustomModesManager,
629+
})
630+
631+
expect(result.success).toBe(false)
632+
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("roo-settings-import-"))
633+
})
634+
635+
it("should handle file write errors when pasting content", async () => {
636+
;(vscode.window.showQuickPick as Mock)
637+
.mockResolvedValueOnce({ value: "local" })
638+
.mockResolvedValueOnce({ value: "paste" })
639+
640+
const mockSettings = {
641+
providerProfiles: {
642+
currentApiConfigName: "test",
643+
apiConfigs: { test: { apiProvider: "openai" as ProviderName, id: "test-id" } },
644+
},
645+
}
646+
647+
;(vscode.window.showInputBox as Mock).mockResolvedValue(JSON.stringify(mockSettings))
648+
;(fs.writeFile as Mock).mockRejectedValue(new Error("Disk full"))
649+
650+
const result = await importSettings({
651+
providerSettingsManager: mockProviderSettingsManager,
652+
contextProxy: mockContextProxy,
653+
customModesManager: mockCustomModesManager,
654+
})
655+
656+
expect(result).toEqual({ success: false, error: "Failed to process settings: Error: Disk full" })
657+
})
658+
})
659+
660+
describe("local environment", () => {
661+
beforeEach(() => {
662+
// Ensure we're in local environment
663+
delete (vscode.env as any).remoteName
664+
})
665+
666+
it("should use standard dialog in local environment", async () => {
667+
;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/local/path/settings.json" }])
668+
669+
const mockSettings = {
670+
providerProfiles: {
671+
currentApiConfigName: "test",
672+
apiConfigs: { test: { apiProvider: "openai" as ProviderName, id: "test-id" } },
673+
},
674+
}
675+
676+
;(fs.readFile as Mock).mockResolvedValue(JSON.stringify(mockSettings))
677+
mockProviderSettingsManager.export.mockResolvedValue({
678+
currentApiConfigName: "default",
679+
apiConfigs: {},
680+
})
681+
mockProviderSettingsManager.listConfig.mockResolvedValue([])
682+
683+
const result = await importSettings({
684+
providerSettingsManager: mockProviderSettingsManager,
685+
contextProxy: mockContextProxy,
686+
customModesManager: mockCustomModesManager,
687+
})
688+
689+
// Should NOT show quick pick in local environment
690+
expect(vscode.window.showQuickPick).not.toHaveBeenCalled()
691+
692+
// Should use standard dialog
693+
expect(vscode.window.showOpenDialog).toHaveBeenCalledWith({
694+
filters: { JSON: ["json"] },
695+
canSelectMany: false,
696+
})
697+
698+
expect(result.success).toBe(true)
699+
})
700+
})
439701
})
440702

441703
describe("exportSettings", () => {

0 commit comments

Comments
 (0)