mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
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:
parent
2d62837877
commit
2a3fcb2ce4
4 changed files with 243 additions and 82 deletions
|
|
@ -1077,6 +1077,7 @@
|
|||
"alt-up": "collab_panel::MoveChannelUp",
|
||||
"alt-down": "collab_panel::MoveChannelDown",
|
||||
"alt-enter": "collab_panel::OpenSelectedChannelNotes",
|
||||
"shift-enter": "collab_panel::ToggleSelectedChannelFavorite",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1138,6 +1138,7 @@
|
|||
"alt-up": "collab_panel::MoveChannelUp",
|
||||
"alt-down": "collab_panel::MoveChannelDown",
|
||||
"alt-enter": "collab_panel::OpenSelectedChannelNotes",
|
||||
"shift-enter": "collab_panel::ToggleSelectedChannelFavorite",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1082,6 +1082,7 @@
|
|||
"alt-up": "collab_panel::MoveChannelUp",
|
||||
"alt-down": "collab_panel::MoveChannelDown",
|
||||
"alt-enter": "collab_panel::OpenSelectedChannelNotes",
|
||||
"shift-enter": "collab_panel::ToggleSelectedChannelFavorite",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue