diff --git a/src/app/mod.rs b/src/app/mod.rs index 4158eeb..5898b92 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -28,10 +28,9 @@ use rust_i18n::t; use tokio::runtime::Runtime; use crate::{ - sftp::SftpHandle, session::config::{AuthMethod, ConfigStore}, system::{SystemSampler, SystemSnapshot}, - terminal::{self, BackendCommand, BackendEvent, TabKind, TerminalTab}, + terminal::{self, BackendEvent, TabKind, TerminalTab}, backend::ssh, }; @@ -558,7 +557,7 @@ impl Ashell { if self .connection_progress .as_ref() - .is_some_and(|progress| progress.tab_id == tab_id) + .is_some_and(|progress| progress.tab_id == tab_id && !progress.failed) { self.connection_progress = None; } @@ -644,6 +643,7 @@ impl Ashell { self.remote_sample_in_flight = false; return changed; } + let mut needs_new_progress = false; if let Some(progress) = self.connection_progress.as_mut() { if progress.tab_id == tab_id { progress.lines.push(reason.clone().into()); @@ -653,16 +653,32 @@ impl Ashell { let _ = tab_title; progress.title = t!("connection_failed").into(); progress.failed = true; + } else if !progress.failed { + // We were showing connecting progress, but another tab dropped! + // Switch to failed state so the user can see it and retry. + progress.tab_id = tab_id.clone(); + let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason); + progress.lines.push(msg.into()); + self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0))); + progress.title = t!("connection_failed").into(); + progress.failed = true; + } else { + // Already showing a failure dialog, just append the new failure + let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason); + progress.lines.push(msg.into()); + self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0))); } } else if let Some(_) = session_label { - if !is_graceful_exit { - self.connection_progress = Some(ConnectionProgress { - tab_id: tab_id.clone(), - title: t!("connection_failed").into(), - lines: vec![reason.clone().into()], - failed: true, - }); - } + needs_new_progress = true; + } + + if needs_new_progress && !is_graceful_exit { + self.connection_progress = Some(ConnectionProgress { + tab_id: tab_id.clone(), + title: t!("connection_failed").into(), + lines: vec![reason.clone().into()], + failed: true, + }); } self.status = reason.into(); } diff --git a/src/backend/ssh.rs b/src/backend/ssh.rs index f80ed42..6714b99 100644 --- a/src/backend/ssh.rs +++ b/src/backend/ssh.rs @@ -115,7 +115,7 @@ async fn run_ssh( tab_id: tab_id.clone(), }); - let mut exit_reason = "ssh connection lost (unknown)".to_string(); + let exit_reason; let mut is_graceful_close = false; loop { @@ -146,7 +146,7 @@ async fn run_ssh( bytes: data.to_vec(), }); } - Some(ChannelMsg::ExitStatus { exit_status: _ }) => { + Some(ChannelMsg::ExitStatus { exit_status: _ }) | Some(ChannelMsg::Eof) => { is_graceful_close = true; } Some(ChannelMsg::Close) => { @@ -158,7 +158,11 @@ async fn run_ssh( break; } None => { - exit_reason = "ssh connection lost (network drop)".to_string(); + if is_graceful_close { + exit_reason = "ssh session closed".to_string(); + } else { + exit_reason = "ssh connection lost (network drop)".to_string(); + } break; } _ => {} @@ -184,6 +188,8 @@ async fn connect_and_authenticate( ) -> Result> { let config = Arc::new(client::Config { inactivity_timeout: Some(std::time::Duration::from_secs(600)), + keepalive_interval: Some(std::time::Duration::from_secs(10)), + keepalive_max: 3, ..Default::default() }); let addr = format!("{}:{}", session.host, session.port); diff --git a/src/session/mod.rs b/src/session/mod.rs index 531c9fd..56a20b4 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -16,7 +16,6 @@ use self::config::{AuthMethod, Session}; use crate::{ Ashell, ConnectionProgress, PaneLayout, SelectorEntry, TabGroup, backend::{local, ssh}, - sftp, terminal::{BackendCommand, RenderSnapshot, TabKind, TerminalTab}, app::constants::{DEFAULT_COLS, DEFAULT_ROWS, SIDEBAR_WIDTH, TAB_BAR_HEIGHT, TERMINAL_PADDING_X, TERMINAL_PADDING_Y}, }; @@ -420,53 +419,71 @@ impl Ashell { let Some(progress) = self.connection_progress.clone() else { return; }; - let Some(ix) = self.tabs.iter().position(|tab| tab.id == progress.tab_id) else { - self.connection_progress = None; - cx.notify(); - return; - }; - let Some(session) = self.tabs[ix].session.clone() else { - self.connection_progress = None; - cx.notify(); - return; - }; + self.connection_progress = None; + let mut groups_to_restart_sftp = std::collections::HashSet::new(); - // Close old backend - self.tabs[ix].backend.send(BackendCommand::Close); - - // Spawn new backend - let backend = ssh::spawn_ssh_terminal( - self.runtime.handle(), - progress.tab_id.clone(), - session.clone(), - DEFAULT_COLS, - DEFAULT_ROWS, - self.events_tx.clone(), - ); - - // Replace tab state in-place to reuse the UI component - self.tabs[ix] = TerminalTab::new_ssh( - progress.tab_id.clone(), - &session, - backend, - self.events_tx.clone(), - ); - - // Restart SFTP if applicable - if let Some(group) = self.tab_groups.iter_mut().find(|g| g.pane_root.contains(&progress.tab_id)) { - if let Some(old_handle) = self.sftp_handles.remove(&group.id) { - old_handle.close(); + let mut retry_tabs = Vec::new(); + for (ix, tab) in self.tabs.iter().enumerate() { + if !tab.connected && tab.session.is_some() { + retry_tabs.push((ix, tab.id.clone(), tab.session.clone().unwrap())); } - let sftp_handle = crate::sftp::spawn_sftp( + } + + if retry_tabs.is_empty() { + cx.notify(); + return; + } + + for (ix, tab_id, session) in retry_tabs { + // Close old backend + self.tabs[ix].backend.send(BackendCommand::Close); + + // Spawn new backend + let backend = ssh::spawn_ssh_terminal( self.runtime.handle(), - group.id.clone(), + tab_id.clone(), session.clone(), + DEFAULT_COLS, + DEFAULT_ROWS, self.events_tx.clone(), ); - self.sftp_handles.insert(group.id.clone(), sftp_handle); - - if let Some(sftp) = group.sftp.as_mut() { - sftp.status = rust_i18n::t!("sftp_connecting").to_string(); + + // Replace tab state in-place to reuse the UI component + self.tabs[ix] = TerminalTab::new_ssh( + tab_id.clone(), + &session, + backend, + self.events_tx.clone(), + ); + + // Find group to restart SFTP + if let Some(group) = self.tab_groups.iter().find(|g| g.pane_root.contains(&tab_id)) { + groups_to_restart_sftp.insert(group.id.clone()); + } + } + + // Restart SFTP for affected groups + for group_id in groups_to_restart_sftp { + if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) { + // Use the session of any tab in that group + let group_session = self.tabs.iter().find(|t| group.pane_root.contains(&t.id) && t.session.is_some()).and_then(|t| t.session.clone()); + + if let Some(session) = group_session { + if let Some(old_handle) = self.sftp_handles.remove(&group.id) { + old_handle.close(); + } + let sftp_handle = crate::sftp::spawn_sftp( + self.runtime.handle(), + group.id.clone(), + session, + self.events_tx.clone(), + ); + self.sftp_handles.insert(group.id.clone(), sftp_handle); + + if let Some(sftp) = group.sftp.as_mut() { + sftp.status = rust_i18n::t!("sftp_connecting").to_string(); + } + } } } @@ -476,16 +493,23 @@ impl Ashell { lines: vec![t!("starting_connection").into()], failed: false, }); - self.status = "ssh tab retrying".into(); + self.status = "ssh tabs retrying".into(); cx.notify(); } pub(crate) fn cancel_connection_progress(&mut self, cx: &mut Context) { - let Some(progress) = self.connection_progress.clone() else { + if self.connection_progress.is_none() { return; - }; + } self.connection_progress = None; - self.close_tab(progress.tab_id, cx); + let tabs_to_close: Vec<_> = self.tabs.iter() + .filter(|tab| !tab.connected && tab.session.is_some()) + .map(|tab| tab.id.clone()) + .collect(); + for id in tabs_to_close { + self.handle_tab_close(id); + } + cx.notify(); } #[allow(dead_code)] diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 71051a8..ab5ee1b 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -435,7 +435,6 @@ impl EventListener for TerminalListener { } } -use rust_i18n::t; fn new_term( cols: u16, rows: u16,