Merge pull request #166 from AlanWanco/fix/ssh-resize

fix(ssh): keep terminal grid in sync on resize
This commit is contained in:
Kuddev authored and GitHub committed 2026-09-16 18:42:50 +08:00
commit dcf7f3bb8c
3 files changed
+102 -4

No files matched your search

@@ -117,7 +117,11 @@ mod tests {
#[gpui::test]
fn ssh_dialog_cancel_does_not_accept_an_unknown_host(cx: &mut TestAppContext) {
cx.update(gpui_component::init);
cx.update(|cx| {
gpui_component::init(cx);
// Keep the button hit target fixed during simulated mouse down/up.
cx.set_reduce_motion(true);
});
let (_, cx) = cx.add_window_view(|window, cx| {
let view = cx.new(|_| DialogProbe);
Root::new(view, window, cx)
@@ -142,7 +146,11 @@ mod tests {
#[gpui::test]
fn ssh_dialog_password_is_delivered_only_on_confirmation(cx: &mut TestAppContext) {
cx.update(gpui_component::init);
cx.update(|cx| {
gpui_component::init(cx);
// Keep the button hit target fixed during simulated mouse down/up.
cx.set_reduce_motion(true);
});
let (_, cx) = cx.add_window_view(|window, cx| {
let view = cx.new(|_| DialogProbe);
Root::new(view, window, cx)
+11 -1
View File
@@ -418,12 +418,22 @@ async fn pump<H: SshEventHost>(
message = input.recv() => match message {
Some(Msg::Input(bytes)) => network("channel write", channel.data(bytes.as_ref())).await?,
Some(Msg::Resize(size)) => {
// SSH has no local EventLoop to apply the grid half of a
// resize. Keep the terminal model in lockstep with the
// stream and the remote PTY, just as the local event loop
// does before calling ResizePseudoConsole.
terminal.lock().resize(size);
stream.resize(size);
event_proxy.send_event(TerminalEvent::Wakeup);
network("channel resize", channel.window_change(u32::from(size.num_cols), u32::from(size.num_lines),
u32::from(size.cell_width) * u32::from(size.num_cols),
u32::from(size.cell_height) * u32::from(size.num_lines))).await?;
},
Some(Msg::ResizeGrid(size)) => stream.resize(size),
Some(Msg::ResizeGrid(size)) => {
terminal.lock().resize(size);
stream.resize(size);
event_proxy.send_event(TerminalEvent::Wakeup);
},
Some(Msg::Shutdown) | None => return Ok(()),
},
message = channel.wait() => match message {
+81 -1
View File
@@ -1,6 +1,7 @@
use std::sync::Mutex;
use nebula_terminal::event::EventListener;
use nebula_terminal::grid::Dimensions;
use russh::keys::ssh_key::{Algorithm, PrivateKey};
use russh::server::{self, Auth, ChannelOpenHandle, Session};
use russh::{ChannelId, Pty};
@@ -69,6 +70,7 @@ struct Loopback {
mode: Mode,
hang: bool,
data: mpsc::UnboundedSender<Vec<u8>>,
window_changes: mpsc::UnboundedSender<WindowSize>,
channels: Vec<Channel<server::Msg>>,
}
@@ -137,6 +139,27 @@ impl server::Handler for Loopback {
}
}
async fn window_change_request(
&mut self,
channel: ChannelId,
col_width: u32,
row_height: u32,
pix_width: u32,
pix_height: u32,
session: &mut Session,
) -> Result<(), Self::Error> {
self.window_changes
.send(WindowSize {
num_cols: u16::try_from(col_width).unwrap(),
num_lines: u16::try_from(row_height).unwrap(),
cell_width: u16::try_from(pix_width / col_width).unwrap(),
cell_height: u16::try_from(pix_height / row_height).unwrap(),
})
.unwrap();
session.channel_success(channel)?;
Ok(())
}
async fn shell_request(
&mut self,
channel: ChannelId,
@@ -335,6 +358,7 @@ fn duplicate_ssh_directory_preserves_literal_paths_and_rejects_control_character
struct Fixture {
route: ResolvedRoute,
data: mpsc::UnboundedReceiver<Vec<u8>>,
window_changes: mpsc::UnboundedReceiver<WindowSize>,
task: tokio::task::JoinHandle<()>,
_directory: tempfile::TempDir,
}
@@ -366,6 +390,7 @@ impl Fixture {
..Default::default()
});
let (data_tx, data) = mpsc::unbounded_channel();
let (window_changes_tx, window_changes) = mpsc::unbounded_channel();
let task = tokio::spawn(async move {
let mut connections = tokio::task::JoinSet::new();
let mut first = true;
@@ -376,6 +401,7 @@ impl Fixture {
mode,
hang: first && mode == Mode::HangFirstConnection,
data: data_tx.clone(),
window_changes: window_changes_tx.clone(),
channels: Vec::new(),
};
first = false;
@@ -394,7 +420,7 @@ impl Fixture {
transport: RouteTransport::Direct,
known_hosts_path: Some(known_hosts),
};
Self { route, data, task, _directory: directory }
Self { route, data, window_changes, task, _directory: directory }
}
async fn connect(&self) -> AcquiredSession {
@@ -491,6 +517,60 @@ fn shell_confirmation_preserves_early_output_and_remote_directory() {
});
}
#[test]
fn ssh_resize_updates_grid_and_remote_pty() {
check(async {
let mut fixture = Fixture::new(Mode::Open).await;
let acquired = fixture.connect().await;
let events = Events::default();
let terminal = terminal(&events);
let (mut channel, token) = open_shell(&acquired, size(), None, &events).await.unwrap();
let (input_tx, mut input) = mpsc::unbounded_channel();
let pump_terminal = Arc::clone(&terminal);
let pump_events = events.clone();
let mut running = tokio::spawn(async move {
pump(&mut channel, token, size(), &pump_terminal, &pump_events, &mut input).await
});
let grid_size = WindowSize { num_cols: 100, num_lines: 30, ..size() };
input_tx.send(Msg::ResizeGrid(grid_size)).unwrap();
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let dimensions = {
let terminal = terminal.lock();
(terminal.columns(), terminal.screen_lines())
};
if dimensions == (100, 30) {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("SSH grid-only resize was not applied");
let remote_size = WindowSize { num_cols: 132, num_lines: 40, ..size() };
input_tx.send(Msg::Resize(remote_size)).unwrap();
let observed = tokio::select! {
observed = fixture.window_changes.recv() => observed.expect("SSH server stopped before window change"),
result = &mut running => panic!("SSH pump stopped before window change: {result:?}"),
};
assert_eq!(observed.num_cols, remote_size.num_cols);
assert_eq!(observed.num_lines, remote_size.num_lines);
assert_eq!(observed.cell_width, remote_size.cell_width);
assert_eq!(observed.cell_height, remote_size.cell_height);
let dimensions = {
let terminal = terminal.lock();
(terminal.columns(), terminal.screen_lines())
};
assert_eq!(dimensions, (132, 40));
input_tx.send(Msg::Shutdown).unwrap();
assert!(running.await.unwrap().is_ok());
fixture.forget(&acquired.session).await;
});
}
#[test]
fn unexpected_disconnect_preserves_pane_but_explicit_exit_closes_it() {
check(async {