Start work on requesting to join projects

Max Brunsfeld and Nathan Sobo created

Co-authored-by: Nathan Sobo <nathan@zed.dev>

Change summary

assets/themes/cave-dark.json                           |  36 -
assets/themes/cave-light.json                          |  36 -
assets/themes/dark.json                                |  36 -
assets/themes/light.json                               |  36 -
assets/themes/solarized-dark.json                      |  36 -
assets/themes/solarized-light.json                     |  36 -
assets/themes/sulphurpool-dark.json                    |  36 -
assets/themes/sulphurpool-light.json                   |  36 -
crates/client/src/client.rs                            |   6 
crates/client/src/user.rs                              |   8 
crates/collab/src/rpc.rs                               | 218 +++++---
crates/collab/src/rpc/store.rs                         | 310 ++++-------
crates/contacts_panel/Cargo.toml                       |   1 
crates/contacts_panel/src/contact_notification.rs      | 177 +-----
crates/contacts_panel/src/contacts_panel.rs            |  45 +
crates/contacts_panel/src/join_project_notification.rs |  71 ++
crates/contacts_panel/src/notifications.rs             | 112 ++++
crates/gpui/src/presenter.rs                           |   8 
crates/project/src/project.rs                          | 167 +++--
crates/rpc/proto/zed.proto                             |  33 
crates/rpc/src/proto.rs                                |   8 
crates/theme/src/theme.rs                              |   3 
crates/workspace/src/workspace.rs                      |  49 -
crates/zed/src/zed.rs                                  |  23 
styles/src/styleTree/contactsPanel.ts                  |  16 
25 files changed, 660 insertions(+), 883 deletions(-)

Detailed changes

assets/themes/cave-dark.json 🔗

@@ -1359,41 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "shared_project_row": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#8b8792",
-        "size": 14,
-        "margin": {
-          "left": 8,
-          "right": 6
-        }
-      },
-      "guests": {
-        "margin": {
-          "left": 8,
-          "right": 8
-        }
-      },
-      "padding": {
-        "left": 12,
-        "right": 12
-      },
-      "background": "#26232a",
-      "hover": {
-        "background": "#5852603d"
-      },
-      "active": {
-        "background": "#5852605c"
-      }
-    },
-    "unshared_project_row": {
+    "project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {

assets/themes/cave-light.json 🔗

@@ -1359,41 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "shared_project_row": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#585260",
-        "size": 14,
-        "margin": {
-          "left": 8,
-          "right": 6
-        }
-      },
-      "guests": {
-        "margin": {
-          "left": 8,
-          "right": 8
-        }
-      },
-      "padding": {
-        "left": 12,
-        "right": 12
-      },
-      "background": "#e2dfe7",
-      "hover": {
-        "background": "#8b87921f"
-      },
-      "active": {
-        "background": "#8b87922e"
-      }
-    },
-    "unshared_project_row": {
+    "project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {

assets/themes/dark.json 🔗

@@ -1359,41 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "shared_project_row": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#9c9c9c",
-        "size": 14,
-        "margin": {
-          "left": 8,
-          "right": 6
-        }
-      },
-      "guests": {
-        "margin": {
-          "left": 8,
-          "right": 8
-        }
-      },
-      "padding": {
-        "left": 12,
-        "right": 12
-      },
-      "background": "#1c1c1c",
-      "hover": {
-        "background": "#232323"
-      },
-      "active": {
-        "background": "#2b2b2b"
-      }
-    },
-    "unshared_project_row": {
+    "project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {

assets/themes/light.json 🔗

@@ -1359,41 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "shared_project_row": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#474747",
-        "size": 14,
-        "margin": {
-          "left": 8,
-          "right": 6
-        }
-      },
-      "guests": {
-        "margin": {
-          "left": 8,
-          "right": 8
-        }
-      },
-      "padding": {
-        "left": 12,
-        "right": 12
-      },
-      "background": "#f8f8f8",
-      "hover": {
-        "background": "#eaeaea"
-      },
-      "active": {
-        "background": "#e3e3e3"
-      }
-    },
-    "unshared_project_row": {
+    "project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {

assets/themes/solarized-dark.json 🔗

@@ -1359,41 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "shared_project_row": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#93a1a1",
-        "size": 14,
-        "margin": {
-          "left": 8,
-          "right": 6
-        }
-      },
-      "guests": {
-        "margin": {
-          "left": 8,
-          "right": 8
-        }
-      },
-      "padding": {
-        "left": 12,
-        "right": 12
-      },
-      "background": "#073642",
-      "hover": {
-        "background": "#586e753d"
-      },
-      "active": {
-        "background": "#586e755c"
-      }
-    },
-    "unshared_project_row": {
+    "project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {

assets/themes/solarized-light.json 🔗

@@ -1359,41 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "shared_project_row": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#586e75",
-        "size": 14,
-        "margin": {
-          "left": 8,
-          "right": 6
-        }
-      },
-      "guests": {
-        "margin": {
-          "left": 8,
-          "right": 8
-        }
-      },
-      "padding": {
-        "left": 12,
-        "right": 12
-      },
-      "background": "#eee8d5",
-      "hover": {
-        "background": "#93a1a11f"
-      },
-      "active": {
-        "background": "#93a1a12e"
-      }
-    },
-    "unshared_project_row": {
+    "project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {

assets/themes/sulphurpool-dark.json 🔗

@@ -1359,41 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "shared_project_row": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#979db4",
-        "size": 14,
-        "margin": {
-          "left": 8,
-          "right": 6
-        }
-      },
-      "guests": {
-        "margin": {
-          "left": 8,
-          "right": 8
-        }
-      },
-      "padding": {
-        "left": 12,
-        "right": 12
-      },
-      "background": "#293256",
-      "hover": {
-        "background": "#5e66873d"
-      },
-      "active": {
-        "background": "#5e66875c"
-      }
-    },
-    "unshared_project_row": {
+    "project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {

assets/themes/sulphurpool-light.json 🔗

@@ -1359,41 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "shared_project_row": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#5e6687",
-        "size": 14,
-        "margin": {
-          "left": 8,
-          "right": 6
-        }
-      },
-      "guests": {
-        "margin": {
-          "left": 8,
-          "right": 8
-        }
-      },
-      "padding": {
-        "left": 12,
-        "right": 12
-      },
-      "background": "#dfe2f1",
-      "hover": {
-        "background": "#979db41f"
-      },
-      "active": {
-        "background": "#979db42e"
-      }
-    },
-    "unshared_project_row": {
+    "project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {

crates/client/src/client.rs 🔗

@@ -1106,7 +1106,7 @@ mod tests {
         let (done_tx1, mut done_rx1) = smol::channel::unbounded();
         let (done_tx2, mut done_rx2) = smol::channel::unbounded();
         client.add_model_message_handler(
-            move |model: ModelHandle<Model>, _: TypedEnvelope<proto::UnshareProject>, _, cx| {
+            move |model: ModelHandle<Model>, _: TypedEnvelope<proto::JoinProject>, _, cx| {
                 match model.read_with(&cx, |model, _| model.id) {
                     1 => done_tx1.try_send(()).unwrap(),
                     2 => done_tx2.try_send(()).unwrap(),
@@ -1135,8 +1135,8 @@ mod tests {
         let subscription3 = model3.update(cx, |_, cx| client.add_model_for_remote_entity(3, cx));
         drop(subscription3);
 
-        server.send(proto::UnshareProject { project_id: 1 });
-        server.send(proto::UnshareProject { project_id: 2 });
+        server.send(proto::JoinProject { project_id: 1 });
+        server.send(proto::JoinProject { project_id: 2 });
         done_rx1.next().await.unwrap();
         done_rx2.next().await.unwrap();
     }

crates/client/src/user.rs 🔗

@@ -17,6 +17,12 @@ pub struct User {
     pub avatar: Option<Arc<ImageData>>,
 }
 
+impl PartialEq for User {
+    fn eq(&self, other: &Self) -> bool {
+        self.id == other.id && self.github_login == other.github_login
+    }
+}
+
 #[derive(Debug)]
 pub struct Contact {
     pub user: Arc<User>,
@@ -27,7 +33,6 @@ pub struct Contact {
 #[derive(Debug)]
 pub struct ProjectMetadata {
     pub id: u64,
-    pub is_shared: bool,
     pub worktree_root_names: Vec<String>,
     pub guests: Vec<Arc<User>>,
 }
@@ -560,7 +565,6 @@ impl Contact {
             projects.push(ProjectMetadata {
                 id: project.id,
                 worktree_root_names: project.worktree_root_names.clone(),
-                is_shared: project.is_shared,
                 guests,
             });
         }

crates/collab/src/rpc.rs 🔗

@@ -66,6 +66,11 @@ impl<R: RequestMessage> Response<R> {
         self.server.peer.respond(self.receipt, payload)?;
         Ok(())
     }
+
+    fn into_receipt(self) -> Receipt<R> {
+        self.responded.store(true, SeqCst);
+        self.receipt
+    }
 }
 
 pub struct Server {
@@ -115,10 +120,9 @@ impl Server {
             .add_request_handler(Server::ping)
             .add_request_handler(Server::register_project)
             .add_message_handler(Server::unregister_project)
-            .add_request_handler(Server::share_project)
-            .add_message_handler(Server::unshare_project)
             .add_request_handler(Server::join_project)
             .add_message_handler(Server::leave_project)
+            .add_message_handler(Server::respond_to_join_project_request)
             .add_request_handler(Server::register_worktree)
             .add_message_handler(Server::unregister_worktree)
             .add_request_handler(Server::update_worktree)
@@ -336,12 +340,10 @@ impl Server {
         let removed_connection = self.store_mut().await.remove_connection(connection_id)?;
 
         for (project_id, project) in removed_connection.hosted_projects {
-            if let Some(share) = project.share {
-                broadcast(connection_id, share.guests.keys().copied(), |conn_id| {
-                    self.peer
-                        .send(conn_id, proto::UnshareProject { project_id })
-                });
-            }
+            broadcast(connection_id, project.guests.keys().copied(), |conn_id| {
+                self.peer
+                    .send(conn_id, proto::UnregisterProject { project_id })
+            });
         }
 
         for (project_id, peer_ids) in removed_connection.guest_project_ids {
@@ -402,20 +404,20 @@ impl Server {
         Ok(())
     }
 
-    async fn share_project(
-        self: Arc<Server>,
-        request: TypedEnvelope<proto::ShareProject>,
-        response: Response<proto::ShareProject>,
-    ) -> Result<()> {
-        let user_id = {
-            let mut state = self.store_mut().await;
-            state.share_project(request.payload.project_id, request.sender_id)?;
-            state.user_id_for_connection(request.sender_id)?
-        };
-        self.update_user_contacts(user_id).await?;
-        response.send(proto::Ack {})?;
-        Ok(())
-    }
+    // async fn share_project(
+    //     self: Arc<Server>,
+    //     request: TypedEnvelope<proto::ShareProject>,
+    //     response: Response<proto::ShareProject>,
+    // ) -> Result<()> {
+    //     let user_id = {
+    //         let mut state = self.store_mut().await;
+    //         state.share_project(request.payload.project_id, request.sender_id)?;
+    //         state.user_id_for_connection(request.sender_id)?
+    //     };
+    //     self.update_user_contacts(user_id).await?;
+    //     response.send(proto::Ack {})?;
+    //     Ok(())
+    // }
 
     async fn update_user_contacts(self: &Arc<Server>, user_id: UserId) -> Result<()> {
         let contacts = self.app_state.db.get_contacts(user_id).await?;
@@ -447,24 +449,6 @@ impl Server {
         Ok(())
     }
 
-    async fn unshare_project(
-        self: Arc<Server>,
-        request: TypedEnvelope<proto::UnshareProject>,
-    ) -> Result<()> {
-        let project_id = request.payload.project_id;
-        let project;
-        {
-            let mut state = self.store_mut().await;
-            project = state.unshare_project(project_id, request.sender_id)?;
-            broadcast(request.sender_id, project.connection_ids, |conn_id| {
-                self.peer
-                    .send(conn_id, proto::UnshareProject { project_id })
-            });
-        }
-        self.update_user_contacts(project.host_user_id).await?;
-        Ok(())
-    }
-
     async fn join_project(
         self: Arc<Server>,
         request: TypedEnvelope<proto::JoinProject>,
@@ -473,9 +457,12 @@ impl Server {
         let project_id = request.payload.project_id;
         let host_user_id;
         let guest_user_id;
+        let host_connection_id;
         {
             let state = self.store().await;
-            host_user_id = state.project(project_id)?.host_user_id;
+            let project = state.project(project_id)?;
+            host_user_id = project.host_user_id;
+            host_connection_id = project.host_connection_id;
             guest_user_id = state.user_id_for_connection(request.sender_id)?;
         };
 
@@ -488,22 +475,71 @@ impl Server {
             return Err(anyhow!("no such project"))?;
         }
 
+        self.store_mut().await.request_join_project(
+            guest_user_id,
+            project_id,
+            response.into_receipt(),
+        )?;
+        self.peer.send(
+            host_connection_id,
+            proto::RequestJoinProject {
+                project_id,
+                requester_id: guest_user_id.to_proto(),
+            },
+        )?;
+        Ok(())
+    }
+
+    async fn respond_to_join_project_request(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::RespondToJoinProjectRequest>,
+    ) -> Result<()> {
+        let host_user_id;
+
         {
-            let state = &mut *self.store_mut().await;
-            let joined = state.join_project(request.sender_id, guest_user_id, project_id)?;
-            let share = joined.project.share()?;
-            let peer_count = share.guests.len();
+            let mut state = self.store_mut().await;
+            let project_id = request.payload.project_id;
+            let project = state.project(project_id)?;
+            if project.host_connection_id != request.sender_id {
+                Err(anyhow!("no such connection"))?;
+            }
+
+            host_user_id = project.host_user_id;
+            let guest_user_id = UserId::from_proto(request.payload.requester_id);
+
+            if !request.payload.allow {
+                let receipts = state
+                    .deny_join_project_request(request.sender_id, guest_user_id, project_id)
+                    .ok_or_else(|| anyhow!("no such request"))?;
+                for receipt in receipts {
+                    self.peer.respond(
+                        receipt,
+                        proto::JoinProjectResponse {
+                            variant: Some(proto::join_project_response::Variant::Decline(
+                                proto::join_project_response::Decline {},
+                            )),
+                        },
+                    )?;
+                }
+                return Ok(());
+            }
+
+            let (receipts_with_replica_ids, project) = state
+                .accept_join_project_request(request.sender_id, guest_user_id, project_id)
+                .ok_or_else(|| anyhow!("no such request"))?;
+
+            let peer_count = project.guests.len();
             let mut collaborators = Vec::with_capacity(peer_count);
             collaborators.push(proto::Collaborator {
-                peer_id: joined.project.host_connection_id.0,
+                peer_id: project.host_connection_id.0,
                 replica_id: 0,
-                user_id: joined.project.host_user_id.to_proto(),
+                user_id: project.host_user_id.to_proto(),
             });
-            let worktrees = share
+            let worktrees = project
                 .worktrees
                 .iter()
                 .filter_map(|(id, shared_worktree)| {
-                    let worktree = joined.project.worktrees.get(&id)?;
+                    let worktree = project.worktrees.get(&id)?;
                     Some(proto::Worktree {
                         id: *id,
                         root_name: worktree.root_name.clone(),
@@ -517,8 +553,8 @@ impl Server {
                         scan_id: shared_worktree.scan_id,
                     })
                 })
-                .collect();
-            for (peer_conn_id, (peer_replica_id, peer_user_id)) in &share.guests {
+                .collect::<Vec<_>>();
+            for (peer_conn_id, (peer_replica_id, peer_user_id)) in &project.guests {
                 if *peer_conn_id != request.sender_id {
                     collaborators.push(proto::Collaborator {
                         peer_id: peer_conn_id.0,
@@ -527,30 +563,41 @@ impl Server {
                     });
                 }
             }
-            broadcast(
-                request.sender_id,
-                joined.project.connection_ids(),
-                |conn_id| {
-                    self.peer.send(
-                        conn_id,
-                        proto::AddProjectCollaborator {
-                            project_id,
-                            collaborator: Some(proto::Collaborator {
-                                peer_id: request.sender_id.0,
-                                replica_id: joined.replica_id as u32,
-                                user_id: guest_user_id.to_proto(),
-                            }),
-                        },
-                    )
-                },
-            );
-            response.send(proto::JoinProjectResponse {
-                worktrees,
-                replica_id: joined.replica_id as u32,
-                collaborators,
-                language_servers: joined.project.language_servers.clone(),
-            })?;
+            for conn_id in project.connection_ids() {
+                for (receipt, replica_id) in &receipts_with_replica_ids {
+                    if conn_id != receipt.sender_id {
+                        self.peer.send(
+                            conn_id,
+                            proto::AddProjectCollaborator {
+                                project_id,
+                                collaborator: Some(proto::Collaborator {
+                                    peer_id: receipt.sender_id.0,
+                                    replica_id: *replica_id as u32,
+                                    user_id: guest_user_id.to_proto(),
+                                }),
+                            },
+                        )?;
+                    }
+                }
+            }
+
+            for (receipt, replica_id) in receipts_with_replica_ids {
+                self.peer.respond(
+                    receipt,
+                    proto::JoinProjectResponse {
+                        variant: Some(proto::join_project_response::Variant::Accept(
+                            proto::join_project_response::Accept {
+                                worktrees: worktrees.clone(),
+                                replica_id: replica_id as u32,
+                                collaborators: collaborators.clone(),
+                                language_servers: project.language_servers.clone(),
+                            },
+                        )),
+                    },
+                )?;
+            }
         }
+
         self.update_user_contacts(host_user_id).await?;
         Ok(())
     }
@@ -599,6 +646,7 @@ impl Server {
                 Worktree {
                     root_name: request.payload.root_name.clone(),
                     visible: request.payload.visible,
+                    ..Default::default()
                 },
             )?;
 
@@ -2782,9 +2830,6 @@ mod tests {
                 let worktree = store
                     .project(project_id)
                     .unwrap()
-                    .share
-                    .as_ref()
-                    .unwrap()
                     .worktrees
                     .get(&worktree_id.to_proto())
                     .unwrap();
@@ -5055,7 +5100,7 @@ mod tests {
                 assert_eq!(
                     contacts(store),
                     [
-                        ("user_a", true, vec![("a", false, vec![])]),
+                        ("user_a", true, vec![("a", vec![])]),
                         ("user_b", true, vec![]),
                         ("user_c", true, vec![])
                     ]
@@ -5077,7 +5122,7 @@ mod tests {
                 assert_eq!(
                     contacts(store),
                     [
-                        ("user_a", true, vec![("a", true, vec![])]),
+                        ("user_a", true, vec![("a", vec![])]),
                         ("user_b", true, vec![]),
                         ("user_c", true, vec![])
                     ]
@@ -5093,7 +5138,7 @@ mod tests {
                 assert_eq!(
                     contacts(store),
                     [
-                        ("user_a", true, vec![("a", true, vec!["user_b"])]),
+                        ("user_a", true, vec![("a", vec!["user_b"])]),
                         ("user_b", true, vec![]),
                         ("user_c", true, vec![])
                     ]
@@ -5112,8 +5157,8 @@ mod tests {
                 assert_eq!(
                     contacts(store),
                     [
-                        ("user_a", true, vec![("a", true, vec!["user_b"])]),
-                        ("user_b", true, vec![("b", false, vec![])]),
+                        ("user_a", true, vec![("a", vec!["user_b"])]),
+                        ("user_b", true, vec![("b", vec![])]),
                         ("user_c", true, vec![])
                     ]
                 )
@@ -5135,7 +5180,7 @@ mod tests {
                     contacts(store),
                     [
                         ("user_a", true, vec![]),
-                        ("user_b", true, vec![("b", false, vec![])]),
+                        ("user_b", true, vec![("b", vec![])]),
                         ("user_c", true, vec![])
                     ]
                 )
@@ -5151,7 +5196,7 @@ mod tests {
                     contacts(store),
                     [
                         ("user_a", true, vec![]),
-                        ("user_b", true, vec![("b", false, vec![])]),
+                        ("user_b", true, vec![("b", vec![])]),
                         ("user_c", false, vec![])
                     ]
                 )
@@ -5174,14 +5219,14 @@ mod tests {
                     contacts(store),
                     [
                         ("user_a", true, vec![]),
-                        ("user_b", true, vec![("b", false, vec![])]),
+                        ("user_b", true, vec![("b", vec![])]),
                         ("user_c", true, vec![])
                     ]
                 )
             });
         }
 
-        fn contacts(user_store: &UserStore) -> Vec<(&str, bool, Vec<(&str, bool, Vec<&str>)>)> {
+        fn contacts(user_store: &UserStore) -> Vec<(&str, bool, Vec<(&str, Vec<&str>)>)> {
             user_store
                 .contacts()
                 .iter()
@@ -5192,7 +5237,6 @@ mod tests {
                         .map(|p| {
                             (
                                 p.worktree_root_names[0].as_str(),
-                                p.is_shared,
                                 p.guests.iter().map(|p| p.github_login.as_str()).collect(),
                             )
                         })

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

@@ -1,7 +1,7 @@
 use crate::db::{self, ChannelId, UserId};
 use anyhow::{anyhow, Result};
 use collections::{BTreeMap, HashMap, HashSet};
-use rpc::{proto, ConnectionId};
+use rpc::{proto, ConnectionId, Receipt};
 use std::{collections::hash_map, path::PathBuf};
 use tracing::instrument;
 
@@ -17,31 +17,24 @@ pub struct Store {
 struct ConnectionState {
     user_id: UserId,
     projects: HashSet<u64>,
+    requested_projects: HashSet<u64>,
     channels: HashSet<ChannelId>,
 }
 
 pub struct Project {
     pub host_connection_id: ConnectionId,
     pub host_user_id: UserId,
-    pub share: Option<ProjectShare>,
+    pub guests: HashMap<ConnectionId, (ReplicaId, UserId)>,
+    pub join_requests: HashMap<UserId, Vec<Receipt<proto::JoinProject>>>,
+    pub active_replica_ids: HashSet<ReplicaId>,
     pub worktrees: HashMap<u64, Worktree>,
     pub language_servers: Vec<proto::LanguageServer>,
 }
 
+#[derive(Default)]
 pub struct Worktree {
     pub root_name: String,
     pub visible: bool,
-}
-
-#[derive(Default)]
-pub struct ProjectShare {
-    pub guests: HashMap<ConnectionId, (ReplicaId, UserId)>,
-    pub active_replica_ids: HashSet<ReplicaId>,
-    pub worktrees: HashMap<u64, WorktreeShare>,
-}
-
-#[derive(Default)]
-pub struct WorktreeShare {
     pub entries: HashMap<u64, proto::Entry>,
     pub diagnostic_summaries: BTreeMap<PathBuf, proto::DiagnosticSummary>,
     pub scan_id: u64,
@@ -62,18 +55,6 @@ pub struct RemovedConnectionState {
     pub contact_ids: HashSet<UserId>,
 }
 
-pub struct JoinedProject<'a> {
-    pub replica_id: ReplicaId,
-    pub project: &'a Project,
-}
-
-pub struct SharedProject {}
-
-pub struct UnsharedProject {
-    pub connection_ids: Vec<ConnectionId>,
-    pub host_user_id: UserId,
-}
-
 pub struct LeftProject {
     pub connection_ids: Vec<ConnectionId>,
     pub host_user_id: UserId,
@@ -93,7 +74,7 @@ impl Store {
         let mut shared_projects = 0;
         for project in self.projects.values() {
             registered_projects += 1;
-            if project.share.is_some() {
+            if !project.guests.is_empty() {
                 shared_projects += 1;
             }
         }
@@ -112,6 +93,7 @@ impl Store {
             ConnectionState {
                 user_id,
                 projects: Default::default(),
+                requested_projects: Default::default(),
                 channels: Default::default(),
             },
         );
@@ -275,18 +257,15 @@ impl Store {
                 if project.host_user_id == user_id {
                     metadata.push(proto::ProjectMetadata {
                         id: project_id,
-                        is_shared: project.share.is_some(),
                         worktree_root_names: project
                             .worktrees
                             .values()
                             .map(|worktree| worktree.root_name.clone())
                             .collect(),
                         guests: project
-                            .share
-                            .iter()
-                            .flat_map(|share| {
-                                share.guests.values().map(|(_, user_id)| user_id.to_proto())
-                            })
+                            .guests
+                            .values()
+                            .map(|(_, user_id)| user_id.to_proto())
                             .collect(),
                     });
                 }
@@ -307,7 +286,9 @@ impl Store {
             Project {
                 host_connection_id,
                 host_user_id,
-                share: None,
+                guests: Default::default(),
+                join_requests: Default::default(),
+                active_replica_ids: Default::default(),
                 worktrees: Default::default(),
                 language_servers: Default::default(),
             },
@@ -332,10 +313,6 @@ impl Store {
             .ok_or_else(|| anyhow!("no such project"))?;
         if project.host_connection_id == connection_id {
             project.worktrees.insert(worktree_id, worktree);
-            if let Ok(share) = project.share_mut() {
-                share.worktrees.insert(worktree_id, Default::default());
-            }
-
             Ok(())
         } else {
             Err(anyhow!("no such project"))?
@@ -356,11 +333,9 @@ impl Store {
                         host_connection.projects.remove(&project_id);
                     }
 
-                    if let Some(share) = &project.share {
-                        for guest_connection in share.guests.keys() {
-                            if let Some(connection) = self.connections.get_mut(&guest_connection) {
-                                connection.projects.remove(&project_id);
-                            }
+                    for guest_connection in project.guests.keys() {
+                        if let Some(connection) = self.connections.get_mut(&guest_connection) {
+                            connection.projects.remove(&project_id);
                         }
                     }
 
@@ -391,64 +366,7 @@ impl Store {
             .worktrees
             .remove(&worktree_id)
             .ok_or_else(|| anyhow!("no such worktree"))?;
-
-        let mut guest_connection_ids = Vec::new();
-        if let Ok(share) = project.share_mut() {
-            guest_connection_ids.extend(share.guests.keys());
-            share.worktrees.remove(&worktree_id);
-        }
-
-        Ok((worktree, guest_connection_ids))
-    }
-
-    pub fn share_project(
-        &mut self,
-        project_id: u64,
-        connection_id: ConnectionId,
-    ) -> Result<SharedProject> {
-        if let Some(project) = self.projects.get_mut(&project_id) {
-            if project.host_connection_id == connection_id {
-                let mut share = ProjectShare::default();
-                for worktree_id in project.worktrees.keys() {
-                    share.worktrees.insert(*worktree_id, Default::default());
-                }
-                project.share = Some(share);
-                return Ok(SharedProject {});
-            }
-        }
-        Err(anyhow!("no such project"))?
-    }
-
-    pub fn unshare_project(
-        &mut self,
-        project_id: u64,
-        acting_connection_id: ConnectionId,
-    ) -> Result<UnsharedProject> {
-        let project = if let Some(project) = self.projects.get_mut(&project_id) {
-            project
-        } else {
-            return Err(anyhow!("no such project"))?;
-        };
-
-        if project.host_connection_id != acting_connection_id {
-            return Err(anyhow!("not your project"))?;
-        }
-
-        let connection_ids = project.connection_ids();
-        if let Some(share) = project.share.take() {
-            for connection_id in share.guests.into_keys() {
-                if let Some(connection) = self.connections.get_mut(&connection_id) {
-                    connection.projects.remove(&project_id);
-                }
-            }
-
-            Ok(UnsharedProject {
-                connection_ids,
-                host_user_id: project.host_user_id,
-            })
-        } else {
-            Err(anyhow!("project is not shared"))?
-        }
+        Ok((worktree, project.guest_connection_ids()))
     }
 
     pub fn update_diagnostic_summary(
@@ -464,7 +382,6 @@ impl Store {
             .ok_or_else(|| anyhow!("no such project"))?;
         if project.host_connection_id == connection_id {
             let worktree = project
-                .share_mut()?
                 .worktrees
                 .get_mut(&worktree_id)
                 .ok_or_else(|| anyhow!("no such worktree"))?;
@@ -495,35 +412,77 @@ impl Store {
         Err(anyhow!("no such project"))?
     }
 
-    pub fn join_project(
+    pub fn request_join_project(
         &mut self,
-        connection_id: ConnectionId,
-        user_id: UserId,
+        requester_id: UserId,
         project_id: u64,
-    ) -> Result<JoinedProject> {
+        receipt: Receipt<proto::JoinProject>,
+    ) -> Result<()> {
         let connection = self
             .connections
-            .get_mut(&connection_id)
+            .get_mut(&receipt.sender_id)
             .ok_or_else(|| anyhow!("no such connection"))?;
         let project = self
             .projects
             .get_mut(&project_id)
             .ok_or_else(|| anyhow!("no such project"))?;
+        connection.requested_projects.insert(project_id);
+        project
+            .join_requests
+            .entry(requester_id)
+            .or_default()
+            .push(receipt);
+        Ok(())
+    }
 
-        let share = project.share_mut()?;
-        connection.projects.insert(project_id);
+    pub fn deny_join_project_request(
+        &mut self,
+        responder_connection_id: ConnectionId,
+        requester_id: UserId,
+        project_id: u64,
+    ) -> Option<Vec<Receipt<proto::JoinProject>>> {
+        let project = self.projects.get_mut(&project_id)?;
+        if responder_connection_id != project.host_connection_id {
+            return None;
+        }
 
-        let mut replica_id = 1;
-        while share.active_replica_ids.contains(&replica_id) {
-            replica_id += 1;
+        let receipts = project.join_requests.remove(&requester_id)?;
+        for receipt in &receipts {
+            let requester_connection = self.connections.get_mut(&receipt.sender_id)?;
+            requester_connection.requested_projects.remove(&project_id);
         }
-        share.active_replica_ids.insert(replica_id);
-        share.guests.insert(connection_id, (replica_id, user_id));
+        Some(receipts)
+    }
 
-        Ok(JoinedProject {
-            replica_id,
-            project: &self.projects[&project_id],
-        })
+    pub fn accept_join_project_request(
+        &mut self,
+        responder_connection_id: ConnectionId,
+        requester_id: UserId,
+        project_id: u64,
+    ) -> Option<(Vec<(Receipt<proto::JoinProject>, ReplicaId)>, &Project)> {
+        let project = self.projects.get_mut(&project_id)?;
+        if responder_connection_id != project.host_connection_id {
+            return None;
+        }
+
+        let receipts = project.join_requests.remove(&requester_id)?;
+        let mut receipts_with_replica_ids = Vec::new();
+        for receipt in receipts {
+            let requester_connection = self.connections.get_mut(&receipt.sender_id)?;
+            requester_connection.requested_projects.remove(&project_id);
+            requester_connection.projects.insert(project_id);
+            let mut replica_id = 1;
+            while project.active_replica_ids.contains(&replica_id) {
+                replica_id += 1;
+            }
+            project.active_replica_ids.insert(replica_id);
+            project
+                .guests
+                .insert(receipt.sender_id, (replica_id, requester_id));
+            receipts_with_replica_ids.push((receipt, replica_id));
+        }
+
+        Some((receipts_with_replica_ids, project))
     }
 
     pub fn leave_project(
@@ -535,15 +494,11 @@ impl Store {
             .projects
             .get_mut(&project_id)
             .ok_or_else(|| anyhow!("no such project"))?;
-        let share = project
-            .share
-            .as_mut()
-            .ok_or_else(|| anyhow!("project is not shared"))?;
-        let (replica_id, _) = share
+        let (replica_id, _) = project
             .guests
             .remove(&connection_id)
             .ok_or_else(|| anyhow!("cannot leave a project before joining it"))?;
-        share.active_replica_ids.remove(&replica_id);
+        project.active_replica_ids.remove(&replica_id);
 
         if let Some(connection) = self.connections.get_mut(&connection_id) {
             connection.projects.remove(&project_id);
@@ -566,7 +521,6 @@ impl Store {
     ) -> Result<Vec<ConnectionId>> {
         let project = self.write_project(project_id, connection_id)?;
         let worktree = project
-            .share_mut()?
             .worktrees
             .get_mut(&worktree_id)
             .ok_or_else(|| anyhow!("no such worktree"))?;
@@ -611,12 +565,7 @@ impl Store {
             .get(&project_id)
             .ok_or_else(|| anyhow!("no such project"))?;
         if project.host_connection_id == connection_id
-            || project
-                .share
-                .as_ref()
-                .ok_or_else(|| anyhow!("project is not shared"))?
-                .guests
-                .contains_key(&connection_id)
+            || project.guests.contains_key(&connection_id)
         {
             Ok(project)
         } else {
@@ -634,12 +583,7 @@ impl Store {
             .get_mut(&project_id)
             .ok_or_else(|| anyhow!("no such project"))?;
         if project.host_connection_id == connection_id
-            || project
-                .share
-                .as_ref()
-                .ok_or_else(|| anyhow!("project is not shared"))?
-                .guests
-                .contains_key(&connection_id)
+            || project.guests.contains_key(&connection_id)
         {
             Ok(project)
         } else {
@@ -653,28 +597,21 @@ impl Store {
             for project_id in &connection.projects {
                 let project = &self.projects.get(&project_id).unwrap();
                 if project.host_connection_id != *connection_id {
-                    assert!(project
-                        .share
-                        .as_ref()
-                        .unwrap()
-                        .guests
-                        .contains_key(connection_id));
+                    assert!(project.guests.contains_key(connection_id));
                 }
 
-                if let Some(share) = project.share.as_ref() {
-                    for (worktree_id, worktree) in share.worktrees.iter() {
-                        let mut paths = HashMap::default();
-                        for entry in worktree.entries.values() {
-                            let prev_entry = paths.insert(&entry.path, entry);
-                            assert_eq!(
-                                prev_entry,
-                                None,
-                                "worktree {:?}, duplicate path for entries {:?} and {:?}",
-                                worktree_id,
-                                prev_entry.unwrap(),
-                                entry
-                            );
-                        }
+                for (worktree_id, worktree) in project.worktrees.iter() {
+                    let mut paths = HashMap::default();
+                    for entry in worktree.entries.values() {
+                        let prev_entry = paths.insert(&entry.path, entry);
+                        assert_eq!(
+                            prev_entry,
+                            None,
+                            "worktree {:?}, duplicate path for entries {:?} and {:?}",
+                            worktree_id,
+                            prev_entry.unwrap(),
+                            entry
+                        );
                     }
                 }
             }
@@ -702,21 +639,19 @@ impl Store {
             let host_connection = self.connections.get(&project.host_connection_id).unwrap();
             assert!(host_connection.projects.contains(project_id));
 
-            if let Some(share) = &project.share {
-                for guest_connection_id in share.guests.keys() {
-                    let guest_connection = self.connections.get(guest_connection_id).unwrap();
-                    assert!(guest_connection.projects.contains(project_id));
-                }
-                assert_eq!(share.active_replica_ids.len(), share.guests.len(),);
-                assert_eq!(
-                    share.active_replica_ids,
-                    share
-                        .guests
-                        .values()
-                        .map(|(replica_id, _)| *replica_id)
-                        .collect::<HashSet<_>>(),
-                );
+            for guest_connection_id in project.guests.keys() {
+                let guest_connection = self.connections.get(guest_connection_id).unwrap();
+                assert!(guest_connection.projects.contains(project_id));
             }
+            assert_eq!(project.active_replica_ids.len(), project.guests.len(),);
+            assert_eq!(
+                project.active_replica_ids,
+                project
+                    .guests
+                    .values()
+                    .map(|(replica_id, _)| *replica_id)
+                    .collect::<HashSet<_>>(),
+            );
         }
 
         for (channel_id, channel) in &self.channels {
@@ -730,38 +665,15 @@ impl Store {
 
 impl Project {
     pub fn guest_connection_ids(&self) -> Vec<ConnectionId> {
-        if let Some(share) = &self.share {
-            share.guests.keys().copied().collect()
-        } else {
-            Vec::new()
-        }
+        self.guests.keys().copied().collect()
     }
 
     pub fn connection_ids(&self) -> Vec<ConnectionId> {
-        if let Some(share) = &self.share {
-            share
-                .guests
-                .keys()
-                .copied()
-                .chain(Some(self.host_connection_id))
-                .collect()
-        } else {
-            vec![self.host_connection_id]
-        }
-    }
-
-    pub fn share(&self) -> Result<&ProjectShare> {
-        Ok(self
-            .share
-            .as_ref()
-            .ok_or_else(|| anyhow!("worktree is not shared"))?)
-    }
-
-    fn share_mut(&mut self) -> Result<&mut ProjectShare> {
-        Ok(self
-            .share
-            .as_mut()
-            .ok_or_else(|| anyhow!("worktree is not shared"))?)
+        self.guests
+            .keys()
+            .copied()
+            .chain(Some(self.host_connection_id))
+            .collect()
     }
 }
 

crates/contacts_panel/Cargo.toml 🔗

@@ -13,6 +13,7 @@ editor = { path = "../editor" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
 picker = { path = "../picker" }
+project = { path = "../project" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }
 util = { path = "../util" }

crates/contacts_panel/src/contact_notification.rs 🔗

@@ -1,13 +1,11 @@
+use crate::notifications::render_user_notification;
 use client::{ContactEvent, ContactEventKind, UserStore};
 use gpui::{
-    elements::*, impl_internal_actions, platform::CursorStyle, Entity, ModelHandle,
-    MutableAppContext, RenderContext, View, ViewContext,
+    elements::*, impl_internal_actions, Entity, ModelHandle, MutableAppContext, RenderContext,
+    View, ViewContext,
 };
-use settings::Settings;
 use workspace::Notification;
 
-use crate::render_icon_button;
-
 impl_internal_actions!(contact_notifications, [Dismiss, RespondToContactRequest]);
 
 pub fn init(cx: &mut MutableAppContext) {
@@ -33,9 +31,6 @@ pub enum Event {
     Dismiss,
 }
 
-enum Decline {}
-enum Accept {}
-
 impl Entity for ContactNotification {
     type Event = Event;
 }
@@ -47,8 +42,38 @@ impl View for ContactNotification {
 
     fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
         match self.event.kind {
-            ContactEventKind::Requested => self.render_incoming_request(cx),
-            ContactEventKind::Accepted => self.render_acceptance(cx),
+            ContactEventKind::Requested => render_user_notification(
+                self.event.user.clone(),
+                "wants to add you as a contact",
+                RespondToContactRequest {
+                    user_id: self.event.user.id,
+                    accept: false,
+                },
+                vec![
+                    (
+                        "Decline",
+                        Box::new(RespondToContactRequest {
+                            user_id: self.event.user.id,
+                            accept: false,
+                        }),
+                    ),
+                    (
+                        "Accept",
+                        Box::new(RespondToContactRequest {
+                            user_id: self.event.user.id,
+                            accept: true,
+                        }),
+                    ),
+                ],
+                cx,
+            ),
+            ContactEventKind::Accepted => render_user_notification(
+                self.event.user.clone(),
+                "accepted your contact request",
+                Dismiss(self.event.user.id),
+                vec![],
+                cx,
+            ),
             _ => unreachable!(),
         }
     }
@@ -82,138 +107,6 @@ impl ContactNotification {
         Self { event, user_store }
     }
 
-    fn render_incoming_request(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let theme = cx.global::<Settings>().theme.clone();
-        let theme = &theme.contact_notification;
-        let user = &self.event.user;
-        let user_id = user.id;
-
-        Flex::column()
-            .with_child(self.render_header("wants to add you as a contact.", theme, cx))
-            .with_child(
-                Label::new(
-                    "They won't know if you decline.".to_string(),
-                    theme.body_message.text.clone(),
-                )
-                .contained()
-                .with_style(theme.body_message.container)
-                .boxed(),
-            )
-            .with_child(
-                Flex::row()
-                    .with_child(
-                        MouseEventHandler::new::<Decline, _, _>(
-                            self.event.user.id as usize,
-                            cx,
-                            |state, _| {
-                                let button = theme.button.style_for(state, false);
-                                Label::new("Decline".to_string(), button.text.clone())
-                                    .contained()
-                                    .with_style(button.container)
-                                    .boxed()
-                            },
-                        )
-                        .with_cursor_style(CursorStyle::PointingHand)
-                        .on_click(move |_, cx| {
-                            cx.dispatch_action(RespondToContactRequest {
-                                user_id,
-                                accept: false,
-                            });
-                        })
-                        .boxed(),
-                    )
-                    .with_child(
-                        MouseEventHandler::new::<Accept, _, _>(user.id as usize, cx, |state, _| {
-                            let button = theme.button.style_for(state, false);
-                            Label::new("Accept".to_string(), button.text.clone())
-                                .contained()
-                                .with_style(button.container)
-                                .boxed()
-                        })
-                        .with_cursor_style(CursorStyle::PointingHand)
-                        .on_click(move |_, cx| {
-                            cx.dispatch_action(RespondToContactRequest {
-                                user_id,
-                                accept: true,
-                            });
-                        })
-                        .boxed(),
-                    )
-                    .aligned()
-                    .right()
-                    .boxed(),
-            )
-            .contained()
-            .boxed()
-    }
-
-    fn render_acceptance(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let theme = cx.global::<Settings>().theme.clone();
-        let theme = &theme.contact_notification;
-
-        self.render_header("accepted your contact request", theme, cx)
-    }
-
-    fn render_header(
-        &self,
-        message: &'static str,
-        theme: &theme::ContactNotification,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox {
-        let user = &self.event.user;
-        let user_id = user.id;
-        Flex::row()
-            .with_children(user.avatar.clone().map(|avatar| {
-                Image::new(avatar)
-                    .with_style(theme.header_avatar)
-                    .aligned()
-                    .constrained()
-                    .with_height(
-                        cx.font_cache()
-                            .line_height(theme.header_message.text.font_size),
-                    )
-                    .aligned()
-                    .top()
-                    .boxed()
-            }))
-            .with_child(
-                Text::new(
-                    format!("{} {}", user.github_login, message),
-                    theme.header_message.text.clone(),
-                )
-                .contained()
-                .with_style(theme.header_message.container)
-                .aligned()
-                .top()
-                .left()
-                .flex(1., true)
-                .boxed(),
-            )
-            .with_child(
-                MouseEventHandler::new::<Dismiss, _, _>(user.id as usize, cx, |state, _| {
-                    render_icon_button(
-                        theme.dismiss_button.style_for(state, false),
-                        "icons/decline.svg",
-                    )
-                    .boxed()
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .with_padding(Padding::uniform(5.))
-                .on_click(move |_, cx| cx.dispatch_action(Dismiss(user_id)))
-                .aligned()
-                .constrained()
-                .with_height(
-                    cx.font_cache()
-                        .line_height(theme.header_message.text.font_size),
-                )
-                .aligned()
-                .top()
-                .flex_float()
-                .boxed(),
-            )
-            .named("contact notification header")
-    }
-
     fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext<Self>) {
         self.user_store.update(cx, |store, cx| {
             store

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -1,5 +1,7 @@
 mod contact_finder;
 mod contact_notification;
+mod join_project_notification;
+mod notifications;
 
 use client::{Contact, ContactEventKind, User, UserStore};
 use contact_notification::ContactNotification;
@@ -13,6 +15,7 @@ use gpui::{
     AppContext, Element, ElementBox, Entity, LayoutContext, ModelHandle, MutableAppContext,
     RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
 };
+use join_project_notification::JoinProjectNotification;
 use serde::Deserialize;
 use settings::Settings;
 use std::sync::Arc;
@@ -76,6 +79,7 @@ pub struct RespondToContactRequest {
 pub fn init(cx: &mut MutableAppContext) {
     contact_finder::init(cx);
     contact_notification::init(cx);
+    join_project_notification::init(cx);
     cx.add_action(ContactsPanel::request_contact);
     cx.add_action(ContactsPanel::remove_contact);
     cx.add_action(ContactsPanel::respond_to_contact_request);
@@ -118,6 +122,33 @@ impl ContactsPanel {
         })
         .detach();
 
+        cx.defer({
+            let workspace = workspace.clone();
+            move |_, cx| {
+                if let Some(workspace_handle) = workspace.upgrade(cx) {
+                    cx.subscribe(&workspace_handle.read(cx).project().clone(), {
+                        let workspace = workspace.clone();
+                        move |_, project, event, cx| match event {
+                            project::Event::ContactRequestedJoin(user) => {
+                                if let Some(workspace) = workspace.upgrade(cx) {
+                                    workspace.update(cx, |workspace, cx| {
+                                        workspace.show_notification(
+                                            cx.add_view(|_| {
+                                                JoinProjectNotification::new(project, user.clone())
+                                            }),
+                                            cx,
+                                        )
+                                    });
+                                }
+                            }
+                            _ => {}
+                        }
+                    })
+                    .detach();
+                }
+            }
+        });
+
         cx.subscribe(&app_state.user_store, {
             let user_store = app_state.user_store.downgrade();
             move |_, _, event, cx| {
@@ -320,7 +351,6 @@ impl ContactsPanel {
                 .guests
                 .iter()
                 .any(|guest| Some(guest.id) == current_user_id);
-        let is_shared = project.is_shared;
 
         let font_cache = cx.font_cache();
         let host_avatar_height = theme
@@ -328,7 +358,7 @@ impl ContactsPanel {
             .width
             .or(theme.contact_avatar.height)
             .unwrap_or(0.);
-        let row = &theme.unshared_project_row.default;
+        let row = &theme.project_row.default;
         let tree_branch = theme.tree_branch.clone();
         let line_height = row.name.text.line_height(font_cache);
         let cap_height = row.name.text.cap_height(font_cache);
@@ -337,12 +367,7 @@ impl ContactsPanel {
 
         MouseEventHandler::new::<JoinProject, _, _>(project_id as usize, cx, |mouse_state, _| {
             let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
-            let row = if project.is_shared {
-                &theme.shared_project_row
-            } else {
-                &theme.unshared_project_row
-            }
-            .style_for(mouse_state, is_selected);
+            let row = theme.project_row.style_for(mouse_state, is_selected);
 
             Flex::row()
                 .with_child(
@@ -412,7 +437,7 @@ impl ContactsPanel {
                 .with_style(row.container)
                 .boxed()
         })
-        .with_cursor_style(if !is_host && is_shared {
+        .with_cursor_style(if !is_host {
             CursorStyle::PointingHand
         } else {
             CursorStyle::Arrow
@@ -947,7 +972,6 @@ mod tests {
                     projects: vec![proto::ProjectMetadata {
                         id: 101,
                         worktree_root_names: vec!["dir1".to_string()],
-                        is_shared: true,
                         guests: vec![2],
                     }],
                 },
@@ -958,7 +982,6 @@ mod tests {
                     projects: vec![proto::ProjectMetadata {
                         id: 102,
                         worktree_root_names: vec!["dir2".to_string()],
-                        is_shared: true,
                         guests: vec![2],
                     }],
                 },

crates/contacts_panel/src/join_project_notification.rs 🔗

@@ -0,0 +1,71 @@
+use client::User;
+use gpui::{
+    actions, ElementBox, Entity, ModelHandle, MutableAppContext, RenderContext, View, ViewContext,
+};
+use project::Project;
+use std::sync::Arc;
+use workspace::Notification;
+
+use crate::notifications::render_user_notification;
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(JoinProjectNotification::decline);
+    cx.add_action(JoinProjectNotification::accept);
+}
+
+pub enum Event {
+    Dismiss,
+}
+
+actions!(contacts_panel, [Accept, Decline]);
+
+pub struct JoinProjectNotification {
+    project: ModelHandle<Project>,
+    user: Arc<User>,
+}
+
+impl JoinProjectNotification {
+    pub fn new(project: ModelHandle<Project>, user: Arc<User>) -> Self {
+        Self { project, user }
+    }
+
+    fn decline(&mut self, _: &Decline, cx: &mut ViewContext<Self>) {
+        self.project.update(cx, |project, cx| {
+            project.respond_to_join_request(self.user.id, false, cx)
+        });
+        cx.emit(Event::Dismiss)
+    }
+
+    fn accept(&mut self, _: &Accept, cx: &mut ViewContext<Self>) {
+        self.project.update(cx, |project, cx| {
+            project.respond_to_join_request(self.user.id, true, cx)
+        });
+        cx.emit(Event::Dismiss)
+    }
+}
+
+impl Entity for JoinProjectNotification {
+    type Event = Event;
+}
+
+impl View for JoinProjectNotification {
+    fn ui_name() -> &'static str {
+        "JoinProjectNotification"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        render_user_notification(
+            self.user.clone(),
+            "wants to join your project",
+            Decline,
+            vec![("Decline", Box::new(Decline)), ("Accept", Box::new(Accept))],
+            cx,
+        )
+    }
+}
+
+impl Notification for JoinProjectNotification {
+    fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
+        matches!(event, Event::Dismiss)
+    }
+}

crates/contacts_panel/src/notifications.rs 🔗

@@ -0,0 +1,112 @@
+use crate::render_icon_button;
+use client::User;
+use gpui::{
+    elements::{Flex, Image, Label, MouseEventHandler, Padding, ParentElement, Text},
+    platform::CursorStyle,
+    Action, Element, ElementBox, RenderContext, View,
+};
+use settings::Settings;
+use std::sync::Arc;
+
+enum Dismiss {}
+enum Button {}
+
+pub fn render_user_notification<V: View, A: Action + Clone>(
+    user: Arc<User>,
+    message: &str,
+    dismiss_action: A,
+    buttons: Vec<(&'static str, Box<dyn Action>)>,
+    cx: &mut RenderContext<V>,
+) -> ElementBox {
+    let theme = cx.global::<Settings>().theme.clone();
+    let theme = &theme.contact_notification;
+
+    Flex::column()
+        .with_child(
+            Flex::row()
+                .with_children(user.avatar.clone().map(|avatar| {
+                    Image::new(avatar)
+                        .with_style(theme.header_avatar)
+                        .aligned()
+                        .constrained()
+                        .with_height(
+                            cx.font_cache()
+                                .line_height(theme.header_message.text.font_size),
+                        )
+                        .aligned()
+                        .top()
+                        .boxed()
+                }))
+                .with_child(
+                    Text::new(
+                        format!("{} {}", user.github_login, message),
+                        theme.header_message.text.clone(),
+                    )
+                    .contained()
+                    .with_style(theme.header_message.container)
+                    .aligned()
+                    .top()
+                    .left()
+                    .flex(1., true)
+                    .boxed(),
+                )
+                .with_child(
+                    MouseEventHandler::new::<Dismiss, _, _>(user.id as usize, cx, |state, _| {
+                        render_icon_button(
+                            theme.dismiss_button.style_for(state, false),
+                            "icons/decline.svg",
+                        )
+                        .boxed()
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .with_padding(Padding::uniform(5.))
+                    .on_click(move |_, cx| cx.dispatch_any_action(dismiss_action.boxed_clone()))
+                    .aligned()
+                    .constrained()
+                    .with_height(
+                        cx.font_cache()
+                            .line_height(theme.header_message.text.font_size),
+                    )
+                    .aligned()
+                    .top()
+                    .flex_float()
+                    .boxed(),
+                )
+                .named("contact notification header"),
+        )
+        .with_child(
+            Label::new(
+                "They won't know if you decline.".to_string(),
+                theme.body_message.text.clone(),
+            )
+            .contained()
+            .with_style(theme.body_message.container)
+            .boxed(),
+        )
+        .with_children(if buttons.is_empty() {
+            None
+        } else {
+            Some(
+                Flex::row()
+                    .with_children(buttons.into_iter().enumerate().map(
+                        |(ix, (message, action))| {
+                            MouseEventHandler::new::<Button, _, _>(ix, cx, |state, _| {
+                                let button = theme.button.style_for(state, false);
+                                Label::new(message.to_string(), button.text.clone())
+                                    .contained()
+                                    .with_style(button.container)
+                                    .boxed()
+                            })
+                            .with_cursor_style(CursorStyle::PointingHand)
+                            .on_click(move |_, cx| cx.dispatch_any_action(action.boxed_clone()))
+                            .boxed()
+                        },
+                    ))
+                    .aligned()
+                    .right()
+                    .boxed(),
+            )
+        })
+        .contained()
+        .boxed()
+}

crates/gpui/src/presenter.rs 🔗

@@ -386,13 +386,17 @@ impl<'a> EventContext<'a> {
         }
     }
 
-    pub fn dispatch_action<A: Action>(&mut self, action: A) {
+    pub fn dispatch_any_action(&mut self, action: Box<dyn Action>) {
         self.dispatched_actions.push(DispatchDirective {
             path: self.view_stack.clone(),
-            action: Box::new(action),
+            action,
         });
     }
 
+    pub fn dispatch_action<A: Action>(&mut self, action: A) {
+        self.dispatch_any_action(Box::new(action));
+    }
+
     pub fn notify(&mut self) {
         self.notify_count += 1;
         if let Some(view_id) = self.view_stack.last() {

crates/project/src/project.rs 🔗

@@ -133,6 +133,7 @@ pub enum Event {
     DiagnosticsUpdated(ProjectPath),
     RemoteIdChanged(Option<u64>),
     CollaboratorLeft(PeerId),
+    ContactRequestedJoin(Arc<User>),
 }
 
 #[derive(Serialize)]
@@ -248,6 +249,7 @@ impl ProjectEntryId {
 
 impl Project {
     pub fn init(client: &Arc<Client>) {
+        client.add_model_message_handler(Self::handle_request_join_project);
         client.add_model_message_handler(Self::handle_add_collaborator);
         client.add_model_message_handler(Self::handle_buffer_reloaded);
         client.add_model_message_handler(Self::handle_buffer_saved);
@@ -256,7 +258,7 @@ impl Project {
         client.add_model_message_handler(Self::handle_remove_collaborator);
         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_unshare_project);
+        client.add_model_message_handler(Self::handle_unregister_project);
         client.add_model_message_handler(Self::handle_update_buffer_file);
         client.add_model_message_handler(Self::handle_update_buffer);
         client.add_model_message_handler(Self::handle_update_diagnostic_summary);
@@ -362,6 +364,11 @@ impl Project {
             })
             .await?;
 
+        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"))?,
+        };
+
         let replica_id = response.replica_id as ReplicaId;
 
         let mut worktrees = Vec::new();
@@ -400,7 +407,7 @@ impl Project {
                             // Even if we're initially connected, any future change of the status means we momentarily disconnected.
                             if !is_connected || status.next().await.is_some() {
                                 if let Some(this) = this.upgrade(&cx) {
-                                    this.update(&mut cx, |this, cx| this.project_unshared(cx))
+                                    this.update(&mut cx, |this, cx| this.removed_from_project(cx))
                                 }
                             }
                             Ok(())
@@ -814,60 +821,59 @@ impl Project {
         self.is_local() && self.visible_worktrees(cx).next().is_some()
     }
 
-    pub fn share(&self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
-        let rpc = self.client.clone();
-        cx.spawn(|this, mut cx| async move {
-            let project_id = this.update(&mut cx, |this, cx| {
-                if let ProjectClientState::Local {
-                    is_shared,
-                    remote_id_rx,
-                    ..
-                } = &mut this.client_state
-                {
-                    *is_shared = true;
-
-                    for open_buffer in this.opened_buffers.values_mut() {
-                        match open_buffer {
-                            OpenBuffer::Strong(_) => {}
-                            OpenBuffer::Weak(buffer) => {
-                                if let Some(buffer) = buffer.upgrade(cx) {
-                                    *open_buffer = OpenBuffer::Strong(buffer);
-                                }
-                            }
-                            OpenBuffer::Loading(_) => unreachable!(),
-                        }
-                    }
+    pub fn share(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        let project_id;
+        if let ProjectClientState::Local {
+            remote_id_rx,
+            is_shared,
+            ..
+        } = &mut self.client_state
+        {
+            if *is_shared {
+                return Task::ready(Ok(()));
+            }
+            *is_shared = true;
+            if let Some(id) = *remote_id_rx.borrow() {
+                project_id = id;
+            } else {
+                return Task::ready(Err(anyhow!("project hasn't been registered")));
+            }
+        } else {
+            return Task::ready(Err(anyhow!("can't share a remote project")));
+        };
 
-                    for worktree_handle in this.worktrees.iter_mut() {
-                        match worktree_handle {
-                            WorktreeHandle::Strong(_) => {}
-                            WorktreeHandle::Weak(worktree) => {
-                                if let Some(worktree) = worktree.upgrade(cx) {
-                                    *worktree_handle = WorktreeHandle::Strong(worktree);
-                                }
-                            }
-                        }
+        for open_buffer in self.opened_buffers.values_mut() {
+            match open_buffer {
+                OpenBuffer::Strong(_) => {}
+                OpenBuffer::Weak(buffer) => {
+                    if let Some(buffer) = buffer.upgrade(cx) {
+                        *open_buffer = OpenBuffer::Strong(buffer);
                     }
-
-                    remote_id_rx
-                        .borrow()
-                        .ok_or_else(|| anyhow!("no project id"))
-                } else {
-                    Err(anyhow!("can't share a remote project"))
                 }
-            })?;
-
-            rpc.request(proto::ShareProject { project_id }).await?;
+                OpenBuffer::Loading(_) => unreachable!(),
+            }
+        }
 
-            let mut tasks = Vec::new();
-            this.update(&mut cx, |this, cx| {
-                for worktree in this.worktrees(cx).collect::<Vec<_>>() {
-                    worktree.update(cx, |worktree, cx| {
-                        let worktree = worktree.as_local_mut().unwrap();
-                        tasks.push(worktree.share(project_id, cx));
-                    });
+        for worktree_handle in self.worktrees.iter_mut() {
+            match worktree_handle {
+                WorktreeHandle::Strong(_) => {}
+                WorktreeHandle::Weak(worktree) => {
+                    if let Some(worktree) = worktree.upgrade(cx) {
+                        *worktree_handle = WorktreeHandle::Strong(worktree);
+                    }
                 }
+            }
+        }
+
+        let mut tasks = Vec::new();
+        for worktree in self.worktrees(cx).collect::<Vec<_>>() {
+            worktree.update(cx, |worktree, cx| {
+                let worktree = worktree.as_local_mut().unwrap();
+                tasks.push(worktree.share(project_id, cx));
             });
+        }
+
+        cx.spawn(|this, mut cx| async move {
             for task in tasks {
                 task.await?;
             }
@@ -877,14 +883,7 @@ impl Project {
     }
 
     pub fn unshare(&mut self, cx: &mut ModelContext<Self>) {
-        let rpc = self.client.clone();
-
-        if let ProjectClientState::Local {
-            is_shared,
-            remote_id_rx,
-            ..
-        } = &mut self.client_state
-        {
+        if let ProjectClientState::Local { is_shared, .. } = &mut self.client_state {
             if !*is_shared {
                 return;
             }
@@ -913,17 +912,35 @@ impl Project {
                 }
             }
 
-            if let Some(project_id) = *remote_id_rx.borrow() {
-                rpc.send(proto::UnshareProject { project_id }).log_err();
-            }
-
             cx.notify();
         } else {
             log::error!("attempted to unshare a remote project");
         }
     }
 
-    fn project_unshared(&mut self, cx: &mut ModelContext<Self>) {
+    pub fn respond_to_join_request(
+        &mut self,
+        requester_id: u64,
+        allow: bool,
+        cx: &mut ModelContext<Self>,
+    ) {
+        if let Some(project_id) = self.remote_id() {
+            let share = self.share(cx);
+            let client = self.client.clone();
+            cx.foreground()
+                .spawn(async move {
+                    share.await?;
+                    client.send(proto::RespondToJoinProjectRequest {
+                        requester_id,
+                        project_id,
+                        allow,
+                    })
+                })
+                .detach_and_log_err(cx);
+        }
+    }
+
+    fn removed_from_project(&mut self, cx: &mut ModelContext<Self>) {
         if let ProjectClientState::Remote {
             sharing_has_stopped,
             ..
@@ -3745,13 +3762,28 @@ impl Project {
 
     // RPC message handlers
 
-    async fn handle_unshare_project(
+    async fn handle_request_join_project(
+        this: ModelHandle<Self>,
+        message: TypedEnvelope<proto::RequestJoinProject>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<()> {
+        let user_id = message.payload.requester_id;
+        let user_store = this.read_with(&cx, |this, _| this.user_store.clone());
+        let user = user_store
+            .update(&mut cx, |store, cx| store.fetch_user(user_id, cx))
+            .await?;
+        this.update(&mut cx, |_, cx| cx.emit(Event::ContactRequestedJoin(user)));
+        Ok(())
+    }
+
+    async fn handle_unregister_project(
         this: ModelHandle<Self>,
-        _: TypedEnvelope<proto::UnshareProject>,
+        _: TypedEnvelope<proto::UnregisterProject>,
         _: Arc<Client>,
         mut cx: AsyncAppContext,
     ) -> Result<()> {
-        this.update(&mut cx, |this, cx| this.project_unshared(cx));
+        this.update(&mut cx, |this, cx| this.removed_from_project(cx));
         Ok(())
     }
 
@@ -3796,6 +3828,9 @@ impl Project {
                     buffer.update(cx, |buffer, cx| buffer.remove_peer(replica_id, cx));
                 }
             }
+            if this.collaborators.is_empty() {
+                this.unshare(cx);
+            }
             cx.emit(Event::CollaboratorLeft(peer_id));
             cx.notify();
             Ok(())

crates/rpc/proto/zed.proto 🔗

@@ -14,8 +14,8 @@ message Envelope {
         RegisterProject register_project = 8;
         RegisterProjectResponse register_project_response = 9;
         UnregisterProject unregister_project = 10;
-        ShareProject share_project = 11;
-        UnshareProject unshare_project = 12;
+        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;
@@ -124,12 +124,15 @@ message UnregisterProject {
     uint64 project_id = 1;
 }
 
-message ShareProject {
-    uint64 project_id = 1;
+message RequestJoinProject {
+    uint64 requester_id = 1;
+    uint64 project_id = 2;
 }
 
-message UnshareProject {
-    uint64 project_id = 1;
+message RespondToJoinProjectRequest {
+    uint64 requester_id = 1;
+    uint64 project_id = 2;
+    bool allow = 3;
 }
 
 message JoinProject {
@@ -137,10 +140,19 @@ message JoinProject {
 }
 
 message JoinProjectResponse {
-    uint32 replica_id = 1;
-    repeated Worktree worktrees = 2;
-    repeated Collaborator collaborators = 3;
-    repeated LanguageServer language_servers = 4;
+    oneof variant {
+        Accept accept = 1;
+        Decline decline = 2;
+    }
+
+    message Accept {
+        uint32 replica_id = 1;
+        repeated Worktree worktrees = 2;
+        repeated Collaborator collaborators = 3;
+        repeated LanguageServer language_servers = 4;        
+    }
+    
+    message Decline {}
 }
 
 message LeaveProject {
@@ -882,7 +894,6 @@ message Contact {
 
 message ProjectMetadata {
     uint64 id = 1;
-    bool is_shared = 2;
     repeated string worktree_root_names = 3;
     repeated uint64 guests = 4;
 }

crates/rpc/src/proto.rs 🔗

@@ -135,19 +135,19 @@ messages!(
     (RemoveProjectCollaborator, Foreground),
     (RenameProjectEntry, Foreground),
     (RequestContact, Foreground),
+    (RequestJoinProject, Foreground),
     (RespondToContactRequest, Foreground),
+    (RespondToJoinProjectRequest, Foreground),
     (SaveBuffer, Foreground),
     (SearchProject, Background),
     (SearchProjectResponse, Background),
     (SendChannelMessage, Foreground),
     (SendChannelMessageResponse, Foreground),
-    (ShareProject, Foreground),
     (StartLanguageServer, Foreground),
     (Test, Foreground),
     (Unfollow, Foreground),
     (UnregisterProject, Foreground),
     (UnregisterWorktree, Foreground),
-    (UnshareProject, Foreground),
     (UpdateBuffer, Foreground),
     (UpdateBufferFile, Foreground),
     (UpdateContacts, Foreground),
@@ -195,7 +195,6 @@ request_messages!(
     (SaveBuffer, BufferSaved),
     (SearchProject, SearchProjectResponse),
     (SendChannelMessage, SendChannelMessageResponse),
-    (ShareProject, Ack),
     (Test, Test),
     (UpdateBuffer, Ack),
     (UpdateWorktree, Ack),
@@ -228,12 +227,13 @@ entity_messages!(
     PrepareRename,
     ReloadBuffers,
     RemoveProjectCollaborator,
+    RequestJoinProject,
     SaveBuffer,
     SearchProject,
     StartLanguageServer,
     Unfollow,
+    UnregisterProject,
     UnregisterWorktree,
-    UnshareProject,
     UpdateBuffer,
     UpdateBufferFile,
     UpdateDiagnosticSummary,

crates/theme/src/theme.rs 🔗

@@ -251,8 +251,7 @@ pub struct ContactsPanel {
     pub add_contact_button: IconButton,
     pub header_row: Interactive<ContainedText>,
     pub contact_row: Interactive<ContainerStyle>,
-    pub shared_project_row: Interactive<ProjectRow>,
-    pub unshared_project_row: Interactive<ProjectRow>,
+    pub project_row: Interactive<ProjectRow>,
     pub row_height: f32,
     pub contact_avatar: ImageStyle,
     pub contact_username: ContainedText,

crates/workspace/src/workspace.rs 🔗

@@ -72,7 +72,6 @@ type FollowableItemBuilders = HashMap<
 actions!(
     workspace,
     [
-        ToggleShare,
         Unfollow,
         Save,
         ActivatePreviousPane,
@@ -121,7 +120,6 @@ pub fn init(client: &Arc<Client>, cx: &mut MutableAppContext) {
         join_project(action.project_id, &action.app_state, cx).detach();
     });
 
-    cx.add_action(Workspace::toggle_share);
     cx.add_async_action(Workspace::toggle_follow);
     cx.add_async_action(Workspace::follow_next_collaborator);
     cx.add_action(
@@ -692,6 +690,7 @@ impl WorkspaceParams {
 
 pub enum Event {
     PaneAdded(ViewHandle<Pane>),
+    ContactRequestedJoin(u64),
 }
 
 pub struct Workspace {
@@ -1366,18 +1365,6 @@ impl Workspace {
         &self.active_pane
     }
 
-    fn toggle_share(&mut self, _: &ToggleShare, cx: &mut ViewContext<Self>) {
-        self.project.update(cx, |project, cx| {
-            if project.is_local() {
-                if project.is_shared() {
-                    project.unshare(cx);
-                } else if project.can_share(cx) {
-                    project.share(cx).detach();
-                }
-            }
-        });
-    }
-
     fn project_remote_id_changed(&mut self, remote_id: Option<u64>, cx: &mut ViewContext<Self>) {
         if let Some(remote_id) = remote_id {
             self.remote_entity_subscription =
@@ -1580,7 +1567,6 @@ impl Workspace {
                                     cx,
                                 ))
                                 .with_children(self.render_connection_status(cx))
-                                .with_children(self.render_share_icon(theme, cx))
                                 .boxed(),
                         )
                         .right()
@@ -1701,39 +1687,6 @@ impl Workspace {
         }
     }
 
-    fn render_share_icon(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> Option<ElementBox> {
-        if self.project().read(cx).is_local()
-            && self.client.user_id().is_some()
-            && self.project().read(cx).can_share(cx)
-        {
-            Some(
-                MouseEventHandler::new::<ToggleShare, _, _>(0, cx, |state, cx| {
-                    let style = &theme
-                        .workspace
-                        .titlebar
-                        .share_icon
-                        .style_for(state, self.project().read(cx).is_shared());
-                    Svg::new("icons/share.svg")
-                        .with_color(style.color)
-                        .constrained()
-                        .with_height(14.)
-                        .aligned()
-                        .contained()
-                        .with_style(style.container)
-                        .constrained()
-                        .with_width(24.)
-                        .aligned()
-                        .boxed()
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(|_, cx| cx.dispatch_action(ToggleShare))
-                .boxed(),
-            )
-        } else {
-            None
-        }
-    }
-
     fn render_disconnected_overlay(&self, cx: &AppContext) -> Option<ElementBox> {
         if self.project.read(cx).is_read_only() {
             let theme = &cx.global::<Settings>().theme;

crates/zed/src/zed.rs 🔗

@@ -123,17 +123,18 @@ pub fn build_workspace(
     cx.subscribe(&cx.handle(), {
         let project = project.clone();
         move |_, _, event, cx| {
-            let workspace::Event::PaneAdded(pane) = event;
-            pane.update(cx, |pane, cx| {
-                pane.toolbar().update(cx, |toolbar, cx| {
-                    let breadcrumbs = cx.add_view(|_| Breadcrumbs::new(project.clone()));
-                    toolbar.add_item(breadcrumbs, cx);
-                    let buffer_search_bar = cx.add_view(|cx| BufferSearchBar::new(cx));
-                    toolbar.add_item(buffer_search_bar, cx);
-                    let project_search_bar = cx.add_view(|_| ProjectSearchBar::new());
-                    toolbar.add_item(project_search_bar, cx);
-                })
-            });
+            if let workspace::Event::PaneAdded(pane) = event {
+                pane.update(cx, |pane, cx| {
+                    pane.toolbar().update(cx, |toolbar, cx| {
+                        let breadcrumbs = cx.add_view(|_| Breadcrumbs::new(project.clone()));
+                        toolbar.add_item(breadcrumbs, cx);
+                        let buffer_search_bar = cx.add_view(|cx| BufferSearchBar::new(cx));
+                        toolbar.add_item(buffer_search_bar, cx);
+                        let project_search_bar = cx.add_view(|_| ProjectSearchBar::new());
+                        toolbar.add_item(project_search_bar, cx);
+                    })
+                });
+            }
         }
     })
     .detach();

styles/src/styleTree/contactsPanel.ts 🔗

@@ -122,7 +122,7 @@ export default function contactsPanel(theme: Theme) {
       background: backgroundColor(theme, 100),
       color: iconColor(theme, "muted"),
     },
-    sharedProjectRow: {
+    projectRow: {
       ...projectRow,
       background: backgroundColor(theme, 300),
       name: {
@@ -136,19 +136,5 @@ export default function contactsPanel(theme: Theme) {
         background: backgroundColor(theme, 300, "active"),
       }
     },
-    unsharedProjectRow: {
-      ...projectRow,
-      background: backgroundColor(theme, 300),
-      name: {
-        ...projectRow.name,
-        ...text(theme, "mono", "secondary", { size: "sm" }),
-      },
-      hover: {
-        background: backgroundColor(theme, 300, "hovered"),
-      },
-      active: {
-        background: backgroundColor(theme, 300, "active"),
-      }
-    }
   }
 }