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

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.
This commit is contained in:
Danilo Leal 2026-03-26 16:24:20 -03:00 committed by GitHub
parent 2d62837877
commit 2a3fcb2ce4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 243 additions and 82 deletions

View file

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

View file

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

View file

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

View file

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