Show the reason why a join request was declined

Antonio Scandurra and Nathan Sobo created

Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

Cargo.lock                        |   1 
crates/collab/src/rpc.rs          | 150 +++++++++++++++++++++++++++++++-
crates/project/Cargo.toml         |   1 
crates/project/src/project.rs     |  30 ++++++
crates/rpc/proto/zed.proto        |  10 +
crates/workspace/src/workspace.rs |  28 ++++-
6 files changed, 203 insertions(+), 17 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3394,6 +3394,7 @@ dependencies = [
  "sum_tree",
  "tempdir",
  "text",
+ "thiserror",
  "toml",
  "unindent",
  "util",

crates/collab/src/rpc.rs 🔗

@@ -354,7 +354,9 @@ impl Server {
                             receipt,
                             proto::JoinProjectResponse {
                                 variant: Some(proto::join_project_response::Variant::Decline(
-                                    proto::join_project_response::Decline {},
+                                    proto::join_project_response::Decline {
+                                        reason: proto::join_project_response::decline::Reason::WentOffline as i32
+                                    },
                                 )),
                             },
                         )?;
@@ -434,7 +436,10 @@ impl Server {
                     receipt,
                     proto::JoinProjectResponse {
                         variant: Some(proto::join_project_response::Variant::Decline(
-                            proto::join_project_response::Decline {},
+                            proto::join_project_response::Decline {
+                                reason: proto::join_project_response::decline::Reason::Closed
+                                    as i32,
+                            },
                         )),
                     },
                 )?;
@@ -542,7 +547,10 @@ impl Server {
                         receipt,
                         proto::JoinProjectResponse {
                             variant: Some(proto::join_project_response::Variant::Decline(
-                                proto::join_project_response::Decline {},
+                                proto::join_project_response::Decline {
+                                    reason: proto::join_project_response::decline::Reason::Declined
+                                        as i32,
+                                },
                             )),
                         },
                     )?;
@@ -1837,17 +1845,26 @@ mod tests {
     }
 
     #[gpui::test(iterations = 10)]
-    async fn test_host_disconnect(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
+    async fn test_host_disconnect(
+        cx_a: &mut TestAppContext,
+        cx_b: &mut TestAppContext,
+        cx_c: &mut TestAppContext,
+    ) {
         let lang_registry = Arc::new(LanguageRegistry::test());
         let fs = FakeFs::new(cx_a.background());
         cx_a.foreground().forbid_parking();
 
-        // Connect to a server as 2 clients.
+        // Connect to a server as 3 clients.
         let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
         let client_a = server.create_client(cx_a, "user_a").await;
         let mut client_b = server.create_client(cx_b, "user_b").await;
+        let client_c = server.create_client(cx_c, "user_c").await;
         server
-            .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+            .make_contacts(vec![
+                (&client_a, cx_a),
+                (&client_b, cx_b),
+                (&client_c, cx_c),
+            ])
             .await;
 
         // Share a project as client A
@@ -1868,6 +1885,9 @@ mod tests {
                 cx,
             )
         });
+        let project_id = project_a
+            .read_with(cx_a, |project, _| project.next_remote_id())
+            .await;
         let (worktree_a, _) = project_a
             .update(cx_a, |p, cx| {
                 p.find_or_create_local_worktree("/a", true, cx)
@@ -1887,6 +1907,24 @@ mod tests {
             .await
             .unwrap();
 
+        // Request to join that project as client C
+        let project_c = cx_c.spawn(|mut cx| {
+            let client = client_c.client.clone();
+            let user_store = client_c.user_store.clone();
+            let lang_registry = lang_registry.clone();
+            async move {
+                Project::remote(
+                    project_id,
+                    client,
+                    user_store,
+                    lang_registry.clone(),
+                    FakeFs::new(cx.background()),
+                    &mut cx,
+                )
+                .await
+            }
+        });
+
         // Drop client A's connection. Collaborators should disappear and the project should not be shown as shared.
         server.disconnect_client(client_a.current_user_id(cx_a));
         cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
@@ -1901,6 +1939,10 @@ mod tests {
         cx_b.update(|_| {
             drop(project_b);
         });
+        assert!(matches!(
+            project_c.await.unwrap_err(),
+            project::JoinProjectError::HostWentOffline
+        ));
 
         // Ensure guests can still join.
         let project_b2 = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
@@ -1911,6 +1953,102 @@ mod tests {
             .unwrap();
     }
 
+    #[gpui::test(iterations = 10)]
+    async fn test_decline_join_request(
+        deterministic: Arc<Deterministic>,
+        cx_a: &mut TestAppContext,
+        cx_b: &mut TestAppContext,
+    ) {
+        let lang_registry = Arc::new(LanguageRegistry::test());
+        let fs = FakeFs::new(cx_a.background());
+        cx_a.foreground().forbid_parking();
+
+        // Connect to a server as 2 clients.
+        let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+        let client_a = server.create_client(cx_a, "user_a").await;
+        let client_b = server.create_client(cx_b, "user_b").await;
+        server
+            .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+            .await;
+
+        // Share a project as client A
+        fs.insert_tree("/a", json!({})).await;
+        let project_a = cx_a.update(|cx| {
+            Project::local(
+                client_a.clone(),
+                client_a.user_store.clone(),
+                lang_registry.clone(),
+                fs.clone(),
+                cx,
+            )
+        });
+        let project_id = project_a
+            .read_with(cx_a, |project, _| project.next_remote_id())
+            .await;
+        let (worktree_a, _) = project_a
+            .update(cx_a, |p, cx| {
+                p.find_or_create_local_worktree("/a", true, cx)
+            })
+            .await
+            .unwrap();
+        worktree_a
+            .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete())
+            .await;
+
+        // Request to join that project as client B
+        let project_b = cx_b.spawn(|mut cx| {
+            let client = client_b.client.clone();
+            let user_store = client_b.user_store.clone();
+            let lang_registry = lang_registry.clone();
+            async move {
+                Project::remote(
+                    project_id,
+                    client,
+                    user_store,
+                    lang_registry.clone(),
+                    FakeFs::new(cx.background()),
+                    &mut cx,
+                )
+                .await
+            }
+        });
+        deterministic.run_until_parked();
+        project_a.update(cx_a, |project, cx| {
+            project.respond_to_join_request(client_b.user_id().unwrap(), false, cx)
+        });
+        assert!(matches!(
+            project_b.await.unwrap_err(),
+            project::JoinProjectError::HostDeclined
+        ));
+
+        // Request to join the project again as client B
+        let project_b = cx_b.spawn(|mut cx| {
+            let client = client_b.client.clone();
+            let user_store = client_b.user_store.clone();
+            let lang_registry = lang_registry.clone();
+            async move {
+                Project::remote(
+                    project_id,
+                    client,
+                    user_store,
+                    lang_registry.clone(),
+                    FakeFs::new(cx.background()),
+                    &mut cx,
+                )
+                .await
+            }
+        });
+
+        // Close the project on the host
+        deterministic.run_until_parked();
+        cx_a.update(|_| drop(project_a));
+        deterministic.run_until_parked();
+        assert!(matches!(
+            project_b.await.unwrap_err(),
+            project::JoinProjectError::HostClosedProject
+        ));
+    }
+
     #[gpui::test(iterations = 10)]
     async fn test_propagate_saves_and_fs_changes(
         cx_a: &mut TestAppContext,

crates/project/Cargo.toml 🔗

@@ -45,6 +45,7 @@ serde_json = { version = "1.0.64", features = ["preserve_order"] }
 sha2 = "0.10"
 similar = "1.3"
 smol = "1.2.5"
+thiserror = "1.0.29"
 toml = "0.5"
 
 [dev-dependencies]

crates/project/src/project.rs 🔗

@@ -49,6 +49,7 @@ use std::{
     },
     time::Instant,
 };
+use thiserror::Error;
 use util::{post_inc, ResultExt, TryFutureExt as _};
 
 pub use fs::*;
@@ -90,6 +91,18 @@ pub struct Project {
     nonce: u128,
 }
 
+#[derive(Error, Debug)]
+pub enum JoinProjectError {
+    #[error("host declined join request")]
+    HostDeclined,
+    #[error("host closed the project")]
+    HostClosedProject,
+    #[error("host went offline")]
+    HostWentOffline,
+    #[error("{0}")]
+    Other(#[from] anyhow::Error),
+}
+
 enum OpenBuffer {
     Strong(ModelHandle<Buffer>),
     Weak(WeakModelHandle<Buffer>),
@@ -356,7 +369,7 @@ impl Project {
         languages: Arc<LanguageRegistry>,
         fs: Arc<dyn Fs>,
         cx: &mut AsyncAppContext,
-    ) -> Result<ModelHandle<Self>> {
+    ) -> Result<ModelHandle<Self>, JoinProjectError> {
         client.authenticate_and_connect(true, &cx).await?;
 
         let response = client
@@ -367,7 +380,20 @@ impl Project {
 
         let response = match response.variant.ok_or_else(|| anyhow!("missing variant"))? {
             proto::join_project_response::Variant::Accept(response) => response,
-            proto::join_project_response::Variant::Decline(_) => Err(anyhow!("rejected"))?,
+            proto::join_project_response::Variant::Decline(decline) => {
+                match proto::join_project_response::decline::Reason::from_i32(decline.reason) {
+                    Some(proto::join_project_response::decline::Reason::Declined) => {
+                        Err(JoinProjectError::HostDeclined)?
+                    }
+                    Some(proto::join_project_response::decline::Reason::Closed) => {
+                        Err(JoinProjectError::HostClosedProject)?
+                    }
+                    Some(proto::join_project_response::decline::Reason::WentOffline) => {
+                        Err(JoinProjectError::HostWentOffline)?
+                    }
+                    None => Err(anyhow!("missing decline reason"))?,
+                }
+            }
         };
 
         let replica_id = response.replica_id as ReplicaId;

crates/rpc/proto/zed.proto 🔗

@@ -153,7 +153,15 @@ message JoinProjectResponse {
         repeated LanguageServer language_servers = 4;        
     }
     
-    message Decline {}
+    message Decline {
+        Reason reason = 1;
+
+        enum Reason {
+            Declined = 0;
+            Closed = 1;
+            WentOffline = 2;
+        }
+    }
 }
 
 message LeaveProject {

crates/workspace/src/workspace.rs 🔗

@@ -2310,10 +2310,11 @@ pub fn join_project(
 
     let app_state = app_state.clone();
     cx.spawn(|mut cx| async move {
-        let (window, joining_notice) =
-            cx.update(|cx| cx.add_window((app_state.build_window_options)(), |_| JoiningNotice {
+        let (window, joining_notice) = cx.update(|cx| {
+            cx.add_window((app_state.build_window_options)(), |_| JoiningNotice {
                 message: "Loading remote project...",
-            }));
+            })
+        });
         let project = Project::remote(
             project_id,
             app_state.client.clone(),
@@ -2336,13 +2337,24 @@ pub fn join_project(
                 );
                 workspace
             })),
-            Err(error) => {
-                joining_notice.update(cx, |joining_notice, cx| {
-                    joining_notice.message = "An error occurred trying to join the project. Please, close this window and retry.";
+            Err(error @ _) => {
+                let message = match error {
+                    project::JoinProjectError::HostDeclined => {
+                        "The host declined your request to join."
+                    }
+                    project::JoinProjectError::HostClosedProject => "The host closed the project.",
+                    project::JoinProjectError::HostWentOffline => "The host went offline.",
+                    project::JoinProjectError::Other(_) => {
+                        "An error occurred when attempting to join the project."
+                    }
+                };
+                joining_notice.update(cx, |notice, cx| {
+                    notice.message = message;
                     cx.notify();
                 });
-                Err(error)
-            },
+
+                Err(error)?
+            }
         })
     })
 }