Show worktree root names when sharing additional projects on a call

Nathan Sobo and Antonio Scandurra created

Co-Authored-By: Antonio Scandurra <antonio@zed.dev>

Change summary

crates/call/src/participant.rs                      |  2 
crates/call/src/room.rs                             | 41 +++++++--
crates/collab/src/integration_tests.rs              |  2 
crates/collab/src/rpc.rs                            | 11 ++
crates/collab/src/rpc/store.rs                      | 64 +++++++++++---
crates/collab_ui/src/project_shared_notification.rs | 49 ++++++++++-
crates/rpc/proto/zed.proto                          |  8 +
crates/theme/src/theme.rs                           |  3 
styles/src/styleTree/projectSharedNotification.ts   |  8 +
9 files changed, 153 insertions(+), 35 deletions(-)

Detailed changes

crates/call/src/participant.rs 🔗

@@ -23,6 +23,6 @@ impl ParticipantLocation {
 #[derive(Clone, Debug)]
 pub struct RemoteParticipant {
     pub user: Arc<User>,
-    pub project_ids: Vec<u64>,
+    pub projects: Vec<proto::ParticipantProject>,
     pub location: ParticipantLocation,
 }

crates/call/src/room.rs 🔗

@@ -13,7 +13,11 @@ use util::ResultExt;
 
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum Event {
-    RemoteProjectShared { owner: Arc<User>, project_id: u64 },
+    RemoteProjectShared {
+        owner: Arc<User>,
+        project_id: u64,
+        worktree_root_names: Vec<String>,
+    },
 }
 
 pub struct Room {
@@ -219,16 +223,19 @@ impl Room {
                         let peer_id = PeerId(participant.peer_id);
                         this.participant_user_ids.insert(participant.user_id);
 
-                        let existing_project_ids = this
+                        let existing_projects = this
                             .remote_participants
                             .get(&peer_id)
-                            .map(|existing| existing.project_ids.clone())
-                            .unwrap_or_default();
-                        for project_id in &participant.project_ids {
-                            if !existing_project_ids.contains(project_id) {
+                            .into_iter()
+                            .flat_map(|existing| &existing.projects)
+                            .map(|project| project.id)
+                            .collect::<HashSet<_>>();
+                        for project in &participant.projects {
+                            if !existing_projects.contains(&project.id) {
                                 cx.emit(Event::RemoteProjectShared {
                                     owner: user.clone(),
-                                    project_id: *project_id,
+                                    project_id: project.id,
+                                    worktree_root_names: project.worktree_root_names.clone(),
                                 });
                             }
                         }
@@ -237,7 +244,7 @@ impl Room {
                             peer_id,
                             RemoteParticipant {
                                 user: user.clone(),
-                                project_ids: participant.project_ids,
+                                projects: participant.projects,
                                 location: ParticipantLocation::from_proto(participant.location)
                                     .unwrap_or(ParticipantLocation::External),
                             },
@@ -334,9 +341,21 @@ impl Room {
             return Task::ready(Ok(project_id));
         }
 
-        let request = self
-            .client
-            .request(proto::ShareProject { room_id: self.id() });
+        let request = self.client.request(proto::ShareProject {
+            room_id: self.id(),
+            worktrees: project
+                .read(cx)
+                .worktrees(cx)
+                .map(|worktree| {
+                    let worktree = worktree.read(cx);
+                    proto::WorktreeMetadata {
+                        id: worktree.id().to_proto(),
+                        root_name: worktree.root_name().into(),
+                        visible: worktree.is_visible(),
+                    }
+                })
+                .collect(),
+        });
         cx.spawn_weak(|_, mut cx| async move {
             let response = request.await?;
             project

crates/collab/src/integration_tests.rs 🔗

@@ -819,6 +819,7 @@ async fn test_active_call_events(
                 avatar: None,
             }),
             project_id: project_a_id,
+            worktree_root_names: vec!["a".to_string()],
         }]
     );
 
@@ -836,6 +837,7 @@ async fn test_active_call_events(
                 avatar: None,
             }),
             project_id: project_b_id,
+            worktree_root_names: vec!["b".to_string()]
         }]
     );
     assert_eq!(mem::take(&mut *events_b.borrow_mut()), vec![]);

crates/collab/src/rpc.rs 🔗

@@ -827,7 +827,12 @@ impl Server {
             .user_id_for_connection(request.sender_id)?;
         let project_id = self.app_state.db.register_project(user_id).await?;
         let mut store = self.store().await;
-        let room = store.share_project(request.payload.room_id, project_id, request.sender_id)?;
+        let room = store.share_project(
+            request.payload.room_id,
+            project_id,
+            request.payload.worktrees,
+            request.sender_id,
+        )?;
         response.send(proto::ShareProjectResponse {
             project_id: project_id.to_proto(),
         })?;
@@ -1036,11 +1041,13 @@ impl Server {
             let guest_connection_ids = state
                 .read_project(project_id, request.sender_id)?
                 .guest_connection_ids();
-            state.update_project(project_id, &request.payload.worktrees, request.sender_id)?;
+            let room =
+                state.update_project(project_id, &request.payload.worktrees, request.sender_id)?;
             broadcast(request.sender_id, guest_connection_ids, |connection_id| {
                 self.peer
                     .forward_send(request.sender_id, connection_id, request.payload.clone())
             });
+            self.room_updated(room);
         };
 
         Ok(())

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

@@ -383,7 +383,7 @@ impl Store {
         room.participants.push(proto::Participant {
             user_id: connection.user_id.to_proto(),
             peer_id: creator_connection_id.0,
-            project_ids: Default::default(),
+            projects: Default::default(),
             location: Some(proto::ParticipantLocation {
                 variant: Some(proto::participant_location::Variant::External(
                     proto::participant_location::External {},
@@ -441,7 +441,7 @@ impl Store {
         room.participants.push(proto::Participant {
             user_id: user_id.to_proto(),
             peer_id: connection_id.0,
-            project_ids: Default::default(),
+            projects: Default::default(),
             location: Some(proto::ParticipantLocation {
                 variant: Some(proto::participant_location::Variant::External(
                     proto::participant_location::External {},
@@ -689,7 +689,8 @@ impl Store {
             anyhow::ensure!(
                 room.participants
                     .iter()
-                    .any(|participant| participant.project_ids.contains(&project.id)),
+                    .flat_map(|participant| &participant.projects)
+                    .any(|participant_project| participant_project.id == project.id),
                 "no such project"
             );
         }
@@ -708,6 +709,7 @@ impl Store {
         &mut self,
         room_id: RoomId,
         project_id: ProjectId,
+        worktrees: Vec<proto::WorktreeMetadata>,
         host_connection_id: ConnectionId,
     ) -> Result<&proto::Room> {
         let connection = self
@@ -724,7 +726,14 @@ impl Store {
             .iter_mut()
             .find(|participant| participant.peer_id == host_connection_id.0)
             .ok_or_else(|| anyhow!("no such room"))?;
-        participant.project_ids.push(project_id.to_proto());
+        participant.projects.push(proto::ParticipantProject {
+            id: project_id.to_proto(),
+            worktree_root_names: worktrees
+                .iter()
+                .filter(|worktree| worktree.visible)
+                .map(|worktree| worktree.root_name.clone())
+                .collect(),
+        });
 
         connection.projects.insert(project_id);
         self.projects.insert(
@@ -741,7 +750,19 @@ impl Store {
                 },
                 guests: Default::default(),
                 active_replica_ids: Default::default(),
-                worktrees: Default::default(),
+                worktrees: worktrees
+                    .into_iter()
+                    .map(|worktree| {
+                        (
+                            worktree.id,
+                            Worktree {
+                                root_name: worktree.root_name,
+                                visible: worktree.visible,
+                                ..Default::default()
+                            },
+                        )
+                    })
+                    .collect(),
                 language_servers: Default::default(),
             },
         );
@@ -779,8 +800,8 @@ impl Store {
                         .find(|participant| participant.peer_id == connection_id.0)
                         .ok_or_else(|| anyhow!("no such room"))?;
                     participant
-                        .project_ids
-                        .retain(|id| *id != project_id.to_proto());
+                        .projects
+                        .retain(|project| project.id != project_id.to_proto());
 
                     Ok((room, project))
                 } else {
@@ -796,7 +817,7 @@ impl Store {
         project_id: ProjectId,
         worktrees: &[proto::WorktreeMetadata],
         connection_id: ConnectionId,
-    ) -> Result<()> {
+    ) -> Result<&proto::Room> {
         let project = self
             .projects
             .get_mut(&project_id)
@@ -818,7 +839,23 @@ impl Store {
                 }
             }
 
-            Ok(())
+            let room = self
+                .rooms
+                .get_mut(&project.room_id)
+                .ok_or_else(|| anyhow!("no such room"))?;
+            let participant_project = room
+                .participants
+                .iter_mut()
+                .flat_map(|participant| &mut participant.projects)
+                .find(|project| project.id == project_id.to_proto())
+                .ok_or_else(|| anyhow!("no such project"))?;
+            participant_project.worktree_root_names = worktrees
+                .iter()
+                .filter(|worktree| worktree.visible)
+                .map(|worktree| worktree.root_name.clone())
+                .collect();
+
+            Ok(room)
         } else {
             Err(anyhow!("no such project"))?
         }
@@ -1132,8 +1169,8 @@ impl Store {
                     "room contains participant that has disconnected"
                 );
 
-                for project_id in &participant.project_ids {
-                    let project = &self.projects[&ProjectId::from_proto(*project_id)];
+                for participant_project in &participant.projects {
+                    let project = &self.projects[&ProjectId::from_proto(participant_project.id)];
                     assert_eq!(
                         project.room_id, *room_id,
                         "project was shared on a different room"
@@ -1173,8 +1210,9 @@ impl Store {
                 .unwrap();
             assert!(
                 room_participant
-                    .project_ids
-                    .contains(&project_id.to_proto()),
+                    .projects
+                    .iter()
+                    .any(|project| project.id == project_id.to_proto()),
                 "project was not shared in room"
             );
         }

crates/collab_ui/src/project_shared_notification.rs 🔗

@@ -19,10 +19,16 @@ pub fn init(cx: &mut MutableAppContext) {
 
     let active_call = ActiveCall::global(cx);
     cx.subscribe(&active_call, move |_, event, cx| match event {
-        room::Event::RemoteProjectShared { owner, project_id } => {
+        room::Event::RemoteProjectShared {
+            owner,
+            project_id,
+            worktree_root_names,
+        } => {
             const PADDING: f32 = 16.;
             let screen_size = cx.platform().screen_size();
-            let window_size = vec2f(366., 64.);
+
+            let theme = &cx.global::<Settings>().theme.project_shared_notification;
+            let window_size = vec2f(theme.window_width, theme.window_height);
             cx.add_window(
                 WindowOptions {
                     bounds: WindowBounds::Fixed(RectF::new(
@@ -34,7 +40,13 @@ pub fn init(cx: &mut MutableAppContext) {
                     kind: WindowKind::PopUp,
                     is_movable: false,
                 },
-                |_| ProjectSharedNotification::new(*project_id, owner.clone()),
+                |_| {
+                    ProjectSharedNotification::new(
+                        owner.clone(),
+                        *project_id,
+                        worktree_root_names.clone(),
+                    )
+                },
             );
         }
     })
@@ -43,12 +55,17 @@ pub fn init(cx: &mut MutableAppContext) {
 
 pub struct ProjectSharedNotification {
     project_id: u64,
+    worktree_root_names: Vec<String>,
     owner: Arc<User>,
 }
 
 impl ProjectSharedNotification {
-    fn new(project_id: u64, owner: Arc<User>) -> Self {
-        Self { project_id, owner }
+    fn new(owner: Arc<User>, project_id: u64, worktree_root_names: Vec<String>) -> Self {
+        Self {
+            project_id,
+            worktree_root_names,
+            owner,
+        }
     }
 
     fn join(&mut self, _: &JoinProject, cx: &mut ViewContext<Self>) {
@@ -84,13 +101,33 @@ impl ProjectSharedNotification {
                     )
                     .with_child(
                         Label::new(
-                            "has shared a project with you".into(),
+                            format!(
+                                "shared a project in Zed{}",
+                                if self.worktree_root_names.is_empty() {
+                                    ""
+                                } else {
+                                    ":"
+                                }
+                            ),
                             theme.message.text.clone(),
                         )
                         .contained()
                         .with_style(theme.message.container)
                         .boxed(),
                     )
+                    .with_children(if self.worktree_root_names.is_empty() {
+                        None
+                    } else {
+                        Some(
+                            Label::new(
+                                self.worktree_root_names.join(", "),
+                                theme.worktree_roots.text.clone(),
+                            )
+                            .contained()
+                            .with_style(theme.worktree_roots.container)
+                            .boxed(),
+                        )
+                    })
                     .contained()
                     .with_style(theme.owner_metadata)
                     .aligned()

crates/rpc/proto/zed.proto 🔗

@@ -163,10 +163,15 @@ message Room {
 message Participant {
     uint64 user_id = 1;
     uint32 peer_id = 2;
-    repeated uint64 project_ids = 3;
+    repeated ParticipantProject projects = 3;
     ParticipantLocation location = 4;
 }
 
+message ParticipantProject {
+    uint64 id = 1;
+    repeated string worktree_root_names = 2;
+}
+
 message ParticipantLocation {
     oneof variant {
         Project project = 1;
@@ -215,6 +220,7 @@ message RoomUpdated {
 
 message ShareProject {
     uint64 room_id = 1;
+    repeated WorktreeMetadata worktrees = 2;
 }
 
 message ShareProjectResponse {

crates/theme/src/theme.rs 🔗

@@ -471,6 +471,8 @@ pub struct UpdateNotification {
 
 #[derive(Deserialize, Default)]
 pub struct ProjectSharedNotification {
+    pub window_height: f32,
+    pub window_width: f32,
     #[serde(default)]
     pub background: Color,
     pub owner_container: ContainerStyle,
@@ -478,6 +480,7 @@ pub struct ProjectSharedNotification {
     pub owner_metadata: ContainerStyle,
     pub owner_username: ContainedText,
     pub message: ContainedText,
+    pub worktree_roots: ContainedText,
     pub button_width: f32,
     pub join_button: ContainedText,
     pub dismiss_button: ContainedText,

styles/src/styleTree/projectSharedNotification.ts 🔗

@@ -2,8 +2,10 @@ import Theme from "../themes/common/theme";
 import { backgroundColor, borderColor, text } from "./components";
 
 export default function projectSharedNotification(theme: Theme): Object {
-  const avatarSize = 32;
+  const avatarSize = 48;
   return {
+    windowHeight: 72,
+    windowWidth: 360,
     background: backgroundColor(theme, 300),
     ownerContainer: {
       padding: 12,
@@ -24,6 +26,10 @@ export default function projectSharedNotification(theme: Theme): Object {
       ...text(theme, "sans", "secondary", { size: "xs" }),
       margin: { top: -3 },
     },
+    worktreeRoots: {
+      ...text(theme, "sans", "secondary", { size: "xs", weight: "bold" }),
+      margin: { top: -3 },
+    },
     buttonWidth: 96,
     joinButton: {
       background: backgroundColor(theme, "info", "active"),