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

+66 -3
Переглянути файл
@@ -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