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]>
Цей коміт міститься в:
authored and GitHub committed 2026-11-05 15:37:19 +08:00
1 джерело 9bf28dd451
коміт b0f65769c0
47 files changed
+1512 -220

No files matched your search

+38 -14
Переглянути файл
@@ -802,10 +802,10 @@ export const commands = {
*/
windowOpenFileInNewWindow: (project: string | null, file: string) => typedError<OpenOutcome, IdeError>(__TAURI_INVOKE("window_open_file_in_new_window", { project, file })),
/**
* [`window_open_project`] for folder `path` on the host of SSH configuration
* `ssh_config` (Remote Development); a new window connects itself.
* [`window_open_project`] for folder `path` of `target` (an SSH host or a WSL distribution;
* Remote Development); a new window connects itself.
*/
windowOpenRemote: (sshConfig: string, path: string, newWindow: boolean) => typedError<OpenOutcome, IdeError>(__TAURI_INVOKE("window_open_remote", { sshConfig, path, newWindow })),
windowOpenRemote: (target: RemoteTarget, path: string, newWindow: boolean) => typedError<OpenOutcome, IdeError>(__TAURI_INVOKE("window_open_remote", { target, path, newWindow })),
/** Opens an empty (welcome) window; returns its label. */
windowNew: () => typedError<string, IdeError>(__TAURI_INVOKE("window_new")),
/**
@@ -1149,6 +1149,19 @@ export const commands = {
dbTrustSshHost: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("db_trust_ssh_host", { id })),
/** Test Connection: connects with `config` as edited (not necessarily saved). */
sshTest: (config: SshConfiguration, secret: string | null) => typedError<string, IdeError>(__TAURI_INVOKE("ssh_test", { config, secret })),
/**
* Installed WSL distros, for the "Connect to WSL" picker. `wsl.exe` can stall while the WSL
* service starts, so the wait is bounded.
*/
wslDistros: () => typedError<string[], IdeError>(__TAURI_INVOKE("wsl_distros")),
/**
* The WSL project a `\\wsl.localhost\<distro>\…` folder stands for, so the frontend opens it
* through the distro and not as a network folder. `None` for any other path.
*/
wslResolve: (path: string) => __TAURI_INVOKE<{
target: RemoteTarget,
path: string,
} | null>("wsl_resolve", { path }),
/** Remembers the password / passphrase of configuration `id` (`None` forgets it). */
sshSetSecret: (id: string, secret: string | null, save: boolean) => __TAURI_INVOKE<void>("ssh_set_secret", { id, secret, save }),
/** Whether connecting with configuration `id` needs a password first. */
@@ -1191,15 +1204,17 @@ export const commands = {
/** Browse before connecting, using the desktop's SSH configuration even in a remote window. */
remoteBootstrapList: (ssh: string, path: string) => typedError<RemoteEntry[], IdeError>(__TAURI_INVOKE("remote_bootstrap_list", { ssh, path })),
/**
* File › Remote Development: opens `path` on the host of SSH configuration
* `ssh_config` in this window (installs / starts the server as needed).
* File › Remote Development: opens `path` on `target` (an SSH host or a WSL distribution)
* in this window (installs / starts the server as needed).
*/
remoteConnect: (sshConfig: string, path: string, onProgress: Channel<RemoteProgress>) => typedError<RemoteProject_Serialize, IdeError>(__TAURI_INVOKE("remote_connect", { sshConfig, path, onProgress })),
remoteConnect: (target: RemoteTarget, path: string, onProgress: Channel<RemoteProgress>) => typedError<RemoteProject_Serialize, IdeError>(__TAURI_INVOKE("remote_connect", { target, path, onProgress })),
/** The remote session of this window (a settings window: its parent's), if any. */
remoteCurrent: () => __TAURI_INVOKE<{
sshConfig: string,
target: RemoteTarget,
root: string,
host: string,
/** `linux`, `macos` or `windows`: the OS the project's commands run on. */
os: string,
commands: string[],
} | null>("remote_current"),
remoteDisconnect: () => __TAURI_INVOKE<void>("remote_disconnect"),
@@ -6110,9 +6125,9 @@ export type RemoteEntry = {
permissions: string,
};
/** A project folder on the host of an SSH configuration. */
/** A project folder on an SSH host or in a WSL distribution. */
export type RemoteLaunch = {
sshConfig: string,
target: RemoteTarget,
path: string,
};
@@ -6140,9 +6155,9 @@ export type RemoteProgress = {
export type RemoteProject = RemoteProject_Serialize | RemoteProject_Deserialize;
export type RemoteProject_Deserialize = {
sshConfig: string,
target: RemoteTarget,
root: string,
/** `user@host:port` of the SSH configuration. */
/** `user@host:port` of the SSH configuration; the distribution name for WSL. */
host: string,
serverVersion: string,
os: string,
@@ -6152,9 +6167,9 @@ export type RemoteProject_Deserialize = {
};
export type RemoteProject_Serialize = {
sshConfig: string,
target: RemoteTarget,
root: string,
/** `user@host:port` of the SSH configuration. */
/** `user@host:port` of the SSH configuration; the distribution name for WSL. */
host: string,
serverVersion: string,
os: string,
@@ -6164,15 +6179,24 @@ export type RemoteProject_Serialize = {
};
export type RemoteSession = {
sshConfig: string,
target: RemoteTarget,
root: string,
host: string,
/** `linux`, `macos` or `windows`: the OS the project's commands run on. */
os: string,
commands: string[],
};
/** Where [`remote_connect`] is (`fraction`: 0..1 of a download / upload). */
export type RemoteStage = "connecting" | "checking" | "downloading" | "uploading" | "starting" | "opening";
/** Where a remote project lives and how the server there is reached. */
export type RemoteTarget =
/** The host of SSH configuration `config`. */
{ kind: "ssh"; config: string } |
/** A WSL distribution on this machine. */
{ kind: "wsl"; distro: string };
export type Renamed = {
edit: WorkspaceEdit,
incomplete: ReferencesIncomplete | null,
+19 -2
Переглянути файл
@@ -6,11 +6,13 @@ const projectCurrent = vi.fn();
const windowOpenProject = vi.fn();
const windowOpenFiles = vi.fn();
const windowTakeLaunch = vi.fn();
const wslResolve = vi.fn();
const cancelClose = vi.fn();
const notify = vi.fn();
vi.mock("@/platform/remote", async (original) => ({
...(await original<typeof import("@/platform/remote")>()),
isRemoteWindow: vi.fn(() => false),
connectRemote: vi.fn(async () => {}),
}));
// The backend answers with the global settings, here: whatever the test set up.
const lightEditOpen = vi.fn(async (_path: string) => ({ status: "ok", data: useProject.getState().settings ?? {} }));
@@ -34,6 +36,7 @@ vi.mock("@/platform/ipc", async (orig) => {
windowOpenProject: (...a: unknown[]) => windowOpenProject(...a),
windowOpenFiles: (...a: unknown[]) => windowOpenFiles(...a),
windowTakeLaunch: () => windowTakeLaunch(),
wslResolve: (p: string) => wslResolve(p),
lightEditOpen: (p: string) => lightEditOpen(p),
fsReadDir: (p: string) => fsReadDir(p),
},
@@ -55,7 +58,7 @@ const {
} = await import("../windows");
const { useProject } = await import("@/platform/project");
const { provideEditorService, provideNotificationService } = await import("@/workbench/contributions");
const { isRemoteWindow } = await import("@/platform/remote");
const { isRemoteWindow, connectRemote } = await import("@/platform/remote");
const { IpcError } = await import("@/platform/ipc");
provideNotificationService({ notify });
@@ -77,7 +80,9 @@ const ok = (data: unknown) => ({ status: "ok", data });
const projectState = (root: string) => ({ project: { root, name: root, trusted: true, isGit: false, settings: {} } });
beforeEach(() => {
for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch]) f.mockReset();
for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch, wslResolve]) f.mockReset();
wslResolve.mockResolvedValue(null);
vi.mocked(connectRemote).mockClear();
for (const f of [lightEditOpen, fsReadDir, openProject]) f.mockClear();
cancelClose.mockClear();
notify.mockClear();
@@ -95,6 +100,18 @@ describe("openProjectInWindow", () => {
expect(openProject).toHaveBeenCalledWith("/p/a");
});
it("connects to the distribution when the folder is on the WSL share", async () => {
const [distro, linuxPath] = ["Debian", "/srv/app"];
const share = `\\\\wsl.localhost\\${distro}${linuxPath.replaceAll("/", "\\")}`;
const target = { kind: "wsl", distro };
wslResolve.mockResolvedValue({ target, path: linuxPath });
windowOpenProject.mockResolvedValue(ok({ kind: "current" }));
await openProjectInWindow(share, false);
expect(wslResolve).toHaveBeenCalledWith(share);
expect(connectRemote).toHaveBeenCalledWith(target, linuxPath);
expect(openProject).not.toHaveBeenCalled();
});
it("asks for a new window when this one already has a project", async () => {
useProject.setState(projectState("/p/b") as never);
windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" }));
+6 -3
Переглянути файл
@@ -53,7 +53,10 @@ async function openHere(path: string): Promise<boolean> {
if (!isRemoteWindow() && useProject.getState().project?.root === path) return true;
if (!(await confirmLeaveProject())) return false;
try {
await openProject(path);
// A folder of a WSL distro runs there, not as a network folder.
const wsl = await commands.wslResolve(path).catch(() => null);
if (wsl) await connectRemote(wsl.target, wsl.path);
else await openProject(path);
return true;
} catch (e) {
maybeEditorService()?.cancelClose?.();
@@ -219,8 +222,8 @@ export async function consumeLaunch(): Promise<void> {
if (await openHere(launch.project)) await openInEditor(files, positions);
} else if (files.length) await openFilesHere(files, positions);
else if (launch?.remote) {
const { sshConfig, path } = launch.remote;
await connectRemote(sshConfig, path).catch((e) =>
const { target, path } = launch.remote;
await connectRemote(target, path).catch((e) =>
notifications().notify({
level: "error",
title: t("app.window.openFailed", { name: basename(path) }),
+7 -1
Переглянути файл
@@ -13,7 +13,13 @@ vi.mock("@/platform/tauriCore", async (original) => ({
beforeEach(() => {
useTasks.setState({ tasks: [] });
useRemote.setState({
session: { sshConfig: "s", root: "/remote", host: "host", commands: ["fs_copy", "cancel_request"] },
session: {
target: { kind: "ssh", config: "s" },
root: "/remote",
host: "host",
os: "linux",
commands: ["fs_copy", "cancel_request"],
},
});
});
afterEach(() => {
+44 -4
Переглянути файл
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MEMBER_PREFIX, type ProjectNode } from "./model";
import {
canPaste,
checkEditingName,
copySelection,
importExternal,
paste,
@@ -13,6 +14,7 @@ import {
import { IpcError } from "@/platform/ipc";
import { closeFileRefactorDialog, pendingFileRefactorDialog } from "./FileRefactorDialogs";
import { useRemote } from "@/platform/remote";
import { usePlatform } from "@/platform/platformInfo";
import { provideNotificationService } from "@/workbench/contributions";
import { MemoryFs, setProjectFsForTests } from "./fs";
import { initProjectTree, loadDir, setClipboard, setSelection, useProjectTree } from "./store";
@@ -21,6 +23,30 @@ import { setOsClipboardForTests } from "./clipboard";
let fs: MemoryFs;
const notify = vi.fn();
describe("file names follow the execution host", () => {
const platform = usePlatform.getState();
afterEach(() => {
usePlatform.setState(platform);
useRemote.setState({ session: null });
setProjectFsForTests(null);
initProjectTree(null);
});
it.each(["linux", "windows", "macos"])("validates names on a %s host from a Windows desktop", async (os) => {
usePlatform.setState({ os: "windows" });
useRemote.setState({
session: { target: { kind: "ssh", config: "host" }, root: "/p", host: "host", os, commands: [] },
});
fs = new MemoryFs({ "/p/readme.txt": "" }, "/p");
setProjectFsForTests(fs, "/p");
initProjectTree("/p", "p");
await loadDir("/p");
useProjectTree.setState({ editing: { kind: "new", parent: "/p", nodeKind: "file" } });
expect(checkEditingName("con").error !== null).toBe(os === "windows");
expect(checkEditingName("README.txt").error !== null).toBe(os !== "linux");
});
});
const node = (path: string, dir = false): ProjectNode => ({
key: path,
path,
@@ -102,7 +128,9 @@ describe("OS file imports", () => {
setProjectFsForTests(fs, "/p");
initProjectTree("/p", "p");
await loadDir("/p");
useRemote.setState({ session: { sshConfig: "ssh", root: "/p", host: "host", commands: ["fs_copy"] } });
useRemote.setState({
session: { target: { kind: "ssh", config: "ssh" }, root: "/p", host: "host", os: "linux", commands: ["fs_copy"] },
});
provideNotificationService({ notify });
notify.mockClear();
});
@@ -292,7 +320,15 @@ describe("project clipboard ownership", () => {
it.each(remoteCases)(
"remote hand-off=$handedOff OS=$osList cut=$cut on $entry",
async ({ handedOff, osList, cut, entry }) => {
useRemote.setState({ session: { sshConfig: "ssh", root: "/p", host: "host", commands: ["fs_copy"] } });
useRemote.setState({
session: {
target: { kind: "ssh", config: "ssh" },
root: "/p",
host: "host",
os: "linux",
commands: ["fs_copy"],
},
});
const writeText = vi.fn(async () => {
if (!handedOff) throw new Error("unavailable");
});
@@ -338,7 +374,9 @@ describe("project clipboard ownership", () => {
);
it("writes multiple remote paths as newline-joined text", async () => {
useRemote.setState({ session: { sshConfig: "ssh", root: "/p", host: "host", commands: ["fs_copy"] } });
useRemote.setState({
session: { target: { kind: "ssh", config: "ssh" }, root: "/p", host: "host", os: "linux", commands: ["fs_copy"] },
});
await fs.createFile("/p/second.txt");
await loadDir("/p", true);
setSelection(new Set(["/p/source.txt", "/p/second.txt"]), "/p/source.txt");
@@ -350,7 +388,9 @@ describe("project clipboard ownership", () => {
});
it("waits for a pending remote text write before reading file URLs", async () => {
useRemote.setState({ session: { sshConfig: "ssh", root: "/p", host: "host", commands: ["fs_copy"] } });
useRemote.setState({
session: { target: { kind: "ssh", config: "ssh" }, root: "/p", host: "host", os: "linux", commands: ["fs_copy"] },
});
let finish!: () => void;
vi.stubGlobal("navigator", {
clipboard: {
+9 -6
Переглянути файл
@@ -39,17 +39,14 @@ import {
useProjectTree,
} from "./store";
import { tx } from "@/platform/i18n";
import { isRemoteWindow } from "@/platform/remote";
import { hostOs, isRemoteWindow } from "@/platform/remote";
import { usePlatform } from "@/platform/platformInfo";
/** Nodes that may be renamed, moved or deleted (not roots, groups or library sources). */
const mutable = (n: ProjectNode) => !n.root && !n.placeholder && !n.readOnly;
const get = () => useProjectTree.getState();
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
const isWindows = /Win/i.test(ua);
/** macOS and Windows file systems are case-insensitive by default. */
const caseInsensitive = /Mac|Win/i.test(ua);
const caseInsensitiveOs = (os: string) => os === "windows" || os === "macos";
function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
@@ -236,6 +233,9 @@ export function cancelEditing() {
export function checkEditingName(name: string) {
const { editing } = get();
if (!editing) return { error: null };
const os = hostOs();
const isWindows = os === "windows";
const caseInsensitive = caseInsensitiveOs(os);
if (editing.kind === "new" && editing.pkg) {
const { segments, error } = packageSegments(name, editing.pkg);
if (!segments) return { error };
@@ -645,6 +645,7 @@ async function pasteFromOsClipboard(paths: string[], target: string) {
/** The same files, as the clipboard keeps them (`/`-separated, any order). */
function sameFiles(files: string[], paths: string[] | undefined): boolean {
if (!paths || files.length !== paths.length) return false;
const caseInsensitive = caseInsensitiveOs(usePlatform.getState().os);
const key = (list: string[]) =>
list
.map((p) => (caseInsensitive ? p.toLowerCase() : p))
@@ -687,6 +688,7 @@ export async function transfer(
if (!root) return false;
const fs = projectFs(root);
await loadDir(target);
const caseInsensitive = caseInsensitiveOs(hostOs());
const taken = new Set(siblingsOf(target).map((n) => (caseInsensitive ? n.toLowerCase() : n)));
const hasName = (n: string) => taken.has(caseInsensitive ? n.toLowerCase() : n);
const isTaken = (n: string) => hasName(n) || (isUnityAssetFolder(target) && hasName(`${n}.meta`));
@@ -744,6 +746,7 @@ export async function transfer(
async function moveInto(paths: string[], target: string): Promise<boolean> {
paths = assetPaths(paths);
await loadDir(target);
const caseInsensitive = caseInsensitiveOs(hostOs());
const taken = new Set(siblingsOf(target).map((n) => (caseInsensitive ? n.toLowerCase() : n)));
const errors: string[] = [];
const moves: { from: string; to: string }[] = [];
+31 -3
Переглянути файл
@@ -6,8 +6,9 @@ import { useRemoteProgress } from "@/platform/remote";
import { ConnectHost } from "./ConnectDialog";
import { closeConnectDialog, openConnectDialog, useConnectDialog } from "./model";
const { list, globalSettings, openRemote, executeAction } = vi.hoisted(() => ({
const { list, distros, globalSettings, openRemote, executeAction } = vi.hoisted(() => ({
list: vi.fn(),
distros: vi.fn(),
globalSettings: vi.fn(),
openRemote: vi.fn(),
executeAction: vi.fn(),
@@ -15,7 +16,10 @@ const { list, globalSettings, openRemote, executeAction } = vi.hoisted(() => ({
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return { ...real, commands: { ...real.commands, remoteBootstrapList: list, settingsGetGlobal: globalSettings } };
return {
...real,
commands: { ...real.commands, remoteBootstrapList: list, wslDistros: distros, settingsGetGlobal: globalSettings },
};
});
vi.mock("@/workbench/projectWindow", () => ({ openRemoteProject: openRemote }));
vi.mock("@/platform/actions", async (importOriginal) => ({
@@ -35,6 +39,7 @@ const entry = (path: string, isDir = true): RemoteEntry => ({
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({
@@ -87,7 +92,9 @@ it("browses the selected SSH host, navigates folders and opens one without a cur
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"));
await waitFor(() =>
expect(openRemote).toHaveBeenCalledWith({ kind: "ssh", config: "selected" }, "/home/dev/projects"),
);
expect(useConnectDialog.getState().open).toBe(false);
expect(useProject.getState().project).toBeNull();
});
@@ -125,3 +132,24 @@ it("offers SSH configuration and disables Browse until one exists, then lists th
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();
});
+78 -22
Переглянути файл
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { executeAction } from "@/platform/actions";
import { useTx } from "@/platform/i18n";
import { call, commands, isCancelled, onIdeEvent, type RemoteEntry } from "@/platform/ipc";
import { call, commands, isCancelled, onIdeEvent, type RemoteEntry, type RemoteTarget } from "@/platform/ipc";
import { useRemoteProgress } from "@/platform/remote";
import { sshLabel, type SshConfigValid, validateRemote } from "@/platform/remoteSettings";
import { Button, Dialog, Icon, Input, ProgressBar, Select, Spinner } from "@/ui";
@@ -52,11 +52,34 @@ function ConnectDialog() {
const configs = useLocalSshConfigs();
const [picked, setSsh] = useState("");
const ssh = configs.some((c) => c.id === picked) ? picked : (configs[0]?.id ?? "");
const [kind, setKind] = useState<RemoteTarget["kind"]>("ssh");
// Installed WSL distributions; none (or no WSL at all) hides the WSL choice.
const [distros, setDistros] = useState<string[]>([]);
const [distro, setDistro] = useState("");
const [path, setPath] = useState("~");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [browse, setBrowse] = useState<{ dir: string; folders: RemoteEntry[] } | null>(null);
useEffect(() => {
let live = true;
// Detection is a convenience: any failure just leaves SSH as the only choice.
void call(commands.wslDistros())
.then((found) => {
if (!live) return;
setDistros(found);
setDistro((d) => d || (found[0] ?? ""));
})
.catch(() => {});
return () => {
live = false;
};
}, []);
const wsl = kind === "wsl";
const name = wsl ? distro : ssh;
const target: RemoteTarget = wsl ? { kind: "wsl", distro } : { kind: "ssh", config: ssh };
const list = async (dir: string) => {
setError(null);
setBusy(true);
@@ -76,8 +99,7 @@ function ConnectDialog() {
setError(null);
setBusy(true);
try {
const target = path.trim() || "~";
if (await openRemoteProject(ssh, target)) closeConnectDialog();
if (await openRemoteProject(target, path.trim() || "~")) closeConnectDialog();
else setBusy(false);
} catch (e) {
if (!isCancelled(e)) setError(e instanceof Error ? e.message : String(e));
@@ -86,7 +108,7 @@ function ConnectDialog() {
};
const options = configs.map((c) => ({ value: c.id, label: c.name.trim() || sshLabel(c), detail: sshLabel(c) }));
const ready = !!ssh && !busy && !!path.trim();
const ready = !!name && !busy && !!path.trim();
const connecting = useRemoteProgress((s) => s.progress !== null);
return (
@@ -115,21 +137,52 @@ function ConnectDialog() {
<p className={styles.hint}>
{tx("The project runs on the remote host; the IDE backend is installed there on first use.")}
</p>
<label className={styles.label}>{tx("SSH configuration:")}</label>
<div className={styles.row}>
<Select
className={styles.grow}
value={ssh || null}
options={options}
placeholder={tx("No SSH configurations")}
onChange={(id) => {
setSsh(id);
setBrowse(null);
}}
aria-label={tx("SSH configuration")}
/>
<Button onClick={() => void executeAction("file.settings", SSH_SETTINGS_PAGE)}>{tx("Configure…")}</Button>
</div>
{distros.length > 0 && (
<>
<label className={styles.label}>{tx("Connect through:")}</label>
<Select
value={kind}
options={[
{ value: "ssh", label: "SSH" },
{ value: "wsl", label: "WSL" },
]}
onChange={(k) => {
setKind(k as RemoteTarget["kind"]);
setBrowse(null);
}}
aria-label={tx("Connect through")}
/>
</>
)}
{wsl ? (
<>
<label className={styles.label}>{tx("WSL distribution:")}</label>
<Select
value={distro || null}
options={distros.map((d) => ({ value: d, label: d }))}
onChange={setDistro}
aria-label={tx("WSL distribution")}
/>
</>
) : (
<>
<label className={styles.label}>{tx("SSH configuration:")}</label>
<div className={styles.row}>
<Select
className={styles.grow}
value={ssh || null}
options={options}
placeholder={tx("No SSH configurations")}
onChange={(id) => {
setSsh(id);
setBrowse(null);
}}
aria-label={tx("SSH configuration")}
/>
<Button onClick={() => void executeAction("file.settings", SSH_SETTINGS_PAGE)}>{tx("Configure…")}</Button>
</div>
</>
)}
<label className={styles.label} htmlFor="remote-dev-path">
{tx("Project directory:")}
</label>
@@ -142,9 +195,12 @@ function ConnectDialog() {
spellCheck={false}
onChange={(e) => setPath(e.target.value)}
/>
<Button disabled={!ssh || busy} onClick={() => void list(path.trim() || "~")}>
{tx("Browse")}
</Button>
{/* Browsing lists over SFTP; a WSL distribution has no such session before connecting. */}
{!wsl && (
<Button disabled={!ssh || busy} onClick={() => void list(path.trim() || "~")}>
{tx("Browse")}
</Button>
)}
</div>
{browse && (
<ul className={styles.folders} aria-label={tx("Remote folders")}>
+25
Переглянути файл
@@ -0,0 +1,25 @@
import { executeAction } from "@/platform/actions";
import { tx } from "@/platform/i18n";
import type { RemoteSession } from "@/platform/ipc";
import { useRemote } from "@/platform/remote";
import { BarButton } from "@/ui";
/** Status bar label of a remote window: where its project runs. */
export function remoteBadge(session: Pick<RemoteSession, "target" | "host">): string {
return `${session.target.kind === "wsl" ? "WSL" : "SSH"}: ${session.host}`;
}
export function RemoteStatus() {
const session = useRemote((s) => s.session);
if (!session) return null;
const label = remoteBadge(session);
return (
<BarButton
tooltip={tx`Project runs on ${label}`}
aria-label={label}
onClick={() => void executeAction("remote.connect")}
>
{label}
</BarButton>
);
}
+13 -5
Переглянути файл
@@ -1,19 +1,20 @@
/**
* Remote development: a window whose project lives on an SSH host. The backend
* Remote development: a window whose project lives on an SSH host or in a WSL distribution. The backend
* (`openidea-server`) runs there and this window routes its commands to it
* (platform/remote.ts).
*/
import { registerActions } from "@/platform/actions";
import { disconnectRemote, useRemote } from "@/platform/remote";
import { lazyComponent } from "@/ui";
import { maybeEditorService, registerOverlay, registerToolWindow } from "@/workbench/contributions";
import { maybeEditorService, registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions";
import { confirmLeaveProject } from "@/platform/project";
import { ConnectHost } from "./ConnectDialog";
import { reloadEnvironment } from "./environment";
import { RemoteStatus } from "./RemoteStatus";
import { closeConnectDialog, openConnectDialog } from "./model";
import { restorePorts, usePorts } from "./ports";
/** The Ports tool window, while this window is connected to a remote host. */
/** SSH tunnels are available while this window is connected to an SSH host. */
function trackPortsWindow(): () => void {
let off: (() => void) | null = null;
const sync = (connected: boolean) => {
@@ -34,8 +35,8 @@ function trackPortsWindow(): () => void {
usePorts.setState({ ports: [] });
}
};
sync(useRemote.getState().session !== null);
const unsubscribe = useRemote.subscribe((s) => sync(s.session !== null));
sync(useRemote.getState().session?.target.kind === "ssh");
const unsubscribe = useRemote.subscribe((s) => sync(s.session?.target.kind === "ssh"));
return () => {
unsubscribe();
off?.();
@@ -46,6 +47,13 @@ export function activate(): () => void {
const disposers = [
trackPortsWindow(),
registerOverlay({ id: "remote.connect", component: ConnectHost }),
registerStatusItem({
id: "remote.location",
title: "Remote Host",
align: "left",
priority: 100,
component: RemoteStatus,
}),
registerActions([
{
id: "remote.connect",
+10 -2
Переглянути файл
@@ -63,7 +63,9 @@ describe("restoring forwarded ports", () => {
it("forwards the ports of this host and folder again after reconnecting", async () => {
const { useRemote } = await import("@/platform/remote");
const { forwardPort, restorePorts, savedPorts } = await import("./ports");
useRemote.setState({ session: { sshConfig: "lh", root: "/srv/app", host: "h", commands: [] } });
useRemote.setState({
session: { target: { kind: "ssh", config: "lh" }, root: "/srv/app", host: "h", os: "linux", commands: [] },
});
await forwardPort(8080);
expect(savedPorts()).toEqual([8080]);
// Reconnected: the new connection forwards nothing yet.
@@ -73,7 +75,13 @@ describe("restoring forwarded ports", () => {
expect(ipc.remoteForwardPort).toHaveBeenCalledWith(8080);
expect(usePorts.getState().ports).toEqual([{ remotePort: 8080, localPort: 8080 }]);
// Another folder on the same host has its own list.
useRemote.setState({ session: { sshConfig: "lh", root: "/srv/other", host: "h", commands: [] } });
useRemote.setState({
session: { target: { kind: "ssh", config: "lh" }, root: "/srv/other", host: "h", os: "linux", commands: [] },
});
expect(savedPorts()).toEqual([]);
useRemote.setState({
session: { target: { kind: "wsl", distro: "lh" }, root: "/srv/app", host: "lh", os: "linux", commands: [] },
});
expect(savedPorts()).toEqual([]);
useRemote.setState({ session: null });
});
+9 -1
Переглянути файл
@@ -10,7 +10,7 @@ const SAVED_KEY = "openidea.remote.ports:";
function savedKey(): string | null {
const session = useRemote.getState().session;
return session ? `${SAVED_KEY}${session.sshConfig}:${session.root}` : null;
return session?.target.kind === "ssh" ? `${SAVED_KEY}${session.target.config}:${session.root}` : null;
}
export function savedPorts(): number[] {
@@ -53,6 +53,14 @@ export function parsePort(text: string): number | null {
}
export async function forwardPort(port: number): Promise<ForwardedPort | null> {
if (useRemote.getState().session?.target.kind === "wsl") {
notifications().notify({
group: "remote",
level: "error",
title: tx("Forwarding ports is not supported in a WSL project yet"),
});
return null;
}
try {
const forwarded = await call(commands.remoteForwardPort(port));
await refreshPorts(true);
+59 -12
Переглянути файл
@@ -10,6 +10,7 @@ import {
remoteLocation,
} from "@/platform/remote";
import { browsableFolders, listedDirectory, remoteParent } from "./model";
import { remoteBadge } from "./RemoteStatus";
const entry = (path: string, isDir = true): RemoteEntry => ({
name: path.slice(path.lastIndexOf("/") + 1),
@@ -47,33 +48,79 @@ describe("remote folder browser", () => {
});
});
const ssh = (config: string) => ({ kind: "ssh", config }) as const;
const wsl = (distro: string) => ({ kind: "wsl", distro }) as const;
describe("remote project locations", () => {
it("round-trip the configuration id and the path", () => {
const loc = remoteLocation("mac mini/1", "/Users/me/repo");
const loc = remoteLocation(ssh("mac mini/1"), "/Users/me/repo");
expect(loc).toBe("ssh://mac%20mini%2F1/Users/me/repo");
expect(parseRemoteLocation(loc)).toEqual({ sshConfig: "mac mini/1", path: "/Users/me/repo" });
expect(parseRemoteLocation(loc)).toEqual({ target: ssh("mac mini/1"), path: "/Users/me/repo" });
expect(parseRemoteLocation("/Users/me/repo")).toBeNull();
});
it("round-trip a WSL distribution and the path", () => {
const loc = remoteLocation(wsl("Ubuntu-24.04"), "/home/me/app");
expect(loc).toBe("wsl://Ubuntu-24.04/home/me/app");
expect(parseRemoteLocation(loc)).toEqual({ target: wsl("Ubuntu-24.04"), path: "/home/me/app" });
expect(parseRemoteLocation("wsl://Ubuntu")).toBeNull();
});
it("remembers recent remote projects most recent first, once each", () => {
localStorage.clear();
rememberRemote({ sshConfig: "a", path: "/p", name: "p", host: "me@a", openedAtMs: 1 });
rememberRemote({ sshConfig: "b", path: "/q", name: "q", host: "me@b", openedAtMs: 2 });
rememberRemote({ sshConfig: "a", path: "/p", name: "p", host: "me@a", openedAtMs: 3 });
rememberRemote({ target: ssh("a"), path: "/p", name: "p", host: "me@a", openedAtMs: 1 });
rememberRemote({ target: ssh("b"), path: "/q", name: "q", host: "me@b", openedAtMs: 2 });
rememberRemote({ target: ssh("a"), path: "/p", name: "p", host: "me@a", openedAtMs: 3 });
expect(recentRemotes().map((r) => r.path)).toEqual(["/p", "/q"]);
forgetRemote("a", "/p");
forgetRemote(ssh("a"), "/p");
expect(recentRemotes().map((r) => r.path)).toEqual(["/q"]);
});
it("keeps an SSH host and a WSL distribution of the same name apart", () => {
localStorage.clear();
rememberRemote({ target: ssh("dev"), path: "/p", name: "p", host: "me@dev", openedAtMs: 1 });
rememberRemote({ target: wsl("dev"), path: "/p", name: "p", host: "dev", openedAtMs: 2 });
expect(recentRemotes()).toHaveLength(2);
forgetRemote(wsl("dev"), "/p");
expect(recentRemotes().map((r) => r.target)).toEqual([ssh("dev")]);
});
it("reads recents saved before WSL support, which carried `sshConfig`", () => {
localStorage.clear();
localStorage.setItem(
"openidea.remote.recent",
JSON.stringify([
{ sshConfig: "old", path: "/p", name: "p", host: "me@old", openedAtMs: 1 },
{ path: "/no-target" },
]),
);
expect(recentRemotes().map((r) => r.target)).toEqual([ssh("old")]);
});
});
it("migrates only the original remote recents once, scoped to the saved host", () => {
localStorage.clear();
rememberRemote({ sshConfig: "a", path: "/old", host: "me@old", name: "old", openedAtMs: 1 });
rememberRemote({ target: ssh("a"), path: "/old", host: "me@old", name: "old", openedAtMs: 1 });
initializeRemoteTrustMigration();
rememberRemote({ sshConfig: "a", path: "/new", host: "me@old", name: "new", openedAtMs: 2 });
rememberRemote({ target: ssh("a"), path: "/new", host: "me@old", name: "new", openedAtMs: 2 });
initializeRemoteTrustMigration();
expect(takeLegacyRemoteTrust("a", "/new", "me@old")).toBe(false);
expect(takeLegacyRemoteTrust("a", "/old", "me@other")).toBe(false);
expect(takeLegacyRemoteTrust("a", "/old", "me@old")).toBe(true);
expect(takeLegacyRemoteTrust("a", "/old", "me@old")).toBe(false);
expect(takeLegacyRemoteTrust(ssh("a"), "/new", "me@old")).toBe(false);
expect(takeLegacyRemoteTrust(ssh("a"), "/old", "me@other")).toBe(false);
expect(takeLegacyRemoteTrust(ssh("a"), "/old", "me@old")).toBe(true);
expect(takeLegacyRemoteTrust(ssh("a"), "/old", "me@old")).toBe(false);
});
it("migrates a snapshot taken before WSL support, and never trusts a WSL distribution", () => {
localStorage.clear();
localStorage.setItem(
"openidea.remote.trustMigration",
JSON.stringify([{ sshConfig: "a", path: "/old", host: "me@old", name: "old", openedAtMs: 1 }]),
);
expect(takeLegacyRemoteTrust(wsl("a"), "/old", "me@old")).toBe(false);
expect(takeLegacyRemoteTrust(ssh("a"), "/old", "me@old")).toBe(true);
});
it("labels the status bar with the transport and where the project runs", () => {
expect(remoteBadge({ target: wsl("Ubuntu"), host: "Ubuntu" })).toBe("WSL: Ubuntu");
expect(remoteBadge({ target: ssh("a"), host: "me@a:22" })).toBe("SSH: me@a:22");
});
+32
Переглянути файл
@@ -16,6 +16,7 @@ vi.mock("@/platform/ipc", async (orig) => {
};
});
const { usePlatform } = await import("@/platform/platformInfo");
const { parseJavaArgs, joinJavaArgs } = await import("@/platform/javaLaunch");
const { parseArgs } = await import("./configs");
const { jdwpLaunch } = await import("./javaDebug");
@@ -247,3 +248,34 @@ describe("Maven / Gradle: VM options of the forked JVM", () => {
await expect(gradle().prepareLaunch!(remote)).rejects.toThrow(/run targets/);
});
});
describe("build wrapper per execution host", () => {
const wrapperCommands = () =>
(["gradle", "maven"] as const).map((t) => typeFor(t)!.commandLine({ wrapper: "true", tasks: "test" })!.command);
const WINDOWS = ["gradlew.bat", "mvnw.cmd"];
const UNIX = ["./gradlew", "./mvnw"];
beforeEach(() => registerBuiltinTypes());
afterEach(async () => {
(await import("@/platform/remote")).disableRemote();
usePlatform.setState({ os: "macos" });
});
it("follows the desktop OS in a local window", () => {
usePlatform.setState({ os: "windows" });
expect(wrapperCommands()).toEqual(WINDOWS);
usePlatform.setState({ os: "linux" });
expect(wrapperCommands()).toEqual(UNIX);
});
it.each([
{ desktop: "windows", host: "linux", target: { kind: "wsl", distro: "Ubuntu" }, expected: UNIX },
{ desktop: "windows", host: "macos", target: { kind: "ssh", config: "box" }, expected: UNIX },
{ desktop: "macos", host: "windows", target: { kind: "ssh", config: "win" }, expected: WINDOWS },
] as const)("follows the $host server of a remote window opened from $desktop", async (c) => {
usePlatform.setState({ os: c.desktop });
const { enableRemote } = await import("@/platform/remote");
enableRemote({ target: c.target, root: "/app", host: "h", os: c.host, commands: [] });
expect(wrapperCommands()).toEqual(c.expected);
});
});
+3 -1
Переглянути файл
@@ -6,6 +6,7 @@
*/
import { call, commands, type RunConfig } from "@/platform/ipc";
import { CLASSPATH, parseJavaArgs, TEST_CLASSPATH } from "@/platform/javaLaunch";
import { hostOs } from "@/platform/remote";
import { basename, useProject } from "@/platform/project";
import { useBuildPresentation } from "@/platform/projectPresentation";
import type { RunConfigTypeContribution, RunConfigTypeField } from "@/workbench/contributions";
@@ -13,7 +14,8 @@ import { formatArgs, parseArgs } from "./configs";
import { commandName } from "./commandName";
import { updatePolicyDefaults, updatePolicyFields } from "./updatePolicies";
export const isWindows = () => typeof navigator !== "undefined" && /win/i.test(navigator.platform);
/** Whether this window's commands run on Windows: the remote server's OS in a remote window, else the desktop's. */
export const isWindows = () => hostOs() === "windows";
/** VM options / program arguments as typed; on Windows a backslash is a path separator. */
export const splitJvmArgs = (text: string | undefined, windows = isWindows()) => parseArgs(text ?? "", windows);
+2 -2
Переглянути файл
@@ -5,6 +5,7 @@
* Rust come from SDK discovery and everything else resolves at launch.
*/
import { call, commands, isTauri } from "@/platform/ipc";
import { isWindows } from "./jvmTypes";
import type { LocatedTool, ToolEnv } from "./producers";
const TTL = 60_000;
@@ -15,7 +16,6 @@ interface Entry {
}
const cache = new Map<string, Entry>();
const isWindows = typeof navigator !== "undefined" && /Win/i.test(navigator.platform || navigator.userAgent);
let compileDir: string | null = null;
let locateUnavailable = false;
@@ -115,7 +115,7 @@ export async function toolEnv(
return {
tool: (n) => tools.get(n),
compileDir: compileDir ?? "/tmp/openidea-run",
windows: isWindows,
windows: isWindows(),
python: py,
...markers,
};
+11 -1
Переглянути файл
@@ -138,7 +138,17 @@ describe("service reordering integration", () => {
render(<ServicesTreeView />);
select("Portal");
fireEvent.click(screen.getByRole("button", { name: "Move Up" }));
act(() => useRemote.setState({ session: { root: "/services", sshConfig: "host", host: "host", commands: [] } }));
act(() =>
useRemote.setState({
session: {
root: "/services",
target: { kind: "ssh", config: "host" },
host: "host",
os: "linux",
commands: [],
},
}),
);
expect(names()).toEqual(["Spring Boot", "Gateway", "Portal", "npm", "Web", "Worker", "Docker"]);
act(() => useRemote.setState({ session: null }));
expect(names()).toEqual(["Spring Boot", "Portal", "Gateway", "npm", "Web", "Worker", "Docker"]);
+2 -1
Переглянути файл
@@ -9,6 +9,7 @@
import { splitCommandLine } from "@/platform/commandLine";
import { call, commands, type RunConfig } from "@/platform/ipc";
import { registerRunConfigType, type RunConfigTypeContribution } from "@/workbench/contributions";
import { hostOs } from "@/platform/remote";
import { imageTag, targetTitle, type DockerTarget } from "./compose";
import { connections, endpointOf, loadSettings, useServices } from "./store";
import { tx } from "@/platform/i18n";
@@ -19,7 +20,7 @@ export const DOCKER_COMPOSE = "docker-compose";
const GROUP = "Docker";
const on = (v: string | undefined) => v === "true";
const isWindows = () => typeof navigator !== "undefined" && /win/i.test(navigator.platform);
const isWindows = () => hostOs() === "windows";
/** One word for the platform shell the run executes the line with (`/bin/sh -c` or `cmd /C`). */
export function shellQuote(word: string, windows = isWindows()): string {
+1 -1
Переглянути файл
@@ -124,7 +124,7 @@ export function recentMenu(project: RecentProject, onOpened?: () => void): MenuI
label: "Open in New Window",
icon: "folder-open",
run: () =>
void call(commands.windowOpenRemote(remote.sshConfig, remote.path, true))
void call(commands.windowOpenRemote(remote.target, remote.path, true))
.then(() => onOpened?.())
.catch(() => undefined),
}
+52
Переглянути файл
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const openProject = vi.fn();
const openRemoteProject = vi.fn();
const UNC = String.raw`\\wsl.localhost\Ubuntu\home\me\app`;
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
commands: {
...real.commands,
wslResolve: async (p: string) =>
p === UNC ? { target: { kind: "wsl", distro: "Ubuntu" }, path: "/home/me/app" } : null,
},
};
});
vi.mock("@/platform/project", async (orig) => {
const real = await orig<typeof import("@/platform/project")>();
return { ...real, openProject: (p: string) => openProject(p) };
});
vi.mock("@/workbench/projectWindow", () => ({
openRemoteProject: (...a: unknown[]) => openRemoteProject(...a),
}));
const { openRecent } = await import("./recent");
const { useProject } = await import("@/platform/project");
beforeEach(() => {
openProject.mockReset();
openRemoteProject.mockReset().mockResolvedValue(true);
useProject.setState({ project: null, lightEdit: false });
});
describe("openRecent", () => {
it("opens a legacy WSL UNC recent through the distro, not as a local folder", async () => {
expect(await openRecent({ path: UNC, name: "app" })).toBe(true);
expect(openRemoteProject).toHaveBeenCalledWith(
{ kind: "wsl", distro: "Ubuntu" },
"/home/me/app",
expect.any(Function),
);
expect(openProject).not.toHaveBeenCalled();
});
it("still opens a plain local recent locally", async () => {
await openRecent({ path: "/p/local", name: "local" });
expect(openProject).toHaveBeenCalledWith("/p/local");
expect(openRemoteProject).not.toHaveBeenCalled();
});
});
+27 -9
Переглянути файл
@@ -1,9 +1,26 @@
/** Recent projects: loading, filtering, opening and removal. */
import { create } from "zustand";
import { executeAction, getAction } from "@/platform/actions";
import { commands, IpcError, isCancelled, isTauri, type RecentProject, type RecentProjectGroup } from "@/platform/ipc";
import {
commands,
IpcError,
isCancelled,
isTauri,
type RecentProject,
type RecentProjectGroup,
type RemoteTarget,
} from "@/platform/ipc";
import { openProject, useProject } from "@/platform/project";
import { connectRemote, forgetRemote, parseRemoteLocation, recentRemotes, remoteLocation } from "@/platform/remote";
import {
connectRemote,
forgetRemote,
parseRemoteLocation,
recentRemotes,
remoteLocation,
resolveRemoteLocation,
sameTarget,
targetName,
} from "@/platform/remote";
import { tx } from "@/platform/i18n";
import { fuzzyMatch } from "@/ui";
import { notifications } from "@/workbench/contributions";
@@ -64,7 +81,7 @@ export async function loadRecent() {
/** The recent remote projects (Remote Development) among the local ones, most recent first. */
function withRemotes(local: RecentProject[]): RecentProject[] {
const remote: RecentProject[] = recentRemotes().map((r) => ({
path: remoteLocation(r.sshConfig, r.path),
path: remoteLocation(r.target, r.path),
name: r.name,
openedAtMs: r.openedAtMs,
branch: null,
@@ -77,7 +94,8 @@ function withRemotes(local: RecentProject[]): RecentProject[] {
export function remoteDisplay(location: string): string | null {
const r = parseRemoteLocation(location);
if (!r) return null;
const host = recentRemotes().find((x) => x.sshConfig === r.sshConfig && x.path === r.path)?.host || r.sshConfig;
const host =
recentRemotes().find((x) => sameTarget(x.target, r.target) && x.path === r.path)?.host || targetName(r.target);
return `${host}:${r.path}`;
}
@@ -88,7 +106,7 @@ export async function removeRecent(path: string) {
}));
const remote = parseRemoteLocation(path);
if (remote) {
forgetRemote(remote.sshConfig, remote.path);
forgetRemote(remote.target, remote.path);
return;
}
if (!isTauri()) return;
@@ -107,7 +125,7 @@ export async function openRecent(project: Pick<RecentProject, "path" | "name">):
});
return false;
}
const remote = parseRemoteLocation(project.path);
const remote = await resolveRemoteLocation(project.path);
if (remote) return openRemote(project, remote);
const current = useProject.getState().project;
if (current?.root === project.path) return true;
@@ -139,12 +157,12 @@ export async function openRecent(project: Pick<RecentProject, "path" | "name">):
async function openRemote(
project: Pick<RecentProject, "path" | "name">,
remote: { sshConfig: string; path: string },
remote: { target: RemoteTarget; path: string },
): Promise<boolean> {
try {
return await openRemoteProject(remote.sshConfig, remote.path, async () => {
return await openRemoteProject(remote.target, remote.path, async () => {
useRecent.setState({ opening: project.path });
await connectRemote(remote.sshConfig, remote.path);
await connectRemote(remote.target, remote.path);
});
} catch (e) {
if (isCancelled(e)) return false;
+21 -1
Переглянути файл
@@ -1,15 +1,26 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const openProject = vi.fn();
const connectRemote = vi.fn();
const UNC = String.raw`\\wsl.localhost\Ubuntu\home\me\app`;
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
commands: { ...real.commands, settingsGet: async () => ({ general: { reopenLastProject: true } }) },
commands: {
...real.commands,
settingsGet: async () => ({ general: { reopenLastProject: true } }),
wslResolve: async (p: string) =>
p === UNC ? { target: { kind: "wsl", distro: "Ubuntu" }, path: "/home/me/app" } : null,
},
};
});
vi.mock("@/platform/remote", async (orig) => {
const real = await orig<typeof import("@/platform/remote")>();
return { ...real, connectRemote: (t: unknown, p: string) => connectRemote(t, p) };
});
vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ label: "main" }) }));
vi.mock("@/platform/project", async (orig) => {
const real = await orig<typeof import("@/platform/project")>();
@@ -21,6 +32,7 @@ const { provideStartupLaunch, useProject } = await import("@/platform/project");
beforeEach(() => {
openProject.mockReset();
connectRemote.mockReset();
useProject.setState({ project: null, lightEdit: false });
sessionStorage.clear();
localStorage.setItem("openidea.session.openProject", "/p/last");
@@ -67,4 +79,12 @@ describe("reopenLastProject", () => {
await reopenLastProject();
expect(openProject).toHaveBeenCalledWith("/p/last");
});
it("restores a session saved as a WSL UNC folder through the distro", async () => {
localStorage.setItem("openidea.session.openProject", UNC);
provideStartupLaunch(Promise.resolve());
await reopenLastProject();
expect(connectRemote).toHaveBeenCalledWith({ kind: "wsl", distro: "Ubuntu" }, "/home/me/app");
expect(openProject).not.toHaveBeenCalled();
});
});
+3 -3
Переглянути файл
@@ -7,7 +7,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
import { create } from "zustand";
import { commands, isCancelled, isTauri, type Settings } from "@/platform/ipc";
import { openProject, startupLaunchHandled, useProject } from "@/platform/project";
import { connectRemote, parseRemoteLocation, projectLocation } from "@/platform/remote";
import { connectRemote, projectLocation, resolveRemoteLocation } from "@/platform/remote";
import { notifications } from "@/workbench/contributions";
const SESSION_KEY = "openidea.session.openProject";
@@ -117,8 +117,8 @@ async function reopen(path: string) {
const name = path.slice(path.lastIndexOf("/") + 1) || path;
useSession.setState({ reopening: path });
try {
const remote = parseRemoteLocation(path);
if (remote) await connectRemote(remote.sshConfig, remote.path);
const remote = await resolveRemoteLocation(path);
if (remote) await connectRemote(remote.target, remote.path);
else await openProject(path);
} catch (e) {
write(SESSION_KEY, null);
+1 -1
Переглянути файл
@@ -248,7 +248,7 @@ describe("catalogs", () => {
it("exist for exactly the supported locales", () => {
expect(translated.length).toBeGreaterThan(0);
expect(Object.keys(translations).sort()).toEqual(translated);
expect(Object.keys(texts).sort()).toEqual(translated);
expect(Object.keys(texts).sort()).toEqual([...SUPPORTED_LOCALES].sort());
});
it("translate source text into non-empty text", () => {
+8
Переглянути файл
@@ -0,0 +1,8 @@
{
"Connect through:": "Connect through:",
"Connect through": "Connect through",
"WSL distribution:": "WSL distribution:",
"WSL distribution": "WSL distribution",
"Project runs on {label}": "Project runs on {label}",
"Forwarding ports is not supported in a WSL project yet": "Forwarding ports is not supported in a WSL project yet"
}
+6
Переглянути файл
@@ -4470,6 +4470,12 @@
"Remote Development": "远程开发",
"The project runs on the remote host; the IDE backend is installed there on first use.": "项目在远程主机上运行,首次使用时会在该主机上安装 IDE 后端。",
"SSH configuration:": "SSH 配置:",
"Connect through:": "连接方式:",
"Connect through": "连接方式",
"WSL distribution:": "WSL 发行版:",
"WSL distribution": "WSL 发行版",
"Project runs on {label}": "项目运行于 {label}",
"Forwarding ports is not supported in a WSL project yet": "WSL 项目暂不支持端口转发",
"No SSH configurations": "没有 SSH 配置",
"Project directory:": "项目目录:",
"Remote Development…": "远程开发…",
+1
Переглянути файл
@@ -108,6 +108,7 @@ const UNTRANSLATED = new Set<string>([
"loopvar,rangefunc",
// SSH and run-target placeholders
"SSH",
"WSL",
"example.com",
"java",
"node",
+81 -28
Переглянути файл
@@ -8,9 +8,18 @@ import { confirmProjectTrust } from "./projectTrust";
import { Channel, type InvokeArgs, type InvokeOptions } from "@tauri-apps/api/core";
import { invokeLocal, setInvokeRoute, type InvokeRoute } from "./tauriCore";
import { create } from "zustand";
import { call, commands, isTauri, type ChannelFrame, type RemoteProgress, type RemoteSession } from "./ipc";
import {
call,
commands,
isTauri,
type ChannelFrame,
type RemoteProgress,
type RemoteSession,
type RemoteTarget,
} from "./ipc";
import { isClientSetting, overlayClientSettings } from "./clientSettings";
import type { Settings } from "./ipc";
import { usePlatform } from "./platformInfo";
import { useProject } from "./project";
interface RemoteState {
@@ -21,6 +30,7 @@ export const useRemote = create<RemoteState>(() => ({ session: null }));
let routed = new Set<string>();
let nextChannel = 1;
const BUILD_MODEL_COMMANDS = new Set(["build_model", "build_sync", "gradle_link_projects", "gradle_unlink_projects"]);
/**
* The arguments with each `Channel` replaced by `{ $channel: id }`, and the
@@ -48,7 +58,12 @@ async function remoteInvoke(cmd: string, args?: InvokeArgs, options?: InvokeOpti
const onChannel = new Channel<ChannelFrame>();
onChannel.onmessage = (frame) => channels.get(frame.id)?.onmessage(JSON.parse(frame.data));
const result = await invokeLocal<string>("remote_invoke", { cmd, args: json, onChannel }, options);
return JSON.parse(result);
const value = JSON.parse(result);
// Released servers predating independent Gradle builds return a single build.
if (BUILD_MODEL_COMMANDS.has(cmd) && value?.gradle && !Array.isArray(value.gradle)) {
value.gradle = [value.gradle];
}
return value;
}
/**
@@ -119,6 +134,11 @@ export function isRemoteWindow(): boolean {
return useRemote.getState().session !== null;
}
/** The OS this window's commands run on: the remote server's, else this desktop's (`usePlatform`). */
export function hostOs(): string {
return useRemote.getState().session?.os ?? usePlatform.getState().os;
}
/** Re-attaches after a reload (the backend keeps the connection per window). */
export async function restoreRemote(): Promise<RemoteSession | null> {
if (!isTauri()) return null;
@@ -133,19 +153,19 @@ export const useRemoteProgress = create<{ progress: RemoteProgress | null; locat
location: null,
}));
/** Opens `path` on the host of SSH configuration `sshConfig` in this window. */
export async function connectRemote(sshConfig: string, path: string) {
/** Opens `path` of `target` (an SSH host or a WSL distribution) in this window. */
export async function connectRemote(target: RemoteTarget, path: string) {
useProject.setState({ opening: true });
const onProgress = new Channel<RemoteProgress>();
onProgress.onmessage = (progress) => useRemoteProgress.setState({ progress });
useRemoteProgress.setState({
progress: { stage: "connecting", fraction: null },
location: remoteLocation(sshConfig, path),
location: remoteLocation(target, path),
});
try {
const r = await call(commands.remoteConnect(sshConfig, path, onProgress));
enableRemote({ sshConfig: r.sshConfig, root: r.root, host: r.host, commands: r.commands });
if (takeLegacyRemoteTrust(r.sshConfig, path, r.host)) {
const r = await call(commands.remoteConnect(target, path, onProgress));
enableRemote({ target: r.target, root: r.root, host: r.host, os: r.os, commands: r.commands });
if (takeLegacyRemoteTrust(r.target, path, r.host)) {
const status = await call(commands.projectTrustStatus(r.root));
if (!status.decided) await call(commands.projectTrustSet(r.root, true, false));
}
@@ -161,7 +181,7 @@ export async function connectRemote(sshConfig: string, path: string) {
r.project.settings = await clientSettings(r.project.settings);
useProject.setState({ project: r.project, settings: r.project.settings });
rememberRemote({
sshConfig: r.sshConfig,
target: r.target,
path: r.root,
name: r.project.name,
host: r.host,
@@ -182,32 +202,54 @@ export async function disconnectRemote() {
}
// ---------------------------------------------------------------------------
// Remote project locations: `ssh://<configuration id>/<absolute path>`, what the
// session and the recent projects remember for a remote window.
// Remote project locations: `ssh://<configuration id>/<absolute path>` or
// `wsl://<distribution>/<absolute path>`, what the session and the recent projects
// remember for a remote window.
const SCHEME = "ssh://";
const SCHEMES = { ssh: "ssh://", wsl: "wsl://" } as const;
export function remoteLocation(sshConfig: string, path: string): string {
return `${SCHEME}${encodeURIComponent(sshConfig)}${path.startsWith("/") ? "" : "/"}${path}`;
/** The SSH configuration id or the WSL distribution name. */
export function targetName(target: RemoteTarget): string {
return target.kind === "ssh" ? target.config : target.distro;
}
export function parseRemoteLocation(location: string): { sshConfig: string; path: string } | null {
if (!location.startsWith(SCHEME)) return null;
const rest = location.slice(SCHEME.length);
export function sameTarget(a: RemoteTarget, b: RemoteTarget): boolean {
return a.kind === b.kind && targetName(a) === targetName(b);
}
export function remoteLocation(target: RemoteTarget, path: string): string {
return `${SCHEMES[target.kind]}${encodeURIComponent(targetName(target))}${path.startsWith("/") ? "" : "/"}${path}`;
}
export function parseRemoteLocation(location: string): { target: RemoteTarget; path: string } | null {
const kind = (Object.keys(SCHEMES) as (keyof typeof SCHEMES)[]).find((k) => location.startsWith(SCHEMES[k]));
if (!kind) return null;
const rest = location.slice(SCHEMES[kind].length);
const slash = rest.indexOf("/");
if (slash <= 0) return null;
return { sshConfig: decodeURIComponent(rest.slice(0, slash)), path: rest.slice(slash) };
const name = decodeURIComponent(rest.slice(0, slash));
const target: RemoteTarget = kind === "ssh" ? { kind, config: name } : { kind, distro: name };
return { target, path: rest.slice(slash) };
}
/**
* The remote project a saved location stands for: a `ssh://` / `wsl://` location, or a
* `\\wsl.localhost\<distro>\…` folder remembered before WSL projects existed (it is a WSL
* project, not a network folder). `null` for a plain local path.
*/
export async function resolveRemoteLocation(location: string): Promise<{ target: RemoteTarget; path: string } | null> {
return parseRemoteLocation(location) ?? (await commands.wslResolve(location).catch(() => null));
}
/** The location of this window's project: remote, else its local root. */
export function projectLocation(root: string | null | undefined): string | null {
if (!root) return null;
const session = useRemote.getState().session;
return session && session.root === root ? remoteLocation(session.sshConfig, root) : root;
return session && session.root === root ? remoteLocation(session.target, root) : root;
}
export interface RecentRemote {
sshConfig: string;
target: RemoteTarget;
path: string;
name: string;
/** `user@host:port` when it was opened. */
@@ -230,12 +272,23 @@ export function initializeRemoteTrustMigration() {
}
}
export function takeLegacyRemoteTrust(sshConfig: string, path: string, host: string): boolean {
/** An entry as stored: before WSL it carried `sshConfig` instead of `target`. */
function normalize(entry: RecentRemote & { sshConfig?: string }): RecentRemote | null {
if (!entry || !entry.path) return null;
const target = entry.target ?? (entry.sshConfig ? { kind: "ssh" as const, config: entry.sshConfig } : null);
return target ? { ...entry, target } : null;
}
/** Whether `path` on SSH `target` was remembered before the trust prompt existed (WSL never was). */
export function takeLegacyRemoteTrust(target: RemoteTarget, path: string, host: string): boolean {
if (target.kind !== "ssh") return false;
try {
const value: unknown = JSON.parse(localStorage.getItem(TRUST_MIGRATION_KEY) ?? "[]");
if (!Array.isArray(value)) return false;
const matches = (entry: RecentRemote) =>
entry?.sshConfig === sshConfig && entry.path === path && entry.host === host;
const matches = (stored: RecentRemote) => {
const entry = normalize(stored);
return entry !== null && sameTarget(entry.target, target) && entry.path === path && entry.host === host;
};
const found = value.some(matches);
localStorage.setItem(TRUST_MIGRATION_KEY, JSON.stringify(value.filter((entry) => !matches(entry))));
return found;
@@ -247,7 +300,7 @@ export function takeLegacyRemoteTrust(sshConfig: string, path: string, host: str
export function recentRemotes(): RecentRemote[] {
try {
const list = JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]") as unknown;
return Array.isArray(list) ? (list as RecentRemote[]).filter((r) => r && r.sshConfig && r.path) : [];
return Array.isArray(list) ? list.map(normalize).filter((r): r is RecentRemote => r !== null) : [];
} catch {
return [];
}
@@ -261,15 +314,15 @@ function saveRemotes(list: RecentRemote[]) {
}
}
const sameRemote = (a: Pick<RecentRemote, "sshConfig" | "path">, b: Pick<RecentRemote, "sshConfig" | "path">) =>
a.sshConfig === b.sshConfig && a.path === b.path;
const sameRemote = (a: Pick<RecentRemote, "target" | "path">, b: Pick<RecentRemote, "target" | "path">) =>
sameTarget(a.target, b.target) && a.path === b.path;
export function rememberRemote(entry: RecentRemote) {
saveRemotes([entry, ...recentRemotes().filter((r) => !sameRemote(r, entry))]);
}
export function forgetRemote(sshConfig: string, path: string) {
saveRemotes(recentRemotes().filter((r) => !sameRemote(r, { sshConfig, path })));
export function forgetRemote(target: RemoteTarget, path: string) {
saveRemotes(recentRemotes().filter((r) => !sameRemote(r, { target, path })));
}
// Any way of leaving the remote project (Close Project, opening a local one) ends remote routing.
+37 -3
Переглянути файл
@@ -3,6 +3,7 @@ import { CLIENT_SETTINGS_KEYS } from "./clientSettings";
import { commands, call, type Settings } from "./ipc";
import { loadSettings, useProject } from "./project";
import { connectRemote, disableRemote, enableRemote, isRemoteWindow, remoteRouting } from "./remote";
import { invoke as routedInvoke } from "./tauriCore";
const handlers = vi.hoisted(() => new Map<string, () => void>());
vi.mock("./ipc", async (original) => ({
@@ -41,7 +42,13 @@ beforeEach(() => {
cmd === "remote_invoke" ? JSON.stringify(remote) : local,
);
Object.assign(window, { __TAURI_INTERNALS__: { invoke, transformCallback: () => 1 } });
enableRemote({ sshConfig: "test", root: "/remote", host: "test", commands: [...served] });
enableRemote({
target: { kind: "ssh", config: "test" },
root: "/remote",
host: "test",
os: "linux",
commands: [...served],
});
});
afterEach(() => {
disableRemote();
@@ -49,6 +56,33 @@ afterEach(() => {
});
describe("remote client settings", () => {
it.each([
{ gradle: { dir: "app", projects: [] }, expected: [{ dir: "app", projects: [] }] },
{
gradle: [
{ dir: "one", projects: [] },
{ dir: "two", projects: [] },
],
expected: [
{ dir: "one", projects: [] },
{ dir: "two", projects: [] },
],
},
{ gradle: null, expected: null },
])("accepts the released and current remote Gradle build models: $gradle", async ({ gradle, expected }) => {
for (const command of ["build_model", "build_sync", "gradle_link_projects", "gradle_unlink_projects"]) {
enableRemote({
target: { kind: "wsl", distro: "Ubuntu" },
root: "/remote",
host: "Ubuntu",
os: "linux",
commands: [command],
});
invoke.mockResolvedValue(JSON.stringify({ gradle, maven: null }));
expect(await routedInvoke(command)).toEqual({ gradle: expected, maven: null });
}
});
it("ends remote routing before a successful local open is published, even at the same path", async () => {
invoke.mockResolvedValueOnce({ root: "/remote", settings: local });
await call(commands.openProject("/remote"));
@@ -91,7 +125,7 @@ describe("remote client settings", () => {
invoke.mockImplementation(async (cmd: string) =>
cmd === "remote_connect"
? {
sshConfig: "test",
target: { kind: "ssh", config: "test" },
root: "/remote",
host: "test",
commands: [...served],
@@ -101,7 +135,7 @@ describe("remote client settings", () => {
? "null"
: local,
);
await connectRemote("test", "/remote");
await connectRemote({ kind: "ssh", config: "test" }, "/remote");
expect(useProject.getState().settings).toMatchObject({ keymap: "my-map", editor: { tabSize: 2, fontSize: 19 } });
});
it("overlays the project snapshot used by the settings window", async () => {
+6 -6
Переглянути файл
@@ -1,6 +1,6 @@
/** Where a project opens when this window already has one: System Settings › Project › "Open project in". */
import { t } from "@/platform/i18n";
import { call, commands } from "@/platform/ipc";
import { call, commands, type RemoteTarget } from "@/platform/ipc";
import { confirmLeaveProject, useProject } from "@/platform/project";
import { connectRemote, isRemoteWindow } from "@/platform/remote";
import { ask } from "./messages";
@@ -50,20 +50,20 @@ export async function chooseProjectWindow(name: string, allowAttach = true): Pro
}
/**
* Opens folder `path` on the host of SSH configuration `sshConfig`: focuses the
* Opens folder `path` of `target` (an SSH host or a WSL distribution): focuses the
* window that has it, else opens it in a new window or (`here`) this one per
* "Open project in". `false`: cancelled.
*/
export async function openRemoteProject(
sshConfig: string,
target: RemoteTarget,
path: string,
here: () => Promise<unknown> = () => connectRemote(sshConfig, path),
here: () => Promise<unknown> = () => connectRemote(target, path),
): Promise<boolean> {
if ((await call(commands.windowOpenRemote(sshConfig, path, false))).kind === "focused") return true;
if ((await call(commands.windowOpenRemote(target, path, false))).kind === "focused") return true;
const mode = await chooseProjectWindow(path.replace(/\/+$/, "").split("/").pop() || path, false);
if (!mode) return false;
if (mode === "new") {
await call(commands.windowOpenRemote(sshConfig, path, true));
await call(commands.windowOpenRemote(target, path, true));
return true;
}
if (!(await confirmLeaveProject())) return false;