feat(remote-dev): forward ports of the remote host (#1131)

Remote windows get a Ports tool window: forward localhost:<port> of the remote host to this machine (same port when free here), with Open in Browser / Copy / Stop. Forwarded ports are remembered per host and folder and forwarded again on reconnect. Tunnels reuse the window's SSH session (ide_ssh::Tunnel can prefer a local port) and close with the connection.

Closes #819
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-04 08:50:25 +08:00
1 джерело a3bbbf90a4
коміт 53b8cdeb51
11 files changed
+469 -7

No files matched your search

+57
Переглянути файл
@@ -0,0 +1,57 @@
.view {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.add {
flex: none;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
}
.input {
width: 200px;
}
.list {
flex: 1;
min-height: 0;
overflow: auto;
margin: 0;
padding: 4px 0;
list-style: none;
}
.row {
display: flex;
align-items: center;
gap: 8px;
height: 26px;
padding: 0 8px 0 12px;
}
.row:hover {
background: var(--bg-hover);
}
.remote {
min-width: 48px;
font-family: var(--font-mono);
color: var(--fg-strong);
}
.arrow {
color: var(--fg-faint);
}
.local {
font-family: var(--font-mono);
color: var(--fg-muted);
}
.actions {
margin-left: auto;
display: flex;
gap: 2px;
opacity: 0;
}
.row:hover .actions,
.row:focus-within .actions {
opacity: 1;
}
+93
Переглянути файл
@@ -0,0 +1,93 @@
/**
* Ports tool window of a remote window: ports of the remote host forwarded to this machine
* (`localhost:<port>` here reaches `localhost:<port>` there), like VS Code's Ports view.
*/
import { useEffect, useState } from "react";
import type { ForwardedPort } from "@/bindings";
import { tx } from "@/platform/i18n";
import { openExternalUrl } from "@/platform/openUrl";
import { Button, EmptyState, IconButton, Input, T, copyText } from "@/ui";
import { forwardPort, parsePort, refreshPorts, stopForwarding, usePorts } from "./ports";
import styles from "./Ports.module.css";
const address = (p: ForwardedPort) => `localhost:${p.localPort}`;
export function PortsView() {
const ports = usePorts((s) => s.ports);
const [input, setInput] = useState("");
const port = parsePort(input);
useEffect(() => {
void refreshPorts();
}, []);
const submit = async () => {
if (port === null) return;
if (await forwardPort(port)) setInput("");
};
return (
<div className={styles.view}>
<form
className={styles.add}
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
>
<Input
aria-label="Remote port"
className={styles.input}
inputMode="numeric"
placeholder={tx("Remote port, e.g. 3000")}
value={input}
invalid={input.trim() !== "" && port === null}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
void submit();
}}
/>
<Button size="small" type="submit" disabled={port === null}>
<T>Forward Port</T>
</Button>
</form>
{ports.length === 0 ? (
<EmptyState title="No forwarded ports">
<T>Forward a port of the remote host to reach it from this machine, e.g. a web server in a browser.</T>
</EmptyState>
) : (
<ul className={styles.list} aria-label={tx("Forwarded ports")}>
{ports.map((p) => {
const addr = address(p);
return (
<li key={p.remotePort} className={styles.row}>
<span className={styles.remote}>{p.remotePort}</span>
<span className={styles.arrow} aria-hidden>
→
</span>
<span className={styles.local}>{addr}</span>
<span className={styles.actions}>
<IconButton
icon="web"
size="small"
title={tx`Open ${addr} in Browser`}
onClick={() => void openExternalUrl(`http://${addr}/`)}
/>
<IconButton icon="copy" size="small" title={tx`Copy ${addr}`} onClick={() => void copyText(addr)} />
<IconButton
icon="close"
size="small"
title={tx`Stop Forwarding Port ${p.remotePort}`}
onClick={() => void stopForwarding(p.remotePort)}
/>
</span>
</li>
);
})}
</ul>
)}
</div>
);
}
+34 -2
Переглянути файл
@@ -4,15 +4,47 @@
* (platform/remote.ts).
*/
import { registerActions } from "@/platform/actions";
import { disconnectRemote } from "@/platform/remote";
import { maybeEditorService, registerOverlay } from "@/workbench/contributions";
import { disconnectRemote, useRemote } from "@/platform/remote";
import { lazyComponent } from "@/ui";
import { maybeEditorService, registerOverlay, registerToolWindow } from "@/workbench/contributions";
import { confirmLeaveProject } from "@/platform/project";
import { ConnectHost } from "./ConnectDialog";
import { reloadEnvironment } from "./environment";
import { closeConnectDialog, openConnectDialog } from "./model";
import { restorePorts, usePorts } from "./ports";
/** The Ports tool window, while this window is connected to a remote host. */
function trackPortsWindow(): () => void {
let off: (() => void) | null = null;
const sync = (connected: boolean) => {
if (connected && !off) {
void restorePorts();
off = registerToolWindow({
id: "remote.ports",
title: "Ports",
icon: "web",
anchor: "bottom",
order: 40,
stripe: "onUse",
component: lazyComponent(() => import("./PortsView").then((m) => m.PortsView)),
});
} else if (!connected && off) {
off();
off = null;
usePorts.setState({ ports: [] });
}
};
sync(useRemote.getState().session !== null);
const unsubscribe = useRemote.subscribe((s) => sync(s.session !== null));
return () => {
unsubscribe();
off?.();
};
}
export function activate(): () => void {
const disposers = [
trackPortsWindow(),
registerOverlay({ id: "remote.connect", component: ConnectHost }),
registerActions([
{
+80
Переглянути файл
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
const forwarded: Array<{ remotePort: number; localPort: number }> = [];
const ipc = vi.hoisted(() => ({
remoteForwardedPorts: vi.fn(),
remoteForwardPort: vi.fn(),
remoteStopForwarding: vi.fn(),
}));
vi.mock("@/platform/ipc", async (importOriginal) => ({
...(await importOriginal<typeof import("@/platform/ipc")>()),
isTauri: () => true,
commands: ipc,
}));
import { PortsView } from "./PortsView";
import { parsePort, usePorts } from "./ports";
ipc.remoteForwardedPorts.mockImplementation(async () => [...forwarded]);
ipc.remoteForwardPort.mockImplementation(async (remotePort: number) => {
// 3000 is taken here: another local port.
const port = { remotePort, localPort: remotePort === 3000 ? 53000 : remotePort };
forwarded.push(port);
return { status: "ok", data: port };
});
ipc.remoteStopForwarding.mockImplementation(async (remotePort: number) => {
forwarded.splice(
forwarded.findIndex((p) => p.remotePort === remotePort),
1,
);
return { status: "ok", data: null };
});
afterEach(() => {
cleanup();
forwarded.length = 0;
usePorts.setState({ ports: [] });
});
describe("Ports tool window", () => {
it("accepts port numbers 1–65535 only", () => {
expect(parsePort(" 8080 ")).toBe(8080);
expect([parsePort("0"), parsePort("65536"), parsePort("80a"), parsePort("")]).toEqual([null, null, null, null]);
});
it("forwards a typed port, shows where it is reachable here, and stops it", async () => {
render(<PortsView />);
expect(screen.getByText("No forwarded ports")).toBeInTheDocument();
const field = screen.getByRole("textbox", { name: "Remote port" });
fireEvent.change(field, { target: { value: "70000" } });
expect(screen.getByRole("button", { name: "Forward Port" })).toBeDisabled();
fireEvent.change(field, { target: { value: "3000" } });
fireEvent.click(screen.getByRole("button", { name: "Forward Port" }));
await screen.findByText("localhost:53000");
expect(ipc.remoteForwardPort).toHaveBeenCalledWith(3000);
expect(field).toHaveValue("");
fireEvent.click(screen.getByRole("button", { name: "Stop Forwarding Port 3000" }));
await waitFor(() => expect(screen.getByText("No forwarded ports")).toBeInTheDocument());
});
});
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: [] } });
await forwardPort(8080);
expect(savedPorts()).toEqual([8080]);
// Reconnected: the new connection forwards nothing yet.
forwarded.length = 0;
ipc.remoteForwardPort.mockClear();
await restorePorts();
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: [] } });
expect(savedPorts()).toEqual([]);
useRemote.setState({ session: null });
});
});
+81
Переглянути файл
@@ -0,0 +1,81 @@
/** Remote ports forwarded to this machine, remembered per host and folder and forwarded again on reconnect. */
import { create } from "zustand";
import type { ForwardedPort } from "@/bindings";
import { tx } from "@/platform/i18n";
import { call, commands } from "@/platform/ipc";
import { useRemote } from "@/platform/remote";
import { notifications } from "@/workbench/contributions";
const SAVED_KEY = "openidea.remote.ports:";
function savedKey(): string | null {
const session = useRemote.getState().session;
return session ? `${SAVED_KEY}${session.sshConfig}:${session.root}` : null;
}
export function savedPorts(): number[] {
const key = savedKey();
if (!key) return [];
try {
const raw = JSON.parse(localStorage.getItem(key) ?? "[]") as unknown;
return Array.isArray(raw) ? raw.filter((p): p is number => Number.isInteger(p) && p > 0 && p < 65536) : [];
} catch {
return [];
}
}
function save(ports: readonly ForwardedPort[]) {
const key = savedKey();
if (!key) return;
try {
if (ports.length) localStorage.setItem(key, JSON.stringify(ports.map((p) => p.remotePort)));
else localStorage.removeItem(key);
} catch {
/* storage unavailable */
}
}
export const usePorts = create<{ ports: ForwardedPort[] }>(() => ({ ports: [] }));
/** Reads the forwarded ports; `remember` keeps them for the next connection (after the user changed them). */
export async function refreshPorts(remember = false): Promise<void> {
const ports = await commands.remoteForwardedPorts().catch(() => []);
usePorts.setState({ ports });
if (remember) save(ports);
}
/** A port number typed by the user, or null. */
export function parsePort(text: string): number | null {
const t = text.trim();
if (!/^\d+$/.test(t)) return null;
const n = Number(t);
return n >= 1 && n <= 65535 ? n : null;
}
export async function forwardPort(port: number): Promise<ForwardedPort | null> {
try {
const forwarded = await call(commands.remoteForwardPort(port));
await refreshPorts(true);
return forwarded;
} catch (e) {
notifications().notify({
group: "remote",
level: "error",
title: tx`Could not forward port ${port}`,
body: e instanceof Error ? e.message : String(e),
});
return null;
}
}
export async function stopForwarding(port: number): Promise<void> {
await call(commands.remoteStopForwarding(port)).catch(() => {});
await refreshPorts(true);
}
/** After (re)connecting: forwards the ports this host and folder had forwarded before. */
export async function restorePorts(): Promise<void> {
const saved = savedPorts();
for (const port of saved) await call(commands.remoteForwardPort(port)).catch(() => null);
usePorts.setState({ ports: await commands.remoteForwardedPorts().catch(() => []) });
}