collab_panel: Add ability to favorite a channel (#52378)

Danilo Leal created

This PR adds the ability to favorite a channel in the collab panel. Note
that favorited channels:
- appear at the very top of the panel
- also appear in their normal place in the tree
- are not stored in settings but rather in the local key-value store

<img width="500" height="618" alt="Screenshot 2026-03-25 at 1  11@2x"
src="https://github.com/user-attachments/assets/dda8d5ae-7b45-4846-acc9-4a940b487ac4"
/>

Release Notes:

- Collab: Added the ability to favorite channels in the collab panel.

Change summary

assets/keymaps/default-linux.json    |   1 
assets/keymaps/default-macos.json    |   1 
assets/keymaps/default-windows.json  |   1 
crates/collab_ui/src/collab_panel.rs | 322 ++++++++++++++++++++++-------
4 files changed, 243 insertions(+), 82 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -1077,6 +1077,7 @@
       "alt-up": "collab_panel::MoveChannelUp",
       "alt-down": "collab_panel::MoveChannelDown",
       "alt-enter": "collab_panel::OpenSelectedChannelNotes",
+      "shift-enter": "collab_panel::ToggleSelectedChannelFavorite",
     },
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -1138,6 +1138,7 @@
       "alt-up": "collab_panel::MoveChannelUp",
       "alt-down": "collab_panel::MoveChannelDown",
       "alt-enter": "collab_panel::OpenSelectedChannelNotes",
+      "shift-enter": "collab_panel::ToggleSelectedChannelFavorite",
     },
   },
   {

assets/keymaps/default-windows.json 🔗

@@ -1082,6 +1082,7 @@
       "alt-up": "collab_panel::MoveChannelUp",
       "alt-down": "collab_panel::MoveChannelDown",
       "alt-enter": "collab_panel::OpenSelectedChannelNotes",
+      "shift-enter": "collab_panel::ToggleSelectedChannelFavorite",
     },
   },
   {

crates/collab_ui/src/collab_panel.rs 🔗

@@ -61,6 +61,8 @@ actions!(
         ///
         /// Use `collab::OpenChannelNotes` to open the channel notes for the current call.
         OpenSelectedChannelNotes,
+        /// Toggles whether the selected channel is in the Favorites section.
+        ToggleSelectedChannelFavorite,
         /// Starts moving a channel to a new location.
         StartMoveChannel,
         /// Moves the selected item to the current location.
@@ -256,6 +258,7 @@ pub struct CollabPanel {
     subscriptions: Vec<Subscription>,
     collapsed_sections: Vec<Section>,
     collapsed_channels: Vec<ChannelId>,
+    favorite_channels: Vec<ChannelId>,
     filter_active_channels: bool,
     workspace: WeakEntity<Workspace>,
 }
@@ -263,11 +266,14 @@ pub struct CollabPanel {
 #[derive(Serialize, Deserialize)]
 struct SerializedCollabPanel {
     collapsed_channels: Option<Vec<u64>>,
+    #[serde(default)]
+    favorite_channels: Option<Vec<u64>>,
 }
 
 #[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
 enum Section {
     ActiveCall,
+    FavoriteChannels,
     Channels,
     ChannelInvites,
     ContactRequests,
@@ -387,6 +393,7 @@ impl CollabPanel {
                 match_candidates: Vec::default(),
                 collapsed_sections: vec![Section::Offline],
                 collapsed_channels: Vec::default(),
+                favorite_channels: Vec::default(),
                 filter_active_channels: false,
                 workspace: workspace.weak_handle(),
                 client: workspace.app_state().client.clone(),
@@ -460,7 +467,13 @@ impl CollabPanel {
                 panel.update(cx, |panel, cx| {
                     panel.collapsed_channels = serialized_panel
                         .collapsed_channels
-                        .unwrap_or_else(Vec::new)
+                        .unwrap_or_default()
+                        .iter()
+                        .map(|cid| ChannelId(*cid))
+                        .collect();
+                    panel.favorite_channels = serialized_panel
+                        .favorite_channels
+                        .unwrap_or_default()
                         .iter()
                         .map(|cid| ChannelId(*cid))
                         .collect();
@@ -493,12 +506,22 @@ impl CollabPanel {
         } else {
             Some(self.collapsed_channels.iter().map(|id| id.0).collect())
         };
+
+        let favorite_channels = if self.favorite_channels.is_empty() {
+            None
+        } else {
+            Some(self.favorite_channels.iter().map(|id| id.0).collect())
+        };
+
         let kvp = KeyValueStore::global(cx);
         self.pending_serialization = cx.background_spawn(
             async move {
                 kvp.write_kvp(
                     serialization_key,
-                    serde_json::to_string(&SerializedCollabPanel { collapsed_channels })?,
+                    serde_json::to_string(&SerializedCollabPanel {
+                        collapsed_channels,
+                        favorite_channels,
+                    })?,
                 )
                 .await?;
                 anyhow::Ok(())
@@ -512,10 +535,8 @@ impl CollabPanel {
     }
 
     fn update_entries(&mut self, select_same_item: bool, cx: &mut Context<Self>) {
-        let channel_store = self.channel_store.read(cx);
-        let user_store = self.user_store.read(cx);
         let query = self.filter_editor.read(cx).text(cx);
-        let fg_executor = cx.foreground_executor();
+        let fg_executor = cx.foreground_executor().clone();
         let executor = cx.background_executor().clone();
 
         let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
@@ -541,7 +562,7 @@ impl CollabPanel {
                 }
 
                 // Populate the active user.
-                if let Some(user) = user_store.current_user() {
+                if let Some(user) = self.user_store.read(cx).current_user() {
                     self.match_candidates.clear();
                     self.match_candidates
                         .push(StringMatchCandidate::new(0, &user.github_login));
@@ -662,6 +683,62 @@ impl CollabPanel {
 
         let mut request_entries = Vec::new();
 
+        let previous_len = self.favorite_channels.len();
+        self.favorite_channels
+            .retain(|id| self.channel_store.read(cx).channel_for_id(*id).is_some());
+        if self.favorite_channels.len() != previous_len {
+            self.serialize(cx);
+        }
+
+        let channel_store = self.channel_store.read(cx);
+        let user_store = self.user_store.read(cx);
+
+        if !self.favorite_channels.is_empty() {
+            let favorite_channels: Vec<_> = self
+                .favorite_channels
+                .iter()
+                .filter_map(|id| channel_store.channel_for_id(*id))
+                .collect();
+
+            self.match_candidates.clear();
+            self.match_candidates.extend(
+                favorite_channels
+                    .iter()
+                    .enumerate()
+                    .map(|(ix, channel)| StringMatchCandidate::new(ix, &channel.name)),
+            );
+
+            let matches = fg_executor.block_on(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+
+            if !matches.is_empty() || query.is_empty() {
+                self.entries
+                    .push(ListEntry::Header(Section::FavoriteChannels));
+
+                let matches_by_candidate: HashMap<usize, &StringMatch> =
+                    matches.iter().map(|mat| (mat.candidate_id, mat)).collect();
+
+                for (ix, channel) in favorite_channels.iter().enumerate() {
+                    if !query.is_empty() && !matches_by_candidate.contains_key(&ix) {
+                        continue;
+                    }
+                    self.entries.push(ListEntry::Channel {
+                        channel: (*channel).clone(),
+                        depth: 0,
+                        has_children: false,
+                        string_match: matches_by_candidate.get(&ix).cloned().cloned(),
+                    });
+                }
+            }
+        }
+
         self.entries.push(ListEntry::Header(Section::Channels));
 
         if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
@@ -1359,6 +1436,18 @@ impl CollabPanel {
                     window.handler_for(&this, move |this, _, cx| {
                         this.copy_channel_notes_link(channel_id, cx)
                     }),
+                )
+                .separator()
+                .entry(
+                    if self.is_channel_favorited(channel_id) {
+                        "Remove from Favorites"
+                    } else {
+                        "Add to Favorites"
+                    },
+                    None,
+                    window.handler_for(&this, move |this, _window, cx| {
+                        this.toggle_favorite_channel(channel_id, cx)
+                    }),
                 );
 
             let mut has_destructive_actions = false;
@@ -1608,7 +1697,8 @@ impl CollabPanel {
                     Section::ActiveCall => Self::leave_call(window, cx),
                     Section::Channels => self.new_root_channel(window, cx),
                     Section::Contacts => self.toggle_contact_finder(window, cx),
-                    Section::ContactRequests
+                    Section::FavoriteChannels
+                    | Section::ContactRequests
                     | Section::Online
                     | Section::Offline
                     | Section::ChannelInvites => {
@@ -1838,6 +1928,24 @@ impl CollabPanel {
         self.collapsed_channels.binary_search(&channel_id).is_ok()
     }
 
+    fn toggle_favorite_channel(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
+        match self.favorite_channels.binary_search(&channel_id) {
+            Ok(ix) => {
+                self.favorite_channels.remove(ix);
+            }
+            Err(ix) => {
+                self.favorite_channels.insert(ix, channel_id);
+            }
+        };
+        self.serialize(cx);
+        self.update_entries(true, cx);
+        cx.notify();
+    }
+
+    fn is_channel_favorited(&self, channel_id: ChannelId) -> bool {
+        self.favorite_channels.binary_search(&channel_id).is_ok()
+    }
+
     fn leave_call(window: &mut Window, cx: &mut App) {
         ActiveCall::global(cx)
             .update(cx, |call, cx| call.hang_up(cx))
@@ -1954,6 +2062,17 @@ impl CollabPanel {
         }
     }
 
+    fn toggle_selected_channel_favorite(
+        &mut self,
+        _: &ToggleSelectedChannelFavorite,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(channel) = self.selected_channel() {
+            self.toggle_favorite_channel(channel.id, cx);
+        }
+    }
+
     fn set_channel_visibility(
         &mut self,
         channel_id: ChannelId,
@@ -2589,6 +2708,7 @@ impl CollabPanel {
                     SharedString::from("Current Call")
                 }
             }
+            Section::FavoriteChannels => SharedString::from("Favorites"),
             Section::ContactRequests => SharedString::from("Requests"),
             Section::Contacts => SharedString::from("Contacts"),
             Section::Channels => SharedString::from("Channels"),
@@ -2606,6 +2726,7 @@ impl CollabPanel {
             }),
             Section::Contacts => Some(
                 IconButton::new("add-contact", IconName::Plus)
+                    .icon_size(IconSize::Small)
                     .on_click(
                         cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)),
                     )
@@ -2619,9 +2740,6 @@ impl CollabPanel {
                             IconButton::new("filter-active-channels", IconName::ListFilter)
                                 .icon_size(IconSize::Small)
                                 .toggle_state(self.filter_active_channels)
-                                .when(!self.filter_active_channels, |button| {
-                                    button.visible_on_hover("section-header")
-                                })
                                 .on_click(cx.listener(|this, _, _window, cx| {
                                     this.filter_active_channels = !this.filter_active_channels;
                                     this.update_entries(true, cx);
@@ -2634,10 +2752,11 @@ impl CollabPanel {
                         )
                         .child(
                             IconButton::new("add-channel", IconName::Plus)
+                                .icon_size(IconSize::Small)
                                 .on_click(cx.listener(|this, _, window, cx| {
                                     this.new_root_channel(window, cx)
                                 }))
-                                .tooltip(Tooltip::text("Create a channel")),
+                                .tooltip(Tooltip::text("Create Channel")),
                         )
                         .into_any_element(),
                 )
@@ -2646,7 +2765,11 @@ impl CollabPanel {
         };
 
         let can_collapse = match section {
-            Section::ActiveCall | Section::Channels | Section::Contacts => false,
+            Section::ActiveCall
+            | Section::Channels
+            | Section::Contacts
+            | Section::FavoriteChannels => false,
+
             Section::ChannelInvites
             | Section::ContactRequests
             | Section::Online
@@ -2932,11 +3055,17 @@ impl CollabPanel {
             .unwrap_or(px(240.));
         let root_id = channel.root_id();
 
-        div()
-            .h_6()
+        let is_favorited = self.is_channel_favorited(channel_id);
+        let (favorite_icon, favorite_color, favorite_tooltip) = if is_favorited {
+            (IconName::StarFilled, Color::Accent, "Remove from Favorites")
+        } else {
+            (IconName::Star, Color::Muted, "Add to Favorites")
+        };
+
+        h_flex()
             .id(channel_id.0 as usize)
             .group("")
-            .flex()
+            .h_6()
             .w_full()
             .when(!channel.is_root_channel(), |el| {
                 el.on_drag(channel.clone(), move |channel, _, _, cx| {
@@ -2966,6 +3095,7 @@ impl CollabPanel {
             .child(
                 ListItem::new(channel_id.0 as usize)
                     // Add one level of depth for the disclosure arrow.
+                    .height(px(26.))
                     .indent_level(depth + 1)
                     .indent_step_size(px(20.))
                     .toggle_state(is_selected || is_active)
@@ -2991,78 +3121,105 @@ impl CollabPanel {
                             )
                         },
                     ))
-                    .start_slot(
-                        div()
-                            .relative()
-                            .child(
-                                Icon::new(if is_public {
-                                    IconName::Public
-                                } else {
-                                    IconName::Hash
-                                })
-                                .size(IconSize::Small)
-                                .color(Color::Muted),
-                            )
-                            .children(has_notes_notification.then(|| {
-                                div()
-                                    .w_1p5()
-                                    .absolute()
-                                    .right(px(-1.))
-                                    .top(px(-1.))
-                                    .child(Indicator::dot().color(Color::Info))
-                            })),
-                    )
                     .child(
                         h_flex()
-                            .id(channel_id.0 as usize)
-                            .child(match string_match {
-                                None => Label::new(channel.name.clone()).into_any_element(),
-                                Some(string_match) => HighlightedLabel::new(
-                                    channel.name.clone(),
-                                    string_match.positions.clone(),
-                                )
-                                .into_any_element(),
-                            })
-                            .children(face_pile.map(|face_pile| face_pile.p_1())),
+                            .id(format!("inside-{}", channel_id.0))
+                            .w_full()
+                            .gap_1()
+                            .child(
+                                div()
+                                    .relative()
+                                    .child(
+                                        Icon::new(if is_public {
+                                            IconName::Public
+                                        } else {
+                                            IconName::Hash
+                                        })
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
+                                    )
+                                    .children(has_notes_notification.then(|| {
+                                        div()
+                                            .w_1p5()
+                                            .absolute()
+                                            .right(px(-1.))
+                                            .top(px(-1.))
+                                            .child(Indicator::dot().color(Color::Info))
+                                    })),
+                            )
+                            .child(
+                                h_flex()
+                                    .id(channel_id.0 as usize)
+                                    .child(match string_match {
+                                        None => Label::new(channel.name.clone()).into_any_element(),
+                                        Some(string_match) => HighlightedLabel::new(
+                                            channel.name.clone(),
+                                            string_match.positions.clone(),
+                                        )
+                                        .into_any_element(),
+                                    })
+                                    .children(face_pile.map(|face_pile| face_pile.p_1())),
+                            )
+                            .tooltip({
+                                let channel_store = self.channel_store.clone();
+                                move |_window, cx| {
+                                    cx.new(|_| JoinChannelTooltip {
+                                        channel_store: channel_store.clone(),
+                                        channel_id,
+                                        has_notes_notification,
+                                    })
+                                    .into()
+                                }
+                            }),
                     ),
             )
             .child(
-                h_flex().absolute().right(rems(0.)).h_full().child(
-                    h_flex()
-                        .h_full()
-                        .bg(cx.theme().colors().background)
-                        .rounded_l_sm()
-                        .gap_1()
-                        .px_1()
-                        .child(
-                            IconButton::new("channel_notes", IconName::Reader)
-                                .style(ButtonStyle::Filled)
-                                .shape(ui::IconButtonShape::Square)
-                                .icon_size(IconSize::Small)
-                                .icon_color(if has_notes_notification {
-                                    Color::Default
-                                } else {
-                                    Color::Muted
-                                })
-                                .on_click(cx.listener(move |this, _, window, cx| {
-                                    this.open_channel_notes(channel_id, window, cx)
-                                }))
-                                .tooltip(Tooltip::text("Open channel notes")),
-                        )
-                        .visible_on_hover(""),
-                ),
-            )
-            .tooltip({
-                let channel_store = self.channel_store.clone();
-                move |_window, cx| {
-                    cx.new(|_| JoinChannelTooltip {
-                        channel_store: channel_store.clone(),
-                        channel_id,
-                        has_notes_notification,
+                h_flex()
+                    .absolute()
+                    .right_0()
+                    .visible_on_hover("")
+                    .h_full()
+                    .pl_1()
+                    .pr_1p5()
+                    .gap_0p5()
+                    .bg(cx.theme().colors().background.opacity(0.5))
+                    .child({
+                        let focus_handle = self.focus_handle.clone();
+                        IconButton::new("channel_favorite", favorite_icon)
+                            .icon_size(IconSize::Small)
+                            .icon_color(favorite_color)
+                            .on_click(cx.listener(move |this, _, _window, cx| {
+                                this.toggle_favorite_channel(channel_id, cx)
+                            }))
+                            .tooltip(move |_window, cx| {
+                                Tooltip::for_action_in(
+                                    favorite_tooltip,
+                                    &ToggleSelectedChannelFavorite,
+                                    &focus_handle,
+                                    cx,
+                                )
+                            })
                     })
-                    .into()
-                }
-            })
+                    .child({
+                        let focus_handle = self.focus_handle.clone();
+                        IconButton::new("channel_notes", IconName::Reader)
+                            .icon_size(IconSize::Small)
+                            .when(!has_notes_notification, |this| {
+                                this.icon_color(Color::Muted)
+                            })
+                            .on_click(cx.listener(move |this, _, window, cx| {
+                                this.open_channel_notes(channel_id, window, cx)
+                            }))
+                            .tooltip(move |_window, cx| {
+                                Tooltip::for_action_in(
+                                    "Open Channel Notes",
+                                    &OpenSelectedChannelNotes,
+                                    &focus_handle,
+                                    cx,
+                                )
+                            })
+                    }),
+            )
     }
 
     fn render_channel_editor(
@@ -3161,6 +3318,7 @@ impl Render for CollabPanel {
             .on_action(cx.listener(CollabPanel::show_inline_context_menu))
             .on_action(cx.listener(CollabPanel::rename_selected_channel))
             .on_action(cx.listener(CollabPanel::open_selected_channel_notes))
+            .on_action(cx.listener(CollabPanel::toggle_selected_channel_favorite))
             .on_action(cx.listener(CollabPanel::collapse_selected_channel))
             .on_action(cx.listener(CollabPanel::expand_selected_channel))
             .on_action(cx.listener(CollabPanel::start_move_selected_channel))
@@ -3382,7 +3540,7 @@ impl Render for JoinChannelTooltip {
                 .channel_participants(self.channel_id);
 
             container
-                .child(Label::new("Join channel"))
+                .child(Label::new("Join Channel"))
                 .children(participants.iter().map(|participant| {
                     h_flex()
                         .gap_2()