mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 00:07:50 +00:00
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
This commit is contained in:
@@ -26,7 +26,19 @@ impl Tunnel {
|
||||
|
||||
/// [`Tunnel::open`] without the reachability check (the target may start later).
|
||||
pub async fn listen(session: Arc<Session>, host: &str, port: u16) -> IdeResult<Tunnel> {
|
||||
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<Session>, preferred: Option<u16>, host: &str, port: u16) -> IdeResult<Tunnel> {
|
||||
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 })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -526,6 +526,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -45,7 +45,9 @@ pub struct Connection {
|
||||
/// connection with its parent).
|
||||
channels: Channels,
|
||||
next_channel: std::sync::atomic::AtomicU64,
|
||||
_session: Arc<ide_ssh::Session>,
|
||||
session: Arc<ide_ssh::Session>,
|
||||
/// Remote ports forwarded to this machine (Ports tool window), by remote port.
|
||||
forwards: Mutex<Vec<(u16, ide_ssh::Tunnel)>>,
|
||||
}
|
||||
|
||||
type Channels = Arc<Mutex<HashMap<u64, (Channel<ChannelFrame>, 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<Remo
|
||||
})
|
||||
}
|
||||
|
||||
/// A port of the remote host reachable on this machine at `localhost:local_port`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ForwardedPort {
|
||||
pub remote_port: u16,
|
||||
pub local_port: u16,
|
||||
}
|
||||
|
||||
fn forwarded(c: &Connection) -> Vec<ForwardedPort> {
|
||||
c.forwards.lock().iter().map(|(remote, t)| ForwardedPort { remote_port: *remote, local_port: t.port() }).collect()
|
||||
}
|
||||
|
||||
fn connection(window: &Window, state: &AppState) -> IdeResult<Arc<Connection>> {
|
||||
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<ForwardedPort> {
|
||||
connection(&window, &state).map(|c| forwarded(&c)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Forwards `localhost:<remote_port>` 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<ForwardedPort> {
|
||||
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
|
||||
|
||||
@@ -1116,6 +1116,15 @@ export const commands = {
|
||||
commands: string[],
|
||||
} | null>("remote_current"),
|
||||
remoteDisconnect: () => __TAURI_INVOKE<void>("remote_disconnect"),
|
||||
/** The remote ports this window forwards to this machine. */
|
||||
remoteForwardedPorts: () => __TAURI_INVOKE<ForwardedPort[]>("remote_forwarded_ports"),
|
||||
/**
|
||||
* Forwards `localhost:<remote_port>` 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<ForwardedPort, IdeError>(__TAURI_INVOKE("remote_forward_port", { remotePort })),
|
||||
/** Stops forwarding `remote_port`. */
|
||||
remoteStopForwarding: (remotePort: number) => typedError<null, IdeError>(__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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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(() => []) });
|
||||
}
|
||||
@@ -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": "端口"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user