Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e44b905
feat(ssh): add pane-scoped local port forwarding
WilliamWang1721 Sep 24, 2026
44fb832
ci: retrigger PR #280 checks
WilliamWang1721 Sep 24, 2026
f291f24
ci(ui): capture Windows and macOS review screenshots for PR #280
WilliamWang1721 Sep 25, 2026
9eadbe4
ci(ui): fix native screenshot capture checks
WilliamWang1721 Sep 25, 2026
204bb49
ci(ui): publish reviewer screenshots outside PR diff
WilliamWang1721 Sep 25, 2026
4399916
ci(ui): polish screenshots before PR presentation
WilliamWang1721 Sep 25, 2026
2c37ccb
test(ui): expose ready SSH state for screenshot review
WilliamWang1721 Sep 25, 2026
c8e1ef2
ci(ui): capture SSH Ports control and dialog
WilliamWang1721 Sep 25, 2026
3a379bd
Merge branch 'main' into feature/ssh-port-forward-253
WilliamWang1721 Sep 26, 2026
dc78ea6
Merge remote-tracking branch 'origin/main' into HEAD
Kuddev Sep 26, 2026
53853b3
fix(ssh): bound pane forwarding and cancel work on session exit
Kuddev Sep 26, 2026
ad1fc9e
test(ssh): use shared platform shortcut selection
Kuddev Sep 26, 2026
ed5bb5e
fix(ssh): import dialog input hit-test trait
Kuddev Sep 26, 2026
a7a55a3
test(ssh): refresh dialog probe and verify edited port values
Kuddev Sep 26, 2026
8ae975b
fix(ssh): render port inputs in the dialog content area
Kuddev Sep 26, 2026
7896492
Merge main into feature/ssh-port-forward-253
WilliamWang1721 Sep 26, 2026
90f4dba
Merge upstream main into feature/ssh-port-forward-253
WilliamWang1721 Sep 26, 2026
03df3f2
Merge upstream main into feature/ssh-port-forward-253
WilliamWang1721 Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Pane-owned local SSH forwarding

## Status

Reviewed for integration on 2026-09-26.

## Context

An authenticated native SSH pane needs a small local forwarding control. Opening
another SSH process would duplicate authentication and connection ownership.
A listener must not survive its pane, including normal shell exit, and a pending
creation must not finish after the owner has closed.

## Evidence

- `ssh_session/forward.rs` reuses authenticated pooled sessions and opens
direct-tcpip channels through the existing SSH runtime.
- The original change cleared established forwards on failure but not normal
exit; its detached creation task outlived the pane.
- An unrestricted accept loop could create arbitrary numbers of channel tasks
and bidirectional copy buffers from local clients.
- The legacy dialog child scroller clipped the port controls: the real-click
regression left the port value unchanged after typing. The form now uses
`DialogContent`, with explicit input geometry and read-back assertions.
- The original screenshot-only readiness override read the process environment
from a render-time query and bypassed real SSH readiness in test builds.

## Decision

Keep listeners in the terminal view and the pending GPUI operation in an owned
Task. Exit, connection failure and view destruction release both. The existing
network-to-GPUI bridge cancels its future when the result receiver is dropped;
a stale completion cannot install a forward or show a toast for a dead pane.

Each listener accepts at most 64 concurrent channel tasks. Excess clients wait
in the OS TCP backlog until a slot is available. This bounds task/copy-buffer
cost per listener; it is an engineering limit, not a throughput guarantee.
Completed channels are reaped and failures are logged without killing the SSH
transport shared by the terminal and other forwards.

The endpoints remain loopback-only on both sides. Creating and stopping a
forward requires explicit user action. New labels use typed catalog messages.
Readiness uses actual session state; screenshots cannot enable SSH on local panes.

## Rejected alternatives

- A second SSH process or global forwarding service would duplicate lifetime
and authentication policy.
- Detached creation cannot guarantee cancellation when the pane disappears.
- Unbounded per-client tasks allow local load to grow memory without a limit.
- Persisted rules, automatic discovery and remote/dynamic forwarding are outside
this local forwarding capability.

## Consequences

The new behavior adds no persistent thread or timer. UI rendering does not read
files, resolve destinations or inspect environment variables. File/profile
resolution and network I/O remain in the existing background runtime. A forward
owns its channels but never disconnects the shared authenticated transport.

## Validation

Regression coverage exercises real SSH channel exchange, half-close, occupied
ports, channel rejection, listener/connection disposal, and bounded concurrency.
UI regressions click the actual port fields and confirmation/cancel controls,
check invalid input, and reject submission after the pane stops being ready.
Terminal and bridge regressions cover normal exit, failure and pending-work
cancellation. These tests do not claim arbitrary-host throughput or physical
platform visual acceptance.

## Supersedes

None.

## Revisit when

Revisit the limit and UI state model if measured workloads need more concurrent
channels, or when persistent/reconnecting forwards are explicitly introduced.
13 changes: 12 additions & 1 deletion nebula_app/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -1000,5 +1000,16 @@
"empty": "This directory is empty.",
"error_retry": "{error} (click Reload in the top-right to retry)",
"busy_other": "Another host is transferring: {destination}"
}
},
"ssh.ports.button": "Ports",
"ssh.ports.title": "SSH port forwarding",
"ssh.ports.empty": "No forwarded ports",
"ssh.ports.stop": "Stop forwarding",
"ssh.ports.remote": "Remote port",
"ssh.ports.local": "Local port",
"ssh.ports.forward": "Forward",
"ssh.ports.invalid": "Enter ports from 1–65535",
"ssh.ports.pending": "Starting port forwarding…",
"ssh.ports.unavailable": "SSH network runtime unavailable",
"ssh.ports.started": "Forwarding 127.0.0.1:{local} to {host} port {remote}"
}
13 changes: 12 additions & 1 deletion nebula_app/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1000,5 +1000,16 @@
"empty": "此目录为空。",
"error_retry": "{error}(点右上角重新读取)",
"busy_other": "另一主机正在传输:{destination}"
}
},
"ssh.ports.button": "端口",
"ssh.ports.title": "SSH 端口转发",
"ssh.ports.empty": "暂无端口转发",
"ssh.ports.stop": "停止转发",
"ssh.ports.remote": "服务器端口",
"ssh.ports.local": "本地端口",
"ssh.ports.forward": "转发",
"ssh.ports.invalid": "请输入 1–65535 的端口",
"ssh.ports.pending": "正在启动端口转发…",
"ssh.ports.unavailable": "SSH 网络运行时不可用",
"ssh.ports.started": "正在将 127.0.0.1:{local} 转发至 {host} 的 {remote} 端口"
}
8 changes: 8 additions & 0 deletions nebula_app/src/gpui_shell/terminal/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,9 @@ pub struct TerminalView {
path_drop: path_drop::PathDropState,
/// SSH 直连目的地(`user@host[:port]`);本地会话为 None。
pub ssh_destination: Option<String>,
/// 本 pane 拥有的本地端口转发;pane 销毁即停止监听。
pub(crate) port_forwards: Vec<crate::ssh_session::LocalForward>,
pub(crate) port_forward_task: Option<gpui::Task<()>>,
ssh_label: Option<String>,
/// 创建本地 PTY 时冻结的受控环境,供独立 `pane.exec` child 复用。
pub(crate) exec_context: Option<crate::runtime_exec::PaneExecContext>,
Expand Down Expand Up @@ -699,6 +702,8 @@ impl TerminalView {

/// `Exited` 只对宿主发一次;重复的退出信号(ChildExit 之后必然跟 Exit)只更新文案。
fn mark_exited(&mut self, message: String, cx: &mut Context<Self>) {
self.port_forward_task = None;
self.port_forwards.clear();
self.confirmation.invalidate();
self.pending_runtime_submit = None;
self.pending_shell_command = None;
Expand Down Expand Up @@ -964,6 +969,9 @@ impl TerminalView {
/// 通道,撞上一个还没建立的传输——用户看到的是文件面板先报一个错,然后
/// 终端才连上。
pub fn ready_ssh_destination(&self) -> Option<&str> {
if self.exited.is_some() {
return None;
}
let destination = self.ssh_destination.as_deref()?;
matches!(self.ssh_stage, Some(crate::ssh_session::SshStage::Ready)).then_some(destination)
}
Expand Down
25 changes: 25 additions & 0 deletions nebula_app/src/gpui_shell/terminal/view/output_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,28 @@ fn hidden_wakeup_still_flushes_shell_commands_and_pending_enter(cx: &mut TestApp
assert!(view.pending_runtime_submit.is_none());
});
}

#[gpui::test]
fn ssh_exit_and_failure_cancel_pending_forward_and_hide_remote_actions(cx: &mut TestAppContext) {
let (view, window, _) = open(cx);
for failed in [false, true] {
view.update(window, |view, cx| {
view.exited = None;
view.ssh_destination = Some("fixture@localhost".into());
view.apply_ssh_stage(crate::ssh_session::SshStage::Ready, cx);
assert_eq!(view.ready_ssh_destination(), Some("fixture@localhost"));
view.port_forward_task =
Some(cx.spawn(async |_, _| std::future::pending::<()>().await));
if failed {
view.apply_ssh_stage(
crate::ssh_session::SshStage::Failed("disconnected".into()),
cx,
);
} else {
view.process_event(TermEvent::Exit, cx);
}
assert!(view.port_forward_task.is_none());
assert!(view.ready_ssh_destination().is_none());
});
}
}
2 changes: 2 additions & 0 deletions nebula_app/src/gpui_shell/terminal/view/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ impl TerminalView {
);
self.ssh_connect_last_step = std::time::Instant::now();
if matches!(stage, crate::ssh_session::SshStage::Failed(_)) {
self.port_forward_task = None;
self.port_forwards.clear();
self.pending_runtime_submit = None;
self.pending_shell_command = None;
self.command_running = false;
Expand Down
2 changes: 2 additions & 0 deletions nebula_app/src/gpui_shell/terminal/view/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,8 @@ impl TerminalView {
image_paste: image_paste::ImagePasteState::default(),
path_drop: path_drop::PathDropState::default(),
ssh_destination,
port_forwards: Vec::new(),
port_forward_task: None,
ssh_label: None,
exec_context,
ssh_stage: None,
Expand Down
41 changes: 38 additions & 3 deletions nebula_app/src/gpui_shell/workspace/remote_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -920,16 +920,20 @@ fn format_transfer_bytes(bytes: u64) -> String {
///
/// 返回 `None` 表示网络 runtime 起不来或任务被丢弃——调用方据此报"连接不
/// 可用",而不是把它和"远端答了个错误"混为一谈。
async fn remote_call<T, F, Fut>(work: F) -> Option<T>
pub(super) async fn remote_call<T, F, Fut>(work: F) -> Option<T>
where
T: Send + 'static,
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = T> + Send,
{
let runtime = crate::ssh_session::runtime().ok()?;
let (tx, rx) = tokio::sync::oneshot::channel();
let (mut tx, rx) = tokio::sync::oneshot::channel();
runtime.spawn(async move {
let _ = tx.send(work().await);
tokio::select! {
biased;
_ = tx.closed() => {},
result = work() => { let _ = tx.send(result); },
}
});
rx.await.ok()
}
Expand All @@ -950,6 +954,37 @@ mod tests {
}
}

#[test]
fn dropped_remote_call_cancels_its_network_work() {
struct SignalDrop(Option<tokio::sync::oneshot::Sender<()>>);
impl Drop for SignalDrop {
fn drop(&mut self) {
if let Some(sender) = self.0.take() {
let _ = sender.send(());
}
}
}
crate::ssh_session::runtime().unwrap().block_on(async {
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
let caller = tokio::spawn(super::remote_call(move || async move {
let _signal = SignalDrop(Some(dropped_tx));
let _ = started_tx.send(());
std::future::pending::<()>().await;
}));
tokio::time::timeout(std::time::Duration::from_secs(5), started_rx)
.await
.unwrap()
.unwrap();
caller.abort();
let _ = caller.await;
tokio::time::timeout(std::time::Duration::from_secs(5), dropped_rx)
.await
.unwrap()
.unwrap();
});
}

#[test]
fn switching_remote_panes_restores_listing_selection_and_scroll_owner_without_reload() {
let mut browser = RemoteBrowser::default();
Expand Down
2 changes: 2 additions & 0 deletions nebula_app/src/gpui_shell/workspace/sidebar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,7 @@ impl NebulaWorkspace {
let settings_active_fg = cx.theme().sidebar_accent_foreground;
let sidebar_visible = !self.sidebar_collapsed && !self.reader_focus_active(cx);
let language = crate::gpui_shell::config::ui_language(cx);
let port_forward_button = self.render_port_forward_button(cx);
h_flex()
.size_full()
.items_center()
Expand Down Expand Up @@ -931,6 +932,7 @@ impl NebulaWorkspace {
this.toggle_command_manager(window, cx);
})),
)
.when_some(port_forward_button, |controls, button| controls.child(button))
.child(self.render_right_sidebar_button(settings_active, cx)),
)
.into_any_element()
Expand Down
Loading
Loading