From 3a98d5c5be6c52e0f920b2aafbe147107286fc3d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:16:18 +0800 Subject: [PATCH] fix(remote-dev): list this machine's SSH configurations in a remote project's window (#1097) A remote project's window gets the remote host's settings, which don't include this machine's SSH configurations, so the Remote Development dialog showed "No SSH configurations" there. The connection is made from this machine, so the dialog reads the local global SSH configurations and reloads them when settings change. Other SSH-configuration consumers run on the host and keep the host's settings. --- .../remote-dev/ConnectDialog.test.tsx | 45 +++++++++++-------- src/features/remote-dev/ConnectDialog.tsx | 35 ++++++++++++--- 2 files changed, 56 insertions(+), 24 deletions(-) diff --git a/src/features/remote-dev/ConnectDialog.test.tsx b/src/features/remote-dev/ConnectDialog.test.tsx index b80ed3e8f..dbfed1b16 100644 --- a/src/features/remote-dev/ConnectDialog.test.tsx +++ b/src/features/remote-dev/ConnectDialog.test.tsx @@ -1,20 +1,21 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import type { RemoteEntry, Settings } from "@/platform/ipc"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { emitLocalIdeEvent, type IdeEvent, type RemoteEntry, type Settings } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { useRemoteProgress } from "@/platform/remote"; import { ConnectHost } from "./ConnectDialog"; import { closeConnectDialog, openConnectDialog, useConnectDialog } from "./model"; -const { list, openRemote, executeAction } = vi.hoisted(() => ({ +const { list, globalSettings, openRemote, executeAction } = vi.hoisted(() => ({ list: vi.fn(), + globalSettings: vi.fn(), openRemote: vi.fn(), executeAction: vi.fn(), })); vi.mock("@/platform/ipc", async (importOriginal) => { const real = await importOriginal(); - return { ...real, commands: { ...real.commands, remoteBootstrapList: list } }; + return { ...real, commands: { ...real.commands, remoteBootstrapList: list, settingsGetGlobal: globalSettings } }; }); vi.mock("@/workbench/projectWindow", () => ({ openRemoteProject: openRemote })); vi.mock("@/platform/actions", async (importOriginal) => ({ @@ -34,18 +35,16 @@ const entry = (path: string, isDir = true): RemoteEntry => ({ beforeEach(() => { vi.resetAllMocks(); - useProject.setState({ - project: null, - opening: false, - settings: { - ssh: { - configs: [ - { id: "first", name: "First host", host: "first.example", user: "dev" }, - { id: "selected", name: "Selected host", host: "selected.example", user: "dev" }, - ], - }, - } as Settings, - }); + // A remote project's window: its settings are the remote host's, without this machine's SSH configurations. + useProject.setState({ project: null, opening: false, settings: { ssh: { configs: [] } } as unknown as Settings }); + globalSettings.mockResolvedValue({ + ssh: { + configs: [ + { id: "first", name: "First host", host: "first.example", user: "dev" }, + { id: "selected", name: "Selected host", host: "selected.example", user: "dev" }, + ], + }, + } as Settings); useRemoteProgress.setState({ progress: null, location: null }); openConnectDialog(); }); @@ -57,6 +56,7 @@ it("browses the selected SSH host, navigates folders and opens one without a cur list.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve))); openRemote.mockResolvedValue(true); render(); + await waitFor(() => expect(screen.getByRole("button", { name: "Browse" })).toBeEnabled()); fireEvent.click(screen.getByRole("button", { name: "SSH configuration" })); fireEvent.click(screen.getByRole("option", { name: /Selected host/ })); fireEvent.click(screen.getByRole("button", { name: "Browse" })); @@ -95,6 +95,7 @@ it("browses the selected SSH host, navigates folders and opens one without a cur it("shows a connection error and lets the welcome window retry Browse", async () => { list.mockRejectedValueOnce(new Error("Connection refused")); render(); + await waitFor(() => expect(screen.getByRole("button", { name: "Browse" })).toBeEnabled()); fireEvent.change(screen.getByLabelText("Project directory:"), { target: { value: " /srv/projects " } }); fireEvent.click(screen.getByRole("button", { name: "Browse" })); expect(await screen.findByRole("alert")).toHaveTextContent("Connection refused"); @@ -107,12 +108,20 @@ it("shows a connection error and lets the welcome window retry Browse", async () expect(openRemote).not.toHaveBeenCalled(); }); -it("offers SSH configuration from the welcome window and disables Browse until one exists", () => { - useProject.setState({ settings: { ssh: { configs: [] } } as unknown as Settings }); +it("offers SSH configuration and disables Browse until one exists, then lists the new one", async () => { + globalSettings.mockResolvedValue({ ssh: { configs: [] } } as unknown as Settings); render(); + await waitFor(() => expect(globalSettings).toHaveBeenCalled()); expect(screen.getByRole("button", { name: "Browse" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Open" })).toBeDisabled(); fireEvent.click(screen.getByRole("button", { name: "Configure…" })); expect(executeAction).toHaveBeenCalledWith("file.settings", "tools.sshConfigurations"); expect(list).not.toHaveBeenCalled(); + + globalSettings.mockResolvedValue({ + ssh: { configs: [{ id: "dev", name: "dev", host: "192.168.1.2", user: "robin" }] }, + } as Settings); + act(() => emitLocalIdeEvent({ type: "settingsChanged", key: "ssh.configs" } as IdeEvent)); + await waitFor(() => expect(screen.getByRole("button", { name: "Browse" })).toBeEnabled()); + expect(screen.getByRole("button", { name: "SSH configuration" })).toHaveTextContent("dev"); }); diff --git a/src/features/remote-dev/ConnectDialog.tsx b/src/features/remote-dev/ConnectDialog.tsx index 3a47357da..35df2afa2 100644 --- a/src/features/remote-dev/ConnectDialog.tsx +++ b/src/features/remote-dev/ConnectDialog.tsx @@ -1,11 +1,10 @@ /** Picks an SSH configuration and a folder on that host, then opens it in this or a new window. */ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { executeAction } from "@/platform/actions"; import { useTx } from "@/platform/i18n"; -import { call, commands, isCancelled, type RemoteEntry } from "@/platform/ipc"; +import { call, commands, isCancelled, onIdeEvent, type RemoteEntry } from "@/platform/ipc"; import { useRemoteProgress } from "@/platform/remote"; -import { sshLabel } from "@/platform/remoteSettings"; -import { useWorkflowSettings } from "@/platform/workflowSettings"; +import { sshLabel, type SshConfigValid, validateRemote } from "@/platform/remoteSettings"; import { Button, Dialog, Icon, Input, ProgressBar, Select, Spinner } from "@/ui"; import { openRemoteProject } from "@/workbench/projectWindow"; import { @@ -25,10 +24,34 @@ export function ConnectHost() { const SSH_SETTINGS_PAGE = "tools.sshConfigurations"; +/** + * This machine's SSH configurations: the connection is made from here. A remote project's + * window has the remote host's settings, which don't list them. + */ +function useLocalSshConfigs(): SshConfigValid[] { + const [configs, setConfigs] = useState([]); + useEffect(() => { + let live = true; + const load = () => + void commands.settingsGetGlobal().then( + (s) => live && setConfigs(validateRemote(s).ssh.configs), + () => {}, + ); + load(); + const off = onIdeEvent("settingsChanged", load); + return () => { + live = false; + off(); + }; + }, []); + return configs; +} + function ConnectDialog() { const tx = useTx(); - const configs = useWorkflowSettings((s) => s.ssh.configs); - const [ssh, setSsh] = useState(configs[0]?.id ?? ""); + const configs = useLocalSshConfigs(); + const [picked, setSsh] = useState(""); + const ssh = configs.some((c) => c.id === picked) ? picked : (configs[0]?.id ?? ""); const [path, setPath] = useState("~"); const [busy, setBusy] = useState(false); const [error, setError] = useState(null);