Cancel join requests when the requester closes the window

Nathan Sobo created

Change summary

crates/client/src/user.rs            |   2 
crates/collab/src/rpc.rs             | 133 +++++++++++++++++++++-
crates/collab/src/rpc/store.rs       |  43 ++++++-
crates/gpui/src/app.rs               |  15 ++
crates/project/src/project.rs        |  25 ++++
crates/rpc/proto/zed.proto           | 170 +++++++++++++++--------------
crates/rpc/src/proto.rs              |   2 
crates/rpc/src/rpc.rs                |   2 
crates/workspace/src/waiting_room.rs | 159 ++++++++++++++++++++++++++++
crates/workspace/src/workspace.rs    | 130 +---------------------
10 files changed, 458 insertions(+), 223 deletions(-)

Detailed changes

crates/client/src/user.rs 🔗

@@ -23,6 +23,8 @@ impl PartialEq for User {
     }
 }
 
+impl Eq for User {}
+
 #[derive(Debug)]
 pub struct Contact {
     pub user: Arc<User>,

crates/collab/src/rpc.rs 🔗

@@ -650,19 +650,32 @@ impl Server {
         let project_id = request.payload.project_id;
         let project;
         {
-            let mut state = self.store_mut().await;
-            project = state.leave_project(sender_id, project_id)?;
-            let unshare = project.connection_ids.len() <= 1;
-            broadcast(sender_id, project.connection_ids, |conn_id| {
+            let mut store = self.store_mut().await;
+            project = store.leave_project(sender_id, project_id)?;
+
+            if project.remove_collaborator {
+                broadcast(sender_id, project.connection_ids, |conn_id| {
+                    self.peer.send(
+                        conn_id,
+                        proto::RemoveProjectCollaborator {
+                            project_id,
+                            peer_id: sender_id.0,
+                        },
+                    )
+                });
+            }
+
+            if let Some(requester_id) = project.cancel_request {
                 self.peer.send(
-                    conn_id,
-                    proto::RemoveProjectCollaborator {
+                    project.host_connection_id,
+                    proto::JoinProjectRequestCancelled {
                         project_id,
-                        peer_id: sender_id.0,
+                        requester_id: requester_id.to_proto(),
                     },
-                )
-            });
-            if unshare {
+                )?;
+            }
+
+            if project.unshare {
                 self.peer.send(
                     project.host_connection_id,
                     proto::ProjectUnshared { project_id },
@@ -1633,6 +1646,7 @@ mod tests {
     use settings::Settings;
     use sqlx::types::time::OffsetDateTime;
     use std::{
+        cell::RefCell,
         env,
         ops::Deref,
         path::{Path, PathBuf},
@@ -2049,6 +2063,105 @@ mod tests {
         ));
     }
 
+    #[gpui::test(iterations = 10)]
+    async fn test_cancel_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 project_a_events = Rc::new(RefCell::new(Vec::new()));
+        let user_b = client_a
+            .user_store
+            .update(cx_a, |store, cx| {
+                store.fetch_user(client_b.user_id().unwrap(), cx)
+            })
+            .await
+            .unwrap();
+        project_a.update(cx_a, {
+            let project_a_events = project_a_events.clone();
+            move |_, cx| {
+                cx.subscribe(&cx.handle(), move |_, _, event, _| {
+                    project_a_events.borrow_mut().push(event.clone());
+                })
+                .detach();
+            }
+        });
+
+        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();
+        assert_eq!(
+            &*project_a_events.borrow(),
+            &[project::Event::ContactRequestedJoin(user_b.clone())]
+        );
+        project_a_events.borrow_mut().clear();
+
+        // Cancel the join request by leaving the project
+        client_b
+            .client
+            .send(proto::LeaveProject { project_id })
+            .unwrap();
+        drop(project_b);
+
+        deterministic.run_until_parked();
+        assert_eq!(
+            &*project_a_events.borrow(),
+            &[project::Event::ContactCancelledJoinRequest(user_b.clone())]
+        );
+    }
+
     #[gpui::test(iterations = 10)]
     async fn test_propagate_saves_and_fs_changes(
         cx_a: &mut TestAppContext,

crates/collab/src/rpc/store.rs 🔗

@@ -1,6 +1,6 @@
 use crate::db::{self, ChannelId, UserId};
 use anyhow::{anyhow, Result};
-use collections::{BTreeMap, HashMap, HashSet};
+use collections::{hash_map::Entry, BTreeMap, HashMap, HashSet};
 use rpc::{proto, ConnectionId, Receipt};
 use std::{collections::hash_map, path::PathBuf};
 use tracing::instrument;
@@ -56,9 +56,12 @@ pub struct RemovedConnectionState {
 }
 
 pub struct LeftProject {
-    pub connection_ids: Vec<ConnectionId>,
     pub host_user_id: UserId,
     pub host_connection_id: ConnectionId,
+    pub connection_ids: Vec<ConnectionId>,
+    pub remove_collaborator: bool,
+    pub cancel_request: Option<UserId>,
+    pub unshare: bool,
 }
 
 #[derive(Copy, Clone)]
@@ -503,24 +506,48 @@ impl Store {
         connection_id: ConnectionId,
         project_id: u64,
     ) -> Result<LeftProject> {
+        let user_id = self.user_id_for_connection(connection_id)?;
         let project = self
             .projects
             .get_mut(&project_id)
             .ok_or_else(|| anyhow!("no such project"))?;
-        let (replica_id, _) = project
-            .guests
-            .remove(&connection_id)
-            .ok_or_else(|| anyhow!("cannot leave a project before joining it"))?;
-        project.active_replica_ids.remove(&replica_id);
+
+        // If the connection leaving the project is a collaborator, remove it.
+        let remove_collaborator =
+            if let Some((replica_id, _)) = project.guests.remove(&connection_id) {
+                project.active_replica_ids.remove(&replica_id);
+                true
+            } else {
+                false
+            };
+
+        // If the connection leaving the project has a pending request, remove it.
+        // If that user has no other pending requests on other connections, indicate that the request should be cancelled.
+        let mut cancel_request = None;
+        if let Entry::Occupied(mut entry) = project.join_requests.entry(user_id) {
+            entry
+                .get_mut()
+                .retain(|receipt| receipt.sender_id != connection_id);
+            if entry.get().is_empty() {
+                entry.remove();
+                cancel_request = Some(user_id);
+            }
+        }
 
         if let Some(connection) = self.connections.get_mut(&connection_id) {
             connection.projects.remove(&project_id);
         }
 
+        let connection_ids = project.connection_ids();
+        let unshare = connection_ids.len() <= 1 && project.join_requests.is_empty();
+
         Ok(LeftProject {
-            connection_ids: project.connection_ids(),
             host_connection_id: project.host_connection_id,
             host_user_id: project.host_user_id,
+            connection_ids,
+            cancel_request,
+            unshare,
+            remove_collaborator,
         })
     }
 

crates/gpui/src/app.rs 🔗

@@ -3231,6 +3231,21 @@ impl<'a, T: View> ViewContext<'a, T> {
         self.app.add_option_view(self.window_id, build_view)
     }
 
+    pub fn replace_root_view<V, F>(&mut self, build_root_view: F) -> ViewHandle<V>
+    where
+        V: View,
+        F: FnOnce(&mut ViewContext<V>) -> V,
+    {
+        let window_id = self.window_id;
+        self.update(|this| {
+            let root_view = this.add_view(window_id, build_root_view);
+            let window = this.cx.windows.get_mut(&window_id).unwrap();
+            window.root_view = root_view.clone().into();
+            window.focused_view_id = Some(root_view.id());
+            root_view
+        })
+    }
+
     pub fn subscribe<E, H, F>(&mut self, handle: &H, mut callback: F) -> Subscription
     where
         E: Entity,

crates/project/src/project.rs 🔗

@@ -136,7 +136,7 @@ pub struct Collaborator {
     pub replica_id: ReplicaId,
 }
 
-#[derive(Clone, Debug, PartialEq)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub enum Event {
     ActiveEntryChanged(Option<ProjectEntryId>),
     WorktreeRemoved(WorktreeId),
@@ -147,6 +147,7 @@ pub enum Event {
     RemoteIdChanged(Option<u64>),
     CollaboratorLeft(PeerId),
     ContactRequestedJoin(Arc<User>),
+    ContactCancelledJoinRequest(Arc<User>),
 }
 
 #[derive(Serialize)]
@@ -269,6 +270,7 @@ impl Project {
         client.add_model_message_handler(Self::handle_start_language_server);
         client.add_model_message_handler(Self::handle_update_language_server);
         client.add_model_message_handler(Self::handle_remove_collaborator);
+        client.add_model_message_handler(Self::handle_join_project_request_cancelled);
         client.add_model_message_handler(Self::handle_register_worktree);
         client.add_model_message_handler(Self::handle_unregister_worktree);
         client.add_model_message_handler(Self::handle_unregister_project);
@@ -3879,6 +3881,27 @@ impl Project {
         })
     }
 
+    async fn handle_join_project_request_cancelled(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::JoinProjectRequestCancelled>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<()> {
+        let user = this
+            .update(&mut cx, |this, cx| {
+                this.user_store.update(cx, |user_store, cx| {
+                    user_store.fetch_user(envelope.payload.requester_id, cx)
+                })
+            })
+            .await?;
+
+        this.update(&mut cx, |_, cx| {
+            cx.emit(Event::ContactCancelledJoinRequest(user));
+        });
+
+        Ok(())
+    }
+
     async fn handle_register_worktree(
         this: ModelHandle<Self>,
         envelope: TypedEnvelope<proto::RegisterWorktree>,

crates/rpc/proto/zed.proto 🔗

@@ -16,88 +16,89 @@ message Envelope {
         UnregisterProject unregister_project = 10;
         RequestJoinProject request_join_project = 11;
         RespondToJoinProjectRequest respond_to_join_project_request = 12;
-        JoinProject join_project = 13;
-        JoinProjectResponse join_project_response = 14;
-        LeaveProject leave_project = 15;
-        AddProjectCollaborator add_project_collaborator = 16;
-        RemoveProjectCollaborator remove_project_collaborator = 17;
-        ProjectUnshared project_unshared = 18;
-
-        GetDefinition get_definition = 19;
-        GetDefinitionResponse get_definition_response = 20;
-        GetReferences get_references = 21;
-        GetReferencesResponse get_references_response = 22;
-        GetDocumentHighlights get_document_highlights = 23;
-        GetDocumentHighlightsResponse get_document_highlights_response = 24;
-        GetProjectSymbols get_project_symbols = 25;
-        GetProjectSymbolsResponse get_project_symbols_response = 26;
-        OpenBufferForSymbol open_buffer_for_symbol = 27;
-        OpenBufferForSymbolResponse open_buffer_for_symbol_response = 28;
-
-        RegisterWorktree register_worktree = 29;
-        UnregisterWorktree unregister_worktree = 30;
-        UpdateWorktree update_worktree = 31;
-
-        CreateProjectEntry create_project_entry = 32;
-        RenameProjectEntry rename_project_entry = 33;
-        DeleteProjectEntry delete_project_entry = 34;
-        ProjectEntryResponse project_entry_response = 35;
-
-        UpdateDiagnosticSummary update_diagnostic_summary = 36;
-        StartLanguageServer start_language_server = 37;
-        UpdateLanguageServer update_language_server = 38;
-
-        OpenBufferById open_buffer_by_id = 39;
-        OpenBufferByPath open_buffer_by_path = 40;
-        OpenBufferResponse open_buffer_response = 41;
-        UpdateBuffer update_buffer = 42;
-        UpdateBufferFile update_buffer_file = 43;
-        SaveBuffer save_buffer = 44;
-        BufferSaved buffer_saved = 45;
-        BufferReloaded buffer_reloaded = 46;
-        ReloadBuffers reload_buffers = 47;
-        ReloadBuffersResponse reload_buffers_response = 48;
-        FormatBuffers format_buffers = 49;
-        FormatBuffersResponse format_buffers_response = 50;
-        GetCompletions get_completions = 51;
-        GetCompletionsResponse get_completions_response = 52;
-        ApplyCompletionAdditionalEdits apply_completion_additional_edits = 53;
-        ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 54;
-        GetCodeActions get_code_actions = 55;
-        GetCodeActionsResponse get_code_actions_response = 56;
-        ApplyCodeAction apply_code_action = 57;
-        ApplyCodeActionResponse apply_code_action_response = 58;
-        PrepareRename prepare_rename = 59;
-        PrepareRenameResponse prepare_rename_response = 60;
-        PerformRename perform_rename = 61;
-        PerformRenameResponse perform_rename_response = 62;
-        SearchProject search_project = 63;
-        SearchProjectResponse search_project_response = 64;
-
-        GetChannels get_channels = 65;
-        GetChannelsResponse get_channels_response = 66;
-        JoinChannel join_channel = 67;
-        JoinChannelResponse join_channel_response = 68;
-        LeaveChannel leave_channel = 69;
-        SendChannelMessage send_channel_message = 70;
-        SendChannelMessageResponse send_channel_message_response = 71;
-        ChannelMessageSent channel_message_sent = 72;
-        GetChannelMessages get_channel_messages = 73;
-        GetChannelMessagesResponse get_channel_messages_response = 74;
-
-        UpdateContacts update_contacts = 75;
-
-        GetUsers get_users = 76;
-        FuzzySearchUsers fuzzy_search_users = 77;
-        UsersResponse users_response = 78;
-        RequestContact request_contact = 79;
-        RespondToContactRequest respond_to_contact_request = 80;
-        RemoveContact remove_contact = 81;
-
-        Follow follow = 82;
-        FollowResponse follow_response = 83;
-        UpdateFollowers update_followers = 84;
-        Unfollow unfollow = 85;
+        JoinProjectRequestCancelled join_project_request_cancelled = 13;
+        JoinProject join_project = 14;
+        JoinProjectResponse join_project_response = 15;
+        LeaveProject leave_project = 16;
+        AddProjectCollaborator add_project_collaborator = 17;
+        RemoveProjectCollaborator remove_project_collaborator = 18;
+        ProjectUnshared project_unshared = 19;
+
+        GetDefinition get_definition = 20;
+        GetDefinitionResponse get_definition_response = 21;
+        GetReferences get_references = 22;
+        GetReferencesResponse get_references_response = 23;
+        GetDocumentHighlights get_document_highlights = 24;
+        GetDocumentHighlightsResponse get_document_highlights_response = 25;
+        GetProjectSymbols get_project_symbols = 26;
+        GetProjectSymbolsResponse get_project_symbols_response = 27;
+        OpenBufferForSymbol open_buffer_for_symbol = 28;
+        OpenBufferForSymbolResponse open_buffer_for_symbol_response = 29;
+
+        RegisterWorktree register_worktree = 30;
+        UnregisterWorktree unregister_worktree = 31;
+        UpdateWorktree update_worktree = 32;
+
+        CreateProjectEntry create_project_entry = 33;
+        RenameProjectEntry rename_project_entry = 34;
+        DeleteProjectEntry delete_project_entry = 35;
+        ProjectEntryResponse project_entry_response = 36;
+
+        UpdateDiagnosticSummary update_diagnostic_summary = 37;
+        StartLanguageServer start_language_server = 38;
+        UpdateLanguageServer update_language_server = 39;
+
+        OpenBufferById open_buffer_by_id = 40;
+        OpenBufferByPath open_buffer_by_path = 41;
+        OpenBufferResponse open_buffer_response = 42;
+        UpdateBuffer update_buffer = 43;
+        UpdateBufferFile update_buffer_file = 44;
+        SaveBuffer save_buffer = 45;
+        BufferSaved buffer_saved = 46;
+        BufferReloaded buffer_reloaded = 47;
+        ReloadBuffers reload_buffers = 48;
+        ReloadBuffersResponse reload_buffers_response = 49;
+        FormatBuffers format_buffers = 50;
+        FormatBuffersResponse format_buffers_response = 51;
+        GetCompletions get_completions = 52;
+        GetCompletionsResponse get_completions_response = 53;
+        ApplyCompletionAdditionalEdits apply_completion_additional_edits = 54;
+        ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 55;
+        GetCodeActions get_code_actions = 56;
+        GetCodeActionsResponse get_code_actions_response = 57;
+        ApplyCodeAction apply_code_action = 58;
+        ApplyCodeActionResponse apply_code_action_response = 59;
+        PrepareRename prepare_rename = 60;
+        PrepareRenameResponse prepare_rename_response = 61;
+        PerformRename perform_rename = 62;
+        PerformRenameResponse perform_rename_response = 63;
+        SearchProject search_project = 64;
+        SearchProjectResponse search_project_response = 65;
+
+        GetChannels get_channels = 66;
+        GetChannelsResponse get_channels_response = 67;
+        JoinChannel join_channel = 68;
+        JoinChannelResponse join_channel_response = 69;
+        LeaveChannel leave_channel = 70;
+        SendChannelMessage send_channel_message = 71;
+        SendChannelMessageResponse send_channel_message_response = 72;
+        ChannelMessageSent channel_message_sent = 73;
+        GetChannelMessages get_channel_messages = 74;
+        GetChannelMessagesResponse get_channel_messages_response = 75;
+
+        UpdateContacts update_contacts = 76;
+
+        GetUsers get_users = 77;
+        FuzzySearchUsers fuzzy_search_users = 78;
+        UsersResponse users_response = 79;
+        RequestContact request_contact = 80;
+        RespondToContactRequest respond_to_contact_request = 81;
+        RemoveContact remove_contact = 82;
+
+        Follow follow = 83;
+        FollowResponse follow_response = 84;
+        UpdateFollowers update_followers = 85;
+        Unfollow unfollow = 86;
     }
 }
 
@@ -136,6 +137,11 @@ message RespondToJoinProjectRequest {
     bool allow = 3;
 }
 
+message JoinProjectRequestCancelled {
+    uint64 requester_id = 1;
+    uint64 project_id = 2;
+}
+
 message JoinProject {
     uint64 project_id = 1;
 }

crates/rpc/src/proto.rs 🔗

@@ -114,6 +114,7 @@ messages!(
     (JoinChannelResponse, Foreground),
     (JoinProject, Foreground),
     (JoinProjectResponse, Foreground),
+    (JoinProjectRequestCancelled, Foreground),
     (LeaveChannel, Foreground),
     (LeaveProject, Foreground),
     (OpenBufferById, Background),
@@ -220,6 +221,7 @@ entity_messages!(
     GetReferences,
     GetProjectSymbols,
     JoinProject,
+    JoinProjectRequestCancelled,
     LeaveProject,
     OpenBufferById,
     OpenBufferByPath,

crates/rpc/src/rpc.rs 🔗

@@ -6,4 +6,4 @@ pub use conn::Connection;
 pub use peer::*;
 mod macros;
 
-pub const PROTOCOL_VERSION: u32 = 17;
+pub const PROTOCOL_VERSION: u32 = 18;

crates/workspace/src/waiting_room.rs 🔗

@@ -0,0 +1,159 @@
+use crate::{
+    sidebar::{Side, ToggleSidebarItem},
+    AppState,
+};
+use anyhow::Result;
+use client::Contact;
+use gpui::{elements::*, ElementBox, Entity, ImageData, RenderContext, Task, View, ViewContext};
+use project::Project;
+use settings::Settings;
+use std::sync::Arc;
+
+pub struct WaitingRoom {
+    avatar: Option<Arc<ImageData>>,
+    message: String,
+    joined: bool,
+    _join_task: Task<Result<()>>,
+}
+
+impl Entity for WaitingRoom {
+    type Event = ();
+
+    fn release(&mut self, _: &mut gpui::MutableAppContext) {
+        if !self.joined {
+            // TODO: Cancel the join request
+        }
+    }
+}
+
+impl View for WaitingRoom {
+    fn ui_name() -> &'static str {
+        "WaitingRoom"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        let theme = &cx.global::<Settings>().theme.workspace;
+
+        Flex::column()
+            .with_children(self.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.joining_project_avatar)
+                    .aligned()
+                    .boxed()
+            }))
+            .with_child(
+                Text::new(
+                    self.message.clone(),
+                    theme.joining_project_message.text.clone(),
+                )
+                .contained()
+                .with_style(theme.joining_project_message.container)
+                .aligned()
+                .boxed(),
+            )
+            .aligned()
+            .contained()
+            .with_background_color(theme.background)
+            .boxed()
+    }
+}
+
+impl WaitingRoom {
+    pub fn new(
+        contact: Arc<Contact>,
+        project_index: usize,
+        app_state: Arc<AppState>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let project_id = contact.projects[project_index].id;
+
+        let _join_task = cx.spawn_weak({
+            let contact = contact.clone();
+            |this, mut cx| async move {
+                let project = Project::remote(
+                    project_id,
+                    app_state.client.clone(),
+                    app_state.user_store.clone(),
+                    app_state.languages.clone(),
+                    app_state.fs.clone(),
+                    &mut cx,
+                )
+                .await;
+
+                if let Some(this) = this.upgrade(&cx) {
+                    this.update(&mut cx, |this, cx| match project {
+                        Ok(project) => {
+                            this.joined = true;
+                            cx.replace_root_view(|cx| {
+                                let mut workspace =
+                                    (app_state.build_workspace)(project, &app_state, cx);
+                                workspace.toggle_sidebar_item(
+                                    &ToggleSidebarItem {
+                                        side: Side::Left,
+                                        item_index: 0,
+                                    },
+                                    cx,
+                                );
+                                workspace
+                            });
+                        }
+                        Err(error @ _) => {
+                            let login = &contact.user.github_login;
+                            let message = match error {
+                                project::JoinProjectError::HostDeclined => {
+                                    format!("@{} declined your request.", login)
+                                }
+                                project::JoinProjectError::HostClosedProject => {
+                                    format!(
+                                        "@{} closed their copy of {}.",
+                                        login,
+                                        humanize_list(
+                                            &contact.projects[project_index].worktree_root_names
+                                        )
+                                    )
+                                }
+                                project::JoinProjectError::HostWentOffline => {
+                                    format!("@{} went offline.", login)
+                                }
+                                project::JoinProjectError::Other(error) => {
+                                    log::error!("error joining project: {}", error);
+                                    "An error occurred.".to_string()
+                                }
+                            };
+                            this.message = message;
+                            cx.notify();
+                        }
+                    })
+                }
+
+                Ok(())
+            }
+        });
+
+        Self {
+            avatar: contact.user.avatar.clone(),
+            message: format!(
+                "Asking to join @{}'s copy of {}...",
+                contact.user.github_login,
+                humanize_list(&contact.projects[project_index].worktree_root_names)
+            ),
+            joined: false,
+            _join_task,
+        }
+    }
+}
+
+fn humanize_list<'a>(items: impl IntoIterator<Item = &'a String>) -> String {
+    let mut list = String::new();
+    let mut items = items.into_iter().enumerate().peekable();
+    while let Some((ix, item)) = items.next() {
+        if ix > 0 {
+            list.push_str(", ");
+        }
+        if items.peek().is_none() {
+            list.push_str("and ");
+        }
+        list.push_str(item);
+    }
+    list
+}

crates/workspace/src/workspace.rs 🔗

@@ -5,6 +5,7 @@ pub mod pane_group;
 pub mod sidebar;
 mod status_bar;
 mod toolbar;
+mod waiting_room;
 
 use anyhow::{anyhow, Context, Result};
 use client::{
@@ -50,6 +51,7 @@ use std::{
 use theme::{Theme, ThemeRegistry};
 pub use toolbar::{ToolbarItemLocation, ToolbarItemView};
 use util::ResultExt;
+use waiting_room::WaitingRoom;
 
 type ProjectItemBuilders = HashMap<
     TypeId,
@@ -124,8 +126,7 @@ pub fn init(client: &Arc<Client>, cx: &mut MutableAppContext) {
             action.project_index,
             &action.app_state,
             cx,
-        )
-        .detach();
+        );
     });
 
     cx.add_async_action(Workspace::toggle_follow);
@@ -2280,119 +2281,21 @@ pub fn join_project(
     project_index: usize,
     app_state: &Arc<AppState>,
     cx: &mut MutableAppContext,
-) -> Task<Result<ViewHandle<Workspace>>> {
+) {
     let project_id = contact.projects[project_index].id;
 
-    struct JoiningNotice {
-        avatar: Option<Arc<ImageData>>,
-        message: String,
-    }
-
-    impl Entity for JoiningNotice {
-        type Event = ();
-    }
-
-    impl View for JoiningNotice {
-        fn ui_name() -> &'static str {
-            "JoiningProjectWindow"
-        }
-
-        fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-            let theme = &cx.global::<Settings>().theme.workspace;
-
-            Flex::column()
-                .with_children(self.avatar.clone().map(|avatar| {
-                    Image::new(avatar)
-                        .with_style(theme.joining_project_avatar)
-                        .aligned()
-                        .boxed()
-                }))
-                .with_child(
-                    Text::new(
-                        self.message.clone(),
-                        theme.joining_project_message.text.clone(),
-                    )
-                    .contained()
-                    .with_style(theme.joining_project_message.container)
-                    .aligned()
-                    .boxed(),
-                )
-                .aligned()
-                .contained()
-                .with_background_color(theme.background)
-                .boxed()
-        }
-    }
-
     for window_id in cx.window_ids().collect::<Vec<_>>() {
         if let Some(workspace) = cx.root_view::<Workspace>(window_id) {
             if workspace.read(cx).project().read(cx).remote_id() == Some(project_id) {
-                return Task::ready(Ok(workspace));
+                cx.activate_window(window_id);
+                return;
             }
         }
     }
 
-    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 {
-                avatar: contact.user.avatar.clone(),
-                message: format!(
-                    "Asking to join @{}'s copy of {}...",
-                    contact.user.github_login,
-                    humanize_list(&contact.projects[project_index].worktree_root_names)
-                ),
-            })
-        });
-        let project = Project::remote(
-            project_id,
-            app_state.client.clone(),
-            app_state.user_store.clone(),
-            app_state.languages.clone(),
-            app_state.fs.clone(),
-            &mut cx,
-        )
-        .await;
-
-        cx.update(|cx| match project {
-            Ok(project) => Ok(cx.replace_root_view(window, |cx| {
-                let mut workspace = (app_state.build_workspace)(project, &app_state, cx);
-                workspace.toggle_sidebar_item(
-                    &ToggleSidebarItem {
-                        side: Side::Left,
-                        item_index: 0,
-                    },
-                    cx,
-                );
-                workspace
-            })),
-            Err(error @ _) => {
-                let login = &contact.user.github_login;
-                let message = match error {
-                    project::JoinProjectError::HostDeclined => {
-                        format!("@{} declined your request.", login)
-                    }
-                    project::JoinProjectError::HostClosedProject => {
-                        format!(
-                            "@{} closed their copy of {}.",
-                            login,
-                            humanize_list(&contact.projects[project_index].worktree_root_names)
-                        )
-                    }
-                    project::JoinProjectError::HostWentOffline => {
-                        format!("@{} went offline.", login)
-                    }
-                    project::JoinProjectError::Other(_) => "An error occurred.".to_string(),
-                };
-                joining_notice.update(cx, |notice, cx| {
-                    notice.message = message;
-                    cx.notify();
-                });
-
-                Err(error)?
-            }
-        })
-    })
+    cx.add_window((app_state.build_window_options)(), |cx| {
+        WaitingRoom::new(contact, project_index, app_state.clone(), cx)
+    });
 }
 
 fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
@@ -2408,18 +2311,3 @@ fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
     });
     cx.dispatch_action(window_id, vec![workspace.id()], &OpenNew(app_state.clone()));
 }
-
-fn humanize_list<'a>(items: impl IntoIterator<Item = &'a String>) -> String {
-    let mut list = String::new();
-    let mut items = items.into_iter().enumerate().peekable();
-    while let Some((ix, item)) = items.next() {
-        if ix > 0 {
-            list.push_str(", ");
-        }
-        if items.peek().is_none() {
-            list.push_str("and ");
-        }
-        list.push_str(item);
-    }
-    list
-}