ssh remote: Handle disconnect on project and show overlay (#19014)

Thorsten Ball and Bennet created

Demo:



https://github.com/user-attachments/assets/e5edf8f3-8c15-482e-a792-6eb619f83de4


Release Notes:

- N/A

---------

Co-authored-by: Bennet <bennet@zed.dev>

Change summary

crates/call/src/room.rs                                       |   2 
crates/collab/src/tests/channel_guest_tests.rs                |   8 
crates/collab/src/tests/dev_server_tests.rs                   |   8 
crates/collab/src/tests/editor_tests.rs                       |   2 
crates/collab/src/tests/integration_tests.rs                  |  34 
crates/collab/src/tests/random_project_collaboration_tests.rs |   6 
crates/collab/src/tests/randomized_test_helpers.rs            |   4 
crates/project/src/lsp_store.rs                               |  27 
crates/project/src/project.rs                                 |  53 ++
crates/project/src/worktree_store.rs                          |   5 
crates/project_panel/src/project_panel.rs                     |  29 
crates/recent_projects/src/disconnected_overlay.rs            | 110 ++++
crates/remote/src/remote.rs                                   |   1 
crates/remote/src/ssh_session.rs                              |  37 +
crates/title_bar/src/title_bar.rs                             |   2 
crates/workspace/src/workspace.rs                             |  14 
16 files changed, 264 insertions(+), 78 deletions(-)

Detailed changes

crates/call/src/room.rs 🔗

@@ -1178,7 +1178,7 @@ impl Room {
             this.update(&mut cx, |this, cx| {
                 this.joined_projects.retain(|project| {
                     if let Some(project) = project.upgrade() {
-                        !project.read(cx).is_disconnected()
+                        !project.read(cx).is_disconnected(cx)
                     } else {
                         false
                     }

crates/collab/src/tests/channel_guest_tests.rs 🔗

@@ -50,7 +50,7 @@ async fn test_channel_guests(
         project_b.read_with(cx_b, |project, _| project.remote_id()),
         Some(project_id),
     );
-    assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
+    assert!(project_b.read_with(cx_b, |project, cx| project.is_read_only(cx)));
     assert!(project_b
         .update(cx_b, |project, cx| {
             let worktree_id = project.worktrees(cx).next().unwrap().read(cx).id();
@@ -103,7 +103,7 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
             workspace.active_item_as::<Editor>(cx).unwrap(),
         )
     });
-    assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
+    assert!(project_b.read_with(cx_b, |project, cx| project.is_read_only(cx)));
     assert!(editor_b.update(cx_b, |e, cx| e.read_only(cx)));
     assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone()));
     assert!(room_b
@@ -127,7 +127,7 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
     cx_a.run_until_parked();
 
     // project and buffers are now editable
-    assert!(project_b.read_with(cx_b, |project, _| !project.is_read_only()));
+    assert!(project_b.read_with(cx_b, |project, cx| !project.is_read_only(cx)));
     assert!(editor_b.update(cx_b, |editor, cx| !editor.read_only(cx)));
 
     // B sees themselves as muted, and can unmute.
@@ -153,7 +153,7 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
     cx_a.run_until_parked();
 
     // project and buffers are no longer editable
-    assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
+    assert!(project_b.read_with(cx_b, |project, cx| project.is_read_only(cx)));
     assert!(editor_b.update(cx_b, |editor, cx| editor.read_only(cx)));
     assert!(room_b
         .update(cx_b, |room, cx| room.share_microphone(cx))

crates/collab/src/tests/dev_server_tests.rs 🔗

@@ -262,7 +262,7 @@ async fn test_dev_server_leave_room(
     cx1.executor().run_until_parked();
 
     let (workspace, cx2) = client2.active_workspace(cx2);
-    cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected()));
+    cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected(cx)));
 }
 
 #[gpui::test]
@@ -308,7 +308,7 @@ async fn test_dev_server_delete(
     cx1.executor().run_until_parked();
 
     let (workspace, cx2) = client2.active_workspace(cx2);
-    cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected()));
+    cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected(cx)));
 
     cx1.update(|cx| {
         dev_server_projects::Store::global(cx).update(cx, |store, _| {
@@ -418,12 +418,12 @@ async fn test_dev_server_refresh_access_token(
 
     // Assert that the other client was disconnected
     let (workspace, cx2) = client2.active_workspace(cx2);
-    cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected()));
+    cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected(cx)));
 
     // Assert that the owner of the dev server does not see the dev server as online anymore
     let (workspace, cx1) = client1.active_workspace(cx1);
     cx1.update(|cx| {
-        assert!(workspace.read(cx).project().read(cx).is_disconnected());
+        assert!(workspace.read(cx).project().read(cx).is_disconnected(cx));
         dev_server_projects::Store::global(cx).update(cx, |store, _| {
             assert_eq!(
                 store.dev_servers().first().unwrap().status,

crates/collab/src/tests/editor_tests.rs 🔗

@@ -114,7 +114,7 @@ async fn test_host_disconnect(
 
     project_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
 
-    project_b.read_with(cx_b, |project, _| project.is_read_only());
+    project_b.read_with(cx_b, |project, cx| project.is_read_only(cx));
 
     assert!(worktree_a.read_with(cx_a, |tree, _| !tree.has_update_observer()));
 

crates/collab/src/tests/integration_tests.rs 🔗

@@ -1389,7 +1389,7 @@ async fn test_unshare_project(
         .unwrap();
     executor.run_until_parked();
 
-    assert!(project_b.read_with(cx_b, |project, _| project.is_disconnected()));
+    assert!(project_b.read_with(cx_b, |project, cx| project.is_disconnected(cx)));
 
     // Client C opens the project.
     let project_c = client_c.join_remote_project(project_id, cx_c).await;
@@ -1402,7 +1402,7 @@ async fn test_unshare_project(
 
     assert!(worktree_a.read_with(cx_a, |tree, _| !tree.has_update_observer()));
 
-    assert!(project_c.read_with(cx_c, |project, _| project.is_disconnected()));
+    assert!(project_c.read_with(cx_c, |project, cx| project.is_disconnected(cx)));
 
     // Client C can open the project again after client A re-shares.
     let project_id = active_call_a
@@ -1427,8 +1427,8 @@ async fn test_unshare_project(
 
     project_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
 
-    project_c2.read_with(cx_c, |project, _| {
-        assert!(project.is_disconnected());
+    project_c2.read_with(cx_c, |project, cx| {
+        assert!(project.is_disconnected(cx));
         assert!(project.collaborators().is_empty());
     });
 }
@@ -1560,8 +1560,8 @@ async fn test_project_reconnect(
         assert_eq!(project.collaborators().len(), 1);
     });
 
-    project_b1.read_with(cx_b, |project, _| {
-        assert!(!project.is_disconnected());
+    project_b1.read_with(cx_b, |project, cx| {
+        assert!(!project.is_disconnected(cx));
         assert_eq!(project.collaborators().len(), 1);
     });
 
@@ -1661,7 +1661,7 @@ async fn test_project_reconnect(
     });
 
     project_b1.read_with(cx_b, |project, cx| {
-        assert!(!project.is_disconnected());
+        assert!(!project.is_disconnected(cx));
         assert_eq!(
             project
                 .worktree_for_id(worktree1_id, cx)
@@ -1695,9 +1695,9 @@ async fn test_project_reconnect(
         );
     });
 
-    project_b2.read_with(cx_b, |project, _| assert!(project.is_disconnected()));
+    project_b2.read_with(cx_b, |project, cx| assert!(project.is_disconnected(cx)));
 
-    project_b3.read_with(cx_b, |project, _| assert!(!project.is_disconnected()));
+    project_b3.read_with(cx_b, |project, cx| assert!(!project.is_disconnected(cx)));
 
     buffer_a1.read_with(cx_a, |buffer, _| assert_eq!(buffer.text(), "WaZ"));
 
@@ -1754,7 +1754,7 @@ async fn test_project_reconnect(
     executor.run_until_parked();
 
     project_b1.read_with(cx_b, |project, cx| {
-        assert!(!project.is_disconnected());
+        assert!(!project.is_disconnected(cx));
         assert_eq!(
             project
                 .worktree_for_id(worktree1_id, cx)
@@ -1788,7 +1788,7 @@ async fn test_project_reconnect(
         );
     });
 
-    project_b3.read_with(cx_b, |project, _| assert!(project.is_disconnected()));
+    project_b3.read_with(cx_b, |project, cx| assert!(project.is_disconnected(cx)));
 
     buffer_a1.read_with(cx_a, |buffer, _| assert_eq!(buffer.text(), "WXaYZ"));
 
@@ -3816,8 +3816,8 @@ async fn test_leaving_project(
         assert_eq!(project.collaborators().len(), 1);
     });
 
-    project_b2.read_with(cx_b, |project, _| {
-        assert!(project.is_disconnected());
+    project_b2.read_with(cx_b, |project, cx| {
+        assert!(project.is_disconnected(cx));
     });
 
     project_c.read_with(cx_c, |project, _| {
@@ -3849,12 +3849,12 @@ async fn test_leaving_project(
         assert_eq!(project.collaborators().len(), 0);
     });
 
-    project_b2.read_with(cx_b, |project, _| {
-        assert!(project.is_disconnected());
+    project_b2.read_with(cx_b, |project, cx| {
+        assert!(project.is_disconnected(cx));
     });
 
-    project_c.read_with(cx_c, |project, _| {
-        assert!(project.is_disconnected());
+    project_c.read_with(cx_c, |project, cx| {
+        assert!(project.is_disconnected(cx));
     });
 }
 

crates/collab/src/tests/random_project_collaboration_tests.rs 🔗

@@ -1168,7 +1168,7 @@ impl RandomizedTest for ProjectCollaborationTest {
                             Some((project, cx))
                         });
 
-                        if !guest_project.is_disconnected() {
+                        if !guest_project.is_disconnected(cx) {
                             if let Some((host_project, host_cx)) = host_project {
                                 let host_worktree_snapshots =
                                     host_project.read_with(host_cx, |host_project, cx| {
@@ -1254,8 +1254,8 @@ impl RandomizedTest for ProjectCollaborationTest {
 
             let buffers = client.buffers().clone();
             for (guest_project, guest_buffers) in &buffers {
-                let project_id = if guest_project.read_with(client_cx, |project, _| {
-                    project.is_local() || project.is_disconnected()
+                let project_id = if guest_project.read_with(client_cx, |project, cx| {
+                    project.is_local() || project.is_disconnected(cx)
                 }) {
                     continue;
                 } else {

crates/collab/src/tests/randomized_test_helpers.rs 🔗

@@ -532,9 +532,9 @@ impl<T: RandomizedTest> TestPlan<T> {
                 server.allow_connections();
 
                 for project in client.dev_server_projects().iter() {
-                    project.read_with(&client_cx, |project, _| {
+                    project.read_with(&client_cx, |project, cx| {
                         assert!(
-                            project.is_disconnected(),
+                            project.is_disconnected(cx),
                             "project {:?} should be read only",
                             project.remote_id()
                         )

crates/project/src/lsp_store.rs 🔗

@@ -647,17 +647,10 @@ pub struct FormattableBuffer {
 }
 
 pub struct RemoteLspStore {
-    upstream_client: AnyProtoClient,
+    upstream_client: Option<AnyProtoClient>,
     upstream_project_id: u64,
 }
 
-impl RemoteLspStore {}
-
-// pub struct SshLspStore {
-//     upstream_client: AnyProtoClient,
-//     current_lsp_settings: HashMap<LanguageServerName, LspSettings>,
-// }
-
 #[allow(clippy::large_enum_variant)]
 pub enum LspStoreMode {
     Local(LocalLspStore),   // ssh host and collab host
@@ -808,10 +801,15 @@ impl LspStore {
     pub fn upstream_client(&self) -> Option<(AnyProtoClient, u64)> {
         match &self.mode {
             LspStoreMode::Remote(RemoteLspStore {
-                upstream_client,
+                upstream_client: Some(upstream_client),
                 upstream_project_id,
                 ..
             }) => Some((upstream_client.clone(), *upstream_project_id)),
+
+            LspStoreMode::Remote(RemoteLspStore {
+                upstream_client: None,
+                ..
+            }) => None,
             LspStoreMode::Local(_) => None,
         }
     }
@@ -924,7 +922,7 @@ impl LspStore {
 
         Self {
             mode: LspStoreMode::Remote(RemoteLspStore {
-                upstream_client,
+                upstream_client: Some(upstream_client),
                 upstream_project_id: project_id,
             }),
             downstream_client: None,
@@ -3099,6 +3097,15 @@ impl LspStore {
         self.downstream_client.take();
     }
 
+    pub fn disconnected_from_ssh_remote(&mut self) {
+        if let LspStoreMode::Remote(RemoteLspStore {
+            upstream_client, ..
+        }) = &mut self.mode
+        {
+            upstream_client.take();
+        }
+    }
+
     pub(crate) fn set_language_server_statuses_from_proto(
         &mut self,
         language_servers: Vec<proto::LanguageServer>,

crates/project/src/project.rs 🔗

@@ -58,7 +58,7 @@ use node_runtime::NodeRuntime;
 use parking_lot::{Mutex, RwLock};
 pub use prettier_store::PrettierStore;
 use project_settings::{ProjectSettings, SettingsObserver, SettingsObserverEvent};
-use remote::SshRemoteClient;
+use remote::{SshConnectionOptions, SshRemoteClient};
 use rpc::{proto::SSH_PROJECT_ID, AnyProtoClient, ErrorCode};
 use search::{SearchInputKind, SearchQuery, SearchResult};
 use search_history::SearchHistory;
@@ -245,6 +245,7 @@ pub enum Event {
     },
     RemoteIdChanged(Option<u64>),
     DisconnectedFromHost,
+    DisconnectedFromSshRemote,
     Closed,
     DeletedEntry(ProjectEntryId),
     CollaboratorUpdated {
@@ -755,6 +756,8 @@ impl Project {
                 }
             })
             .detach();
+
+            cx.subscribe(&ssh, Self::on_ssh_event).detach();
             cx.observe(&ssh, |_, _, cx| cx.notify()).detach();
 
             let this = Self {
@@ -1313,6 +1316,12 @@ impl Project {
             .map(|ssh| ssh.read(cx).connection_state())
     }
 
+    pub fn ssh_connection_options(&self, cx: &AppContext) -> Option<SshConnectionOptions> {
+        self.ssh_client
+            .as_ref()
+            .map(|ssh| ssh.read(cx).connection_options())
+    }
+
     pub fn replica_id(&self) -> ReplicaId {
         match self.client_state {
             ProjectClientState::Remote { replica_id, .. } => replica_id,
@@ -1658,7 +1667,7 @@ impl Project {
     }
 
     pub fn disconnected_from_host(&mut self, cx: &mut ModelContext<Self>) {
-        if self.is_disconnected() {
+        if self.is_disconnected(cx) {
             return;
         }
         self.disconnected_from_host_internal(cx);
@@ -1708,16 +1717,24 @@ impl Project {
         cx.emit(Event::Closed);
     }
 
-    pub fn is_disconnected(&self) -> bool {
+    pub fn is_disconnected(&self, cx: &AppContext) -> bool {
         match &self.client_state {
             ProjectClientState::Remote {
                 sharing_has_stopped,
                 ..
             } => *sharing_has_stopped,
+            ProjectClientState::Local if self.is_via_ssh() => self.ssh_is_disconnected(cx),
             _ => false,
         }
     }
 
+    fn ssh_is_disconnected(&self, cx: &AppContext) -> bool {
+        self.ssh_client
+            .as_ref()
+            .map(|ssh| ssh.read(cx).is_disconnected())
+            .unwrap_or(false)
+    }
+
     pub fn capability(&self) -> Capability {
         match &self.client_state {
             ProjectClientState::Remote { capability, .. } => *capability,
@@ -1725,8 +1742,8 @@ impl Project {
         }
     }
 
-    pub fn is_read_only(&self) -> bool {
-        self.is_disconnected() || self.capability() == Capability::ReadOnly
+    pub fn is_read_only(&self, cx: &AppContext) -> bool {
+        self.is_disconnected(cx) || self.capability() == Capability::ReadOnly
     }
 
     pub fn is_local(&self) -> bool {
@@ -1807,7 +1824,7 @@ impl Project {
         path: impl Into<ProjectPath>,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<Model<Buffer>>> {
-        if self.is_via_collab() && self.is_disconnected() {
+        if (self.is_via_collab() || self.is_via_ssh()) && self.is_disconnected(cx) {
             return Task::ready(Err(anyhow!(ErrorCode::Disconnected)));
         }
 
@@ -2114,6 +2131,30 @@ impl Project {
         }
     }
 
+    fn on_ssh_event(
+        &mut self,
+        _: Model<SshRemoteClient>,
+        event: &remote::SshRemoteEvent,
+        cx: &mut ModelContext<Self>,
+    ) {
+        match event {
+            remote::SshRemoteEvent::Disconnected => {
+                // if self.is_via_ssh() {
+                // self.collaborators.clear();
+                self.worktree_store.update(cx, |store, cx| {
+                    store.disconnected_from_host(cx);
+                });
+                self.buffer_store.update(cx, |buffer_store, cx| {
+                    buffer_store.disconnected_from_host(cx)
+                });
+                self.lsp_store.update(cx, |lsp_store, _cx| {
+                    lsp_store.disconnected_from_ssh_remote()
+                });
+                cx.emit(Event::DisconnectedFromSshRemote);
+            }
+        }
+    }
+
     fn on_settings_observer_event(
         &mut self,
         _: Model<SettingsObserver>,

crates/project/src/worktree_store.rs 🔗

@@ -509,6 +509,11 @@ impl WorktreeStore {
         for worktree in &self.worktrees {
             if let Some(worktree) = worktree.upgrade() {
                 worktree.update(cx, |worktree, _| {
+                    println!(
+                        "worktree. is_local: {:?}, is_remote: {:?}",
+                        worktree.is_local(),
+                        worktree.is_remote()
+                    );
                     if let Some(worktree) = worktree.as_remote_mut() {
                         worktree.disconnected_from_host();
                     }

crates/project_panel/src/project_panel.rs 🔗

@@ -344,16 +344,17 @@ impl ProjectPanel {
                             let worktree_id = worktree.read(cx).id();
                             let entry_id = entry.id;
 
-                                project_panel.update(cx, |this, _| {
-                                    if !mark_selected {
-                                        this.marked_entries.clear();
-                                    }
-                                    this.marked_entries.insert(SelectedEntry {
-                                        worktree_id,
-                                        entry_id
-                                    });
-                                }).ok();
+                            project_panel.update(cx, |this, _| {
+                                if !mark_selected {
+                                    this.marked_entries.clear();
+                                }
+                                this.marked_entries.insert(SelectedEntry {
+                                    worktree_id,
+                                    entry_id
+                                });
+                            }).ok();
 
+                            let is_via_ssh = project.read(cx).is_via_ssh();
 
                             workspace
                                 .open_path_preview(
@@ -368,7 +369,11 @@ impl ProjectPanel {
                                 )
                                 .detach_and_prompt_err("Failed to open file", cx, move |e, _| {
                                     match e.error_code() {
-                                        ErrorCode::Disconnected => Some("Disconnected from remote project".to_string()),
+                                        ErrorCode::Disconnected => if is_via_ssh {
+                                            Some("Disconnected from SSH host".to_string())
+                                        } else {
+                                            Some("Disconnected from remote project".to_string())
+                                        },
                                         ErrorCode::UnsharedItem => Some(format!(
                                             "{} is not shared by the host. This could be because it has been marked as `private`",
                                             file_path.display()
@@ -493,7 +498,7 @@ impl ProjectPanel {
             let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree);
             let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree);
             let worktree_id = worktree.id();
-            let is_read_only = project.is_read_only();
+            let is_read_only = project.is_read_only(cx);
             let is_remote = project.is_via_collab() && project.dev_server_project_id().is_none();
             let is_local = project.is_local();
 
@@ -2901,7 +2906,7 @@ impl Render for ProjectPanel {
                 .on_action(cx.listener(Self::new_search_in_directory))
                 .on_action(cx.listener(Self::unfold_directory))
                 .on_action(cx.listener(Self::fold_directory))
-                .when(!project.is_read_only(), |el| {
+                .when(!project.is_read_only(cx), |el| {
                     el.on_action(cx.listener(Self::new_file))
                         .on_action(cx.listener(Self::new_directory))
                         .on_action(cx.listener(Self::rename))

crates/recent_projects/src/disconnected_overlay.rs 🔗

@@ -1,19 +1,29 @@
+use std::path::PathBuf;
+
 use dev_server_projects::DevServer;
 use gpui::{ClickEvent, DismissEvent, EventEmitter, FocusHandle, FocusableView, Render, WeakView};
+use remote::SshConnectionOptions;
 use ui::{
     div, h_flex, rems, Button, ButtonCommon, ButtonStyle, Clickable, ElevationIndex, FluentBuilder,
     Headline, HeadlineSize, IconName, IconPosition, InteractiveElement, IntoElement, Label, Modal,
     ModalFooter, ModalHeader, ParentElement, Section, Styled, StyledExt, ViewContext,
 };
-use workspace::{notifications::DetachAndPromptErr, ModalView, Workspace};
+use workspace::{notifications::DetachAndPromptErr, ModalView, OpenOptions, Workspace};
 
 use crate::{
-    dev_servers::reconnect_to_dev_server_project, open_dev_server_project, DevServerProjects,
+    dev_servers::reconnect_to_dev_server_project, open_dev_server_project, open_ssh_project,
+    DevServerProjects,
 };
 
+enum Host {
+    RemoteProject,
+    DevServerProject(DevServer),
+    SshRemoteProject(SshConnectionOptions),
+}
+
 pub struct DisconnectedOverlay {
     workspace: WeakView<Workspace>,
-    dev_server: Option<DevServer>,
+    host: Host,
     focus_handle: FocusHandle,
 }
 
@@ -32,7 +42,10 @@ impl ModalView for DisconnectedOverlay {
 impl DisconnectedOverlay {
     pub fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
         cx.subscribe(workspace.project(), |workspace, project, event, cx| {
-            if !matches!(event, project::Event::DisconnectedFromHost) {
+            if !matches!(
+                event,
+                project::Event::DisconnectedFromHost | project::Event::DisconnectedFromSshRemote
+            ) {
                 return;
             }
             let handle = cx.view().downgrade();
@@ -45,9 +58,19 @@ impl DisconnectedOverlay {
                         .dev_server_for_project(id)
                 })
                 .cloned();
+
+            let ssh_connection_options = project.read(cx).ssh_connection_options(cx);
+            let host = if let Some(dev_server) = dev_server {
+                Host::DevServerProject(dev_server)
+            } else if let Some(ssh_connection_options) = ssh_connection_options {
+                Host::SshRemoteProject(ssh_connection_options)
+            } else {
+                Host::RemoteProject
+            };
+
             workspace.toggle_modal(cx, |cx| DisconnectedOverlay {
                 workspace: handle,
-                dev_server,
+                host,
                 focus_handle: cx.focus_handle(),
             });
         })
@@ -56,12 +79,22 @@ impl DisconnectedOverlay {
 
     fn handle_reconnect(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
         cx.emit(DismissEvent);
+
+        match &self.host {
+            Host::DevServerProject(dev_server) => {
+                self.reconnect_to_dev_server(dev_server.clone(), cx);
+            }
+            Host::SshRemoteProject(ssh_connection_options) => {
+                self.reconnect_to_ssh_remote(ssh_connection_options.clone(), cx);
+            }
+            _ => {}
+        }
+    }
+
+    fn reconnect_to_dev_server(&self, dev_server: DevServer, cx: &mut ViewContext<Self>) {
         let Some(workspace) = self.workspace.upgrade() else {
             return;
         };
-        let Some(dev_server) = self.dev_server.clone() else {
-            return;
-        };
         let Some(dev_server_project_id) = workspace
             .read(cx)
             .project()
@@ -102,6 +135,44 @@ impl DisconnectedOverlay {
         }
     }
 
+    fn reconnect_to_ssh_remote(
+        &self,
+        connection_options: SshConnectionOptions,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
+
+        let Some(ssh_project) = workspace.read(cx).serialized_ssh_project() else {
+            return;
+        };
+
+        let Some(window) = cx.window_handle().downcast::<Workspace>() else {
+            return;
+        };
+
+        let app_state = workspace.read(cx).app_state().clone();
+
+        let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
+
+        cx.spawn(move |_, mut cx| async move {
+            open_ssh_project(
+                connection_options,
+                paths,
+                app_state,
+                OpenOptions {
+                    replace_window: Some(window),
+                    ..Default::default()
+                },
+                &mut cx,
+            )
+            .await?;
+            Ok(())
+        })
+        .detach_and_prompt_err("Failed to reconnect", cx, |_, _| None);
+    }
+
     fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
         cx.emit(DismissEvent)
     }
@@ -109,6 +180,23 @@ impl DisconnectedOverlay {
 
 impl Render for DisconnectedOverlay {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let can_reconnect = matches!(
+            self.host,
+            Host::DevServerProject(_) | Host::SshRemoteProject(_)
+        );
+
+        let message = match &self.host {
+            Host::RemoteProject | Host::DevServerProject(_) => {
+                "Your connection to the remote project has been lost.".to_string()
+            }
+            Host::SshRemoteProject(options) => {
+                format!(
+                    "Your connection to {} has been lost",
+                    options.connection_string()
+                )
+            }
+        };
+
         div()
             .track_focus(&self.focus_handle)
             .elevation_3(cx)
@@ -123,9 +211,7 @@ impl Render for DisconnectedOverlay {
                             .show_dismiss_button(true)
                             .child(Headline::new("Disconnected").size(HeadlineSize::Small)),
                     )
-                    .section(Section::new().child(Label::new(
-                        "Your connection to the remote project has been lost.",
-                    )))
+                    .section(Section::new().child(Label::new(message)))
                     .footer(
                         ModalFooter::new().end_slot(
                             h_flex()
@@ -138,7 +224,7 @@ impl Render for DisconnectedOverlay {
                                             cx.remove_window();
                                         })),
                                 )
-                                .when_some(self.dev_server.clone(), |el, _| {
+                                .when(can_reconnect, |el| {
                                     el.child(
                                         Button::new("reconnect", "Reconnect")
                                             .style(ButtonStyle::Filled)

crates/remote/src/remote.rs 🔗

@@ -5,4 +5,5 @@ pub mod ssh_session;
 
 pub use ssh_session::{
     ConnectionState, SshClientDelegate, SshConnectionOptions, SshPlatform, SshRemoteClient,
+    SshRemoteEvent,
 };

crates/remote/src/ssh_session.rs 🔗

@@ -17,7 +17,8 @@ use futures::{
     StreamExt as _,
 };
 use gpui::{
-    AppContext, AsyncAppContext, Context, Model, ModelContext, SemanticVersion, Task, WeakModel,
+    AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, SemanticVersion, Task,
+    WeakModel,
 };
 use parking_lot::Mutex;
 use rpc::{
@@ -315,6 +316,10 @@ impl State {
         matches!(self, Self::ReconnectFailed { .. })
     }
 
+    fn is_reconnect_exhausted(&self) -> bool {
+        matches!(self, Self::ReconnectExhausted { .. })
+    }
+
     fn is_reconnecting(&self) -> bool {
         matches!(self, Self::Reconnecting { .. })
     }
@@ -376,7 +381,7 @@ impl State {
 }
 
 /// The state of the ssh connection.
-#[derive(Clone, Copy, Debug)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum ConnectionState {
     Connecting,
     Connected,
@@ -411,6 +416,13 @@ impl Drop for SshRemoteClient {
     }
 }
 
+#[derive(Debug)]
+pub enum SshRemoteEvent {
+    Disconnected,
+}
+
+impl EventEmitter<SshRemoteEvent> for SshRemoteClient {}
+
 impl SshRemoteClient {
     pub fn new(
         unique_identifier: String,
@@ -672,6 +684,9 @@ impl SshRemoteClient {
 
                 if this.state_is(State::is_reconnect_failed) {
                     this.reconnect(cx)
+                } else if this.state_is(State::is_reconnect_exhausted) {
+                    cx.emit(SshRemoteEvent::Disconnected);
+                    Ok(())
                 } else {
                     log::debug!("State has transition from Reconnecting into new state while attempting reconnect. Ignoring new state.");
                     Ok(())
@@ -851,11 +866,15 @@ impl SshRemoteClient {
                                 log::error!("failed to reconnect because server is not running");
                                 this.update(&mut cx, |this, cx| {
                                     this.set_state(State::ServerNotRunning, cx);
+                                    cx.emit(SshRemoteEvent::Disconnected);
                                 })?;
                             }
                         }
                     } else if exit_code > 0 {
                         log::error!("proxy process terminated unexpectedly");
+                        this.update(&mut cx, |this, cx| {
+                            this.reconnect(cx).ok();
+                        })?;
                     }
                 }
                 Ok(None) => {}
@@ -963,6 +982,11 @@ impl SshRemoteClient {
         self.connection_options.connection_string()
     }
 
+    pub fn connection_options(&self) -> SshConnectionOptions {
+        self.connection_options.clone()
+    }
+
+    #[cfg(not(any(test, feature = "test-support")))]
     pub fn connection_state(&self) -> ConnectionState {
         self.state
             .lock()
@@ -971,6 +995,15 @@ impl SshRemoteClient {
             .unwrap_or(ConnectionState::Disconnected)
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn connection_state(&self) -> ConnectionState {
+        ConnectionState::Connected
+    }
+
+    pub fn is_disconnected(&self) -> bool {
+        self.connection_state() == ConnectionState::Disconnected
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn fake(
         client_cx: &mut gpui::TestAppContext,

crates/title_bar/src/title_bar.rs 🔗

@@ -366,7 +366,7 @@ impl TitleBar {
             return self.render_ssh_project_host(cx);
         }
 
-        if self.project.read(cx).is_disconnected() {
+        if self.project.read(cx).is_disconnected(cx) {
             return Some(
                 Button::new("disconnected", "Disconnected")
                     .disabled(true)

crates/workspace/src/workspace.rs 🔗

@@ -823,6 +823,10 @@ impl Workspace {
                     }
                 }
 
+                project::Event::DisconnectedFromSshRemote => {
+                    this.update_window_edited(cx);
+                }
+
                 project::Event::Closed => {
                     cx.remove_window();
                 }
@@ -1464,6 +1468,10 @@ impl Workspace {
         self.on_prompt_for_open_path = Some(prompt)
     }
 
+    pub fn serialized_ssh_project(&self) -> Option<SerializedSshProject> {
+        self.serialized_ssh_project.clone()
+    }
+
     pub fn set_serialized_ssh_project(&mut self, serialized_ssh_project: SerializedSshProject) {
         self.serialized_ssh_project = Some(serialized_ssh_project);
     }
@@ -1791,7 +1799,7 @@ impl Workspace {
         mut save_intent: SaveIntent,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<bool>> {
-        if self.project.read(cx).is_disconnected() {
+        if self.project.read(cx).is_disconnected(cx) {
             return Task::ready(Ok(true));
         }
         let dirty_items = self
@@ -3447,7 +3455,7 @@ impl Workspace {
     }
 
     fn update_window_edited(&mut self, cx: &mut WindowContext) {
-        let is_edited = !self.project.read(cx).is_disconnected()
+        let is_edited = !self.project.read(cx).is_disconnected(cx)
             && self
                 .items(cx)
                 .any(|item| item.has_conflict(cx) || item.is_dirty(cx));
@@ -4858,7 +4866,7 @@ impl Render for Workspace {
                         .children(self.render_notifications(cx)),
                 )
                 .child(self.status_bar.clone())
-                .children(if self.project.read(cx).is_disconnected() {
+                .children(if self.project.read(cx).is_disconnected(cx) {
                     if let Some(render) = self.render_disconnected_overlay.take() {
                         let result = render(self, cx);
                         self.render_disconnected_overlay = Some(render);