diff --git a/nebula_app/src/gpui_shell/workspace/ssh_dialog.rs b/nebula_app/src/gpui_shell/workspace/ssh_dialog.rs index 9f428ea..fa89fd1 100644 --- a/nebula_app/src/gpui_shell/workspace/ssh_dialog.rs +++ b/nebula_app/src/gpui_shell/workspace/ssh_dialog.rs @@ -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) diff --git a/nebula_app/src/ssh_session/lifecycle.rs b/nebula_app/src/ssh_session/lifecycle.rs index 6505742..73a7245 100644 --- a/nebula_app/src/ssh_session/lifecycle.rs +++ b/nebula_app/src/ssh_session/lifecycle.rs @@ -418,12 +418,22 @@ async fn pump( 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 { diff --git a/nebula_app/src/ssh_session/lifecycle/tests.rs b/nebula_app/src/ssh_session/lifecycle/tests.rs index 1c144de..a7c424e 100644 --- a/nebula_app/src/ssh_session/lifecycle/tests.rs +++ b/nebula_app/src/ssh_session/lifecycle/tests.rs @@ -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>, + window_changes: mpsc::UnboundedSender, channels: Vec>, } @@ -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>, + window_changes: mpsc::UnboundedReceiver, 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 {