Show participant projects in contacts popover

Antonio Scandurra created

Change summary

crates/call/src/participant.rs       |   5 
crates/call/src/room.rs              |  25 ++
crates/collab_ui/src/contact_list.rs | 202 ++++++++++++++++++++++++++++-
crates/theme/src/theme.rs            |  15 ++
styles/src/styleTree/contactList.ts  |  51 +++++++
5 files changed, 279 insertions(+), 19 deletions(-)

Detailed changes

crates/call/src/participant.rs 🔗

@@ -20,6 +20,11 @@ impl ParticipantLocation {
     }
 }
 
+#[derive(Clone, Debug, Default)]
+pub struct LocalParticipant {
+    pub projects: Vec<proto::ParticipantProject>,
+}
+
 #[derive(Clone, Debug)]
 pub struct RemoteParticipant {
     pub user: Arc<User>,

crates/call/src/room.rs 🔗

@@ -1,5 +1,5 @@
 use crate::{
-    participant::{ParticipantLocation, RemoteParticipant},
+    participant::{LocalParticipant, ParticipantLocation, RemoteParticipant},
     IncomingCall,
 };
 use anyhow::{anyhow, Result};
@@ -27,6 +27,7 @@ pub enum Event {
 pub struct Room {
     id: u64,
     status: RoomStatus,
+    local_participant: LocalParticipant,
     remote_participants: BTreeMap<PeerId, RemoteParticipant>,
     pending_participants: Vec<Arc<User>>,
     participant_user_ids: HashSet<u64>,
@@ -72,6 +73,7 @@ impl Room {
             id,
             status: RoomStatus::Online,
             participant_user_ids: Default::default(),
+            local_participant: Default::default(),
             remote_participants: Default::default(),
             pending_participants: Default::default(),
             pending_call_count: 0,
@@ -170,6 +172,10 @@ impl Room {
         self.status
     }
 
+    pub fn local_participant(&self) -> &LocalParticipant {
+        &self.local_participant
+    }
+
     pub fn remote_participants(&self) -> &BTreeMap<PeerId, RemoteParticipant> {
         &self.remote_participants
     }
@@ -201,8 +207,11 @@ impl Room {
         cx: &mut ModelContext<Self>,
     ) -> Result<()> {
         // Filter ourselves out from the room's participants.
-        room.participants
-            .retain(|participant| Some(participant.user_id) != self.client.user_id());
+        let local_participant_ix = room
+            .participants
+            .iter()
+            .position(|participant| Some(participant.user_id) == self.client.user_id());
+        let local_participant = local_participant_ix.map(|ix| room.participants.swap_remove(ix));
 
         let remote_participant_user_ids = room
             .participants
@@ -223,6 +232,12 @@ impl Room {
             this.update(&mut cx, |this, cx| {
                 this.participant_user_ids.clear();
 
+                if let Some(participant) = local_participant {
+                    this.local_participant.projects = participant.projects;
+                } else {
+                    this.local_participant.projects.clear();
+                }
+
                 if let Some(participants) = remote_participants.log_err() {
                     for (participant, user) in room.participants.into_iter().zip(participants) {
                         let peer_id = PeerId(participant.peer_id);
@@ -280,8 +295,6 @@ impl Room {
                             false
                         }
                     });
-
-                    cx.notify();
                 }
 
                 if let Some(pending_participants) = pending_participants.log_err() {
@@ -289,7 +302,6 @@ impl Room {
                     for participant in &this.pending_participants {
                         this.participant_user_ids.insert(participant.id);
                     }
-                    cx.notify();
                 }
 
                 this.pending_room_update.take();
@@ -298,6 +310,7 @@ impl Room {
                 }
 
                 this.check_invariants();
+                cx.notify();
             });
         }));
 

crates/collab_ui/src/contact_list.rs 🔗

@@ -6,9 +6,11 @@ use client::{Contact, PeerId, User, UserStore};
 use editor::{Cancel, Editor};
 use fuzzy::{match_strings, StringMatchCandidate};
 use gpui::{
-    elements::*, impl_actions, impl_internal_actions, keymap, AppContext, ClipboardItem,
-    CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext, Subscription,
-    View, ViewContext, ViewHandle,
+    elements::*,
+    geometry::{rect::RectF, vector::vec2f},
+    impl_actions, impl_internal_actions, keymap, AppContext, ClipboardItem, CursorStyle, Entity,
+    ModelHandle, MouseButton, MutableAppContext, RenderContext, Subscription, View, ViewContext,
+    ViewHandle,
 };
 use menu::{Confirm, SelectNext, SelectPrev};
 use project::Project;
@@ -16,6 +18,7 @@ use serde::Deserialize;
 use settings::Settings;
 use theme::IconButton;
 use util::ResultExt;
+use workspace::JoinProject;
 
 impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]);
 impl_internal_actions!(contact_list, [ToggleExpanded, Call, LeaveCall]);
@@ -55,7 +58,17 @@ enum Section {
 #[derive(Clone)]
 enum ContactEntry {
     Header(Section),
-    CallParticipant { user: Arc<User>, is_pending: bool },
+    CallParticipant {
+        user: Arc<User>,
+        is_pending: bool,
+    },
+    ParticipantProject {
+        project_id: u64,
+        worktree_root_names: Vec<String>,
+        host_user_id: u64,
+        is_host: bool,
+        is_last: bool,
+    },
     IncomingRequest(Arc<User>),
     OutgoingRequest(Arc<User>),
     Contact(Arc<Contact>),
@@ -74,6 +87,18 @@ impl PartialEq for ContactEntry {
                     return user_1.id == user_2.id;
                 }
             }
+            ContactEntry::ParticipantProject {
+                project_id: project_id_1,
+                ..
+            } => {
+                if let ContactEntry::ParticipantProject {
+                    project_id: project_id_2,
+                    ..
+                } = other
+                {
+                    return project_id_1 == project_id_2;
+                }
+            }
             ContactEntry::IncomingRequest(user_1) => {
                 if let ContactEntry::IncomingRequest(user_2) = other {
                     return user_1.id == user_2.id;
@@ -177,6 +202,22 @@ impl ContactList {
                         &theme.contact_list,
                     )
                 }
+                ContactEntry::ParticipantProject {
+                    project_id,
+                    worktree_root_names,
+                    host_user_id,
+                    is_host,
+                    is_last,
+                } => Self::render_participant_project(
+                    *project_id,
+                    worktree_root_names,
+                    *host_user_id,
+                    *is_host,
+                    *is_last,
+                    is_selected,
+                    &theme.contact_list,
+                    cx,
+                ),
                 ContactEntry::IncomingRequest(user) => Self::render_contact_request(
                     user.clone(),
                     this.user_store.clone(),
@@ -298,6 +339,19 @@ impl ContactList {
                             );
                         }
                     }
+                    ContactEntry::ParticipantProject {
+                        project_id,
+                        host_user_id,
+                        is_host,
+                        ..
+                    } => {
+                        if !is_host {
+                            cx.dispatch_global_action(JoinProject {
+                                project_id: *project_id,
+                                follow_user_id: *host_user_id,
+                            });
+                        }
+                    }
                     _ => {}
                 }
             }
@@ -324,7 +378,7 @@ impl ContactList {
 
         if let Some(room) = ActiveCall::global(cx).read(cx).room() {
             let room = room.read(cx);
-            let mut call_participants = Vec::new();
+            let mut participant_entries = Vec::new();
 
             // Populate the active user.
             if let Some(user) = user_store.current_user() {
@@ -343,10 +397,21 @@ impl ContactList {
                     executor.clone(),
                 ));
                 if !matches.is_empty() {
-                    call_participants.push(ContactEntry::CallParticipant {
+                    let user_id = user.id;
+                    participant_entries.push(ContactEntry::CallParticipant {
                         user,
                         is_pending: false,
                     });
+                    let mut projects = room.local_participant().projects.iter().peekable();
+                    while let Some(project) = projects.next() {
+                        participant_entries.push(ContactEntry::ParticipantProject {
+                            project_id: project.id,
+                            worktree_root_names: project.worktree_root_names.clone(),
+                            host_user_id: user_id,
+                            is_host: true,
+                            is_last: projects.peek().is_none(),
+                        });
+                    }
                 }
             }
 
@@ -370,14 +435,25 @@ impl ContactList {
                 &Default::default(),
                 executor.clone(),
             ));
-            call_participants.extend(matches.iter().map(|mat| {
-                ContactEntry::CallParticipant {
+            for mat in matches {
+                let participant = &room.remote_participants()[&PeerId(mat.candidate_id as u32)];
+                participant_entries.push(ContactEntry::CallParticipant {
                     user: room.remote_participants()[&PeerId(mat.candidate_id as u32)]
                         .user
                         .clone(),
                     is_pending: false,
+                });
+                let mut projects = participant.projects.iter().peekable();
+                while let Some(project) = projects.next() {
+                    participant_entries.push(ContactEntry::ParticipantProject {
+                        project_id: project.id,
+                        worktree_root_names: project.worktree_root_names.clone(),
+                        host_user_id: participant.user.id,
+                        is_host: false,
+                        is_last: projects.peek().is_none(),
+                    });
                 }
-            }));
+            }
 
             // Populate pending participants.
             self.match_candidates.clear();
@@ -400,15 +476,15 @@ impl ContactList {
                 &Default::default(),
                 executor.clone(),
             ));
-            call_participants.extend(matches.iter().map(|mat| ContactEntry::CallParticipant {
+            participant_entries.extend(matches.iter().map(|mat| ContactEntry::CallParticipant {
                 user: room.pending_participants()[mat.candidate_id].clone(),
                 is_pending: true,
             }));
 
-            if !call_participants.is_empty() {
+            if !participant_entries.is_empty() {
                 self.entries.push(ContactEntry::Header(Section::ActiveCall));
                 if !self.collapsed_sections.contains(&Section::ActiveCall) {
-                    self.entries.extend(call_participants);
+                    self.entries.extend(participant_entries);
                 }
             }
         }
@@ -588,6 +664,108 @@ impl ContactList {
             .boxed()
     }
 
+    fn render_participant_project(
+        project_id: u64,
+        worktree_root_names: &[String],
+        host_user_id: u64,
+        is_host: bool,
+        is_last: bool,
+        is_selected: bool,
+        theme: &theme::ContactList,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        let font_cache = cx.font_cache();
+        let host_avatar_height = theme
+            .contact_avatar
+            .width
+            .or(theme.contact_avatar.height)
+            .unwrap_or(0.);
+        let row = &theme.project_row.default;
+        let tree_branch = theme.tree_branch;
+        let line_height = row.name.text.line_height(font_cache);
+        let cap_height = row.name.text.cap_height(font_cache);
+        let baseline_offset =
+            row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
+        let project_name = if worktree_root_names.is_empty() {
+            "untitled".to_string()
+        } else {
+            worktree_root_names.join(", ")
+        };
+
+        MouseEventHandler::<JoinProject>::new(project_id as usize, cx, |mouse_state, _| {
+            let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
+            let row = theme.project_row.style_for(mouse_state, is_selected);
+
+            Flex::row()
+                .with_child(
+                    Stack::new()
+                        .with_child(
+                            Canvas::new(move |bounds, _, cx| {
+                                let start_x = bounds.min_x() + (bounds.width() / 2.)
+                                    - (tree_branch.width / 2.);
+                                let end_x = bounds.max_x();
+                                let start_y = bounds.min_y();
+                                let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
+
+                                cx.scene.push_quad(gpui::Quad {
+                                    bounds: RectF::from_points(
+                                        vec2f(start_x, start_y),
+                                        vec2f(
+                                            start_x + tree_branch.width,
+                                            if is_last { end_y } else { bounds.max_y() },
+                                        ),
+                                    ),
+                                    background: Some(tree_branch.color),
+                                    border: gpui::Border::default(),
+                                    corner_radius: 0.,
+                                });
+                                cx.scene.push_quad(gpui::Quad {
+                                    bounds: RectF::from_points(
+                                        vec2f(start_x, end_y),
+                                        vec2f(end_x, end_y + tree_branch.width),
+                                    ),
+                                    background: Some(tree_branch.color),
+                                    border: gpui::Border::default(),
+                                    corner_radius: 0.,
+                                });
+                            })
+                            .boxed(),
+                        )
+                        .constrained()
+                        .with_width(host_avatar_height)
+                        .boxed(),
+                )
+                .with_child(
+                    Label::new(project_name, row.name.text.clone())
+                        .aligned()
+                        .left()
+                        .contained()
+                        .with_style(row.name.container)
+                        .flex(1., false)
+                        .boxed(),
+                )
+                .constrained()
+                .with_height(theme.row_height)
+                .contained()
+                .with_style(row.container)
+                .boxed()
+        })
+        .with_cursor_style(if !is_host {
+            CursorStyle::PointingHand
+        } else {
+            CursorStyle::Arrow
+        })
+        .on_click(MouseButton::Left, move |_, cx| {
+            if !is_host {
+                cx.dispatch_global_action(JoinProject {
+                    project_id,
+                    follow_user_id: host_user_id,
+                });
+            }
+        })
+        .boxed()
+    }
+
     fn render_header(
         section: Section,
         theme: &theme::ContactList,

crates/theme/src/theme.rs 🔗

@@ -100,6 +100,8 @@ pub struct ContactList {
     pub leave_call: Interactive<ContainedText>,
     pub contact_row: Interactive<ContainerStyle>,
     pub row_height: f32,
+    pub project_row: Interactive<ProjectRow>,
+    pub tree_branch: Interactive<TreeBranch>,
     pub contact_avatar: ImageStyle,
     pub contact_status_free: ContainerStyle,
     pub contact_status_busy: ContainerStyle,
@@ -112,6 +114,19 @@ pub struct ContactList {
     pub calling_indicator: ContainedText,
 }
 
+#[derive(Deserialize, Default)]
+pub struct ProjectRow {
+    #[serde(flatten)]
+    pub container: ContainerStyle,
+    pub name: ContainedText,
+}
+
+#[derive(Deserialize, Default, Clone, Copy)]
+pub struct TreeBranch {
+    pub width: f32,
+    pub color: Color,
+}
+
 #[derive(Deserialize, Default)]
 pub struct ContactFinder {
     pub picker: Picker,

styles/src/styleTree/contactList.ts 🔗

@@ -12,6 +12,31 @@ export default function contactList(theme: Theme) {
     buttonWidth: 16,
     cornerRadius: 8,
   };
+  const projectRow = {
+    guestAvatarSpacing: 4,
+    height: 24,
+    guestAvatar: {
+      cornerRadius: 8,
+      width: 14,
+    },
+    name: {
+      ...text(theme, "mono", "placeholder", { size: "sm" }),
+      margin: {
+        left: nameMargin,
+        right: 6,
+      },
+    },
+    guests: {
+      margin: {
+        left: nameMargin,
+        right: nameMargin,
+      },
+    },
+    padding: {
+      left: sidePadding,
+      right: sidePadding,
+    },
+  };
 
   return {
     userQueryEditor: {
@@ -129,6 +154,30 @@ export default function contactList(theme: Theme) {
     },
     callingIndicator: {
       ...text(theme, "mono", "muted", { size: "xs" })
-    }
+    },
+    treeBranch: {
+      color: borderColor(theme, "active"),
+      width: 1,
+      hover: {
+        color: borderColor(theme, "active"),
+      },
+      active: {
+        color: borderColor(theme, "active"),
+      },
+    },
+    projectRow: {
+      ...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"),
+      },
+    },
   }
 }