collab panel2 (#3447)

Conrad Irwin created

[[PR Description]]

Release Notes:

- N/A

Change summary

crates/collab_ui2/src/collab_panel.rs       | 240 ++++++++--------------
crates/gpui2/src/geometry.rs                |   2 
crates/ui2/src/components/avatar.rs         |  41 +++
crates/ui2/src/components/list/list_item.rs |  28 +-
crates/ui2/src/components/stories/avatar.rs |   8 
5 files changed, 144 insertions(+), 175 deletions(-)

Detailed changes

crates/collab_ui2/src/collab_panel.rs 🔗

@@ -175,7 +175,7 @@ use gpui::{
     Point, PromptLevel, Render, RenderOnce, SharedString, Stateful, Styled, Subscription, Task,
     View, ViewContext, VisualContext, WeakView,
 };
-use project::Fs;
+use project::{Fs, Project};
 use serde_derive::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
 use ui::prelude::*;
@@ -300,7 +300,7 @@ pub struct CollabPanel {
     channel_store: Model<ChannelStore>,
     user_store: Model<UserStore>,
     client: Arc<Client>,
-    // project: ModelHandle<Project>,
+    project: Model<Project>,
     match_candidates: Vec<StringMatchCandidate>,
     // list_state: ListState<Self>,
     subscriptions: Vec<Subscription>,
@@ -583,7 +583,7 @@ impl CollabPanel {
                 selection: None,
                 channel_store: ChannelStore::global(cx),
                 user_store: workspace.user_store().clone(),
-                //                 project: workspace.project().clone(),
+                project: workspace.project().clone(),
                 subscriptions: Vec::default(),
                 match_candidates: Vec::default(),
                 collapsed_sections: vec![Section::Offline],
@@ -2281,18 +2281,13 @@ impl CollabPanel {
     //             .detach();
     //     }
 
-    //     fn call(
-    //         &mut self,
-    //         recipient_user_id: u64,
-    //         initial_project: Option<ModelHandle<Project>>,
-    //         cx: &mut ViewContext<Self>,
-    //     ) {
-    //         ActiveCall::global(cx)
-    //             .update(cx, |call, cx| {
-    //                 call.invite(recipient_user_id, initial_project, cx)
-    //             })
-    //             .detach_and_log_err(cx);
-    //     }
+    fn call(&mut self, recipient_user_id: u64, cx: &mut ViewContext<Self>) {
+        ActiveCall::global(cx)
+            .update(cx, |call, cx| {
+                call.invite(recipient_user_id, Some(self.project.clone()), cx)
+            })
+            .detach_and_log_err(cx);
+    }
 
     fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
         let Some(handle) = cx.window_handle().downcast::<Workspace>() else {
@@ -2476,23 +2471,11 @@ impl CollabPanel {
                     .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
                     .tooltip(|cx| Tooltip::text("Search for new contact", cx)),
             ),
-            Section::Channels => {
-                // todo!()
-                // if cx
-                //     .global::<DragAndDrop<Workspace>>()
-                //     .currently_dragged::<Channel>(cx.window())
-                //     .is_some()
-                //     && self.drag_target_channel == ChannelDragTarget::Root
-                // {
-                //     is_dragged_over = true;
-                // }
-
-                Some(
-                    IconButton::new("add-channel", Icon::Plus)
-                        .on_click(cx.listener(|this, _, cx| this.new_root_channel(cx)))
-                        .tooltip(|cx| Tooltip::text("Create a channel", cx)),
-                )
-            }
+            Section::Channels => Some(
+                IconButton::new("add-channel", Icon::Plus)
+                    .on_click(cx.listener(|this, _, cx| this.new_root_channel(cx)))
+                    .tooltip(|cx| Tooltip::text("Create a channel", cx)),
+            ),
             _ => None,
         };
 
@@ -2504,18 +2487,26 @@ impl CollabPanel {
             | Section::Offline => true,
         };
 
-        let header = ListHeader::new(text)
-            .when_some(button, |el, button| el.right_button(button))
-            .selected(is_selected)
-            .when(can_collapse, |el| {
-                el.toggle(is_collapsed).on_toggle(
-                    cx.listener(move |this, _, cx| this.toggle_section_expanded(section, cx)),
-                )
-            });
-
         h_stack()
             .w_full()
-            .child(header)
+            .map(|el| {
+                if can_collapse {
+                    el.child(
+                        ListItem::new(text.clone())
+                            .child(div().w_full().child(Label::new(text)))
+                            .toggle(Some(!is_collapsed))
+                            .on_click(cx.listener(move |this, _, cx| {
+                                this.toggle_section_expanded(section, cx)
+                            })),
+                    )
+                } else {
+                    el.child(
+                        ListHeader::new(text)
+                            .when_some(button, |el, button| el.right_button(button))
+                            .selected(is_selected),
+                    )
+                }
+            })
             .when(section == Section::Channels, |el| {
                 el.drag_over::<DraggedChannelView>(|style| {
                     style.bg(cx.theme().colors().ghost_element_hover)
@@ -2560,113 +2551,57 @@ impl CollabPanel {
                     .w_full()
                     .justify_between()
                     .child(Label::new(github_login.clone()))
-                    .child(
-                        div()
-                            .id("remove_contact")
-                            .invisible()
-                            .group_hover("", |style| style.visible())
-                            .child(
-                                IconButton::new("remove_contact", Icon::Close)
-                                    .icon_color(Color::Muted)
-                                    .tooltip(|cx| Tooltip::text("Remove Contact", cx))
-                                    .on_click(cx.listener(move |this, _, cx| {
-                                        this.remove_contact(user_id, &github_login, cx);
-                                    })),
-                            ),
-                    ),
-            );
-
-        if let Some(avatar) = contact.user.avatar.clone() {
-            item = item.left_avatar(avatar);
-        }
-
-        div().group("").child(item)
-        // let event_handler =
-        //     MouseEventHandler::new::<Contact, _>(contact.user.id as usize, cx, |state, cx| {
-        //         Flex::row()
-        //             .with_children(contact.user.avatar.clone().map(|avatar| {
-        //                 let status_badge = if contact.online {
-        //                     Some(
-        //                         Empty::new()
-        //                             .collapsed()
-        //                             .contained()
-        //                             .with_style(if busy {
-        //                                 collab_theme.contact_status_busy
-        //                             } else {
-        //                                 collab_theme.contact_status_free
-        //                             })
-        //                             .aligned(),
-        //                     )
-        //                 } else {
-        //                     None
-        //                 };
-        //                 Stack::new()
-        //                     .with_child(
-        //                         Image::from_data(avatar)
-        //                             .with_style(collab_theme.contact_avatar)
-        //                             .aligned()
-        //                             .left(),
-        //                     )
-        //                     .with_children(status_badge)
-        //             }))
-
-        //             .with_children(if calling {
-        //                 Some(
-        //                     Label::new("Calling", collab_theme.calling_indicator.text.clone())
-        //                         .contained()
-        //                         .with_style(collab_theme.calling_indicator.container)
-        //                         .aligned(),
-        //                 )
-        //             } else {
-        //                 None
-        //             })
-        //             .constrained()
-        //             .with_height(collab_theme.row_height)
-        //             .contained()
-        //             .with_style(
-        //                 *collab_theme
-        //                     .contact_row
-        //                     .in_state(is_selected)
-        //                     .style_for(state),
-        //             )
-        //     });
-
-        // if online && !busy {
-        //     let room = ActiveCall::global(cx).read(cx).room();
-        //     let label = if room.is_some() {
-        //         format!("Invite {} to join call", contact.user.github_login)
-        //     } else {
-        //         format!("Call {}", contact.user.github_login)
-        //     };
+                    .when(calling, |el| {
+                        el.child(Label::new("Calling").color(Color::Muted))
+                    })
+                    .when(!calling, |el| {
+                        el.child(
+                            div()
+                                .id("remove_contact")
+                                .invisible()
+                                .group_hover("", |style| style.visible())
+                                .child(
+                                    IconButton::new("remove_contact", Icon::Close)
+                                        .icon_color(Color::Muted)
+                                        .tooltip(|cx| Tooltip::text("Remove Contact", cx))
+                                        .on_click(cx.listener({
+                                            let github_login = github_login.clone();
+                                            move |this, _, cx| {
+                                                this.remove_contact(user_id, &github_login, cx);
+                                            }
+                                        })),
+                                ),
+                        )
+                    }),
+            )
+            .left_child(
+                // todo!() handle contacts with no avatar
+                Avatar::data(contact.user.avatar.clone().unwrap())
+                    .availability_indicator(if online { Some(!busy) } else { None }),
+            )
+            .when(online && !busy, |el| {
+                el.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
+            });
 
-        //     event_handler
-        //         .on_click(MouseButton::Left, move |_, this, cx| {
-        //             this.call(user_id, Some(initial_project.clone()), cx);
-        //         })
-        //         .with_cursor_style(CursorStyle::PointingHand)
-        //         .with_tooltip::<ContactTooltip>(
-        //             contact.user.id as usize,
-        //             label,
-        //             None,
-        //             theme.tooltip.clone(),
-        //             cx,
-        //         )
-        //         .into_any()
-        // } else {
-        //     event_handler
-        //         .with_tooltip::<ContactTooltip>(
-        //             contact.user.id as usize,
-        //             format!(
-        //                 "{} is {}",
-        //                 contact.user.github_login,
-        //                 if busy { "on a call" } else { "offline" }
-        //             ),
-        //             None,
-        //             theme.tooltip.clone(),
-        //             cx,
-        //         )
-        //         .into_any()
-        // };
+        div()
+            .id(github_login.clone())
+            .group("")
+            .child(item)
+            .tooltip(move |cx| {
+                let text = if !online {
+                    format!(" {} is offline", &github_login)
+                } else if busy {
+                    format!(" {} is on a call", &github_login)
+                } else {
+                    let room = ActiveCall::global(cx).read(cx).room();
+                    if room.is_some() {
+                        format!("Invite {} to join call", &github_login)
+                    } else {
+                        format!("Call {}", &github_login)
+                    }
+                };
+                Tooltip::text(text, cx)
+            })
     }
 
     fn render_contact_request(
@@ -2834,8 +2769,7 @@ impl CollabPanel {
                                 h_stack()
                                     .id(channel_id as usize)
                                     .child(Label::new(channel.name.clone()))
-                                    .children(face_pile.map(|face_pile| face_pile.render(cx)))
-                                    .tooltip(|cx| Tooltip::text("Join channel", cx)),
+                                    .children(face_pile.map(|face_pile| face_pile.render(cx))),
                             )
                             .child(
                                 h_stack()
@@ -2897,6 +2831,7 @@ impl CollabPanel {
                         },
                     )),
             )
+            .tooltip(|cx| Tooltip::text("Join channel", cx))
 
         // let channel_id = channel.id;
         // let collab_theme = &theme.collab_panel;
@@ -3279,12 +3214,15 @@ impl CollabPanel {
 // }
 
 impl Render for CollabPanel {
-    type Element = Focusable<Div>;
+    type Element = Focusable<Stateful<Div>>;
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
         div()
+            .id("collab-panel")
             .key_context("CollabPanel")
             .track_focus(&self.focus_handle)
+            .size_full()
+            .overflow_scroll()
             .on_action(cx.listener(Self::confirm))
             .on_action(cx.listener(Self::insert_space))
             .map(|el| {

crates/gpui2/src/geometry.rs 🔗

@@ -1034,7 +1034,7 @@ impl sqlez::bindable::Bind for GlobalPixels {
 }
 
 #[derive(Clone, Copy, Default, Add, Sub, Mul, Div, Neg)]
-pub struct Rems(f32);
+pub struct Rems(pub f32);
 
 impl Mul<Pixels> for Rems {
     type Output = Pixels;

crates/ui2/src/components/avatar.rs 🔗

@@ -1,7 +1,7 @@
 use std::sync::Arc;
 
 use crate::prelude::*;
-use gpui::{img, ImageData, ImageSource, Img, IntoElement};
+use gpui::{img, rems, Div, ImageData, ImageSource, IntoElement, Styled};
 
 #[derive(Debug, Default, PartialEq, Clone)]
 pub enum Shape {
@@ -13,13 +13,14 @@ pub enum Shape {
 #[derive(IntoElement)]
 pub struct Avatar {
     src: ImageSource,
+    is_available: Option<bool>,
     shape: Shape,
 }
 
 impl RenderOnce for Avatar {
-    type Rendered = Img;
+    type Rendered = Div;
 
-    fn render(self, _: &mut WindowContext) -> Self::Rendered {
+    fn render(self, cx: &mut WindowContext) -> Self::Rendered {
         let mut img = img();
 
         if self.shape == Shape::Circle {
@@ -28,10 +29,29 @@ impl RenderOnce for Avatar {
             img = img.rounded_md();
         }
 
-        img.source(self.src.clone())
-            .size_4()
-            // todo!(Pull the avatar fallback background from the theme.)
-            .bg(gpui::red())
+        let size = rems(1.0);
+
+        div()
+            .size(size)
+            .child(
+                img.source(self.src.clone())
+                    .size(size)
+                    // todo!(Pull the avatar fallback background from the theme.)
+                    .bg(gpui::red()),
+            )
+            .children(self.is_available.map(|is_free| {
+                // HACK: non-integer sizes result in oval indicators.
+                let indicator_size = (size.0 * cx.rem_size() * 0.4).round();
+
+                div()
+                    .absolute()
+                    .z_index(1)
+                    .bg(if is_free { gpui::green() } else { gpui::red() })
+                    .size(indicator_size)
+                    .rounded(indicator_size)
+                    .bottom_0()
+                    .right_0()
+            }))
     }
 }
 
@@ -40,12 +60,14 @@ impl Avatar {
         Self {
             src: src.into().into(),
             shape: Shape::Circle,
+            is_available: None,
         }
     }
     pub fn data(src: Arc<ImageData>) -> Self {
         Self {
             src: src.into(),
             shape: Shape::Circle,
+            is_available: None,
         }
     }
 
@@ -53,10 +75,15 @@ impl Avatar {
         Self {
             src,
             shape: Shape::Circle,
+            is_available: None,
         }
     }
     pub fn shape(mut self, shape: Shape) -> Self {
         self.shape = shape;
         self
     }
+    pub fn availability_indicator(mut self, is_available: impl Into<Option<bool>>) -> Self {
+        self.is_available = is_available.into();
+        self
+    }
 }

crates/ui2/src/components/list/list_item.rs 🔗

@@ -6,7 +6,7 @@ use gpui::{
 use smallvec::SmallVec;
 
 use crate::prelude::*;
-use crate::{Avatar, Disclosure, GraphicSlot, Icon, IconElement, IconSize};
+use crate::{Avatar, Disclosure, Icon, IconElement, IconSize};
 
 #[derive(IntoElement)]
 pub struct ListItem {
@@ -16,7 +16,7 @@ pub struct ListItem {
     // disclosure_control_style: DisclosureControlVisibility,
     indent_level: usize,
     indent_step_size: Pixels,
-    left_slot: Option<GraphicSlot>,
+    left_slot: Option<AnyElement>,
     toggle: Option<bool>,
     inset: bool,
     on_click: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
@@ -88,18 +88,23 @@ impl ListItem {
         self
     }
 
-    pub fn left_content(mut self, left_content: GraphicSlot) -> Self {
-        self.left_slot = Some(left_content);
+    pub fn left_child(mut self, left_content: impl IntoElement) -> Self {
+        self.left_slot = Some(left_content.into_any_element());
         self
     }
 
     pub fn left_icon(mut self, left_icon: Icon) -> Self {
-        self.left_slot = Some(GraphicSlot::Icon(left_icon));
+        self.left_slot = Some(
+            IconElement::new(left_icon)
+                .size(IconSize::Small)
+                .color(Color::Muted)
+                .into_any_element(),
+        );
         self
     }
 
     pub fn left_avatar(mut self, left_avatar: impl Into<ImageSource>) -> Self {
-        self.left_slot = Some(GraphicSlot::Avatar(left_avatar.into()));
+        self.left_slot = Some(Avatar::source(left_avatar.into()).into_any_element());
         self
     }
 }
@@ -154,16 +159,7 @@ impl RenderOnce for ListItem {
                         self.toggle
                             .map(|is_open| Disclosure::new(is_open).on_toggle(self.on_toggle)),
                     )
-                    .map(|this| match self.left_slot {
-                        Some(GraphicSlot::Icon(i)) => this.child(
-                            IconElement::new(i)
-                                .size(IconSize::Small)
-                                .color(Color::Muted),
-                        ),
-                        Some(GraphicSlot::Avatar(src)) => this.child(Avatar::source(src)),
-                        Some(GraphicSlot::PublicActor(src)) => this.child(Avatar::uri(src)),
-                        None => this,
-                    })
+                    .children(self.left_slot)
                     .children(self.children),
             )
     }

crates/ui2/src/components/stories/avatar.rs 🔗

@@ -19,5 +19,13 @@ impl Render for AvatarStory {
             .child(Avatar::uri(
                 "https://avatars.githubusercontent.com/u/326587?v=4",
             ))
+            .child(
+                Avatar::uri("https://avatars.githubusercontent.com/u/326587?v=4")
+                    .availability_indicator(true),
+            )
+            .child(
+                Avatar::uri("https://avatars.githubusercontent.com/u/326587?v=4")
+                    .availability_indicator(false),
+            )
     }
 }