From ff959989dc052a0eaf02a38fb6ce03ba887c7155 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:05:36 +0800 Subject: [PATCH] fix: allow remote browsing and connection checks without a project --- crates/ide-host/Cargo.toml | 1 + crates/ide-host/src/ops/dependencies.rs | 1 + crates/ide-host/src/trust.rs | 2 + crates/ide-host/tests/trust.rs | 60 +++++++++ .../remote-dev/ConnectDialog.test.tsx | 118 ++++++++++++++++++ 5 files changed, 182 insertions(+) create mode 100644 src/features/remote-dev/ConnectDialog.test.tsx diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index 39922852..a6e21c88 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -51,6 +51,7 @@ uuid.workspace = true tempfile.workspace = true [dev-dependencies] +ide-ssh = { workspace = true, features = ["testing"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] } [target.'cfg(windows)'.dependencies] diff --git a/crates/ide-host/src/ops/dependencies.rs b/crates/ide-host/src/ops/dependencies.rs index 96dfad9b..0dc62207 100644 --- a/crates/ide-host/src/ops/dependencies.rs +++ b/crates/ide-host/src/ops/dependencies.rs @@ -91,6 +91,7 @@ mod tests { let host = Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(|_| {})), diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index b829bd63..7e8522e1 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -222,6 +222,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "index_status" | "jvm_file_presentations" | "deploy_coverage" + | "deploy_test_connection" | "find_files" | "find_symbols" | "mark_recent" @@ -243,6 +244,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "remote_current" | "remote_disconnect" | "remote_connect" + | "remote_bootstrap_list" | "remote_list" | "remote_open" | "remote_of" diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index aa41a315..eb2868ad 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -56,6 +56,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( "db_console_path", "db_export_file", "vcs_write_patch", + "remote_bootstrap_list", + "deploy_test_connection", ] { assert!(ide_host::trust::restricted_command_allowed(command), "{command}"); for window in ["main", "welcome"] { @@ -75,6 +77,15 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( "vcs_git_version", "docker_compose_op", "db_execute", + "remote_delete", + "remote_mkdir", + "remote_rename", + "remote_import_local", + "remote_bootstrap_exec", + "deploy_upload", + "deploy_download", + "deploy_delete_remote", + "deploy_sync", "unknown_command", ] { assert!(!ide_host::trust::restricted_command_allowed(command), "{command}"); @@ -109,6 +120,55 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( host.shutdown().await; } +#[tokio::test] +async fn welcome_window_browses_selected_ssh_host_without_executing_commands() { + use ide_core::remote_settings::{SshAuth, SshConfiguration}; + use ide_ssh::{FsConnection, SshFiles, SshManager}; + + let tmp = tempfile::tempdir().unwrap(); + let server_root = tmp.path().join("server"); + std::fs::create_dir_all(server_root.join("projects/demo")).unwrap(); + std::fs::write(server_root.join("projects/README.md"), "remote file").unwrap(); + let port = ide_ssh::testing::sshd(server_root, Arc::new(|cmd| panic!("unexpected SSH exec: {cmd}"))).await; + let ssh = Arc::new(SshManager::with_files( + tmp.path().join("secrets.json"), + SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None }, + )); + ssh.set_configs(vec![SshConfiguration { + id: "selected-host".into(), + host: "127.0.0.1".into(), + port: Some(port), + user: "dev".into(), + auth: SshAuth::Password, + ..Default::default() + }]); + ssh.set_secret("selected-host", Some("secret".into()), false); + assert!(ssh.session("selected-host").await.is_err()); + ssh.trust_host("selected-host").await.unwrap(); + + let mut host = host(&tmp.path().join("app"), false); + let state = Arc::get_mut(&mut host).unwrap(); + state.ssh = ssh; + state.remote = Arc::new(ide_remote::RemoteService::new(state.ssh.clone(), tmp.path().join("cache"))); + assert!(host.project("welcome").is_err()); + host.authorize_command("welcome", "remote_bootstrap_list").unwrap(); + let connection = FsConnection::Sftp { ssh: "selected-host".into() }; + let entries = host.remote.list(&connection, "/projects").await.unwrap(); + assert!(entries.iter().any(|e| e.path == "/projects/demo" && e.is_dir)); + assert!(entries.iter().any(|e| e.name == "README.md" && !e.is_dir)); + assert!(host.remote.list(&connection, "/projects/demo").await.unwrap().is_empty()); + host.authorize_command("welcome", "deploy_test_connection").unwrap(); + host.remote.test_connection(&connection, "/projects").await.unwrap(); + let local = tmp.path().join("local-deployment"); + std::fs::create_dir(&local).unwrap(); + ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into()) + .await + .unwrap(); + assert_eq!(std::fs::read_dir(local).unwrap().count(), 0, "write probe is cleaned up"); + assert!(host.project("welcome").is_err()); + host.shutdown().await; +} + #[test] fn persists_user_decisions_locations_revocation_and_one_time_migration() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/features/remote-dev/ConnectDialog.test.tsx b/src/features/remote-dev/ConnectDialog.test.tsx new file mode 100644 index 00000000..b80ed3e8 --- /dev/null +++ b/src/features/remote-dev/ConnectDialog.test.tsx @@ -0,0 +1,118 @@ +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 { 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(() => ({ + list: 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 } }; +}); +vi.mock("@/workbench/projectWindow", () => ({ openRemoteProject: openRemote })); +vi.mock("@/platform/actions", async (importOriginal) => ({ + ...(await importOriginal()), + executeAction, +})); + +const entry = (path: string, isDir = true): RemoteEntry => ({ + name: path.slice(path.lastIndexOf("/") + 1), + path, + isDir, + isSymlink: false, + size: 0, + mtime: null, + permissions: "rwxr-xr-x", +}); + +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, + }); + useRemoteProgress.setState({ progress: null, location: null }); + openConnectDialog(); +}); + +afterEach(() => closeConnectDialog()); + +it("browses the selected SSH host, navigates folders and opens one without a current project", async () => { + let finish!: (result: { status: "ok"; data: RemoteEntry[] }) => void; + list.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve))); + openRemote.mockResolvedValue(true); + render(); + fireEvent.click(screen.getByRole("button", { name: "SSH configuration" })); + fireEvent.click(screen.getByRole("option", { name: /Selected host/ })); + fireEvent.click(screen.getByRole("button", { name: "Browse" })); + expect(list).toHaveBeenCalledWith("selected", "~"); + expect(screen.getByRole("button", { name: "Browse" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Open" })).toBeDisabled(); + finish({ + status: "ok", + data: [entry("/home/dev/.cache"), entry("/home/dev/projects"), entry("/home/dev/README.md", false)], + }); + const folders = await screen.findByRole("list", { name: "Remote folders" }); + expect( + within(folders) + .getAllByRole("button") + .map((button) => button.textContent), + ).toEqual(["..", "projects", ".cache"]); + expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev"); + list.mockResolvedValueOnce({ status: "ok", data: [entry("/home/dev/projects/demo")] }); + fireEvent.click(within(folders).getByRole("button", { name: "projects" })); + await screen.findByRole("button", { name: "demo" }); + expect(list).toHaveBeenLastCalledWith("selected", "/home/dev/projects"); + list.mockResolvedValueOnce({ status: "ok", data: [entry("/home/dev/projects")] }); + fireEvent.click(within(folders).getByRole("button", { name: ".." })); + await waitFor(() => expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev")); + expect(list).toHaveBeenLastCalledWith("selected", "/home/dev"); + list.mockResolvedValueOnce({ status: "ok", data: [] }); + fireEvent.click(within(folders).getByRole("button", { name: "projects" })); + await waitFor(() => expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev/projects")); + expect(within(folders).getAllByRole("button")).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Open" })); + await waitFor(() => expect(openRemote).toHaveBeenCalledWith("selected", "/home/dev/projects")); + expect(useConnectDialog.getState().open).toBe(false); + expect(useProject.getState().project).toBeNull(); +}); + +it("shows a connection error and lets the welcome window retry Browse", async () => { + list.mockRejectedValueOnce(new Error("Connection refused")); + render(); + 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"); + expect(list).toHaveBeenCalledWith("first", "/srv/projects"); + expect(screen.getByRole("button", { name: "Browse" })).toBeEnabled(); + list.mockResolvedValueOnce({ status: "ok", data: [] }); + fireEvent.click(screen.getByRole("button", { name: "Browse" })); + await screen.findByRole("list", { name: "Remote folders" }); + expect(screen.queryByRole("alert")).toBeNull(); + 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 }); + render(); + 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(); +});