fix: allow remote browsing and connection checks without a project

This commit is contained in:
l0ng-ai
2026-11-01 20:05:36 +08:00
parent 245fe3cdf4
commit ff959989dc
5 changed files with 182 additions and 0 deletions
+1
View File
@@ -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]
+1
View File
@@ -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(|_| {})),
+2
View File
@@ -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"
+60
View File
@@ -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();
@@ -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<typeof import("@/platform/ipc")>();
return { ...real, commands: { ...real.commands, remoteBootstrapList: list } };
});
vi.mock("@/workbench/projectWindow", () => ({ openRemoteProject: openRemote }));
vi.mock("@/platform/actions", async (importOriginal) => ({
...(await importOriginal<typeof import("@/platform/actions")>()),
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(<ConnectHost />);
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(<ConnectHost />);
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(<ConnectHost />);
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();
});