diff --git a/src/app/mod.rs b/src/app/mod.rs index d6d0476..a518748 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -260,6 +260,8 @@ pub(crate) struct Ashell { pub(crate) tabs_scroll_handle: gpui::ScrollHandle, pub(crate) selector_scroll_handle: gpui::ScrollHandle, pub(crate) saved_scroll_handle: gpui::ScrollHandle, + pub(crate) connection_scroll_handle: gpui::ScrollHandle, + pub(crate) connection_progress: Option, pub(crate) pending_sftp_path_sync: Option, pub(crate) sftp_context_menu: Option, pub(crate) sftp_creating_folder: bool, @@ -326,6 +328,14 @@ pub(crate) enum SelectorEntry { Saved(String), } +#[derive(Clone)] +pub(crate) struct ConnectionProgress { + pub(crate) tab_id: String, + pub(crate) title: SharedString, + pub(crate) lines: Vec, + pub(crate) failed: bool, +} + #[derive(Clone)] pub(crate) struct SftpContextMenuState { pub(crate) remote_path: String, @@ -607,6 +617,8 @@ impl Ashell { tabs_scroll_handle: gpui::ScrollHandle::new(), selector_scroll_handle: gpui::ScrollHandle::new(), saved_scroll_handle: gpui::ScrollHandle::new(), + connection_scroll_handle: gpui::ScrollHandle::new(), + connection_progress: None, pending_sftp_path_sync: Some("/".into()), sftp_context_menu: None, sftp_creating_folder: false, @@ -799,6 +811,14 @@ impl Ashell { tab.backend_initialized = true; tab.status = text.clone(); } + if let Some(progress) = self.connection_progress.as_mut() { + if progress.tab_id == tab_id { + progress.lines.push(text.clone().into()); + let _idx = progress.lines.len().saturating_sub(1); + self.connection_scroll_handle + .set_offset(gpui::point(px(0.), px(-99999.0))); + } + } self.status = text.into(); } BackendEvent::Connected { tab_id } => { @@ -809,6 +829,13 @@ impl Ashell { } self.sync_system_tab_to_active_group(); self.request_active_system_snapshot(); + if self + .connection_progress + .as_ref() + .is_some_and(|progress| progress.tab_id == tab_id && !progress.failed) + { + self.connection_progress = None; + } } BackendEvent::PromptRequest { tab_id, @@ -914,6 +941,16 @@ impl Ashell { if self.system_tab_id.as_deref() == Some(tab_id.as_str()) { self.system_status = Some(reason.clone().into()); } + if let Some(progress) = self.connection_progress.as_mut() { + if progress.tab_id == tab_id { + progress.lines.push(reason.clone().into()); + let _idx = progress.lines.len().saturating_sub(1); + self.connection_scroll_handle + .set_offset(gpui::point(px(0.), px(-99999.0))); + progress.title = t!("connection_failed").into(); + progress.failed = true; + } + } self.status = reason.into(); } BackendEvent::TransferProgress { @@ -1086,6 +1123,97 @@ impl Ashell { cx.notify(); } + pub(crate) fn retry_connection_progress(&mut self, cx: &mut Context) { + let Some(progress) = self.connection_progress.clone() else { + return; + }; + self.connection_progress = None; + let mut retry_tabs = Vec::new(); + for (ix, tab) in self.tabs.iter().enumerate() { + if !tab.connected && tab.session.is_some() && tab.id == progress.tab_id { + retry_tabs.push((ix, tab.id.clone(), tab.session.clone().unwrap())); + } + } + + if retry_tabs.is_empty() { + cx.notify(); + return; + } + + for (ix, tab_id, session) in retry_tabs { + // Close old backend + self.tabs[ix].send_backend(crate::terminal::BackendCommand::Close); + + // Spawn new backend + let backend = crate::backend::ssh::spawn_ssh_terminal( + self.runtime.handle(), + tab_id.clone(), + session.clone(), + self.tabs[ix].cols, + self.tabs[ix].rows, + self.events_tx.clone(), + ); + + // Replace tab state + self.tabs[ix].set_backend(backend); + self.tabs[ix].connected = false; + self.tabs[ix].status = "connecting".into(); + self.tabs[ix].disconnected_reason = None; + self.tabs[ix].backend_initialized = false; + + // Restart SFTP for the group containing this tab + if let Some(group) = self + .tab_groups + .iter() + .find(|g| g.pane_root.contains(&tab_id)) + { + let group_id = group.id.clone(); + 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(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) { + if let Some(sftp) = group.sftp.as_mut() { + sftp.status = rust_i18n::t!("sftp_connecting").to_string(); + } + } + } + } + } + + self.connection_progress = Some(ConnectionProgress { + tab_id: progress.tab_id.clone(), + title: t!("connecting").into(), + lines: vec![t!("starting_connection").into()], + failed: false, + }); + self.status = "ssh tabs retrying".into(); + cx.notify(); + } + + pub(crate) fn cancel_connection_progress(&mut self, cx: &mut Context) { + if let Some(progress) = &self.connection_progress { + let tab_id = progress.tab_id.clone(); + self.connection_progress = None; + self.handle_tab_close(tab_id); + } + cx.notify(); + } + pub(crate) fn save_layout_state(&self, window: &mut gpui::Window, cx: &gpui::App) { if self.is_layout_reset { tracing::info!("[ui] layout was reset, skipping save layout state."); diff --git a/src/app/ui.rs b/src/app/ui.rs index a9f858d..49423b1 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -2903,6 +2903,115 @@ impl Render for Ashell { ), ) }) + .when_some(self.connection_progress.clone(), |this, progress| { + this.child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .bg(gpui::Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.48, + }) + .flex() + .items_center() + .justify_center() + .child( + div() + .w(px(420.)) + .p_5() + .rounded_lg() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().popover) + .shadow_lg() + .child( + v_flex() + .gap_4() + .child( + Button::new("ssh-connect-progress") + .primary() + .loading(!progress.failed) + .label(progress.title.clone()), + ) + .child( + div() + .relative() + .min_h(px(0.)) + .max_h(px(220.)) + .child( + div() + .id("connection-progress-scroll") + .max_h(px(220.)) + .overflow_hidden() + .overflow_y_scroll() + .track_scroll(&self.connection_scroll_handle) + .child( + v_flex().gap_2().children( + progress.lines.iter().cloned().map(|line| { + div() + .text_size(rems(1.0)) + .text_color(if progress.failed { + cx.theme().danger + } else { + cx.theme().muted_foreground + }) + .child(line) + }), + ), + ) + ) + .child( + div() + .absolute() + .top_0() + .right_0() + .bottom_0() + .w(px(16.)) + .child( + Scrollbar::vertical(&self.connection_scroll_handle) + .scrollbar_show(ScrollbarShow::Scrolling) + ) + ) + ) + .when(progress.failed, |this| { + this.child( + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("ssh-connect-progress-retry") + .primary() + .label(t!("retry").to_string()) + .on_click(cx.listener( + |this, _, _, cx| { + this.retry_connection_progress( + cx, + ) + }, + )), + ) + .child( + Button::new("ssh-connect-progress-close") + .label(t!("cancel").to_string()) + .on_click(cx.listener( + |this, _, _, cx| { + this.cancel_connection_progress( + cx, + ) + }, + )), + ), + ) + }), + ), + ), + ) + }) .on_prepaint({ let view = cx.entity().clone(); move |_, window, cx| { diff --git a/src/session/mod.rs b/src/session/mod.rs index 7eeaa7b..26a0998 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -494,6 +494,12 @@ impl Ashell { self.events_tx.clone(), )); self.active_tab = Some(id.clone()); + self.connection_progress = Some(crate::app::ConnectionProgress { + tab_id: id.clone(), + title: rust_i18n::t!("connecting").into(), + lines: vec![rust_i18n::t!("starting_connection").into()], + failed: false, + }); self.pane_root = PaneLayout::Single(id.clone()); self.focused_pane_path = vec![]; let group_id = Uuid::new_v4().to_string(); diff --git a/src/terminal/input.rs b/src/terminal/input.rs index 37703a3..4f335e3 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -119,6 +119,15 @@ impl Ashell { && !event.keystroke.modifiers.alt && !event.keystroke.modifiers.platform { + if let Some(progress) = &self.connection_progress { + if progress.failed { + self.retry_connection_progress(cx); + window.prevent_default(); + cx.stop_propagation(); + return; + } + } + let active_id = self.active_tab.clone(); if let Some(active_id) = active_id { let is_disconnected = self