Restructure collab panel to allow opening chat + notes w/ one click

Max Brunsfeld created

Change summary

crates/channel/src/channel_chat.rs   |   4 
crates/channel/src/channel_store.rs  |   6 
crates/collab_ui/src/chat_panel.rs   |  52 +++--
crates/collab_ui/src/collab_panel.rs | 257 +++++++++++++++++++++--------
crates/gpui/src/views.rs             |   4 
crates/gpui/src/views/select.rs      |  26 --
6 files changed, 234 insertions(+), 115 deletions(-)

Detailed changes

crates/channel/src/channel_chat.rs 🔗

@@ -106,8 +106,8 @@ impl ChannelChat {
         }))
     }
 
-    pub fn name(&self) -> &str {
-        &self.channel.name
+    pub fn channel(&self) -> &Arc<Channel> {
+        &self.channel
     }
 
     pub fn send_message(

crates/channel/src/channel_store.rs 🔗

@@ -129,6 +129,12 @@ impl ChannelStore {
         self.channel_paths.len()
     }
 
+    pub fn index_of_channel(&self, channel_id: ChannelId) -> Option<usize> {
+        self.channel_paths
+            .iter()
+            .position(|path| path.ends_with(&[channel_id]))
+    }
+
     pub fn channels(&self) -> impl '_ + Iterator<Item = (usize, &Arc<Channel>)> {
         self.channel_paths.iter().map(move |path| {
             let id = path.last().unwrap();

crates/collab_ui/src/chat_panel.rs 🔗

@@ -157,16 +157,8 @@ impl ChatPanel {
                     .channel_at_index(selected_ix)
                     .map(|e| e.1.id);
                 if let Some(selected_channel_id) = selected_channel_id {
-                    let open_chat = this.channel_store.update(cx, |store, cx| {
-                        store.open_channel_chat(selected_channel_id, cx)
-                    });
-                    cx.spawn(|this, mut cx| async move {
-                        let chat = open_chat.await?;
-                        this.update(&mut cx, |this, cx| {
-                            this.set_active_channel(chat, cx);
-                        })
-                    })
-                    .detach_and_log_err(cx);
+                    this.select_channel(selected_channel_id, cx)
+                        .detach_and_log_err(cx);
                 }
             })
             .detach();
@@ -230,22 +222,24 @@ impl ChatPanel {
         });
     }
 
-    fn set_active_channel(
-        &mut self,
-        channel: ModelHandle<ChannelChat>,
-        cx: &mut ViewContext<Self>,
-    ) {
-        if self.active_channel.as_ref().map(|e| &e.0) != Some(&channel) {
+    fn set_active_channel(&mut self, chat: ModelHandle<ChannelChat>, cx: &mut ViewContext<Self>) {
+        if self.active_channel.as_ref().map(|e| &e.0) != Some(&chat) {
+            let id = chat.read(cx).channel().id;
             {
-                let channel = channel.read(cx);
-                self.message_list.reset(channel.message_count());
-                let placeholder = format!("Message #{}", channel.name());
+                let chat = chat.read(cx);
+                self.message_list.reset(chat.message_count());
+                let placeholder = format!("Message #{}", chat.channel().name);
                 self.input_editor.update(cx, move |editor, cx| {
                     editor.set_placeholder_text(placeholder, cx);
                 });
             }
-            let subscription = cx.subscribe(&channel, Self::channel_did_change);
-            self.active_channel = Some((channel, subscription));
+            let subscription = cx.subscribe(&chat, Self::channel_did_change);
+            self.active_channel = Some((chat, subscription));
+            self.channel_select.update(cx, |select, cx| {
+                if let Some(ix) = self.channel_store.read(cx).index_of_channel(id) {
+                    select.set_selected_index(ix, cx);
+                }
+            });
         }
     }
 
@@ -424,6 +418,22 @@ impl ChatPanel {
             })
         }
     }
+
+    pub fn select_channel(
+        &mut self,
+        selected_channel_id: u64,
+        cx: &mut ViewContext<ChatPanel>,
+    ) -> Task<Result<()>> {
+        let open_chat = self.channel_store.update(cx, |store, cx| {
+            store.open_channel_chat(selected_channel_id, cx)
+        });
+        cx.spawn(|this, mut cx| async move {
+            let chat = open_chat.await?;
+            this.update(&mut cx, |this, cx| {
+                this.set_active_channel(chat, cx);
+            })
+        })
+    }
 }
 
 impl Entity for ChatPanel {

crates/collab_ui/src/collab_panel.rs 🔗

@@ -3,6 +3,7 @@ mod contact_finder;
 
 use crate::{
     channel_view::{self, ChannelView},
+    chat_panel::ChatPanel,
     face_pile::FacePile,
     CollaborationPanelSettings,
 };
@@ -203,7 +204,7 @@ enum Section {
 
 #[derive(Clone, Debug)]
 enum ListEntry {
-    Header(Section, usize),
+    Header(Section),
     CallParticipant {
         user: Arc<User>,
         is_pending: bool,
@@ -225,6 +226,10 @@ enum ListEntry {
         channel: Arc<Channel>,
         depth: usize,
     },
+    ChannelCall {
+        channel: Arc<Channel>,
+        depth: usize,
+    },
     ChannelNotes {
         channel_id: ChannelId,
     },
@@ -269,7 +274,7 @@ impl CollabPanel {
                         this.selection = this
                             .entries
                             .iter()
-                            .position(|entry| !matches!(entry, ListEntry::Header(_, _)));
+                            .position(|entry| !matches!(entry, ListEntry::Header(_)));
                     }
                 }
             })
@@ -305,16 +310,9 @@ impl CollabPanel {
                     let current_project_id = this.project.read(cx).remote_id();
 
                     match &this.entries[ix] {
-                        ListEntry::Header(section, depth) => {
+                        ListEntry::Header(section) => {
                             let is_collapsed = this.collapsed_sections.contains(section);
-                            this.render_header(
-                                *section,
-                                &theme,
-                                *depth,
-                                is_selected,
-                                is_collapsed,
-                                cx,
-                            )
+                            this.render_header(*section, &theme, is_selected, is_collapsed, cx)
                         }
                         ListEntry::CallParticipant { user, is_pending } => {
                             Self::render_call_participant(
@@ -371,6 +369,13 @@ impl CollabPanel {
                                 return channel_row;
                             }
                         }
+                        ListEntry::ChannelCall { channel, depth } => this.render_channel_call(
+                            &*channel,
+                            *depth,
+                            &theme.collab_panel,
+                            is_selected,
+                            cx,
+                        ),
                         ListEntry::ChannelNotes { channel_id } => this.render_channel_notes(
                             *channel_id,
                             &theme.collab_panel,
@@ -558,7 +563,7 @@ impl CollabPanel {
         let old_entries = mem::take(&mut self.entries);
 
         if let Some(room) = ActiveCall::global(cx).read(cx).room() {
-            self.entries.push(ListEntry::Header(Section::ActiveCall, 0));
+            self.entries.push(ListEntry::Header(Section::ActiveCall));
 
             if !self.collapsed_sections.contains(&Section::ActiveCall) {
                 let room = room.read(cx);
@@ -673,7 +678,7 @@ impl CollabPanel {
         let mut request_entries = Vec::new();
 
         if cx.has_flag::<ChannelsAlpha>() {
-            self.entries.push(ListEntry::Header(Section::Channels, 0));
+            self.entries.push(ListEntry::Header(Section::Channels));
 
             if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
                 self.match_candidates.clear();
@@ -746,6 +751,12 @@ impl CollabPanel {
                                 channel: channel.clone(),
                                 depth,
                             });
+                            if !channel_store.channel_participants(channel.id).is_empty() {
+                                self.entries.push(ListEntry::ChannelCall {
+                                    channel: channel.clone(),
+                                    depth,
+                                });
+                            }
                         }
                     }
                 }
@@ -776,7 +787,7 @@ impl CollabPanel {
 
                 if !request_entries.is_empty() {
                     self.entries
-                        .push(ListEntry::Header(Section::ChannelInvites, 1));
+                        .push(ListEntry::Header(Section::ChannelInvites));
                     if !self.collapsed_sections.contains(&Section::ChannelInvites) {
                         self.entries.append(&mut request_entries);
                     }
@@ -784,7 +795,7 @@ impl CollabPanel {
             }
         }
 
-        self.entries.push(ListEntry::Header(Section::Contacts, 0));
+        self.entries.push(ListEntry::Header(Section::Contacts));
 
         request_entries.clear();
         let incoming = user_store.incoming_contact_requests();
@@ -847,7 +858,7 @@ impl CollabPanel {
 
         if !request_entries.is_empty() {
             self.entries
-                .push(ListEntry::Header(Section::ContactRequests, 1));
+                .push(ListEntry::Header(Section::ContactRequests));
             if !self.collapsed_sections.contains(&Section::ContactRequests) {
                 self.entries.append(&mut request_entries);
             }
@@ -886,7 +897,7 @@ impl CollabPanel {
                 (offline_contacts, Section::Offline),
             ] {
                 if !matches.is_empty() {
-                    self.entries.push(ListEntry::Header(section, 1));
+                    self.entries.push(ListEntry::Header(section));
                     if !self.collapsed_sections.contains(&section) {
                         let active_call = &ActiveCall::global(cx).read(cx);
                         for mat in matches {
@@ -1174,7 +1185,6 @@ impl CollabPanel {
         &self,
         section: Section,
         theme: &theme::Theme,
-        depth: usize,
         is_selected: bool,
         is_collapsed: bool,
         cx: &mut ViewContext<Self>,
@@ -1282,7 +1292,13 @@ impl CollabPanel {
             _ => None,
         };
 
-        let can_collapse = depth > 0;
+        let can_collapse = match section {
+            Section::ActiveCall | Section::Channels | Section::Contacts => false,
+            Section::ChannelInvites
+            | Section::ContactRequests
+            | Section::Online
+            | Section::Offline => true,
+        };
         let icon_size = (&theme.collab_panel).section_icon_size;
         let mut result = MouseEventHandler::new::<Header, _>(section as usize, cx, |state, _| {
             let header_style = if can_collapse {
@@ -1550,17 +1566,8 @@ impl CollabPanel {
         let disclosed =
             has_children.then(|| !self.collapsed_channels.binary_search(&channel_id).is_ok());
 
-        let is_active = iife!({
-            let call_channel = ActiveCall::global(cx)
-                .read(cx)
-                .room()?
-                .read(cx)
-                .channel_id()?;
-            Some(call_channel == channel_id)
-        })
-        .unwrap_or(false);
-
-        const FACEPILE_LIMIT: usize = 3;
+        enum ChannelCall {}
+        enum ChannelNotes {}
 
         MouseEventHandler::new::<Channel, _>(channel.id as usize, cx, |state, cx| {
             Flex::<Self>::row()
@@ -1580,37 +1587,32 @@ impl CollabPanel {
                         .left()
                         .flex(1., true),
                 )
-                .with_children({
-                    let participants = self.channel_store.read(cx).channel_participants(channel_id);
-                    if !participants.is_empty() {
-                        let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
-
-                        Some(
-                            FacePile::new(theme.face_overlap)
-                                .with_children(
-                                    participants
-                                        .iter()
-                                        .filter_map(|user| {
-                                            Some(
-                                                Image::from_data(user.avatar.clone()?)
-                                                    .with_style(theme.channel_avatar),
-                                            )
-                                        })
-                                        .take(FACEPILE_LIMIT),
-                                )
-                                .with_children((extra_count > 0).then(|| {
-                                    Label::new(
-                                        format!("+{}", extra_count),
-                                        theme.extra_participant_label.text.clone(),
-                                    )
-                                    .contained()
-                                    .with_style(theme.extra_participant_label.container)
-                                })),
-                        )
-                    } else {
-                        None
-                    }
-                })
+                .with_child(
+                    MouseEventHandler::new::<ChannelCall, _>(channel_id as usize, cx, |_, _| {
+                        Svg::new("icons/radix/speaker-loud.svg")
+                            .with_color(theme.channel_hash.color)
+                            .constrained()
+                            .with_width(theme.channel_hash.width)
+                            .aligned()
+                            .right()
+                    })
+                    .on_click(MouseButton::Left, move |_, this, cx| {
+                        this.join_channel_call(channel_id, cx)
+                    }),
+                )
+                .with_child(
+                    MouseEventHandler::new::<ChannelNotes, _>(channel_id as usize, cx, |_, _| {
+                        Svg::new("icons/radix/file.svg")
+                            .with_color(theme.channel_hash.color)
+                            .constrained()
+                            .with_width(theme.channel_hash.width)
+                            .aligned()
+                            .right()
+                    })
+                    .on_click(MouseButton::Left, move |_, this, cx| {
+                        this.open_channel_buffer(&OpenChannelBuffer { channel_id }, cx);
+                    }),
+                )
                 .align_children_center()
                 .styleable_component()
                 .disclosable(disclosed, Box::new(ToggleCollapse { channel_id }))
@@ -1620,14 +1622,102 @@ impl CollabPanel {
                 .constrained()
                 .with_height(theme.row_height)
                 .contained()
-                .with_style(*theme.channel_row.style_for(is_selected || is_active, state))
+                .with_style(*theme.channel_row.style_for(is_selected, state))
                 .with_padding_left(
                     theme.channel_row.default_style().padding.left
                         + theme.channel_indent * depth as f32,
                 )
         })
         .on_click(MouseButton::Left, move |_, this, cx| {
-            this.join_channel(channel_id, cx);
+            this.join_channel_chat(channel_id, cx);
+        })
+        .on_click(MouseButton::Right, move |e, this, cx| {
+            this.deploy_channel_context_menu(Some(e.position), channel_id, cx);
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .into_any()
+    }
+
+    fn render_channel_call(
+        &self,
+        channel: &Channel,
+        depth: usize,
+        theme: &theme::CollabPanel,
+        is_selected: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        let channel_id = channel.id;
+
+        let is_active = iife!({
+            let call_channel = ActiveCall::global(cx)
+                .read(cx)
+                .room()?
+                .read(cx)
+                .channel_id()?;
+            Some(call_channel == channel_id)
+        })
+        .unwrap_or(false);
+
+        const FACEPILE_LIMIT: usize = 5;
+
+        enum ChannelCall {}
+
+        let host_avatar_width = theme
+            .contact_avatar
+            .width
+            .or(theme.contact_avatar.height)
+            .unwrap_or(0.);
+
+        MouseEventHandler::new::<ChannelCall, _>(channel.id as usize, cx, |state, cx| {
+            let participants = self.channel_store.read(cx).channel_participants(channel_id);
+            let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
+            let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state);
+            let row = theme.project_row.in_state(is_selected).style_for(state);
+
+            Flex::<Self>::row()
+                .with_child(render_tree_branch(
+                    tree_branch,
+                    &row.name.text,
+                    true,
+                    vec2f(host_avatar_width, theme.row_height),
+                    cx.font_cache(),
+                ))
+                .with_child(
+                    FacePile::new(theme.face_overlap)
+                        .with_children(
+                            participants
+                                .iter()
+                                .filter_map(|user| {
+                                    Some(
+                                        Image::from_data(user.avatar.clone()?)
+                                            .with_style(theme.channel_avatar),
+                                    )
+                                })
+                                .take(FACEPILE_LIMIT),
+                        )
+                        .with_children((extra_count > 0).then(|| {
+                            Label::new(
+                                format!("+{}", extra_count),
+                                theme.extra_participant_label.text.clone(),
+                            )
+                            .contained()
+                            .with_style(theme.extra_participant_label.container)
+                        })),
+                )
+                .align_children_center()
+                .constrained()
+                .with_height(theme.row_height)
+                .aligned()
+                .left()
+                .contained()
+                .with_style(*theme.channel_row.style_for(is_selected || is_active, state))
+                .with_padding_left(
+                    theme.channel_row.default_style().padding.left
+                        + theme.channel_indent * (depth + 1) as f32,
+                )
+        })
+        .on_click(MouseButton::Left, move |_, this, cx| {
+            this.join_channel_call(channel_id, cx);
         })
         .on_click(MouseButton::Right, move |e, this, cx| {
             this.deploy_channel_context_menu(Some(e.position), channel_id, cx);
@@ -1982,7 +2072,7 @@ impl CollabPanel {
         if let Some(selection) = self.selection {
             if let Some(entry) = self.entries.get(selection) {
                 match entry {
-                    ListEntry::Header(section, _) => match section {
+                    ListEntry::Header(section) => match section {
                         Section::ActiveCall => Self::leave_call(cx),
                         Section::Channels => self.new_root_channel(cx),
                         Section::Contacts => self.toggle_contact_finder(cx),
@@ -2022,7 +2112,10 @@ impl CollabPanel {
                         }
                     }
                     ListEntry::Channel { channel, .. } => {
-                        self.join_channel(channel.id, cx);
+                        self.join_channel_chat(channel.id, cx);
+                    }
+                    ListEntry::ChannelCall { channel, .. } => {
+                        self.join_channel_call(channel.id, cx);
                     }
                     ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
                     _ => {}
@@ -2411,11 +2504,25 @@ impl CollabPanel {
             .detach_and_log_err(cx);
     }
 
-    fn join_channel(&self, channel: u64, cx: &mut ViewContext<Self>) {
+    fn join_channel_call(&self, channel: u64, cx: &mut ViewContext<Self>) {
         ActiveCall::global(cx)
             .update(cx, |call, cx| call.join_channel(channel, cx))
             .detach_and_log_err(cx);
     }
+
+    fn join_channel_chat(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
+        if let Some(workspace) = self.workspace.upgrade(cx) {
+            cx.app_context().defer(move |cx| {
+                workspace.update(cx, |workspace, cx| {
+                    if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
+                        panel.update(cx, |panel, cx| {
+                            panel.select_channel(channel_id, cx).detach_and_log_err(cx);
+                        });
+                    }
+                });
+            });
+        }
+    }
 }
 
 fn render_tree_branch(
@@ -2606,9 +2713,9 @@ impl Panel for CollabPanel {
 impl PartialEq for ListEntry {
     fn eq(&self, other: &Self) -> bool {
         match self {
-            ListEntry::Header(section_1, depth_1) => {
-                if let ListEntry::Header(section_2, depth_2) = other {
-                    return section_1 == section_2 && depth_1 == depth_2;
+            ListEntry::Header(section_1) => {
+                if let ListEntry::Header(section_2) = other {
+                    return section_1 == section_2;
                 }
             }
             ListEntry::CallParticipant { user: user_1, .. } => {
@@ -2650,6 +2757,18 @@ impl PartialEq for ListEntry {
                     return channel_1.id == channel_2.id && depth_1 == depth_2;
                 }
             }
+            ListEntry::ChannelCall {
+                channel: channel_1,
+                depth: depth_1,
+            } => {
+                if let ListEntry::ChannelCall {
+                    channel: channel_2,
+                    depth: depth_2,
+                } = other
+                {
+                    return channel_1.id == channel_2.id && depth_1 == depth_2;
+                }
+            }
             ListEntry::ChannelNotes { channel_id } => {
                 if let ListEntry::ChannelNotes {
                     channel_id: other_id,

crates/gpui/src/views.rs 🔗

@@ -2,6 +2,4 @@ mod select;
 
 pub use select::{ItemType, Select, SelectStyle};
 
-pub fn init(cx: &mut super::AppContext) {
-    select::init(cx);
-}
+pub fn init(_: &mut super::AppContext) {}

crates/gpui/src/views/select.rs 🔗

@@ -1,8 +1,5 @@
-use serde::Deserialize;
-
 use crate::{
-    actions, elements::*, impl_actions, platform::MouseButton, AppContext, Entity, View,
-    ViewContext, WeakViewHandle,
+    elements::*, platform::MouseButton, AppContext, Entity, View, ViewContext, WeakViewHandle,
 };
 
 pub struct Select {
@@ -27,19 +24,8 @@ pub enum ItemType {
     Unselected,
 }
 
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct SelectItem(pub usize);
-
-actions!(select, [ToggleSelect]);
-impl_actions!(select, [SelectItem]);
-
 pub enum Event {}
 
-pub fn init(cx: &mut AppContext) {
-    cx.add_action(Select::toggle);
-    cx.add_action(Select::select_item);
-}
-
 impl Select {
     pub fn new<F: 'static + Fn(usize, ItemType, bool, &AppContext) -> AnyElement<Self>>(
         item_count: usize,
@@ -67,13 +53,13 @@ impl Select {
         cx.notify();
     }
 
-    fn toggle(&mut self, _: &ToggleSelect, cx: &mut ViewContext<Self>) {
+    fn toggle(&mut self, cx: &mut ViewContext<Self>) {
         self.is_open = !self.is_open;
         cx.notify();
     }
 
-    fn select_item(&mut self, action: &SelectItem, cx: &mut ViewContext<Self>) {
-        self.selected_item_ix = action.0;
+    pub fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Self>) {
+        self.selected_item_ix = ix;
         self.is_open = false;
         cx.notify();
     }
@@ -117,7 +103,7 @@ impl View for Select {
                 .with_style(style.header)
             })
             .on_click(MouseButton::Left, move |_, this, cx| {
-                this.toggle(&Default::default(), cx);
+                this.toggle(cx);
             }),
         );
         if self.is_open {
@@ -143,7 +129,7 @@ impl View for Select {
                                 )
                             })
                             .on_click(MouseButton::Left, move |_, this, cx| {
-                                this.select_item(&SelectItem(ix), cx);
+                                this.set_selected_index(ix, cx);
                             })
                             .into_any()
                         }))