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
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()