дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +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
@@ -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();
|
||||
});
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача