From 2a3fcb2ce4a47b87b103d6a5760777f7d03a11ce Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:24:20 -0300 Subject: [PATCH] collab_panel: Add ability to favorite a channel (#52378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Screenshot 2026-03-25 at 1  11@2x Release Notes: - Collab: Added the ability to favorite channels in the collab panel. --- 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(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 412bec85625412089b2435e46573c1cf40c50b4f..617d7a6d0662264858ac3066d40481135dab9ae6 100644 --- a/assets/keymaps/default-linux.json +++ b/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", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 5741c5a9af5517533c214f0f77050aa2faf1a669..d3dda49c9a52a8c9b52dfddc04ae573f2fa4cf28 100644 --- a/assets/keymaps/default-macos.json +++ b/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", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index d94cbfdac16b5a86c380c158fae9f467abd5d202..e665d26aaf0c90d6c2fa4ee66284687c843fcd62 100644 --- a/assets/keymaps/default-windows.json +++ b/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", }, }, { diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 392e2340f14c5b633bcd9a0a8128d9423aed6a22..74e7a7c82b2123bfca8d4fc4a9e8f02463e3f7d3 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/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, collapsed_sections: Vec
, collapsed_channels: Vec, + favorite_channels: Vec, filter_active_channels: bool, workspace: WeakEntity, } @@ -263,11 +266,14 @@ pub struct CollabPanel { #[derive(Serialize, Deserialize)] struct SerializedCollabPanel { collapsed_channels: Option>, + #[serde(default)] + favorite_channels: Option>, } #[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) { - 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 = + 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) { + 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, + ) { + 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()