дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
47 files changed
+1512
-220
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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…": "远程开发…",
|
||||
|
||||
@@ -108,6 +108,7 @@ const UNTRANSLATED = new Set<string>([
|
||||
"loopvar,rangefunc",
|
||||
// SSH and run-target placeholders
|
||||
"SSH",
|
||||
"WSL",
|
||||
"example.com",
|
||||
"java",
|
||||
"node",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Заблокувати користувача