ssh: Add session state indicator to title bar (#18645)

Piotr Osiewicz created

![image](https://github.com/user-attachments/assets/0ed6f59c-e0e7-49e6-8db7-f09ec5cdf653)
The indicator turns yellow when ssh client is trying to reconnect. Note
that the state tracking is probably not ideal (we'll see how it pans out
once we start dog-fooding), but at the very least "green=good" should be
a decent mental model for now.

Release Notes:

- N/A

Change summary

crates/project/src/project.rs                 |  9 +++
crates/recent_projects/src/ssh_connections.rs |  1 
crates/remote/src/ssh_session.rs              | 16 ++++++-
crates/title_bar/src/title_bar.rs             | 45 ++++++++++++++++++++
4 files changed, 66 insertions(+), 5 deletions(-)

Detailed changes

crates/project/src/project.rs 🔗

@@ -1217,7 +1217,10 @@ impl Project {
         server.ssh_connection_string.is_some()
     }
 
-    pub fn ssh_connection_string(&self, cx: &ModelContext<Self>) -> Option<SharedString> {
+    pub fn ssh_connection_string(&self, cx: &AppContext) -> Option<SharedString> {
+        if let Some(ssh_state) = &self.ssh_client {
+            return Some(ssh_state.connection_string().into());
+        }
         let dev_server_id = self.dev_server_project_id()?;
         dev_server_projects::Store::global(cx)
             .read(cx)
@@ -1226,6 +1229,10 @@ impl Project {
             .clone()
     }
 
+    pub fn ssh_is_connected(&self) -> Option<bool> {
+        Some(!self.ssh_client.as_ref()?.is_reconnect_underway())
+    }
+
     pub fn replica_id(&self) -> ReplicaId {
         match self.client_state {
             ProjectClientState::Remote { replica_id, .. } => replica_id,

crates/recent_projects/src/ssh_connections.rs 🔗

@@ -317,6 +317,7 @@ impl SshClientDelegate {
         if release_channel == ReleaseChannel::Dev
             && platform.arch == std::env::consts::ARCH
             && platform.os == std::env::consts::OS
+            && false
         {
             use smol::process::{Command, Stdio};
 

crates/remote/src/ssh_session.rs 🔗

@@ -36,6 +36,7 @@ use std::{
     time::Instant,
 };
 use tempfile::TempDir;
+use util::maybe;
 
 #[derive(
     Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, serde::Serialize, serde::Deserialize,
@@ -48,7 +49,7 @@ pub struct SshSocket {
     socket_path: PathBuf,
 }
 
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
 pub struct SshConnectionOptions {
     pub host: String,
     pub username: Option<String>,
@@ -250,6 +251,7 @@ struct SshRemoteClientState {
 pub struct SshRemoteClient {
     client: Arc<ChannelClient>,
     inner_state: Mutex<Option<SshRemoteClientState>>,
+    connection_options: SshConnectionOptions,
 }
 
 impl SshRemoteClient {
@@ -265,6 +267,7 @@ impl SshRemoteClient {
         let this = Arc::new(Self {
             client,
             inner_state: Mutex::new(None),
+            connection_options: connection_options.clone(),
         });
 
         let inner_state = {
@@ -272,8 +275,7 @@ impl SshRemoteClient {
                 ChannelForwarder::new(incoming_tx, outgoing_rx, cx);
 
             let (ssh_connection, ssh_process) =
-                Self::establish_connection(connection_options.clone(), delegate.clone(), cx)
-                    .await?;
+                Self::establish_connection(connection_options, delegate.clone(), cx).await?;
 
             let multiplex_task = Self::multiplex(
                 Arc::downgrade(&this),
@@ -505,6 +507,13 @@ impl SshRemoteClient {
         self.client.clone().into()
     }
 
+    pub fn connection_string(&self) -> String {
+        self.connection_options.connection_string()
+    }
+
+    pub fn is_reconnect_underway(&self) -> bool {
+        maybe!({ Some(self.inner_state.try_lock()?.is_none()) }).unwrap_or_default()
+    }
     #[cfg(any(test, feature = "test-support"))]
     pub fn fake(
         client_cx: &mut gpui::TestAppContext,
@@ -519,6 +528,7 @@ impl SshRemoteClient {
                 Arc::new(Self {
                     client,
                     inner_state: Mutex::new(None),
+                    connection_options: SshConnectionOptions::default(),
                 })
             }),
             server_cx.update(|cx| ChannelClient::new(client_to_server_rx, server_to_client_tx, cx)),

crates/title_bar/src/title_bar.rs 🔗

@@ -18,7 +18,7 @@ use gpui::{
     StatefulInteractiveElement, Styled, Subscription, View, ViewContext, VisualContext, WeakView,
 };
 use project::{Project, RepositoryEntry};
-use recent_projects::RecentProjects;
+use recent_projects::{OpenRemote, RecentProjects};
 use rpc::proto::{self, DevServerStatus};
 use smallvec::SmallVec;
 use std::sync::Arc;
@@ -262,6 +262,46 @@ impl TitleBar {
         self
     }
 
+    fn render_ssh_project_host(&self, cx: &mut ViewContext<Self>) -> Option<AnyElement> {
+        let host = self.project.read(cx).ssh_connection_string(cx)?;
+        let meta = SharedString::from(format!("Connected to: {host}"));
+        let indicator_color = if self.project.read(cx).ssh_is_connected()? {
+            Color::Success
+        } else {
+            Color::Warning
+        };
+        let indicator = div()
+            .absolute()
+            .w_1_4()
+            .h_1_4()
+            .right_0p5()
+            .bottom_0p5()
+            .p_1()
+            .rounded_2xl()
+            .bg(indicator_color.color(cx));
+
+        Some(
+            div()
+                .child(
+                    IconButton::new("ssh-server-icon", IconName::Server)
+                        .tooltip(move |cx| {
+                            Tooltip::with_meta(
+                                "Remote Project",
+                                Some(&OpenRemote),
+                                meta.clone(),
+                                cx,
+                            )
+                        })
+                        .shape(ui::IconButtonShape::Square)
+                        .on_click(|_, cx| {
+                            cx.dispatch_action(OpenRemote.boxed_clone());
+                        }),
+                )
+                .child(indicator)
+                .into_any_element(),
+        )
+    }
+
     pub fn render_project_host(&self, cx: &mut ViewContext<Self>) -> Option<AnyElement> {
         if let Some(dev_server) =
             self.project
@@ -296,6 +336,9 @@ impl TitleBar {
                     .into_any_element(),
             );
         }
+        if self.project.read(cx).is_via_ssh() {
+            return self.render_ssh_project_host(cx);
+        }
 
         if self.project.read(cx).is_disconnected() {
             return Some(