From 53b8cdeb51183ab6272be98500ec4bde290a3eba Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:50:25 +0800 Subject: [PATCH] feat(remote-dev): forward ports of the remote host (#1131) Remote windows get a Ports tool window: forward localhost: 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 --- crates/ide-ssh/src/tunnel.rs | 14 +++- crates/ide-ssh/tests/session.rs | 15 ++++ src-tauri/src/commands/mod.rs | 3 + src-tauri/src/remote_dev.rs | 69 +++++++++++++++++- src/bindings.ts | 15 ++++ src/features/remote-dev/Ports.module.css | 57 +++++++++++++++ src/features/remote-dev/PortsView.tsx | 93 ++++++++++++++++++++++++ src/features/remote-dev/index.ts | 36 ++++++++- src/features/remote-dev/ports.test.tsx | 80 ++++++++++++++++++++ src/features/remote-dev/ports.ts | 81 +++++++++++++++++++++ src/platform/locales/text/zh.json | 13 +++- 11 files changed, 469 insertions(+), 7 deletions(-) create mode 100644 src/features/remote-dev/Ports.module.css create mode 100644 src/features/remote-dev/PortsView.tsx create mode 100644 src/features/remote-dev/ports.test.tsx create mode 100644 src/features/remote-dev/ports.ts diff --git a/crates/ide-ssh/src/tunnel.rs b/crates/ide-ssh/src/tunnel.rs index c076ee2a..f53c8b98 100644 --- a/crates/ide-ssh/src/tunnel.rs +++ b/crates/ide-ssh/src/tunnel.rs @@ -26,7 +26,19 @@ impl Tunnel { /// [`Tunnel::open`] without the reachability check (the target may start later). pub async fn listen(session: Arc, host: &str, port: u16) -> IdeResult { - let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + Self::listen_on(session, None, host, port).await + } + + /// [`Tunnel::listen`] on local port `preferred` when it is free, else on any free port + /// (a forwarded remote port keeps its number locally when it can, like VS Code's Ports). + pub async fn listen_on(session: Arc, preferred: Option, host: &str, port: u16) -> IdeResult { + let listener = match preferred { + Some(p) => match TcpListener::bind(("127.0.0.1", p)).await { + Ok(l) => l, + Err(_) => TcpListener::bind(("127.0.0.1", 0)).await?, + }, + None => TcpListener::bind(("127.0.0.1", 0)).await?, + }; let local = listener.local_addr()?.port(); let accept = tokio::spawn(forward(listener, session.clone(), host.to_owned(), port)); Ok(Tunnel { port: local, session, accept }) diff --git a/crates/ide-ssh/tests/session.rs b/crates/ide-ssh/tests/session.rs index 819037d1..b1a00e28 100644 --- a/crates/ide-ssh/tests/session.rs +++ b/crates/ide-ssh/tests/session.rs @@ -217,6 +217,21 @@ async fn password_authentication_and_forwarding() { assert!(e.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", e.message); } +#[tokio::test] +async fn forwarding_keeps_the_port_number_when_it_is_free() { + let env = env().await; + let target = env.target(SshAuth::Password); + let session = Arc::new(env.connect_trusted(&target, Some("secret")).await.ok().unwrap()); + let free = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port(); + let tunnel = Tunnel::listen_on(session.clone(), Some(free), "127.0.0.1", env.echo_port).await.unwrap(); + assert_eq!(tunnel.port(), free); + round_trip(&tunnel).await; + // Taken here: another free port. + let other = Tunnel::listen_on(session, Some(free), "127.0.0.1", env.echo_port).await.unwrap(); + assert_ne!(other.port(), free); + round_trip(&other).await; +} + #[tokio::test] async fn key_pair_authentication_with_and_without_passphrase() { let env = env().await; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 176d7e27..e0d6d051 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -526,6 +526,9 @@ pub fn builder() -> tauri_specta::Builder { crate::remote_dev::remote_connect, crate::remote_dev::remote_current, crate::remote_dev::remote_disconnect, + crate::remote_dev::remote_forwarded_ports, + crate::remote_dev::remote_forward_port, + crate::remote_dev::remote_stop_forwarding, crate::remote_dev::remote_invoke, crate::remote_dev::remote_import_local, env::env_reload, diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index 7be979e3..0a6c74cc 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -45,7 +45,9 @@ pub struct Connection { /// connection with its parent). channels: Channels, next_channel: std::sync::atomic::AtomicU64, - _session: Arc, + session: Arc, + /// Remote ports forwarded to this machine (Ports tool window), by remote port. + forwards: Mutex>, } type Channels = Arc, u64)>>>; @@ -424,7 +426,8 @@ pub async fn remote_connect( commands: commands.clone(), channels, next_channel: std::sync::atomic::AtomicU64::new(1), - _session: session, + session, + forwards: Mutex::new(Vec::new()), }); state.remote_dev.0.lock().insert(label.clone(), connection.clone()); let plugins = state.plugins.clone(); @@ -539,6 +542,66 @@ pub fn remote_current(window: Window, state: State<'_, AppState>) -> Option Vec { + c.forwards.lock().iter().map(|(remote, t)| ForwardedPort { remote_port: *remote, local_port: t.port() }).collect() +} + +fn connection(window: &Window, state: &AppState) -> IdeResult> { + state + .remote_dev + .get(AppState::owner_window(window.label())) + .ok_or_else(|| IdeError::unavailable("This window is not connected to a remote host")) +} + +/// The remote ports this window forwards to this machine. +#[tauri::command] +#[specta::specta] +pub fn remote_forwarded_ports(window: Window, state: State<'_, AppState>) -> Vec { + connection(&window, &state).map(|c| forwarded(&c)).unwrap_or_default() +} + +/// Forwards `localhost:` of the remote host to the same port here when it is free, +/// else to a free one. Nothing needs to listen there yet; connections are opened per use. +#[tauri::command] +#[specta::specta] +pub async fn remote_forward_port( + window: Window, + state: State<'_, AppState>, + remote_port: u16, +) -> IdeResult { + if remote_port == 0 { + return Err(IdeError::invalid("Port must be between 1 and 65535")); + } + let c = connection(&window, &state)?; + if let Some(p) = forwarded(&c).into_iter().find(|p| p.remote_port == remote_port) { + return Ok(p); + } + let tunnel = ide_ssh::Tunnel::listen_on(c.session.clone(), Some(remote_port), "localhost", remote_port).await?; + let port = ForwardedPort { remote_port, local_port: tunnel.port() }; + let mut forwards = c.forwards.lock(); + if let Some(existing) = forwards.iter().find(|(remote, _)| *remote == remote_port) { + return Ok(ForwardedPort { remote_port, local_port: existing.1.port() }); + } + forwards.push((remote_port, tunnel)); + Ok(port) +} + +/// Stops forwarding `remote_port`. +#[tauri::command] +#[specta::specta] +pub fn remote_stop_forwarding(window: Window, state: State<'_, AppState>, remote_port: u16) -> IdeResult<()> { + connection(&window, &state)?.forwards.lock().retain(|(remote, _)| *remote != remote_port); + Ok(()) +} + #[tauri::command] #[specta::specta] pub fn remote_disconnect(window: Window, state: State<'_, AppState>) { @@ -592,7 +655,7 @@ pub async fn remote_import_local( c.authorize_command(label, "fs_copy").await?; let cancel = state.host.begin_request(request); let result = async { - let fs = ide_ssh::RemoteFs::new(c._session.sftp().await?); + let fs = ide_ssh::RemoteFs::new(c.session.sftp().await?); let result = ide_remote::import::import_local(&fs, &from, &to, &cancel).await; fs.close().await; result diff --git a/src/bindings.ts b/src/bindings.ts index 9e45fe83..bc3842b3 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1116,6 +1116,15 @@ export const commands = { commands: string[], } | null>("remote_current"), remoteDisconnect: () => __TAURI_INVOKE("remote_disconnect"), + /** The remote ports this window forwards to this machine. */ + remoteForwardedPorts: () => __TAURI_INVOKE("remote_forwarded_ports"), + /** + * Forwards `localhost:` of the remote host to the same port here when it is free, + * else to a free one. Nothing needs to listen there yet; connections are opened per use. + */ + remoteForwardPort: (remotePort: number) => typedError(__TAURI_INVOKE("remote_forward_port", { remotePort })), + /** Stops forwarding `remote_port`. */ + remoteStopForwarding: (remotePort: number) => typedError(__TAURI_INVOKE("remote_stop_forwarding", { remotePort })), /** * Runs a command on the window's remote server; `args` and the result are JSON * text. Streaming arguments are `{ "$channel": id }`; their values arrive on @@ -3464,6 +3473,12 @@ export type FormatOptions = { tool?: ExternalFormatter | null, }; +/** A port of the remote host reachable on this machine at `localhost:local_port`. */ +export type ForwardedPort = { + remotePort: number, + localPort: number, +}; + /** One occurrence of a duplicated code fragment. */ export type Fragment = { path: IpcPath, diff --git a/src/features/remote-dev/Ports.module.css b/src/features/remote-dev/Ports.module.css new file mode 100644 index 00000000..8e90f0d3 --- /dev/null +++ b/src/features/remote-dev/Ports.module.css @@ -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; +} diff --git a/src/features/remote-dev/PortsView.tsx b/src/features/remote-dev/PortsView.tsx new file mode 100644 index 00000000..a53306d9 --- /dev/null +++ b/src/features/remote-dev/PortsView.tsx @@ -0,0 +1,93 @@ +/** + * Ports tool window of a remote window: ports of the remote host forwarded to this machine + * (`localhost:` here reaches `localhost:` 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 ( +
+
{ + e.preventDefault(); + void submit(); + }} + > + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + void submit(); + }} + /> + +
+ {ports.length === 0 ? ( + + Forward a port of the remote host to reach it from this machine, e.g. a web server in a browser. + + ) : ( +
    + {ports.map((p) => { + const addr = address(p); + return ( +
  • + {p.remotePort} + + → + + {addr} + + void openExternalUrl(`http://${addr}/`)} + /> + void copyText(addr)} /> + void stopForwarding(p.remotePort)} + /> + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/features/remote-dev/index.ts b/src/features/remote-dev/index.ts index 607a4165..f690583e 100644 --- a/src/features/remote-dev/index.ts +++ b/src/features/remote-dev/index.ts @@ -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([ { diff --git a/src/features/remote-dev/ports.test.tsx b/src/features/remote-dev/ports.test.tsx new file mode 100644 index 00000000..81037ec9 --- /dev/null +++ b/src/features/remote-dev/ports.test.tsx @@ -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()), + 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(); + 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 }); + }); +}); diff --git a/src/features/remote-dev/ports.ts b/src/features/remote-dev/ports.ts new file mode 100644 index 00000000..856c2b9d --- /dev/null +++ b/src/features/remote-dev/ports.ts @@ -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 { + 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 { + 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 { + 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 { + const saved = savedPorts(); + for (const port of saved) await call(commands.remoteForwardPort(port)).catch(() => null); + usePorts.setState({ ports: await commands.remoteForwardedPorts().catch(() => []) }); +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 0febc640..be6dbdaf 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5812,5 +5812,16 @@ "No matching repositories": "没有匹配的仓库", "No repositories": "没有仓库", "fork": "复刻", - "archived": "已归档" + "archived": "已归档", + "Could not forward port {port}": "无法转发端口 {port}", + "Remote port": "远程端口", + "Remote port, e.g. 3000": "远程端口,例如 3000", + "Forward Port": "转发端口", + "No forwarded ports": "没有转发的端口", + "Forward a port of the remote host to reach it from this machine, e.g. a web server in a browser.": "转发远程主机的端口后,就能在本机访问它,例如在浏览器中打开 Web 服务。", + "Forwarded ports": "已转发的端口", + "Open {addr} in Browser": "在浏览器中打开 {addr}", + "Copy {addr}": "复制 {addr}", + "Stop Forwarding Port {remotePort}": "停止转发端口 {remotePort}", + "Ports": "端口" }