collab_panel: Add indentation guides and some other design adjustements (#61397)

This PR converts the collab panel list from a GPUI list into a uniform
list, which was a requirement for the indent guides to be displayed. I
have been wanting to add that to the channels list for a while, and
finally carved out some time. Ended up adding some more tweaks here and
there as overall improvements (e.g., using a lock icon for private
icons) and to make the indent guide fully work design-wise given we have
the chevron buttons rendering on top of them.

Here's how it looks:

<img width="600" alt="Screenshot 2026-07-21 at 10  08@2x"
src="https://github.com/user-attachments/assets/351b7e0f-aeb4-4c79-8177-7f1cdf635fbe"
/>

Release Notes:

- N/A

---------

Co-authored-by: MrSubidubi <finn@zed.dev>
This commit is contained in:
Danilo LealandMrSubidubi authored and GitHub committed 2026-07-21 19:34:49 +00:00
1 parent 3375c7b6c0
commit 502cfeb9c3
5 files changed
+319 -152

No files matched your search

+3 -3
View File
@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.23593 2.55334C10.3635 2.55334 11.4448 3.00126 12.2421 3.79854C13.0394 4.59583 13.4873 5.67718 13.4873 6.80472" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.23593 4.44281C9.86234 4.44281 10.4631 4.69165 10.906 5.13459C11.349 5.57752 11.5978 6.17828 11.5978 6.80468" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.97096 10.421C9.08042 10.4713 9.20373 10.4828 9.32059 10.4536C9.43745 10.4244 9.54088 10.3563 9.61385 10.2604L9.802 10.014C9.90073 9.88235 10.0288 9.7755 10.1759 9.7019C10.3231 9.62831 10.4854 9.59 10.65 9.59H12.24C12.5211 9.59 12.7907 9.70167 12.9895 9.90046C13.1883 10.0993 13.3 10.3689 13.3 10.65V12.24C13.3 12.5211 13.1883 12.7907 12.9895 12.9895C12.7907 13.1883 12.5211 13.3 12.24 13.3C9.70983 13.3 7.2833 12.2949 5.49421 10.5058C3.70511 8.7167 2.70001 6.29017 2.70001 3.76001C2.70001 3.47888 2.81169 3.20927 3.01048 3.01048C3.20927 2.81169 3.47888 2.70001 3.76001 2.70001H5.35001C5.63113 2.70001 5.90075 2.81169 6.09954 3.01048C6.29833 3.20927 6.41 3.47888 6.41 3.76001V5.35001C6.41 5.51457 6.37169 5.67687 6.2981 5.82405C6.2245 5.97124 6.11765 6.09927 5.986 6.198L5.73797 6.38403C5.64067 6.45833 5.57209 6.56401 5.54387 6.68314C5.51566 6.80226 5.52956 6.92748 5.58321 7.03752C6.30754 8.50873 7.49885 9.69854 8.97096 10.421Z" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.5 3C10.3409 3.22145 11.108 3.66214 11.723 4.27705C12.3379 4.89196 12.7785 5.65906 13 6.5" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 5C10.0325 5.27625 10.7237 5.9675 11 7" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.59842 9.46784C9.66164 9.42577 9.73436 9.40013 9.81 9.39326C9.88563 9.38638 9.96178 9.39848 10.0316 9.42846L12.7229 10.6343C12.8136 10.6731 12.8893 10.7402 12.9387 10.8256C12.988 10.9109 13.0084 11.01 12.9968 11.108C12.9081 11.7706 12.5819 12.3784 12.0786 12.8184C11.5754 13.2585 10.9295 13.5008 10.261 13.5003C8.20257 13.5003 6.22847 12.6826 4.77295 11.227C3.31744 9.77153 2.49974 7.79743 2.49974 5.73902C2.49923 5.07053 2.74153 4.42461 3.18157 3.92137C3.62161 3.41814 4.22944 3.09185 4.89203 3.00319C4.98997 2.99155 5.08906 3.01195 5.17444 3.06133C5.25983 3.11071 5.32693 3.18642 5.36569 3.27711L6.57154 5.97072C6.60116 6.0399 6.61323 6.11534 6.60666 6.19031C6.60008 6.26528 6.57508 6.33747 6.53387 6.40044L5.31433 7.85054C5.27107 7.91581 5.24549 7.9912 5.24009 8.06932C5.23469 8.14744 5.24965 8.22563 5.28351 8.29624C5.75546 9.26239 6.75415 10.2491 7.72317 10.7165C7.79415 10.7502 7.87269 10.7648 7.95105 10.7589C8.02941 10.753 8.10488 10.7268 8.17001 10.6828L9.59842 9.46784Z" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+264 -124
View File
@@ -14,10 +14,10 @@ use editor::{Editor, EditorElement, EditorStyle};
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{
AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, DismissEvent, Div,
Empty, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, KeyContext, ListOffset,
ListState, MouseDownEvent, Pixels, Point, PromptLevel, SharedString, Subscription, Task,
TextStyle, WeakEntity, Window, actions, anchored, canvas, deferred, div, fill, list, point,
prelude::*, px,
Empty, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, KeyContext, MouseButton,
MouseDownEvent, Pixels, Point, PromptLevel, ScrollStrategy, SharedString, Subscription, Task,
TextStyle, UniformListScrollHandle, WeakEntity, Window, actions, anchored, canvas, deferred,
div, fill, point, prelude::*, px, size, uniform_list,
};
use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious};
@@ -30,13 +30,14 @@ use rpc::{
use serde::{Deserialize, Serialize};
use settings::Settings;
use smallvec::SmallVec;
use std::{mem, sync::Arc, time::Duration};
use std::{mem, ops::Range, sync::Arc, time::Duration};
use theme::ActiveTheme;
use theme_settings::ThemeSettings;
use ui::{
Avatar, AvatarAvailabilityIndicator, CollabNotification, ContextMenu, CopyButton, Facepile,
HighlightedLabel, IconButtonShape, Indicator, ListHeader, ListItem, Tab, TintColor, Tooltip,
prelude::*, tooltip_container,
Avatar, AvatarAvailabilityIndicator, CollabNotification, ContextMenu, CopyButton,
DecoratedIcon, Disclosure, Facepile, HighlightedLabel, IconButtonShape, IconDecoration,
IconDecorationKind, IndentGuideColors, Indicator, ListHeader, ListItem, ScrollAxes, Scrollbars,
Tab, TintColor, Tooltip, WithScrollbar, prelude::*, tooltip_container,
};
use util::{ResultExt, TryFutureExt, maybe};
use workspace::{
@@ -51,6 +52,8 @@ use workspace::{
const FILTER_OCCUPIED_CHANNELS_KEY: &str = "filter_occupied_channels";
const FAVORITE_CHANNELS_KEY: &str = "favorite_channels";
const COLLABORATION_PANEL_KEY: &str = "CollaborationPanel";
const TOAST_DURATION: Duration = Duration::from_secs(5);
actions!(
collab_panel,
@@ -91,8 +94,10 @@ struct ChannelMoveClipboard {
channel_id: ChannelId,
}
const COLLABORATION_PANEL_KEY: &str = "CollaborationPanel";
const TOAST_DURATION: Duration = Duration::from_secs(5);
struct ContactContextMenu {
user_id: u64,
via_ellipsis_button: bool,
}
pub fn init(cx: &mut App) {
cx.observe_new(|workspace: &mut Workspace, _, _| {
@@ -257,7 +262,8 @@ pub struct CollabPanel {
pending_favorites_serialization: Task<Option<()>>,
pending_filter_serialization: Task<Option<()>>,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
list_state: ListState,
contact_context_menu: Option<ContactContextMenu>,
scroll_handle: UniformListScrollHandle,
filter_editor: Entity<Editor>,
channel_name_editor: Entity<Editor>,
channel_editing_state: Option<ChannelEditingState>,
@@ -273,6 +279,7 @@ pub struct CollabPanel {
collapsed_channels: Vec<ChannelId>,
filter_occupied_channels: bool,
workspace: WeakEntity<Workspace>,
hovered_channel: Option<(ChannelId, bool)>,
notification_store: Entity<NotificationStore>,
current_notification_toast: Option<(u64, Task<()>)>,
mark_as_read_tasks: HashMap<u64, Task<anyhow::Result<()>>>,
@@ -391,12 +398,14 @@ impl CollabPanel {
let mut this = Self {
focus_handle: cx.focus_handle(),
channel_clipboard: None,
hovered_channel: None,
fs: workspace.app_state().fs.clone(),
pending_panel_serialization: Task::ready(None),
pending_favorites_serialization: Task::ready(None),
pending_filter_serialization: Task::ready(None),
context_menu: None,
list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
contact_context_menu: None,
scroll_handle: UniformListScrollHandle::new(),
channel_name_editor,
filter_editor,
entries: Vec::default(),
@@ -572,7 +581,8 @@ impl CollabPanel {
}
fn scroll_to_item(&mut self, ix: usize) {
self.list_state.scroll_to_reveal_item(ix)
self.scroll_handle
.scroll_to_item(ix, ScrollStrategy::Nearest)
}
fn update_entries(&mut self, select_same_item: bool, cx: &mut Context<Self>) {
@@ -1068,54 +1078,75 @@ impl CollabPanel {
});
}
let old_scroll_top = self.list_state.logical_scroll_top();
self.list_state.reset(self.entries.len());
if scroll_to_top {
self.list_state.scroll_to(ListOffset::default());
} else {
// Attempt to maintain the same scroll position.
if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
let new_scroll_top = self
let state = self.scroll_handle.0.borrow();
state.base_handle.set_offset(Point::default());
} else if let Some(row_height) = self.row_height(old_entries.len()) {
// Attempt to maintain the same scroll position. Since every row has
// the same height, the scroll offset can be translated to and from a
// logical entry index plus a pixel remainder.
let old_scroll_y = -self.scroll_handle.0.borrow().base_handle.offset().y;
let old_top_ix = (old_scroll_y / row_height) as usize;
let offset_in_item = old_scroll_y - row_height * old_top_ix as f32;
if let Some(old_top_entry) = old_entries.get(old_top_ix) {
let new_scroll_y = self
.entries
.iter()
.position(|entry| entry == old_top_entry)
.map(|item_ix| ListOffset {
item_ix,
offset_in_item: old_scroll_top.offset_in_item,
})
.map(|item_ix| row_height * item_ix as f32 + offset_in_item)
.or_else(|| {
let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
let entry_after_old_top = old_entries.get(old_top_ix + 1)?;
let item_ix = self
.entries
.iter()
.position(|entry| entry == entry_after_old_top)?;
Some(ListOffset {
item_ix,
offset_in_item: Pixels::ZERO,
})
Some(row_height * item_ix as f32)
})
.or_else(|| {
let entry_before_old_top =
old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
let entry_before_old_top = old_entries.get(old_top_ix.saturating_sub(1))?;
let item_ix = self
.entries
.iter()
.position(|entry| entry == entry_before_old_top)?;
Some(ListOffset {
item_ix,
offset_in_item: Pixels::ZERO,
})
});
Some(row_height * item_ix as f32)
})
.unwrap_or(old_scroll_y);
self.list_state
.scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
let state = self.scroll_handle.0.borrow();
let mut offset = state.base_handle.offset();
offset.y = -new_scroll_y;
state.base_handle.set_offset(offset);
}
}
cx.notify();
}
fn row_height(&self, item_count: usize) -> Option<Pixels> {
if item_count == 0 {
return None;
}
let state = self.scroll_handle.0.borrow();
let height = state.last_item_size?.contents.height / item_count as f32;
(height > px(0.)).then_some(height)
}
fn bounds_for_item(&self, ix: usize) -> Option<Bounds<Pixels>> {
let row_height = self.row_height(self.entries.len())?;
let state = self.scroll_handle.0.borrow();
let list_bounds = state.base_handle.bounds();
let origin = list_bounds.origin
+ point(
px(0.),
state.base_handle.offset().y + row_height * ix as f32,
);
Some(Bounds::new(
origin,
size(list_bounds.size.width, row_height),
))
}
fn render_call_participant(
&self,
user: &Arc<User>,
@@ -1650,9 +1681,14 @@ impl CollabPanel {
&mut self,
position: Point<Pixels>,
contact: Arc<Contact>,
via_ellipsis_button: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.contact_context_menu = Some(ContactContextMenu {
user_id: contact.user.legacy_id,
via_ellipsis_button,
});
let this = cx.entity();
let in_room = ActiveCall::global(cx).read(cx).room().is_some();
@@ -1701,6 +1737,7 @@ impl CollabPanel {
cx.focus_self(window);
}
this.context_menu.take();
this.contact_context_menu.take();
cx.notify();
},
);
@@ -2354,10 +2391,7 @@ impl CollabPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(bounds) = self
.selection
.and_then(|ix| self.list_state.bounds_for_item(ix))
else {
let Some(bounds) = self.selection.and_then(|ix| self.bounds_for_item(ix)) else {
return;
};
@@ -2374,7 +2408,7 @@ impl CollabPanel {
};
if let Some(contact) = self.selected_contact() {
self.deploy_contact_context_menu(bounds.center(), contact, window, cx);
self.deploy_contact_context_menu(bounds.center(), contact, false, window, cx);
cx.stop_propagation();
}
}
@@ -2729,13 +2763,14 @@ impl CollabPanel {
channel,
depth,
has_children,
is_favorite,
string_match,
..
} => self
.render_channel(
&channel,
depth,
has_children,
is_favorite,
is_selected,
ix,
string_match.as_ref(),
@@ -2781,7 +2816,7 @@ impl CollabPanel {
}
}
fn render_signed_in(&mut self, _: &mut Window, cx: &mut Context<Self>) -> Div {
fn render_signed_in(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Div {
self.channel_store.update(cx, |channel_store, _| {
channel_store.initialize();
});
@@ -2817,11 +2852,40 @@ impl CollabPanel {
}),
)
.child(
list(
self.list_state.clone(),
cx.processor(Self::render_list_entry),
)
.size_full(),
div()
.size_full()
.child(
uniform_list(
"collab-panel-entries",
self.entries.len(),
cx.processor(|this, range: Range<usize>, window, cx| {
range
.map(|ix| this.render_list_entry(ix, window, cx))
.collect()
}),
)
.size_full()
.track_scroll(&self.scroll_handle)
.with_decoration(
ui::indent_guides(px(20.), IndentGuideColors::panel(cx))
.with_left_offset(ui::LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET)
.with_compute_indents_fn(cx.entity(), |this, range, _, _| {
range
.map(|ix| match this.entries.get(ix) {
Some(ListEntry::Channel { depth, .. })
| Some(ListEntry::ChannelEditor { depth }) => *depth,
_ => 0,
})
.collect()
}),
),
)
.custom_scrollbars(
Scrollbars::new(ScrollAxes::Vertical)
.tracked_scroll_handle(&self.scroll_handle),
window,
cx,
),
)
}
@@ -2878,10 +2942,10 @@ impl CollabPanel {
channel_link = Some(channel.link(cx));
(channel_icon, channel_tooltip_text) = match channel.visibility {
proto::ChannelVisibility::Public => {
(Some("icons/public.svg"), Some("Copy public channel link."))
(Some(IconName::Hash), Some("Copy Public Channel Link"))
}
proto::ChannelVisibility::Members => {
(Some("icons/hash.svg"), Some("Copy private channel link."))
(Some(IconName::Lock), Some("Copy Private Channel Link"))
}
};
@@ -2958,7 +3022,7 @@ impl CollabPanel {
.on_click(
cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)),
)
.tooltip(Tooltip::text("Search for new contact"))
.tooltip(Tooltip::text("Search for New Contact"))
.into_any_element(),
),
Section::Channels => {
@@ -3006,14 +3070,15 @@ impl CollabPanel {
| Section::Offline => true,
};
h_flex().w_full().group("section-header").child(
h_flex().group("section-header").w_full().child(
ListHeader::new(text)
.when(can_collapse, |header| {
header.toggle(Some(!is_collapsed)).on_toggle(cx.listener(
move |this, _, _, cx| {
header
.toggle(Some(!is_collapsed))
.disclosure_shape(IconButtonShape::Square)
.on_toggle(cx.listener(move |this, _, _, cx| {
this.toggle_section_expanded(section, cx);
},
))
}))
})
.inset(true)
.end_slot::<AnyElement>(button)
@@ -3031,29 +3096,67 @@ impl CollabPanel {
let online = contact.online;
let busy = contact.busy || calling;
let username = contact.user.username.clone();
let open_context_menu = self
.contact_context_menu
.as_ref()
.filter(|menu| menu.user_id == contact.user.legacy_id);
let context_menu_open_via_button =
open_context_menu.is_some_and(|menu| menu.via_ellipsis_button);
let context_menu_open_via_row =
open_context_menu.is_some_and(|menu| !menu.via_ellipsis_button);
let item = ListItem::new(username.clone())
.indent_level(1)
.indent_step_size(px(20.))
.toggle_state(is_selected)
.toggle_state(is_selected || context_menu_open_via_row)
.child(
h_flex()
.w_full()
.justify_between()
.child(render_participant_name_and_handle(&contact.user))
.child(
h_flex()
.pl_2()
.gap_1p5()
.child(
Avatar::new(contact.user.avatar_uri.clone())
.indicator::<AvatarAvailabilityIndicator>(if online {
let background = if is_selected || context_menu_open_via_row {
cx.theme().colors().ghost_element_selected
} else {
cx.theme().colors().panel_background
};
Some(
AvatarAvailabilityIndicator::new(match busy {
true => ui::CollaboratorAvailability::Busy,
false => ui::CollaboratorAvailability::Free,
})
.border_color(background),
)
} else {
None
}),
)
.child(render_participant_name_and_handle(&contact.user)),
)
.when(calling, |el| {
el.child(Label::new("Calling").color(Color::Muted))
el.child(Label::new("Calling…").color(Color::Muted))
})
.when(!calling, |el| {
el.child(
IconButton::new("contact context menu", IconName::Ellipsis)
.icon_color(Color::Muted)
.visible_on_hover("")
.toggle_state(context_menu_open_via_button)
.when(!context_menu_open_via_button, |this| {
this.visible_on_hover("")
})
.on_click(cx.listener({
let contact = contact.clone();
move |this, event: &ClickEvent, window, cx| {
this.deploy_contact_context_menu(
event.position(),
contact.clone(),
true,
window,
cx,
);
@@ -3065,40 +3168,36 @@ impl CollabPanel {
.on_secondary_mouse_down(cx.listener({
let contact = contact.clone();
move |this, event: &MouseDownEvent, window, cx| {
this.deploy_contact_context_menu(event.position, contact.clone(), window, cx);
this.deploy_contact_context_menu(
event.position,
contact.clone(),
false,
window,
cx,
);
}
}))
.start_slot(
// todo handle contacts with no avatar
Avatar::new(contact.user.avatar_uri.clone())
.indicator::<AvatarAvailabilityIndicator>(if online {
Some(AvatarAvailabilityIndicator::new(match busy {
true => ui::CollaboratorAvailability::Busy,
false => ui::CollaboratorAvailability::Free,
}))
} else {
None
}),
);
}));
div()
.id(username.clone())
.group("")
.child(item)
.tooltip(move |_, cx| {
let text = if !online {
format!(" {} is offline", &username)
} else if busy {
format!(" {} is on a call", &username)
} else {
let room = ActiveCall::global(cx).read(cx).room();
if room.is_some() {
format!("Invite {} to join call", &username)
.when(open_context_menu.is_none(), |this| {
this.tooltip(move |_, cx| {
let text = if !online {
format!(" {} is Offline", &username)
} else if busy {
format!(" {} is on a Call", &username)
} else {
format!("Call {}", &username)
}
};
Tooltip::simple(text, cx)
let room = ActiveCall::global(cx).read(cx).room();
if room.is_some() {
format!("Invite {} to Join Call", &username)
} else {
format!("Call {}", &username)
}
};
Tooltip::simple(text, cx)
})
})
}
@@ -3220,6 +3319,7 @@ impl CollabPanel {
channel: &Channel,
depth: usize,
has_children: bool,
is_favorite_entry: bool,
is_selected: bool,
ix: usize,
string_match: Option<&StringMatch>,
@@ -3294,12 +3394,75 @@ impl CollabPanel {
let height = rems_from_px(24.);
let icon_name = if is_public {
IconName::Hash
} else {
IconName::Lock
};
let icon_knockout_bg = if is_selected || is_active {
cx.theme().colors().ghost_element_selected
} else {
cx.theme().colors().panel_background
};
let is_hovered = self.hovered_channel == Some((channel_id, is_favorite_entry));
let icon_slot = h_flex().size_4().flex_none().justify_center().map(|slot| {
if has_children && is_hovered {
slot.child(
h_flex()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child(
Disclosure::new("toggle", disclosed.unwrap_or(false))
.shape(IconButtonShape::Square)
.on_click(cx.listener(move |this, _, window, cx| {
this.toggle_channel_collapsed(channel_id, window, cx)
})),
),
)
} else if has_notes_notification {
slot.child(DecoratedIcon::new(
Icon::new(icon_name)
.size(IconSize::Small)
.color(Color::Muted),
Some(
IconDecoration::new(IconDecorationKind::Dot, icon_knockout_bg, cx)
.color(cx.theme().colors().text_accent)
.position(Point {
x: px(-3.),
y: px(6.),
}),
),
))
} else {
slot.child(
Icon::new(icon_name)
.size(IconSize::Small)
.color(Color::Muted),
)
}
});
let panel_bg = cx.theme().colors().panel_background;
let hover_bg = panel_bg.blend(cx.theme().colors().ghost_element_hover);
let active_bg = panel_bg.blend(cx.theme().colors().ghost_element_active);
h_flex()
.id(ix)
.group("")
.h(height)
.w_full()
.overflow_hidden()
.on_hover(cx.listener(move |this, hovered: &bool, _, cx| {
let hovered_channel = hovered.then_some((channel_id, is_favorite_entry));
if this.hovered_channel != hovered_channel
&& (*hovered || this.hovered_channel == Some((channel_id, is_favorite_entry)))
{
this.hovered_channel = hovered_channel;
cx.notify();
}
}))
.when(!channel.is_root_channel(), |el| {
el.on_drag(channel.clone(), move |channel, _, _, cx| {
cx.new(|_| DraggedChannelView {
@@ -3328,14 +3491,9 @@ impl CollabPanel {
.child(
ListItem::new(ix)
.height(height)
// Add one level of depth for the disclosure arrow.
.indent_level(depth + 1)
.indent_level(depth)
.indent_step_size(px(20.))
.toggle_state(is_selected || is_active)
.toggle(disclosed)
.on_toggle(cx.listener(move |this, _, window, cx| {
this.toggle_channel_collapsed(channel_id, window, cx)
}))
.on_click(cx.listener(move |this, _, window, cx| {
if is_active {
this.open_channel_notes(channel_id, window, cx)
@@ -3359,27 +3517,8 @@ impl CollabPanel {
.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))
})),
)
.pl_0p5()
.child(icon_slot)
.child(
h_flex()
.id(channel_id.0 as usize)
@@ -3408,14 +3547,16 @@ impl CollabPanel {
)
.child(
h_flex()
.id("channel-controls")
.visible_on_hover("")
.h_full()
.absolute()
.right_0()
.px_1()
.pl_1()
.pr_1p5()
.gap_px()
.rounded_l_md()
.bg(cx.theme().colors().background)
.bg(hover_bg)
.group_active("", |s| s.bg(active_bg))
.child({
let focus_handle = self.focus_handle.clone();
IconButton::new("channel_favorite", favorite_icon)
@@ -3460,8 +3601,7 @@ impl CollabPanel {
) -> impl IntoElement {
let item = ListItem::new("channel-editor")
.inset(false)
// Add one level of depth for the disclosure arrow.
.indent_level(depth + 1)
.indent_level(depth)
.indent_step_size(px(20.))
.start_slot(
Icon::new(IconName::Hash)
@@ -3946,9 +4086,9 @@ impl Render for DraggedChannelView {
.child(
Icon::new(
if self.channel.visibility == proto::ChannelVisibility::Public {
IconName::Public
} else {
IconName::Hash
} else {
IconName::Lock
},
)
.size(IconSize::Small)
+22 -7
View File
@@ -1,7 +1,7 @@
use crate::prelude::*;
use documented::Documented;
use gpui::{AnyElement, Hsla, ImageSource, Img, IntoElement, Styled, img};
use gpui::{AnyElement, AnyView, Hsla, ImageSource, Img, IntoElement, Styled, img};
/// An element that renders a user avatar with customizable appearance options.
///
@@ -108,8 +108,6 @@ impl RenderOnce for Avatar {
}
}
use gpui::AnyView;
/// The audio status of an player, for use in representing
/// their status visually on their avatar.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
@@ -191,6 +189,7 @@ pub enum CollaboratorAvailability {
pub struct AvatarAvailabilityIndicator {
availability: CollaboratorAvailability,
avatar_size: Option<Pixels>,
border_color: Option<Hsla>,
}
impl AvatarAvailabilityIndicator {
@@ -199,6 +198,7 @@ impl AvatarAvailabilityIndicator {
Self {
availability,
avatar_size: None,
border_color: None,
}
}
@@ -207,6 +207,11 @@ impl AvatarAvailabilityIndicator {
self.avatar_size = size.into();
self
}
pub fn border_color(mut self, color: impl Into<Hsla>) -> Self {
self.border_color = Some(color.into());
self
}
}
impl RenderOnce for AvatarAvailabilityIndicator {
@@ -216,12 +221,22 @@ impl RenderOnce for AvatarAvailabilityIndicator {
// HACK: non-integer sizes result in oval indicators.
let indicator_size = (avatar_size * 0.4).round();
let border_width = if self.border_color.is_some() {
px(2.)
} else {
px(0.)
};
let container_size = indicator_size + border_width * 2.;
div()
.absolute()
.bottom_0()
.right_0()
.size(indicator_size)
.rounded(indicator_size)
.bottom(-border_width)
.right(-border_width)
.size(container_size)
.rounded(container_size)
.when_some(self.border_color, |this, color| {
this.border(border_width).border_color(color)
})
.bg(match self.availability {
CollaboratorAvailability::Free => cx.theme().status().created,
CollaboratorAvailability::Busy => cx.theme().status().deleted,
+18 -17
View File
@@ -1,7 +1,8 @@
use std::sync::Arc;
use gpui::{AnyView, ClickEvent, CursorStyle, SharedString};
use gpui::{AnyView, ClickEvent, SharedString};
use crate::IconButtonShape;
use crate::prelude::*;
#[derive(IntoElement, RegisterComponent)]
@@ -10,12 +11,12 @@ pub struct Disclosure {
is_open: bool,
selected: bool,
disabled: bool,
on_toggle_expanded: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
cursor_style: CursorStyle,
opened_icon: IconName,
closed_icon: IconName,
shape: Option<IconButtonShape>,
visible_on_hover: Option<SharedString>,
tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
on_toggle_expanded: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
opened_icon: IconName,
}
impl Disclosure {
@@ -26,9 +27,9 @@ impl Disclosure {
selected: false,
disabled: false,
on_toggle_expanded: None,
cursor_style: CursorStyle::PointingHand,
opened_icon: IconName::ChevronDown,
closed_icon: IconName::ChevronRight,
shape: None,
visible_on_hover: None,
tooltip: None,
}
@@ -61,6 +62,17 @@ impl Disclosure {
self.disabled = disabled;
self
}
/// Sets the shape of the underlying [`IconButton`].
pub fn shape(mut self, shape: IconButtonShape) -> Self {
self.shape = Some(shape);
self
}
/// Alias for [`Self::on_toggle_expanded`].
pub fn on_click(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.on_toggle_expanded(Arc::new(handler) as Arc<_>)
}
}
impl Toggleable for Disclosure {
@@ -70,18 +82,6 @@ impl Toggleable for Disclosure {
}
}
impl Clickable for Disclosure {
fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.on_toggle_expanded = Some(Arc::new(handler));
self
}
fn cursor_style(mut self, cursor_style: gpui::CursorStyle) -> Self {
self.cursor_style = cursor_style;
self
}
}
impl VisibleOnHover for Disclosure {
fn visible_on_hover(mut self, group_name: impl Into<SharedString>) -> Self {
self.visible_on_hover = Some(group_name.into());
@@ -103,6 +103,7 @@ impl RenderOnce for Disclosure {
.aria_label(if self.is_open { "Collapse" } else { "Expand" })
.aria_expanded(self.is_open)
.disabled(self.disabled)
.when_some(self.shape, |this, shape| this.shape(shape))
.toggle_state(self.selected)
.when_some(self.visible_on_hover.clone(), |this, group_name| {
this.visible_on_hover(group_name)
+12 -1
View File
@@ -1,6 +1,6 @@
use std::sync::Arc;
use crate::{Disclosure, prelude::*};
use crate::{Disclosure, IconButtonShape, prelude::*};
use component::{Component, ComponentScope, example_group_with_title, single_example};
use gpui::{AnyElement, ClickEvent};
use theme::UiDensity;
@@ -18,6 +18,7 @@ pub struct ListHeader {
/// It will obscure the `end_slot` when visible.
end_hover_slot: Option<AnyElement>,
toggle: Option<bool>,
disclosure_shape: Option<IconButtonShape>,
on_toggle: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
inset: bool,
selected: bool,
@@ -32,6 +33,7 @@ impl ListHeader {
end_hover_slot: None,
inset: false,
toggle: None,
disclosure_shape: None,
on_toggle: None,
selected: false,
}
@@ -42,6 +44,12 @@ impl ListHeader {
self
}
/// Sets the shape of the disclosure button.
pub fn disclosure_shape(mut self, shape: IconButtonShape) -> Self {
self.disclosure_shape = Some(shape);
self
}
pub fn on_toggle(
mut self,
on_toggle: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -108,6 +116,9 @@ impl RenderOnce for ListHeader {
.gap(DynamicSpacing::Base04.rems(cx))
.children(self.toggle.map(|is_open| {
Disclosure::new("toggle", is_open)
.when_some(self.disclosure_shape, |this, shape| {
this.shape(shape)
})
.on_toggle_expanded(self.on_toggle.clone())
}))
.child(