Files
openidea/src/features/remote-dev/ConnectDialog.test.tsx
T
b0f65769c0 feat(remote): open WSL projects through a WSL transport (#989)
* perf(vfs): list directories off the main thread and reuse the repository lookup

Opening a folder on a network share (\\wsl.localhost\<distro>\...) is slow
because every listing ran on the main thread and walked up to the repository
root and ran a repository discovery again.

- fs_read_dir runs on the blocking pool.
- GitignoreStack::cached keeps the repository root and the repo-level rules
  for a few seconds. An edit of .git/info/exclude is still seen at once (its
  mtime and size are checked) and every .gitignore / .ignore is read fresh.

Refs #916

* feat(remote): open WSL projects through a WSL transport

A folder of a WSL distro (\\wsl.localhost\<distro>\... or \\wsl$\...) now opens
as a remote project whose openidea-server runs inside the distro, so the
terminal, SDK discovery, run, language servers and git are native Linux.

- RemoteTarget (ssh | wsl) replaces the SSH configuration id in the remote
  launch, connection, session and recents; old recents are read as SSH and a
  WSL target never inherits the legacy trust migration.
- remote_wsl: probe the distro, stream the server into ~/.openidea/server and
  start it with `wsl.exe -d <distro> --exec <bin> --stdio`. Distro names are
  validated and every command goes through --exec with positional arguments.
- wsl_distros / wsl_resolve commands; File > Open, the CLI and recents route
  WSL folders to the distro and focus the window that already has them.
- Remote Development dialog: SSH/WSL choice with the detected distros; the
  status bar shows `WSL: <distro>` / `SSH: <host>`.
- ide-jsonrpc: skip the NUL left at the start of a line by UTF-16 output of
  wsl.exe, which made the first message of the server disappear.

Not run on Windows yet: only a stand-in for wsl.exe is tested.

Refs #925, #327, #721

* fix(remote): satisfy clippy in WSL output decoding

* style(run): format run types

* fix: regenerate bindings and drop untranslated WSL catalog entry

* test(i18n): list WSL as an untranslated product name

* fix(run): pick build wrappers and shell quoting by the execution host

A remote window runs commands on its Linux or macOS server, so a Windows
desktop no longer selects gradlew.bat, mvnw.cmd or Windows quoting for it.

* fix(welcome): open legacy WSL UNC recents and sessions through the distro

Recents and the restored session called the local openProject for a
\\wsl.localhost folder saved before WSL projects, which fails with OS
error 123. They now resolve it like the other entry points.

* fix(windows): keep WSL files out of remote project launches

path_launch tested the WSL share before is_dir, so a first-instance CLI
launch with a WSL file opened a remote project instead of LightEdit.

* fix(remote): run-command OS comes from the remote host, not an assumption

The server's OS was dropped after connect, so a restored session had none and
isWindows() assumed every remote host is Unix. Keep it on the connection and
the session, and resolve it through hostOs().

---------

Co-authored-by: NguyenBang <[email protected]>
Co-authored-by: NguyenBang <[email protected]>
Co-authored-by: l0ng-ai <[email protected]>
2026-11-05 15:37:19 +08:00

156 lines
7.6 KiB
TypeScript

import { afterEach, beforeEach, expect, it, vi } from "vitest";
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, distros, globalSettings, openRemote, executeAction } = vi.hoisted(() => ({
list: vi.fn(),
distros: vi.fn(),
globalSettings: 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, wslDistros: distros, settingsGetGlobal: globalSettings },
};
});
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();
distros.mockResolvedValue({ status: "ok", data: [] });
// 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();
});
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 />);
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" }));
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({ kind: "ssh", config: "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 />);
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");
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 and disables Browse until one exists, then lists the new one", async () => {
globalSettings.mockResolvedValue({ ssh: { configs: [] } } as unknown as Settings);
render(<ConnectHost />);
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");
});
it("hides the WSL choice when no distribution is installed", async () => {
render(<ConnectHost />);
await waitFor(() => expect(distros).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: "Connect through" })).toBeNull();
});
it("opens a folder of the chosen WSL distribution, without browsing over SFTP", async () => {
const installed = ["Ubuntu-24.04", "Debian"];
distros.mockResolvedValue({ status: "ok", data: installed });
openRemote.mockResolvedValue(true);
render(<ConnectHost />);
fireEvent.click(await screen.findByRole("button", { name: "Connect through" }));
fireEvent.click(screen.getByRole("option", { name: "WSL" }));
expect(screen.queryByRole("button", { name: "Browse" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "WSL distribution" }));
fireEvent.click(screen.getByRole("option", { name: installed[1] }));
fireEvent.click(screen.getByRole("button", { name: "Open" }));
await waitFor(() => expect(openRemote).toHaveBeenCalledWith({ kind: "wsl", distro: installed[1] }, "~"));
expect(list).not.toHaveBeenCalled();
});