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);