1mod channel_modal;
2mod contact_finder;
3
4use self::channel_modal::ChannelModal;
5use crate::{CollaborationPanelSettings, channel_view::ChannelView, chat_panel::ChatPanel};
6use call::ActiveCall;
7use channel::{Channel, ChannelEvent, ChannelStore};
8use client::{ChannelId, Client, Contact, User, UserStore};
9use contact_finder::ContactFinder;
10use db::kvp::KEY_VALUE_STORE;
11use editor::{Editor, EditorElement, EditorStyle};
12use fuzzy::{StringMatchCandidate, match_strings};
13use gpui::{
14 AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, Context, DismissEvent,
15 Div, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, InteractiveElement, IntoElement,
16 ListOffset, ListState, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render,
17 SharedString, Styled, Subscription, Task, TextStyle, WeakEntity, Window, actions, anchored,
18 canvas, deferred, div, fill, list, point, prelude::*, px,
19};
20use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious};
21use project::{Fs, Project};
22use rpc::{
23 ErrorCode, ErrorExt,
24 proto::{self, ChannelVisibility, PeerId},
25};
26use serde_derive::{Deserialize, Serialize};
27use settings::Settings;
28use smallvec::SmallVec;
29use std::{mem, sync::Arc};
30use theme::{ActiveTheme, ThemeSettings};
31use ui::{
32 Avatar, AvatarAvailabilityIndicator, Button, Color, ContextMenu, Facepile, Icon, IconButton,
33 IconName, IconSize, Indicator, Label, ListHeader, ListItem, Tooltip, prelude::*,
34 tooltip_container,
35};
36use util::{ResultExt, TryFutureExt, maybe};
37use workspace::{
38 OpenChannelNotes, Workspace,
39 dock::{DockPosition, Panel, PanelEvent},
40 notifications::{DetachAndPromptErr, NotifyResultExt, NotifyTaskExt},
41};
42
43actions!(
44 collab_panel,
45 [
46 ToggleFocus,
47 Remove,
48 Secondary,
49 CollapseSelectedChannel,
50 ExpandSelectedChannel,
51 StartMoveChannel,
52 MoveSelected,
53 InsertSpace,
54 ]
55);
56
57#[derive(Debug, Copy, Clone, PartialEq, Eq)]
58struct ChannelMoveClipboard {
59 channel_id: ChannelId,
60}
61
62const COLLABORATION_PANEL_KEY: &str = "CollaborationPanel";
63
64pub fn init(cx: &mut App) {
65 cx.observe_new(|workspace: &mut Workspace, _, _| {
66 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
67 workspace.toggle_panel_focus::<CollabPanel>(window, cx);
68 });
69 workspace.register_action(|_, _: &OpenChannelNotes, window, cx| {
70 let channel_id = ActiveCall::global(cx)
71 .read(cx)
72 .room()
73 .and_then(|room| room.read(cx).channel_id());
74
75 if let Some(channel_id) = channel_id {
76 let workspace = cx.entity().clone();
77 window.defer(cx, move |window, cx| {
78 ChannelView::open(channel_id, None, workspace, window, cx)
79 .detach_and_log_err(cx)
80 });
81 }
82 });
83 })
84 .detach();
85}
86
87#[derive(Debug)]
88pub enum ChannelEditingState {
89 Create {
90 location: Option<ChannelId>,
91 pending_name: Option<String>,
92 },
93 Rename {
94 location: ChannelId,
95 pending_name: Option<String>,
96 },
97}
98
99impl ChannelEditingState {
100 fn pending_name(&self) -> Option<String> {
101 match self {
102 ChannelEditingState::Create { pending_name, .. } => pending_name.clone(),
103 ChannelEditingState::Rename { pending_name, .. } => pending_name.clone(),
104 }
105 }
106}
107
108pub struct CollabPanel {
109 width: Option<Pixels>,
110 fs: Arc<dyn Fs>,
111 focus_handle: FocusHandle,
112 channel_clipboard: Option<ChannelMoveClipboard>,
113 pending_serialization: Task<Option<()>>,
114 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
115 list_state: ListState,
116 filter_editor: Entity<Editor>,
117 channel_name_editor: Entity<Editor>,
118 channel_editing_state: Option<ChannelEditingState>,
119 entries: Vec<ListEntry>,
120 selection: Option<usize>,
121 channel_store: Entity<ChannelStore>,
122 user_store: Entity<UserStore>,
123 client: Arc<Client>,
124 project: Entity<Project>,
125 match_candidates: Vec<StringMatchCandidate>,
126 subscriptions: Vec<Subscription>,
127 collapsed_sections: Vec<Section>,
128 collapsed_channels: Vec<ChannelId>,
129 workspace: WeakEntity<Workspace>,
130}
131
132#[derive(Serialize, Deserialize)]
133struct SerializedCollabPanel {
134 width: Option<Pixels>,
135 collapsed_channels: Option<Vec<u64>>,
136}
137
138#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
139enum Section {
140 ActiveCall,
141 Channels,
142 ChannelInvites,
143 ContactRequests,
144 Contacts,
145 Online,
146 Offline,
147}
148
149#[derive(Clone, Debug)]
150enum ListEntry {
151 Header(Section),
152 CallParticipant {
153 user: Arc<User>,
154 peer_id: Option<PeerId>,
155 is_pending: bool,
156 role: proto::ChannelRole,
157 },
158 ParticipantProject {
159 project_id: u64,
160 worktree_root_names: Vec<String>,
161 host_user_id: u64,
162 is_last: bool,
163 },
164 ParticipantScreen {
165 peer_id: Option<PeerId>,
166 is_last: bool,
167 },
168 IncomingRequest(Arc<User>),
169 OutgoingRequest(Arc<User>),
170 ChannelInvite(Arc<Channel>),
171 Channel {
172 channel: Arc<Channel>,
173 depth: usize,
174 has_children: bool,
175 },
176 ChannelNotes {
177 channel_id: ChannelId,
178 },
179 ChannelChat {
180 channel_id: ChannelId,
181 },
182 ChannelEditor {
183 depth: usize,
184 },
185 Contact {
186 contact: Arc<Contact>,
187 calling: bool,
188 },
189 ContactPlaceholder,
190}
191
192impl CollabPanel {
193 pub fn new(
194 workspace: &mut Workspace,
195 window: &mut Window,
196 cx: &mut Context<Workspace>,
197 ) -> Entity<Self> {
198 cx.new(|cx| {
199 let filter_editor = cx.new(|cx| {
200 let mut editor = Editor::single_line(window, cx);
201 editor.set_placeholder_text("Filter...", cx);
202 editor
203 });
204
205 cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
206 if let editor::EditorEvent::BufferEdited = event {
207 let query = this.filter_editor.read(cx).text(cx);
208 if !query.is_empty() {
209 this.selection.take();
210 }
211 this.update_entries(true, cx);
212 if !query.is_empty() {
213 this.selection = this
214 .entries
215 .iter()
216 .position(|entry| !matches!(entry, ListEntry::Header(_)));
217 }
218 }
219 })
220 .detach();
221
222 let channel_name_editor = cx.new(|cx| Editor::single_line(window, cx));
223
224 cx.subscribe_in(
225 &channel_name_editor,
226 window,
227 |this: &mut Self, _, event, window, cx| {
228 if let editor::EditorEvent::Blurred = event {
229 if let Some(state) = &this.channel_editing_state {
230 if state.pending_name().is_some() {
231 return;
232 }
233 }
234 this.take_editing_state(window, cx);
235 this.update_entries(false, cx);
236 cx.notify();
237 }
238 },
239 )
240 .detach();
241
242 let entity = cx.entity().downgrade();
243 let list_state = ListState::new(
244 0,
245 gpui::ListAlignment::Top,
246 px(1000.),
247 move |ix, window, cx| {
248 if let Some(entity) = entity.upgrade() {
249 entity.update(cx, |this, cx| this.render_list_entry(ix, window, cx))
250 } else {
251 div().into_any()
252 }
253 },
254 );
255
256 let mut this = Self {
257 width: None,
258 focus_handle: cx.focus_handle(),
259 channel_clipboard: None,
260 fs: workspace.app_state().fs.clone(),
261 pending_serialization: Task::ready(None),
262 context_menu: None,
263 list_state,
264 channel_name_editor,
265 filter_editor,
266 entries: Vec::default(),
267 channel_editing_state: None,
268 selection: None,
269 channel_store: ChannelStore::global(cx),
270 user_store: workspace.user_store().clone(),
271 project: workspace.project().clone(),
272 subscriptions: Vec::default(),
273 match_candidates: Vec::default(),
274 collapsed_sections: vec![Section::Offline],
275 collapsed_channels: Vec::default(),
276 workspace: workspace.weak_handle(),
277 client: workspace.app_state().client.clone(),
278 };
279
280 this.update_entries(false, cx);
281
282 let active_call = ActiveCall::global(cx);
283 this.subscriptions
284 .push(cx.observe(&this.user_store, |this, _, cx| {
285 this.update_entries(true, cx)
286 }));
287 this.subscriptions
288 .push(cx.observe(&this.channel_store, move |this, _, cx| {
289 this.update_entries(true, cx)
290 }));
291 this.subscriptions
292 .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
293 this.subscriptions.push(cx.subscribe_in(
294 &this.channel_store,
295 window,
296 |this, _channel_store, e, window, cx| match e {
297 ChannelEvent::ChannelCreated(channel_id)
298 | ChannelEvent::ChannelRenamed(channel_id) => {
299 if this.take_editing_state(window, cx) {
300 this.update_entries(false, cx);
301 this.selection = this.entries.iter().position(|entry| {
302 if let ListEntry::Channel { channel, .. } = entry {
303 channel.id == *channel_id
304 } else {
305 false
306 }
307 });
308 }
309 }
310 },
311 ));
312
313 this
314 })
315 }
316
317 pub async fn load(
318 workspace: WeakEntity<Workspace>,
319 mut cx: AsyncWindowContext,
320 ) -> anyhow::Result<Entity<Self>> {
321 let serialized_panel = cx
322 .background_spawn(async move { KEY_VALUE_STORE.read_kvp(COLLABORATION_PANEL_KEY) })
323 .await
324 .map_err(|_| anyhow::anyhow!("Failed to read collaboration panel from key value store"))
325 .log_err()
326 .flatten()
327 .map(|panel| serde_json::from_str::<SerializedCollabPanel>(&panel))
328 .transpose()
329 .log_err()
330 .flatten();
331
332 workspace.update_in(&mut cx, |workspace, window, cx| {
333 let panel = CollabPanel::new(workspace, window, cx);
334 if let Some(serialized_panel) = serialized_panel {
335 panel.update(cx, |panel, cx| {
336 panel.width = serialized_panel.width.map(|w| w.round());
337 panel.collapsed_channels = serialized_panel
338 .collapsed_channels
339 .unwrap_or_else(Vec::new)
340 .iter()
341 .map(|cid| ChannelId(*cid))
342 .collect();
343 cx.notify();
344 });
345 }
346 panel
347 })
348 }
349
350 fn serialize(&mut self, cx: &mut Context<Self>) {
351 let width = self.width;
352 let collapsed_channels = self.collapsed_channels.clone();
353 self.pending_serialization = cx.background_spawn(
354 async move {
355 KEY_VALUE_STORE
356 .write_kvp(
357 COLLABORATION_PANEL_KEY.into(),
358 serde_json::to_string(&SerializedCollabPanel {
359 width,
360 collapsed_channels: Some(
361 collapsed_channels.iter().map(|cid| cid.0).collect(),
362 ),
363 })?,
364 )
365 .await?;
366 anyhow::Ok(())
367 }
368 .log_err(),
369 );
370 }
371
372 fn scroll_to_item(&mut self, ix: usize) {
373 self.list_state.scroll_to_reveal_item(ix)
374 }
375
376 fn update_entries(&mut self, select_same_item: bool, cx: &mut Context<Self>) {
377 let channel_store = self.channel_store.read(cx);
378 let user_store = self.user_store.read(cx);
379 let query = self.filter_editor.read(cx).text(cx);
380 let executor = cx.background_executor().clone();
381
382 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
383 let old_entries = mem::take(&mut self.entries);
384 let mut scroll_to_top = false;
385
386 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
387 self.entries.push(ListEntry::Header(Section::ActiveCall));
388 if !old_entries
389 .iter()
390 .any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall)))
391 {
392 scroll_to_top = true;
393 }
394
395 if !self.collapsed_sections.contains(&Section::ActiveCall) {
396 let room = room.read(cx);
397
398 if query.is_empty() {
399 if let Some(channel_id) = room.channel_id() {
400 self.entries.push(ListEntry::ChannelNotes { channel_id });
401 self.entries.push(ListEntry::ChannelChat { channel_id });
402 }
403 }
404
405 // Populate the active user.
406 if let Some(user) = user_store.current_user() {
407 self.match_candidates.clear();
408 self.match_candidates
409 .push(StringMatchCandidate::new(0, &user.github_login));
410 let matches = executor.block(match_strings(
411 &self.match_candidates,
412 &query,
413 true,
414 usize::MAX,
415 &Default::default(),
416 executor.clone(),
417 ));
418 if !matches.is_empty() {
419 let user_id = user.id;
420 self.entries.push(ListEntry::CallParticipant {
421 user,
422 peer_id: None,
423 is_pending: false,
424 role: room.local_participant().role,
425 });
426 let mut projects = room.local_participant().projects.iter().peekable();
427 while let Some(project) = projects.next() {
428 self.entries.push(ListEntry::ParticipantProject {
429 project_id: project.id,
430 worktree_root_names: project.worktree_root_names.clone(),
431 host_user_id: user_id,
432 is_last: projects.peek().is_none() && !room.is_screen_sharing(),
433 });
434 }
435 if room.is_screen_sharing() {
436 self.entries.push(ListEntry::ParticipantScreen {
437 peer_id: None,
438 is_last: true,
439 });
440 }
441 }
442 }
443
444 // Populate remote participants.
445 self.match_candidates.clear();
446 self.match_candidates
447 .extend(room.remote_participants().values().map(|participant| {
448 StringMatchCandidate::new(
449 participant.user.id as usize,
450 &participant.user.github_login,
451 )
452 }));
453 let mut matches = executor.block(match_strings(
454 &self.match_candidates,
455 &query,
456 true,
457 usize::MAX,
458 &Default::default(),
459 executor.clone(),
460 ));
461 matches.sort_by(|a, b| {
462 let a_is_guest = room.role_for_user(a.candidate_id as u64)
463 == Some(proto::ChannelRole::Guest);
464 let b_is_guest = room.role_for_user(b.candidate_id as u64)
465 == Some(proto::ChannelRole::Guest);
466 a_is_guest
467 .cmp(&b_is_guest)
468 .then_with(|| a.string.cmp(&b.string))
469 });
470 for mat in matches {
471 let user_id = mat.candidate_id as u64;
472 let participant = &room.remote_participants()[&user_id];
473 self.entries.push(ListEntry::CallParticipant {
474 user: participant.user.clone(),
475 peer_id: Some(participant.peer_id),
476 is_pending: false,
477 role: participant.role,
478 });
479 let mut projects = participant.projects.iter().peekable();
480 while let Some(project) = projects.next() {
481 self.entries.push(ListEntry::ParticipantProject {
482 project_id: project.id,
483 worktree_root_names: project.worktree_root_names.clone(),
484 host_user_id: participant.user.id,
485 is_last: projects.peek().is_none() && !participant.has_video_tracks(),
486 });
487 }
488 if participant.has_video_tracks() {
489 self.entries.push(ListEntry::ParticipantScreen {
490 peer_id: Some(participant.peer_id),
491 is_last: true,
492 });
493 }
494 }
495
496 // Populate pending participants.
497 self.match_candidates.clear();
498 self.match_candidates
499 .extend(room.pending_participants().iter().enumerate().map(
500 |(id, participant)| {
501 StringMatchCandidate::new(id, &participant.github_login)
502 },
503 ));
504 let matches = executor.block(match_strings(
505 &self.match_candidates,
506 &query,
507 true,
508 usize::MAX,
509 &Default::default(),
510 executor.clone(),
511 ));
512 self.entries
513 .extend(matches.iter().map(|mat| ListEntry::CallParticipant {
514 user: room.pending_participants()[mat.candidate_id].clone(),
515 peer_id: None,
516 is_pending: true,
517 role: proto::ChannelRole::Member,
518 }));
519 }
520 }
521
522 let mut request_entries = Vec::new();
523
524 self.entries.push(ListEntry::Header(Section::Channels));
525
526 if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
527 self.match_candidates.clear();
528 self.match_candidates.extend(
529 channel_store
530 .ordered_channels()
531 .enumerate()
532 .map(|(ix, (_, channel))| StringMatchCandidate::new(ix, &channel.name)),
533 );
534 let matches = executor.block(match_strings(
535 &self.match_candidates,
536 &query,
537 true,
538 usize::MAX,
539 &Default::default(),
540 executor.clone(),
541 ));
542 if let Some(state) = &self.channel_editing_state {
543 if matches!(state, ChannelEditingState::Create { location: None, .. }) {
544 self.entries.push(ListEntry::ChannelEditor { depth: 0 });
545 }
546 }
547 let mut collapse_depth = None;
548 for mat in matches {
549 let channel = channel_store.channel_at_index(mat.candidate_id).unwrap();
550 let depth = channel.parent_path.len();
551
552 if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) {
553 collapse_depth = Some(depth);
554 } else if let Some(collapsed_depth) = collapse_depth {
555 if depth > collapsed_depth {
556 continue;
557 }
558 if self.is_channel_collapsed(channel.id) {
559 collapse_depth = Some(depth);
560 } else {
561 collapse_depth = None;
562 }
563 }
564
565 let has_children = channel_store
566 .channel_at_index(mat.candidate_id + 1)
567 .map_or(false, |next_channel| {
568 next_channel.parent_path.ends_with(&[channel.id])
569 });
570
571 match &self.channel_editing_state {
572 Some(ChannelEditingState::Create {
573 location: parent_id,
574 ..
575 }) if *parent_id == Some(channel.id) => {
576 self.entries.push(ListEntry::Channel {
577 channel: channel.clone(),
578 depth,
579 has_children: false,
580 });
581 self.entries
582 .push(ListEntry::ChannelEditor { depth: depth + 1 });
583 }
584 Some(ChannelEditingState::Rename {
585 location: parent_id,
586 ..
587 }) if parent_id == &channel.id => {
588 self.entries.push(ListEntry::ChannelEditor { depth });
589 }
590 _ => {
591 self.entries.push(ListEntry::Channel {
592 channel: channel.clone(),
593 depth,
594 has_children,
595 });
596 }
597 }
598 }
599 }
600
601 let channel_invites = channel_store.channel_invitations();
602 if !channel_invites.is_empty() {
603 self.match_candidates.clear();
604 self.match_candidates.extend(
605 channel_invites
606 .iter()
607 .enumerate()
608 .map(|(ix, channel)| StringMatchCandidate::new(ix, &channel.name)),
609 );
610 let matches = executor.block(match_strings(
611 &self.match_candidates,
612 &query,
613 true,
614 usize::MAX,
615 &Default::default(),
616 executor.clone(),
617 ));
618 request_entries.extend(
619 matches
620 .iter()
621 .map(|mat| ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())),
622 );
623
624 if !request_entries.is_empty() {
625 self.entries
626 .push(ListEntry::Header(Section::ChannelInvites));
627 if !self.collapsed_sections.contains(&Section::ChannelInvites) {
628 self.entries.append(&mut request_entries);
629 }
630 }
631 }
632
633 self.entries.push(ListEntry::Header(Section::Contacts));
634
635 request_entries.clear();
636 let incoming = user_store.incoming_contact_requests();
637 if !incoming.is_empty() {
638 self.match_candidates.clear();
639 self.match_candidates.extend(
640 incoming
641 .iter()
642 .enumerate()
643 .map(|(ix, user)| StringMatchCandidate::new(ix, &user.github_login)),
644 );
645 let matches = executor.block(match_strings(
646 &self.match_candidates,
647 &query,
648 true,
649 usize::MAX,
650 &Default::default(),
651 executor.clone(),
652 ));
653 request_entries.extend(
654 matches
655 .iter()
656 .map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
657 );
658 }
659
660 let outgoing = user_store.outgoing_contact_requests();
661 if !outgoing.is_empty() {
662 self.match_candidates.clear();
663 self.match_candidates.extend(
664 outgoing
665 .iter()
666 .enumerate()
667 .map(|(ix, user)| StringMatchCandidate::new(ix, &user.github_login)),
668 );
669 let matches = executor.block(match_strings(
670 &self.match_candidates,
671 &query,
672 true,
673 usize::MAX,
674 &Default::default(),
675 executor.clone(),
676 ));
677 request_entries.extend(
678 matches
679 .iter()
680 .map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
681 );
682 }
683
684 if !request_entries.is_empty() {
685 self.entries
686 .push(ListEntry::Header(Section::ContactRequests));
687 if !self.collapsed_sections.contains(&Section::ContactRequests) {
688 self.entries.append(&mut request_entries);
689 }
690 }
691
692 let contacts = user_store.contacts();
693 if !contacts.is_empty() {
694 self.match_candidates.clear();
695 self.match_candidates.extend(
696 contacts
697 .iter()
698 .enumerate()
699 .map(|(ix, contact)| StringMatchCandidate::new(ix, &contact.user.github_login)),
700 );
701
702 let matches = executor.block(match_strings(
703 &self.match_candidates,
704 &query,
705 true,
706 usize::MAX,
707 &Default::default(),
708 executor.clone(),
709 ));
710
711 let (online_contacts, offline_contacts) = matches
712 .iter()
713 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
714
715 for (matches, section) in [
716 (online_contacts, Section::Online),
717 (offline_contacts, Section::Offline),
718 ] {
719 if !matches.is_empty() {
720 self.entries.push(ListEntry::Header(section));
721 if !self.collapsed_sections.contains(§ion) {
722 let active_call = &ActiveCall::global(cx).read(cx);
723 for mat in matches {
724 let contact = &contacts[mat.candidate_id];
725 self.entries.push(ListEntry::Contact {
726 contact: contact.clone(),
727 calling: active_call.pending_invites().contains(&contact.user.id),
728 });
729 }
730 }
731 }
732 }
733 }
734
735 if incoming.is_empty() && outgoing.is_empty() && contacts.is_empty() {
736 self.entries.push(ListEntry::ContactPlaceholder);
737 }
738
739 if select_same_item {
740 if let Some(prev_selected_entry) = prev_selected_entry {
741 self.selection.take();
742 for (ix, entry) in self.entries.iter().enumerate() {
743 if *entry == prev_selected_entry {
744 self.selection = Some(ix);
745 break;
746 }
747 }
748 }
749 } else {
750 self.selection = self.selection.and_then(|prev_selection| {
751 if self.entries.is_empty() {
752 None
753 } else {
754 Some(prev_selection.min(self.entries.len() - 1))
755 }
756 });
757 }
758
759 let old_scroll_top = self.list_state.logical_scroll_top();
760 self.list_state.reset(self.entries.len());
761
762 if scroll_to_top {
763 self.list_state.scroll_to(ListOffset::default());
764 } else {
765 // Attempt to maintain the same scroll position.
766 if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
767 let new_scroll_top = self
768 .entries
769 .iter()
770 .position(|entry| entry == old_top_entry)
771 .map(|item_ix| ListOffset {
772 item_ix,
773 offset_in_item: old_scroll_top.offset_in_item,
774 })
775 .or_else(|| {
776 let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
777 let item_ix = self
778 .entries
779 .iter()
780 .position(|entry| entry == entry_after_old_top)?;
781 Some(ListOffset {
782 item_ix,
783 offset_in_item: Pixels::ZERO,
784 })
785 })
786 .or_else(|| {
787 let entry_before_old_top =
788 old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
789 let item_ix = self
790 .entries
791 .iter()
792 .position(|entry| entry == entry_before_old_top)?;
793 Some(ListOffset {
794 item_ix,
795 offset_in_item: Pixels::ZERO,
796 })
797 });
798
799 self.list_state
800 .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
801 }
802 }
803
804 cx.notify();
805 }
806
807 fn render_call_participant(
808 &self,
809 user: &Arc<User>,
810 peer_id: Option<PeerId>,
811 is_pending: bool,
812 role: proto::ChannelRole,
813 is_selected: bool,
814 cx: &mut Context<Self>,
815 ) -> ListItem {
816 let user_id = user.id;
817 let is_current_user =
818 self.user_store.read(cx).current_user().map(|user| user.id) == Some(user_id);
819 let tooltip = format!("Follow {}", user.github_login);
820
821 let is_call_admin = ActiveCall::global(cx).read(cx).room().is_some_and(|room| {
822 room.read(cx).local_participant().role == proto::ChannelRole::Admin
823 });
824
825 ListItem::new(SharedString::from(user.github_login.clone()))
826 .start_slot(Avatar::new(user.avatar_uri.clone()))
827 .child(Label::new(user.github_login.clone()))
828 .toggle_state(is_selected)
829 .end_slot(if is_pending {
830 Label::new("Calling").color(Color::Muted).into_any_element()
831 } else if is_current_user {
832 IconButton::new("leave-call", IconName::Exit)
833 .style(ButtonStyle::Subtle)
834 .on_click(move |_, window, cx| Self::leave_call(window, cx))
835 .tooltip(Tooltip::text("Leave Call"))
836 .into_any_element()
837 } else if role == proto::ChannelRole::Guest {
838 Label::new("Guest").color(Color::Muted).into_any_element()
839 } else if role == proto::ChannelRole::Talker {
840 Label::new("Mic only")
841 .color(Color::Muted)
842 .into_any_element()
843 } else {
844 div().into_any_element()
845 })
846 .when_some(peer_id, |el, peer_id| {
847 if role == proto::ChannelRole::Guest {
848 return el;
849 }
850 el.tooltip(Tooltip::text(tooltip.clone()))
851 .on_click(cx.listener(move |this, _, window, cx| {
852 this.workspace
853 .update(cx, |workspace, cx| workspace.follow(peer_id, window, cx))
854 .ok();
855 }))
856 })
857 .when(is_call_admin, |el| {
858 el.on_secondary_mouse_down(cx.listener(
859 move |this, event: &MouseDownEvent, window, cx| {
860 this.deploy_participant_context_menu(
861 event.position,
862 user_id,
863 role,
864 window,
865 cx,
866 )
867 },
868 ))
869 })
870 }
871
872 fn render_participant_project(
873 &self,
874 project_id: u64,
875 worktree_root_names: &[String],
876 host_user_id: u64,
877 is_last: bool,
878 is_selected: bool,
879 window: &mut Window,
880 cx: &mut Context<Self>,
881 ) -> impl IntoElement {
882 let project_name: SharedString = if worktree_root_names.is_empty() {
883 "untitled".to_string()
884 } else {
885 worktree_root_names.join(", ")
886 }
887 .into();
888
889 ListItem::new(project_id as usize)
890 .toggle_state(is_selected)
891 .on_click(cx.listener(move |this, _, window, cx| {
892 this.workspace
893 .update(cx, |workspace, cx| {
894 let app_state = workspace.app_state().clone();
895 workspace::join_in_room_project(project_id, host_user_id, app_state, cx)
896 .detach_and_prompt_err(
897 "Failed to join project",
898 window,
899 cx,
900 |_, _, _| None,
901 );
902 })
903 .ok();
904 }))
905 .start_slot(
906 h_flex()
907 .gap_1()
908 .child(render_tree_branch(is_last, false, window, cx))
909 .child(IconButton::new(0, IconName::Folder)),
910 )
911 .child(Label::new(project_name.clone()))
912 .tooltip(Tooltip::text(format!("Open {}", project_name)))
913 }
914
915 fn render_participant_screen(
916 &self,
917 peer_id: Option<PeerId>,
918 is_last: bool,
919 is_selected: bool,
920 window: &mut Window,
921 cx: &mut Context<Self>,
922 ) -> impl IntoElement {
923 let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize);
924
925 ListItem::new(("screen", id))
926 .toggle_state(is_selected)
927 .start_slot(
928 h_flex()
929 .gap_1()
930 .child(render_tree_branch(is_last, false, window, cx))
931 .child(IconButton::new(0, IconName::Screen)),
932 )
933 .child(Label::new("Screen"))
934 .when_some(peer_id, |this, _| {
935 this.on_click(cx.listener(move |this, _, window, cx| {
936 this.workspace
937 .update(cx, |workspace, cx| {
938 workspace.open_shared_screen(peer_id.unwrap(), window, cx)
939 })
940 .ok();
941 }))
942 .tooltip(Tooltip::text("Open shared screen"))
943 })
944 }
945
946 fn take_editing_state(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
947 if self.channel_editing_state.take().is_some() {
948 self.channel_name_editor.update(cx, |editor, cx| {
949 editor.set_text("", window, cx);
950 });
951 true
952 } else {
953 false
954 }
955 }
956
957 fn render_channel_notes(
958 &self,
959 channel_id: ChannelId,
960 is_selected: bool,
961 window: &mut Window,
962 cx: &mut Context<Self>,
963 ) -> impl IntoElement {
964 let channel_store = self.channel_store.read(cx);
965 let has_channel_buffer_changed = channel_store.has_channel_buffer_changed(channel_id);
966 ListItem::new("channel-notes")
967 .toggle_state(is_selected)
968 .on_click(cx.listener(move |this, _, window, cx| {
969 this.open_channel_notes(channel_id, window, cx);
970 }))
971 .start_slot(
972 h_flex()
973 .relative()
974 .gap_1()
975 .child(render_tree_branch(false, true, window, cx))
976 .child(IconButton::new(0, IconName::File))
977 .children(has_channel_buffer_changed.then(|| {
978 div()
979 .w_1p5()
980 .absolute()
981 .right(px(2.))
982 .top(px(2.))
983 .child(Indicator::dot().color(Color::Info))
984 })),
985 )
986 .child(Label::new("notes"))
987 .tooltip(Tooltip::text("Open Channel Notes"))
988 }
989
990 fn render_channel_chat(
991 &self,
992 channel_id: ChannelId,
993 is_selected: bool,
994 window: &mut Window,
995 cx: &mut Context<Self>,
996 ) -> impl IntoElement {
997 let channel_store = self.channel_store.read(cx);
998 let has_messages_notification = channel_store.has_new_messages(channel_id);
999 ListItem::new("channel-chat")
1000 .toggle_state(is_selected)
1001 .on_click(cx.listener(move |this, _, window, cx| {
1002 this.join_channel_chat(channel_id, window, cx);
1003 }))
1004 .start_slot(
1005 h_flex()
1006 .relative()
1007 .gap_1()
1008 .child(render_tree_branch(false, false, window, cx))
1009 .child(IconButton::new(0, IconName::MessageBubbles))
1010 .children(has_messages_notification.then(|| {
1011 div()
1012 .w_1p5()
1013 .absolute()
1014 .right(px(2.))
1015 .top(px(4.))
1016 .child(Indicator::dot().color(Color::Info))
1017 })),
1018 )
1019 .child(Label::new("chat"))
1020 .tooltip(Tooltip::text("Open Chat"))
1021 }
1022
1023 fn has_subchannels(&self, ix: usize) -> bool {
1024 self.entries.get(ix).map_or(false, |entry| {
1025 if let ListEntry::Channel { has_children, .. } = entry {
1026 *has_children
1027 } else {
1028 false
1029 }
1030 })
1031 }
1032
1033 fn deploy_participant_context_menu(
1034 &mut self,
1035 position: Point<Pixels>,
1036 user_id: u64,
1037 role: proto::ChannelRole,
1038 window: &mut Window,
1039 cx: &mut Context<Self>,
1040 ) {
1041 let this = cx.entity().clone();
1042 if !(role == proto::ChannelRole::Guest
1043 || role == proto::ChannelRole::Talker
1044 || role == proto::ChannelRole::Member)
1045 {
1046 return;
1047 }
1048
1049 let context_menu = ContextMenu::build(window, cx, |mut context_menu, window, _| {
1050 if role == proto::ChannelRole::Guest {
1051 context_menu = context_menu.entry(
1052 "Grant Mic Access",
1053 None,
1054 window.handler_for(&this, move |_, window, cx| {
1055 ActiveCall::global(cx)
1056 .update(cx, |call, cx| {
1057 let Some(room) = call.room() else {
1058 return Task::ready(Ok(()));
1059 };
1060 room.update(cx, |room, cx| {
1061 room.set_participant_role(
1062 user_id,
1063 proto::ChannelRole::Talker,
1064 cx,
1065 )
1066 })
1067 })
1068 .detach_and_prompt_err(
1069 "Failed to grant mic access",
1070 window,
1071 cx,
1072 |_, _, _| None,
1073 )
1074 }),
1075 );
1076 }
1077 if role == proto::ChannelRole::Guest || role == proto::ChannelRole::Talker {
1078 context_menu = context_menu.entry(
1079 "Grant Write Access",
1080 None,
1081 window.handler_for(&this, move |_, window, cx| {
1082 ActiveCall::global(cx)
1083 .update(cx, |call, cx| {
1084 let Some(room) = call.room() else {
1085 return Task::ready(Ok(()));
1086 };
1087 room.update(cx, |room, cx| {
1088 room.set_participant_role(
1089 user_id,
1090 proto::ChannelRole::Member,
1091 cx,
1092 )
1093 })
1094 })
1095 .detach_and_prompt_err("Failed to grant write access", window, cx, |e, _, _| {
1096 match e.error_code() {
1097 ErrorCode::NeedsCla => Some("This user has not yet signed the CLA at https://zed.dev/cla.".into()),
1098 _ => None,
1099 }
1100 })
1101 }),
1102 );
1103 }
1104 if role == proto::ChannelRole::Member || role == proto::ChannelRole::Talker {
1105 let label = if role == proto::ChannelRole::Talker {
1106 "Mute"
1107 } else {
1108 "Revoke Access"
1109 };
1110 context_menu = context_menu.entry(
1111 label,
1112 None,
1113 window.handler_for(&this, move |_, window, cx| {
1114 ActiveCall::global(cx)
1115 .update(cx, |call, cx| {
1116 let Some(room) = call.room() else {
1117 return Task::ready(Ok(()));
1118 };
1119 room.update(cx, |room, cx| {
1120 room.set_participant_role(
1121 user_id,
1122 proto::ChannelRole::Guest,
1123 cx,
1124 )
1125 })
1126 })
1127 .detach_and_prompt_err(
1128 "Failed to revoke access",
1129 window,
1130 cx,
1131 |_, _, _| None,
1132 )
1133 }),
1134 );
1135 }
1136
1137 context_menu
1138 });
1139
1140 window.focus(&context_menu.focus_handle(cx));
1141 let subscription = cx.subscribe_in(
1142 &context_menu,
1143 window,
1144 |this, _, _: &DismissEvent, window, cx| {
1145 if this.context_menu.as_ref().is_some_and(|context_menu| {
1146 context_menu.0.focus_handle(cx).contains_focused(window, cx)
1147 }) {
1148 cx.focus_self(window);
1149 }
1150 this.context_menu.take();
1151 cx.notify();
1152 },
1153 );
1154 self.context_menu = Some((context_menu, position, subscription));
1155 }
1156
1157 fn deploy_channel_context_menu(
1158 &mut self,
1159 position: Point<Pixels>,
1160 channel_id: ChannelId,
1161 ix: usize,
1162 window: &mut Window,
1163 cx: &mut Context<Self>,
1164 ) {
1165 let clipboard_channel_name = self.channel_clipboard.as_ref().and_then(|clipboard| {
1166 self.channel_store
1167 .read(cx)
1168 .channel_for_id(clipboard.channel_id)
1169 .map(|channel| channel.name.clone())
1170 });
1171 let this = cx.entity().clone();
1172
1173 let context_menu = ContextMenu::build(window, cx, |mut context_menu, window, cx| {
1174 if self.has_subchannels(ix) {
1175 let expand_action_name = if self.is_channel_collapsed(channel_id) {
1176 "Expand Subchannels"
1177 } else {
1178 "Collapse Subchannels"
1179 };
1180 context_menu = context_menu.entry(
1181 expand_action_name,
1182 None,
1183 window.handler_for(&this, move |this, window, cx| {
1184 this.toggle_channel_collapsed(channel_id, window, cx)
1185 }),
1186 );
1187 }
1188
1189 context_menu = context_menu
1190 .entry(
1191 "Open Notes",
1192 None,
1193 window.handler_for(&this, move |this, window, cx| {
1194 this.open_channel_notes(channel_id, window, cx)
1195 }),
1196 )
1197 .entry(
1198 "Open Chat",
1199 None,
1200 window.handler_for(&this, move |this, window, cx| {
1201 this.join_channel_chat(channel_id, window, cx)
1202 }),
1203 )
1204 .entry(
1205 "Copy Channel Link",
1206 None,
1207 window.handler_for(&this, move |this, _, cx| {
1208 this.copy_channel_link(channel_id, cx)
1209 }),
1210 );
1211
1212 let mut has_destructive_actions = false;
1213 if self.channel_store.read(cx).is_channel_admin(channel_id) {
1214 has_destructive_actions = true;
1215 context_menu = context_menu
1216 .separator()
1217 .entry(
1218 "New Subchannel",
1219 None,
1220 window.handler_for(&this, move |this, window, cx| {
1221 this.new_subchannel(channel_id, window, cx)
1222 }),
1223 )
1224 .entry(
1225 "Rename",
1226 Some(Box::new(SecondaryConfirm)),
1227 window.handler_for(&this, move |this, window, cx| {
1228 this.rename_channel(channel_id, window, cx)
1229 }),
1230 );
1231
1232 if let Some(channel_name) = clipboard_channel_name {
1233 context_menu = context_menu.separator().entry(
1234 format!("Move '#{}' here", channel_name),
1235 None,
1236 window.handler_for(&this, move |this, window, cx| {
1237 this.move_channel_on_clipboard(channel_id, window, cx)
1238 }),
1239 );
1240 }
1241
1242 if self.channel_store.read(cx).is_root_channel(channel_id) {
1243 context_menu = context_menu.separator().entry(
1244 "Manage Members",
1245 None,
1246 window.handler_for(&this, move |this, window, cx| {
1247 this.manage_members(channel_id, window, cx)
1248 }),
1249 )
1250 } else {
1251 context_menu = context_menu.entry(
1252 "Move this channel",
1253 None,
1254 window.handler_for(&this, move |this, window, cx| {
1255 this.start_move_channel(channel_id, window, cx)
1256 }),
1257 );
1258 if self.channel_store.read(cx).is_public_channel(channel_id) {
1259 context_menu = context_menu.separator().entry(
1260 "Make Channel Private",
1261 None,
1262 window.handler_for(&this, move |this, window, cx| {
1263 this.set_channel_visibility(
1264 channel_id,
1265 ChannelVisibility::Members,
1266 window,
1267 cx,
1268 )
1269 }),
1270 )
1271 } else {
1272 context_menu = context_menu.separator().entry(
1273 "Make Channel Public",
1274 None,
1275 window.handler_for(&this, move |this, window, cx| {
1276 this.set_channel_visibility(
1277 channel_id,
1278 ChannelVisibility::Public,
1279 window,
1280 cx,
1281 )
1282 }),
1283 )
1284 }
1285 }
1286
1287 context_menu = context_menu.entry(
1288 "Delete",
1289 None,
1290 window.handler_for(&this, move |this, window, cx| {
1291 this.remove_channel(channel_id, window, cx)
1292 }),
1293 );
1294 }
1295
1296 if self.channel_store.read(cx).is_root_channel(channel_id) {
1297 if !has_destructive_actions {
1298 context_menu = context_menu.separator()
1299 }
1300 context_menu = context_menu.entry(
1301 "Leave Channel",
1302 None,
1303 window.handler_for(&this, move |this, window, cx| {
1304 this.leave_channel(channel_id, window, cx)
1305 }),
1306 );
1307 }
1308
1309 context_menu
1310 });
1311
1312 window.focus(&context_menu.focus_handle(cx));
1313 let subscription = cx.subscribe_in(
1314 &context_menu,
1315 window,
1316 |this, _, _: &DismissEvent, window, cx| {
1317 if this.context_menu.as_ref().is_some_and(|context_menu| {
1318 context_menu.0.focus_handle(cx).contains_focused(window, cx)
1319 }) {
1320 cx.focus_self(window);
1321 }
1322 this.context_menu.take();
1323 cx.notify();
1324 },
1325 );
1326 self.context_menu = Some((context_menu, position, subscription));
1327
1328 cx.notify();
1329 }
1330
1331 fn deploy_contact_context_menu(
1332 &mut self,
1333 position: Point<Pixels>,
1334 contact: Arc<Contact>,
1335 window: &mut Window,
1336 cx: &mut Context<Self>,
1337 ) {
1338 let this = cx.entity().clone();
1339 let in_room = ActiveCall::global(cx).read(cx).room().is_some();
1340
1341 let context_menu = ContextMenu::build(window, cx, |mut context_menu, _, _| {
1342 let user_id = contact.user.id;
1343
1344 if contact.online && !contact.busy {
1345 let label = if in_room {
1346 format!("Invite {} to join", contact.user.github_login)
1347 } else {
1348 format!("Call {}", contact.user.github_login)
1349 };
1350 context_menu = context_menu.entry(label, None, {
1351 let this = this.clone();
1352 move |window, cx| {
1353 this.update(cx, |this, cx| {
1354 this.call(user_id, window, cx);
1355 });
1356 }
1357 });
1358 }
1359
1360 context_menu.entry("Remove Contact", None, {
1361 let this = this.clone();
1362 move |window, cx| {
1363 this.update(cx, |this, cx| {
1364 this.remove_contact(
1365 contact.user.id,
1366 &contact.user.github_login,
1367 window,
1368 cx,
1369 );
1370 });
1371 }
1372 })
1373 });
1374
1375 window.focus(&context_menu.focus_handle(cx));
1376 let subscription = cx.subscribe_in(
1377 &context_menu,
1378 window,
1379 |this, _, _: &DismissEvent, window, cx| {
1380 if this.context_menu.as_ref().is_some_and(|context_menu| {
1381 context_menu.0.focus_handle(cx).contains_focused(window, cx)
1382 }) {
1383 cx.focus_self(window);
1384 }
1385 this.context_menu.take();
1386 cx.notify();
1387 },
1388 );
1389 self.context_menu = Some((context_menu, position, subscription));
1390
1391 cx.notify();
1392 }
1393
1394 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1395 self.filter_editor.update(cx, |editor, cx| {
1396 if editor.buffer().read(cx).len(cx) > 0 {
1397 editor.set_text("", window, cx);
1398 true
1399 } else {
1400 false
1401 }
1402 })
1403 }
1404
1405 fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1406 if self.take_editing_state(window, cx) {
1407 window.focus(&self.filter_editor.focus_handle(cx));
1408 } else if !self.reset_filter_editor_text(window, cx) {
1409 self.focus_handle.focus(window);
1410 }
1411
1412 if self.context_menu.is_some() {
1413 self.context_menu.take();
1414 cx.notify();
1415 }
1416
1417 self.update_entries(false, cx);
1418 }
1419
1420 fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
1421 let ix = self.selection.map_or(0, |ix| ix + 1);
1422 if ix < self.entries.len() {
1423 self.selection = Some(ix);
1424 }
1425
1426 if let Some(ix) = self.selection {
1427 self.scroll_to_item(ix)
1428 }
1429 cx.notify();
1430 }
1431
1432 fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context<Self>) {
1433 let ix = self.selection.take().unwrap_or(0);
1434 if ix > 0 {
1435 self.selection = Some(ix - 1);
1436 }
1437
1438 if let Some(ix) = self.selection {
1439 self.scroll_to_item(ix)
1440 }
1441 cx.notify();
1442 }
1443
1444 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1445 if self.confirm_channel_edit(window, cx) {
1446 return;
1447 }
1448
1449 if let Some(selection) = self.selection {
1450 if let Some(entry) = self.entries.get(selection) {
1451 match entry {
1452 ListEntry::Header(section) => match section {
1453 Section::ActiveCall => Self::leave_call(window, cx),
1454 Section::Channels => self.new_root_channel(window, cx),
1455 Section::Contacts => self.toggle_contact_finder(window, cx),
1456 Section::ContactRequests
1457 | Section::Online
1458 | Section::Offline
1459 | Section::ChannelInvites => {
1460 self.toggle_section_expanded(*section, cx);
1461 }
1462 },
1463 ListEntry::Contact { contact, calling } => {
1464 if contact.online && !contact.busy && !calling {
1465 self.call(contact.user.id, window, cx);
1466 }
1467 }
1468 ListEntry::ParticipantProject {
1469 project_id,
1470 host_user_id,
1471 ..
1472 } => {
1473 if let Some(workspace) = self.workspace.upgrade() {
1474 let app_state = workspace.read(cx).app_state().clone();
1475 workspace::join_in_room_project(
1476 *project_id,
1477 *host_user_id,
1478 app_state,
1479 cx,
1480 )
1481 .detach_and_prompt_err(
1482 "Failed to join project",
1483 window,
1484 cx,
1485 |_, _, _| None,
1486 );
1487 }
1488 }
1489 ListEntry::ParticipantScreen { peer_id, .. } => {
1490 let Some(peer_id) = peer_id else {
1491 return;
1492 };
1493 if let Some(workspace) = self.workspace.upgrade() {
1494 workspace.update(cx, |workspace, cx| {
1495 workspace.open_shared_screen(*peer_id, window, cx)
1496 });
1497 }
1498 }
1499 ListEntry::Channel { channel, .. } => {
1500 let is_active = maybe!({
1501 let call_channel = ActiveCall::global(cx)
1502 .read(cx)
1503 .room()?
1504 .read(cx)
1505 .channel_id()?;
1506
1507 Some(call_channel == channel.id)
1508 })
1509 .unwrap_or(false);
1510 if is_active {
1511 self.open_channel_notes(channel.id, window, cx)
1512 } else {
1513 self.join_channel(channel.id, window, cx)
1514 }
1515 }
1516 ListEntry::ContactPlaceholder => self.toggle_contact_finder(window, cx),
1517 ListEntry::CallParticipant { user, peer_id, .. } => {
1518 if Some(user) == self.user_store.read(cx).current_user().as_ref() {
1519 Self::leave_call(window, cx);
1520 } else if let Some(peer_id) = peer_id {
1521 self.workspace
1522 .update(cx, |workspace, cx| workspace.follow(*peer_id, window, cx))
1523 .ok();
1524 }
1525 }
1526 ListEntry::IncomingRequest(user) => {
1527 self.respond_to_contact_request(user.id, true, window, cx)
1528 }
1529 ListEntry::ChannelInvite(channel) => {
1530 self.respond_to_channel_invite(channel.id, true, cx)
1531 }
1532 ListEntry::ChannelNotes { channel_id } => {
1533 self.open_channel_notes(*channel_id, window, cx)
1534 }
1535 ListEntry::ChannelChat { channel_id } => {
1536 self.join_channel_chat(*channel_id, window, cx)
1537 }
1538 ListEntry::OutgoingRequest(_) => {}
1539 ListEntry::ChannelEditor { .. } => {}
1540 }
1541 }
1542 }
1543 }
1544
1545 fn insert_space(&mut self, _: &InsertSpace, window: &mut Window, cx: &mut Context<Self>) {
1546 if self.channel_editing_state.is_some() {
1547 self.channel_name_editor.update(cx, |editor, cx| {
1548 editor.insert(" ", window, cx);
1549 });
1550 }
1551 }
1552
1553 fn confirm_channel_edit(&mut self, window: &mut Window, cx: &mut Context<CollabPanel>) -> bool {
1554 if let Some(editing_state) = &mut self.channel_editing_state {
1555 match editing_state {
1556 ChannelEditingState::Create {
1557 location,
1558 pending_name,
1559 ..
1560 } => {
1561 if pending_name.is_some() {
1562 return false;
1563 }
1564 let channel_name = self.channel_name_editor.read(cx).text(cx);
1565
1566 *pending_name = Some(channel_name.clone());
1567
1568 let create = self.channel_store.update(cx, |channel_store, cx| {
1569 channel_store.create_channel(&channel_name, *location, cx)
1570 });
1571 if location.is_none() {
1572 cx.spawn_in(window, async move |this, cx| {
1573 let channel_id = create.await?;
1574 this.update_in(cx, |this, window, cx| {
1575 this.show_channel_modal(
1576 channel_id,
1577 channel_modal::Mode::InviteMembers,
1578 window,
1579 cx,
1580 )
1581 })
1582 })
1583 .detach_and_prompt_err(
1584 "Failed to create channel",
1585 window,
1586 cx,
1587 |_, _, _| None,
1588 );
1589 } else {
1590 create.detach_and_prompt_err(
1591 "Failed to create channel",
1592 window,
1593 cx,
1594 |_, _, _| None,
1595 );
1596 }
1597 cx.notify();
1598 }
1599 ChannelEditingState::Rename {
1600 location,
1601 pending_name,
1602 } => {
1603 if pending_name.is_some() {
1604 return false;
1605 }
1606 let channel_name = self.channel_name_editor.read(cx).text(cx);
1607 *pending_name = Some(channel_name.clone());
1608
1609 self.channel_store
1610 .update(cx, |channel_store, cx| {
1611 channel_store.rename(*location, &channel_name, cx)
1612 })
1613 .detach();
1614 cx.notify();
1615 }
1616 }
1617 cx.focus_self(window);
1618 true
1619 } else {
1620 false
1621 }
1622 }
1623
1624 fn toggle_section_expanded(&mut self, section: Section, cx: &mut Context<Self>) {
1625 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
1626 self.collapsed_sections.remove(ix);
1627 } else {
1628 self.collapsed_sections.push(section);
1629 }
1630 self.update_entries(false, cx);
1631 }
1632
1633 fn collapse_selected_channel(
1634 &mut self,
1635 _: &CollapseSelectedChannel,
1636 window: &mut Window,
1637 cx: &mut Context<Self>,
1638 ) {
1639 let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
1640 return;
1641 };
1642
1643 if self.is_channel_collapsed(channel_id) {
1644 return;
1645 }
1646
1647 self.toggle_channel_collapsed(channel_id, window, cx);
1648 }
1649
1650 fn expand_selected_channel(
1651 &mut self,
1652 _: &ExpandSelectedChannel,
1653 window: &mut Window,
1654 cx: &mut Context<Self>,
1655 ) {
1656 let Some(id) = self.selected_channel().map(|channel| channel.id) else {
1657 return;
1658 };
1659
1660 if !self.is_channel_collapsed(id) {
1661 return;
1662 }
1663
1664 self.toggle_channel_collapsed(id, window, cx)
1665 }
1666
1667 fn toggle_channel_collapsed(
1668 &mut self,
1669 channel_id: ChannelId,
1670 window: &mut Window,
1671 cx: &mut Context<Self>,
1672 ) {
1673 match self.collapsed_channels.binary_search(&channel_id) {
1674 Ok(ix) => {
1675 self.collapsed_channels.remove(ix);
1676 }
1677 Err(ix) => {
1678 self.collapsed_channels.insert(ix, channel_id);
1679 }
1680 };
1681 self.serialize(cx);
1682 self.update_entries(true, cx);
1683 cx.notify();
1684 cx.focus_self(window);
1685 }
1686
1687 fn is_channel_collapsed(&self, channel_id: ChannelId) -> bool {
1688 self.collapsed_channels.binary_search(&channel_id).is_ok()
1689 }
1690
1691 fn leave_call(window: &mut Window, cx: &mut App) {
1692 ActiveCall::global(cx)
1693 .update(cx, |call, cx| call.hang_up(cx))
1694 .detach_and_prompt_err("Failed to hang up", window, cx, |_, _, _| None);
1695 }
1696
1697 fn toggle_contact_finder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1698 if let Some(workspace) = self.workspace.upgrade() {
1699 workspace.update(cx, |workspace, cx| {
1700 workspace.toggle_modal(window, cx, |window, cx| {
1701 let mut finder = ContactFinder::new(self.user_store.clone(), window, cx);
1702 finder.set_query(self.filter_editor.read(cx).text(cx), window, cx);
1703 finder
1704 });
1705 });
1706 }
1707 }
1708
1709 fn new_root_channel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1710 self.channel_editing_state = Some(ChannelEditingState::Create {
1711 location: None,
1712 pending_name: None,
1713 });
1714 self.update_entries(false, cx);
1715 self.select_channel_editor();
1716 window.focus(&self.channel_name_editor.focus_handle(cx));
1717 cx.notify();
1718 }
1719
1720 fn select_channel_editor(&mut self) {
1721 self.selection = self.entries.iter().position(|entry| match entry {
1722 ListEntry::ChannelEditor { .. } => true,
1723 _ => false,
1724 });
1725 }
1726
1727 fn new_subchannel(
1728 &mut self,
1729 channel_id: ChannelId,
1730 window: &mut Window,
1731 cx: &mut Context<Self>,
1732 ) {
1733 self.collapsed_channels
1734 .retain(|channel| *channel != channel_id);
1735 self.channel_editing_state = Some(ChannelEditingState::Create {
1736 location: Some(channel_id),
1737 pending_name: None,
1738 });
1739 self.update_entries(false, cx);
1740 self.select_channel_editor();
1741 window.focus(&self.channel_name_editor.focus_handle(cx));
1742 cx.notify();
1743 }
1744
1745 fn manage_members(
1746 &mut self,
1747 channel_id: ChannelId,
1748 window: &mut Window,
1749 cx: &mut Context<Self>,
1750 ) {
1751 self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, window, cx);
1752 }
1753
1754 fn remove_selected_channel(&mut self, _: &Remove, window: &mut Window, cx: &mut Context<Self>) {
1755 if let Some(channel) = self.selected_channel() {
1756 self.remove_channel(channel.id, window, cx)
1757 }
1758 }
1759
1760 fn rename_selected_channel(
1761 &mut self,
1762 _: &SecondaryConfirm,
1763 window: &mut Window,
1764 cx: &mut Context<Self>,
1765 ) {
1766 if let Some(channel) = self.selected_channel() {
1767 self.rename_channel(channel.id, window, cx);
1768 }
1769 }
1770
1771 fn rename_channel(
1772 &mut self,
1773 channel_id: ChannelId,
1774 window: &mut Window,
1775 cx: &mut Context<Self>,
1776 ) {
1777 let channel_store = self.channel_store.read(cx);
1778 if !channel_store.is_channel_admin(channel_id) {
1779 return;
1780 }
1781 if let Some(channel) = channel_store.channel_for_id(channel_id).cloned() {
1782 self.channel_editing_state = Some(ChannelEditingState::Rename {
1783 location: channel_id,
1784 pending_name: None,
1785 });
1786 self.channel_name_editor.update(cx, |editor, cx| {
1787 editor.set_text(channel.name.clone(), window, cx);
1788 editor.select_all(&Default::default(), window, cx);
1789 });
1790 window.focus(&self.channel_name_editor.focus_handle(cx));
1791 self.update_entries(false, cx);
1792 self.select_channel_editor();
1793 }
1794 }
1795
1796 fn set_channel_visibility(
1797 &mut self,
1798 channel_id: ChannelId,
1799 visibility: ChannelVisibility,
1800 window: &mut Window,
1801 cx: &mut Context<Self>,
1802 ) {
1803 self.channel_store
1804 .update(cx, |channel_store, cx| {
1805 channel_store.set_channel_visibility(channel_id, visibility, cx)
1806 })
1807 .detach_and_prompt_err("Failed to set channel visibility", window, cx, |e, _, _| match e.error_code() {
1808 ErrorCode::BadPublicNesting =>
1809 if e.error_tag("direction") == Some("parent") {
1810 Some("To make a channel public, its parent channel must be public.".to_string())
1811 } else {
1812 Some("To make a channel private, all of its subchannels must be private.".to_string())
1813 },
1814 _ => None
1815 });
1816 }
1817
1818 fn start_move_channel(
1819 &mut self,
1820 channel_id: ChannelId,
1821 _window: &mut Window,
1822 _cx: &mut Context<Self>,
1823 ) {
1824 self.channel_clipboard = Some(ChannelMoveClipboard { channel_id });
1825 }
1826
1827 fn start_move_selected_channel(
1828 &mut self,
1829 _: &StartMoveChannel,
1830 window: &mut Window,
1831 cx: &mut Context<Self>,
1832 ) {
1833 if let Some(channel) = self.selected_channel() {
1834 self.start_move_channel(channel.id, window, cx);
1835 }
1836 }
1837
1838 fn move_channel_on_clipboard(
1839 &mut self,
1840 to_channel_id: ChannelId,
1841 window: &mut Window,
1842 cx: &mut Context<CollabPanel>,
1843 ) {
1844 if let Some(clipboard) = self.channel_clipboard.take() {
1845 self.move_channel(clipboard.channel_id, to_channel_id, window, cx)
1846 }
1847 }
1848
1849 fn move_channel(
1850 &self,
1851 channel_id: ChannelId,
1852 to: ChannelId,
1853 window: &mut Window,
1854 cx: &mut Context<Self>,
1855 ) {
1856 self.channel_store
1857 .update(cx, |channel_store, cx| {
1858 channel_store.move_channel(channel_id, to, cx)
1859 })
1860 .detach_and_prompt_err("Failed to move channel", window, cx, |e, _, _| {
1861 match e.error_code() {
1862 ErrorCode::BadPublicNesting => {
1863 Some("Public channels must have public parents".into())
1864 }
1865 ErrorCode::CircularNesting => {
1866 Some("You cannot move a channel into itself".into())
1867 }
1868 ErrorCode::WrongMoveTarget => {
1869 Some("You cannot move a channel into a different root channel".into())
1870 }
1871 _ => None,
1872 }
1873 })
1874 }
1875
1876 fn open_channel_notes(
1877 &mut self,
1878 channel_id: ChannelId,
1879 window: &mut Window,
1880 cx: &mut Context<Self>,
1881 ) {
1882 if let Some(workspace) = self.workspace.upgrade() {
1883 ChannelView::open(channel_id, None, workspace, window, cx).detach();
1884 }
1885 }
1886
1887 fn show_inline_context_menu(
1888 &mut self,
1889 _: &menu::SecondaryConfirm,
1890 window: &mut Window,
1891 cx: &mut Context<Self>,
1892 ) {
1893 let Some(bounds) = self
1894 .selection
1895 .and_then(|ix| self.list_state.bounds_for_item(ix))
1896 else {
1897 return;
1898 };
1899
1900 if let Some(channel) = self.selected_channel() {
1901 self.deploy_channel_context_menu(
1902 bounds.center(),
1903 channel.id,
1904 self.selection.unwrap(),
1905 window,
1906 cx,
1907 );
1908 cx.stop_propagation();
1909 return;
1910 };
1911
1912 if let Some(contact) = self.selected_contact() {
1913 self.deploy_contact_context_menu(bounds.center(), contact, window, cx);
1914 cx.stop_propagation();
1915 }
1916 }
1917
1918 fn selected_channel(&self) -> Option<&Arc<Channel>> {
1919 self.selection
1920 .and_then(|ix| self.entries.get(ix))
1921 .and_then(|entry| match entry {
1922 ListEntry::Channel { channel, .. } => Some(channel),
1923 _ => None,
1924 })
1925 }
1926
1927 fn selected_contact(&self) -> Option<Arc<Contact>> {
1928 self.selection
1929 .and_then(|ix| self.entries.get(ix))
1930 .and_then(|entry| match entry {
1931 ListEntry::Contact { contact, .. } => Some(contact.clone()),
1932 _ => None,
1933 })
1934 }
1935
1936 fn show_channel_modal(
1937 &mut self,
1938 channel_id: ChannelId,
1939 mode: channel_modal::Mode,
1940 window: &mut Window,
1941 cx: &mut Context<Self>,
1942 ) {
1943 let workspace = self.workspace.clone();
1944 let user_store = self.user_store.clone();
1945 let channel_store = self.channel_store.clone();
1946
1947 cx.spawn_in(window, async move |_, cx| {
1948 workspace.update_in(cx, |workspace, window, cx| {
1949 workspace.toggle_modal(window, cx, |window, cx| {
1950 ChannelModal::new(
1951 user_store.clone(),
1952 channel_store.clone(),
1953 channel_id,
1954 mode,
1955 window,
1956 cx,
1957 )
1958 });
1959 })
1960 })
1961 .detach();
1962 }
1963
1964 fn leave_channel(&self, channel_id: ChannelId, window: &mut Window, cx: &mut Context<Self>) {
1965 let Some(user_id) = self.user_store.read(cx).current_user().map(|u| u.id) else {
1966 return;
1967 };
1968 let Some(channel) = self.channel_store.read(cx).channel_for_id(channel_id) else {
1969 return;
1970 };
1971 let prompt_message = format!("Are you sure you want to leave \"#{}\"?", channel.name);
1972 let answer = window.prompt(
1973 PromptLevel::Warning,
1974 &prompt_message,
1975 None,
1976 &["Leave", "Cancel"],
1977 cx,
1978 );
1979 cx.spawn_in(window, async move |this, cx| {
1980 if answer.await? != 0 {
1981 return Ok(());
1982 }
1983 this.update(cx, |this, cx| {
1984 this.channel_store.update(cx, |channel_store, cx| {
1985 channel_store.remove_member(channel_id, user_id, cx)
1986 })
1987 })?
1988 .await
1989 })
1990 .detach_and_prompt_err("Failed to leave channel", window, cx, |_, _, _| None)
1991 }
1992
1993 fn remove_channel(
1994 &mut self,
1995 channel_id: ChannelId,
1996 window: &mut Window,
1997 cx: &mut Context<Self>,
1998 ) {
1999 let channel_store = self.channel_store.clone();
2000 if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
2001 let prompt_message = format!(
2002 "Are you sure you want to remove the channel \"{}\"?",
2003 channel.name
2004 );
2005 let answer = window.prompt(
2006 PromptLevel::Warning,
2007 &prompt_message,
2008 None,
2009 &["Remove", "Cancel"],
2010 cx,
2011 );
2012 cx.spawn_in(window, async move |this, cx| {
2013 if answer.await? == 0 {
2014 channel_store
2015 .update(cx, |channels, _| channels.remove_channel(channel_id))?
2016 .await
2017 .notify_async_err(cx);
2018 this.update_in(cx, |_, window, cx| cx.focus_self(window))
2019 .ok();
2020 }
2021 anyhow::Ok(())
2022 })
2023 .detach();
2024 }
2025 }
2026
2027 fn remove_contact(
2028 &mut self,
2029 user_id: u64,
2030 github_login: &str,
2031 window: &mut Window,
2032 cx: &mut Context<Self>,
2033 ) {
2034 let user_store = self.user_store.clone();
2035 let prompt_message = format!(
2036 "Are you sure you want to remove \"{}\" from your contacts?",
2037 github_login
2038 );
2039 let answer = window.prompt(
2040 PromptLevel::Warning,
2041 &prompt_message,
2042 None,
2043 &["Remove", "Cancel"],
2044 cx,
2045 );
2046 cx.spawn_in(window, async move |_, cx| {
2047 if answer.await? == 0 {
2048 user_store
2049 .update(cx, |store, cx| store.remove_contact(user_id, cx))?
2050 .await
2051 .notify_async_err(cx);
2052 }
2053 anyhow::Ok(())
2054 })
2055 .detach_and_prompt_err("Failed to remove contact", window, cx, |_, _, _| None);
2056 }
2057
2058 fn respond_to_contact_request(
2059 &mut self,
2060 user_id: u64,
2061 accept: bool,
2062 window: &mut Window,
2063 cx: &mut Context<Self>,
2064 ) {
2065 self.user_store
2066 .update(cx, |store, cx| {
2067 store.respond_to_contact_request(user_id, accept, cx)
2068 })
2069 .detach_and_prompt_err(
2070 "Failed to respond to contact request",
2071 window,
2072 cx,
2073 |_, _, _| None,
2074 );
2075 }
2076
2077 fn respond_to_channel_invite(
2078 &mut self,
2079 channel_id: ChannelId,
2080 accept: bool,
2081 cx: &mut Context<Self>,
2082 ) {
2083 self.channel_store
2084 .update(cx, |store, cx| {
2085 store.respond_to_channel_invite(channel_id, accept, cx)
2086 })
2087 .detach();
2088 }
2089
2090 fn call(&mut self, recipient_user_id: u64, window: &mut Window, cx: &mut Context<Self>) {
2091 ActiveCall::global(cx)
2092 .update(cx, |call, cx| {
2093 call.invite(recipient_user_id, Some(self.project.clone()), cx)
2094 })
2095 .detach_and_prompt_err("Call failed", window, cx, |_, _, _| None);
2096 }
2097
2098 fn join_channel(&self, channel_id: ChannelId, window: &mut Window, cx: &mut Context<Self>) {
2099 let Some(workspace) = self.workspace.upgrade() else {
2100 return;
2101 };
2102 let Some(handle) = window.window_handle().downcast::<Workspace>() else {
2103 return;
2104 };
2105 workspace::join_channel(
2106 channel_id,
2107 workspace.read(cx).app_state().clone(),
2108 Some(handle),
2109 cx,
2110 )
2111 .detach_and_prompt_err("Failed to join channel", window, cx, |_, _, _| None)
2112 }
2113
2114 fn join_channel_chat(
2115 &mut self,
2116 channel_id: ChannelId,
2117 window: &mut Window,
2118 cx: &mut Context<Self>,
2119 ) {
2120 let Some(workspace) = self.workspace.upgrade() else {
2121 return;
2122 };
2123 window.defer(cx, move |window, cx| {
2124 workspace.update(cx, |workspace, cx| {
2125 if let Some(panel) = workspace.focus_panel::<ChatPanel>(window, cx) {
2126 panel.update(cx, |panel, cx| {
2127 panel
2128 .select_channel(channel_id, None, cx)
2129 .detach_and_notify_err(window, cx);
2130 });
2131 }
2132 });
2133 });
2134 }
2135
2136 fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
2137 let channel_store = self.channel_store.read(cx);
2138 let Some(channel) = channel_store.channel_for_id(channel_id) else {
2139 return;
2140 };
2141 let item = ClipboardItem::new_string(channel.link(cx));
2142 cx.write_to_clipboard(item)
2143 }
2144
2145 fn render_signed_out(&mut self, cx: &mut Context<Self>) -> Div {
2146 let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more.";
2147
2148 v_flex()
2149 .gap_6()
2150 .p_4()
2151 .child(Label::new(collab_blurb))
2152 .child(
2153 v_flex()
2154 .gap_2()
2155 .child(
2156 Button::new("sign_in", "Sign in")
2157 .icon_color(Color::Muted)
2158 .icon(IconName::Github)
2159 .icon_position(IconPosition::Start)
2160 .style(ButtonStyle::Filled)
2161 .full_width()
2162 .on_click(cx.listener(|this, _, window, cx| {
2163 let client = this.client.clone();
2164 cx.spawn_in(window, async move |_, cx| {
2165 client
2166 .authenticate_and_connect(true, &cx)
2167 .await
2168 .notify_async_err(cx);
2169 })
2170 .detach()
2171 })),
2172 )
2173 .child(
2174 div().flex().w_full().items_center().child(
2175 Label::new("Sign in to enable collaboration.")
2176 .color(Color::Muted)
2177 .size(LabelSize::Small),
2178 ),
2179 ),
2180 )
2181 }
2182
2183 fn render_list_entry(
2184 &mut self,
2185 ix: usize,
2186 window: &mut Window,
2187 cx: &mut Context<Self>,
2188 ) -> AnyElement {
2189 let entry = &self.entries[ix];
2190
2191 let is_selected = self.selection == Some(ix);
2192 match entry {
2193 ListEntry::Header(section) => {
2194 let is_collapsed = self.collapsed_sections.contains(section);
2195 self.render_header(*section, is_selected, is_collapsed, cx)
2196 .into_any_element()
2197 }
2198 ListEntry::Contact { contact, calling } => self
2199 .render_contact(contact, *calling, is_selected, cx)
2200 .into_any_element(),
2201 ListEntry::ContactPlaceholder => self
2202 .render_contact_placeholder(is_selected, cx)
2203 .into_any_element(),
2204 ListEntry::IncomingRequest(user) => self
2205 .render_contact_request(user, true, is_selected, cx)
2206 .into_any_element(),
2207 ListEntry::OutgoingRequest(user) => self
2208 .render_contact_request(user, false, is_selected, cx)
2209 .into_any_element(),
2210 ListEntry::Channel {
2211 channel,
2212 depth,
2213 has_children,
2214 } => self
2215 .render_channel(channel, *depth, *has_children, is_selected, ix, cx)
2216 .into_any_element(),
2217 ListEntry::ChannelEditor { depth } => self
2218 .render_channel_editor(*depth, window, cx)
2219 .into_any_element(),
2220 ListEntry::ChannelInvite(channel) => self
2221 .render_channel_invite(channel, is_selected, cx)
2222 .into_any_element(),
2223 ListEntry::CallParticipant {
2224 user,
2225 peer_id,
2226 is_pending,
2227 role,
2228 } => self
2229 .render_call_participant(user, *peer_id, *is_pending, *role, is_selected, cx)
2230 .into_any_element(),
2231 ListEntry::ParticipantProject {
2232 project_id,
2233 worktree_root_names,
2234 host_user_id,
2235 is_last,
2236 } => self
2237 .render_participant_project(
2238 *project_id,
2239 worktree_root_names,
2240 *host_user_id,
2241 *is_last,
2242 is_selected,
2243 window,
2244 cx,
2245 )
2246 .into_any_element(),
2247 ListEntry::ParticipantScreen { peer_id, is_last } => self
2248 .render_participant_screen(*peer_id, *is_last, is_selected, window, cx)
2249 .into_any_element(),
2250 ListEntry::ChannelNotes { channel_id } => self
2251 .render_channel_notes(*channel_id, is_selected, window, cx)
2252 .into_any_element(),
2253 ListEntry::ChannelChat { channel_id } => self
2254 .render_channel_chat(*channel_id, is_selected, window, cx)
2255 .into_any_element(),
2256 }
2257 }
2258
2259 fn render_signed_in(&mut self, _: &mut Window, cx: &mut Context<Self>) -> Div {
2260 self.channel_store.update(cx, |channel_store, _| {
2261 channel_store.initialize();
2262 });
2263 v_flex()
2264 .size_full()
2265 .child(list(self.list_state.clone()).size_full())
2266 .child(
2267 v_flex()
2268 .child(div().mx_2().border_primary(cx).border_t_1())
2269 .child(
2270 v_flex()
2271 .p_2()
2272 .child(self.render_filter_input(&self.filter_editor, cx)),
2273 ),
2274 )
2275 }
2276
2277 fn render_filter_input(
2278 &self,
2279 editor: &Entity<Editor>,
2280 cx: &mut Context<Self>,
2281 ) -> impl IntoElement {
2282 let settings = ThemeSettings::get_global(cx);
2283 let text_style = TextStyle {
2284 color: if editor.read(cx).read_only(cx) {
2285 cx.theme().colors().text_disabled
2286 } else {
2287 cx.theme().colors().text
2288 },
2289 font_family: settings.ui_font.family.clone(),
2290 font_features: settings.ui_font.features.clone(),
2291 font_fallbacks: settings.ui_font.fallbacks.clone(),
2292 font_size: rems(0.875).into(),
2293 font_weight: settings.ui_font.weight,
2294 font_style: FontStyle::Normal,
2295 line_height: relative(1.3),
2296 ..Default::default()
2297 };
2298
2299 EditorElement::new(
2300 editor,
2301 EditorStyle {
2302 local_player: cx.theme().players().local(),
2303 text: text_style,
2304 ..Default::default()
2305 },
2306 )
2307 }
2308
2309 fn render_header(
2310 &self,
2311 section: Section,
2312 is_selected: bool,
2313 is_collapsed: bool,
2314 cx: &mut Context<Self>,
2315 ) -> impl IntoElement {
2316 let mut channel_link = None;
2317 let mut channel_tooltip_text = None;
2318 let mut channel_icon = None;
2319
2320 let text = match section {
2321 Section::ActiveCall => {
2322 let channel_name = maybe!({
2323 let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
2324
2325 let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
2326
2327 channel_link = Some(channel.link(cx));
2328 (channel_icon, channel_tooltip_text) = match channel.visibility {
2329 proto::ChannelVisibility::Public => {
2330 (Some("icons/public.svg"), Some("Copy public channel link."))
2331 }
2332 proto::ChannelVisibility::Members => {
2333 (Some("icons/hash.svg"), Some("Copy private channel link."))
2334 }
2335 };
2336
2337 Some(channel.name.as_ref())
2338 });
2339
2340 if let Some(name) = channel_name {
2341 SharedString::from(name.to_string())
2342 } else {
2343 SharedString::from("Current Call")
2344 }
2345 }
2346 Section::ContactRequests => SharedString::from("Requests"),
2347 Section::Contacts => SharedString::from("Contacts"),
2348 Section::Channels => SharedString::from("Channels"),
2349 Section::ChannelInvites => SharedString::from("Invites"),
2350 Section::Online => SharedString::from("Online"),
2351 Section::Offline => SharedString::from("Offline"),
2352 };
2353
2354 let button = match section {
2355 Section::ActiveCall => channel_link.map(|channel_link| {
2356 let channel_link_copy = channel_link.clone();
2357 IconButton::new("channel-link", IconName::Copy)
2358 .icon_size(IconSize::Small)
2359 .size(ButtonSize::None)
2360 .visible_on_hover("section-header")
2361 .on_click(move |_, _, cx| {
2362 let item = ClipboardItem::new_string(channel_link_copy.clone());
2363 cx.write_to_clipboard(item)
2364 })
2365 .tooltip(Tooltip::text("Copy channel link"))
2366 .into_any_element()
2367 }),
2368 Section::Contacts => Some(
2369 IconButton::new("add-contact", IconName::Plus)
2370 .on_click(
2371 cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)),
2372 )
2373 .tooltip(Tooltip::text("Search for new contact"))
2374 .into_any_element(),
2375 ),
2376 Section::Channels => Some(
2377 IconButton::new("add-channel", IconName::Plus)
2378 .on_click(cx.listener(|this, _, window, cx| this.new_root_channel(window, cx)))
2379 .tooltip(Tooltip::text("Create a channel"))
2380 .into_any_element(),
2381 ),
2382 _ => None,
2383 };
2384
2385 let can_collapse = match section {
2386 Section::ActiveCall | Section::Channels | Section::Contacts => false,
2387 Section::ChannelInvites
2388 | Section::ContactRequests
2389 | Section::Online
2390 | Section::Offline => true,
2391 };
2392
2393 h_flex().w_full().group("section-header").child(
2394 ListHeader::new(text)
2395 .when(can_collapse, |header| {
2396 header.toggle(Some(!is_collapsed)).on_toggle(cx.listener(
2397 move |this, _, _, cx| {
2398 this.toggle_section_expanded(section, cx);
2399 },
2400 ))
2401 })
2402 .inset(true)
2403 .end_slot::<AnyElement>(button)
2404 .toggle_state(is_selected),
2405 )
2406 }
2407
2408 fn render_contact(
2409 &self,
2410 contact: &Arc<Contact>,
2411 calling: bool,
2412 is_selected: bool,
2413 cx: &mut Context<Self>,
2414 ) -> impl IntoElement {
2415 let online = contact.online;
2416 let busy = contact.busy || calling;
2417 let github_login = SharedString::from(contact.user.github_login.clone());
2418 let item = ListItem::new(github_login.clone())
2419 .indent_level(1)
2420 .indent_step_size(px(20.))
2421 .toggle_state(is_selected)
2422 .child(
2423 h_flex()
2424 .w_full()
2425 .justify_between()
2426 .child(Label::new(github_login.clone()))
2427 .when(calling, |el| {
2428 el.child(Label::new("Calling").color(Color::Muted))
2429 })
2430 .when(!calling, |el| {
2431 el.child(
2432 IconButton::new("contact context menu", IconName::Ellipsis)
2433 .icon_color(Color::Muted)
2434 .visible_on_hover("")
2435 .on_click(cx.listener({
2436 let contact = contact.clone();
2437 move |this, event: &ClickEvent, window, cx| {
2438 this.deploy_contact_context_menu(
2439 event.down.position,
2440 contact.clone(),
2441 window,
2442 cx,
2443 );
2444 }
2445 })),
2446 )
2447 }),
2448 )
2449 .on_secondary_mouse_down(cx.listener({
2450 let contact = contact.clone();
2451 move |this, event: &MouseDownEvent, window, cx| {
2452 this.deploy_contact_context_menu(event.position, contact.clone(), window, cx);
2453 }
2454 }))
2455 .start_slot(
2456 // todo handle contacts with no avatar
2457 Avatar::new(contact.user.avatar_uri.clone())
2458 .indicator::<AvatarAvailabilityIndicator>(if online {
2459 Some(AvatarAvailabilityIndicator::new(match busy {
2460 true => ui::CollaboratorAvailability::Busy,
2461 false => ui::CollaboratorAvailability::Free,
2462 }))
2463 } else {
2464 None
2465 }),
2466 );
2467
2468 div()
2469 .id(github_login.clone())
2470 .group("")
2471 .child(item)
2472 .tooltip(move |_, cx| {
2473 let text = if !online {
2474 format!(" {} is offline", &github_login)
2475 } else if busy {
2476 format!(" {} is on a call", &github_login)
2477 } else {
2478 let room = ActiveCall::global(cx).read(cx).room();
2479 if room.is_some() {
2480 format!("Invite {} to join call", &github_login)
2481 } else {
2482 format!("Call {}", &github_login)
2483 }
2484 };
2485 Tooltip::simple(text, cx)
2486 })
2487 }
2488
2489 fn render_contact_request(
2490 &self,
2491 user: &Arc<User>,
2492 is_incoming: bool,
2493 is_selected: bool,
2494 cx: &mut Context<Self>,
2495 ) -> impl IntoElement {
2496 let github_login = SharedString::from(user.github_login.clone());
2497 let user_id = user.id;
2498 let is_response_pending = self.user_store.read(cx).is_contact_request_pending(user);
2499 let color = if is_response_pending {
2500 Color::Muted
2501 } else {
2502 Color::Default
2503 };
2504
2505 let controls = if is_incoming {
2506 vec![
2507 IconButton::new("decline-contact", IconName::Close)
2508 .on_click(cx.listener(move |this, _, window, cx| {
2509 this.respond_to_contact_request(user_id, false, window, cx);
2510 }))
2511 .icon_color(color)
2512 .tooltip(Tooltip::text("Decline invite")),
2513 IconButton::new("accept-contact", IconName::Check)
2514 .on_click(cx.listener(move |this, _, window, cx| {
2515 this.respond_to_contact_request(user_id, true, window, cx);
2516 }))
2517 .icon_color(color)
2518 .tooltip(Tooltip::text("Accept invite")),
2519 ]
2520 } else {
2521 let github_login = github_login.clone();
2522 vec![
2523 IconButton::new("remove_contact", IconName::Close)
2524 .on_click(cx.listener(move |this, _, window, cx| {
2525 this.remove_contact(user_id, &github_login, window, cx);
2526 }))
2527 .icon_color(color)
2528 .tooltip(Tooltip::text("Cancel invite")),
2529 ]
2530 };
2531
2532 ListItem::new(github_login.clone())
2533 .indent_level(1)
2534 .indent_step_size(px(20.))
2535 .toggle_state(is_selected)
2536 .child(
2537 h_flex()
2538 .w_full()
2539 .justify_between()
2540 .child(Label::new(github_login.clone()))
2541 .child(h_flex().children(controls)),
2542 )
2543 .start_slot(Avatar::new(user.avatar_uri.clone()))
2544 }
2545
2546 fn render_channel_invite(
2547 &self,
2548 channel: &Arc<Channel>,
2549 is_selected: bool,
2550 cx: &mut Context<Self>,
2551 ) -> ListItem {
2552 let channel_id = channel.id;
2553 let response_is_pending = self
2554 .channel_store
2555 .read(cx)
2556 .has_pending_channel_invite_response(channel);
2557 let color = if response_is_pending {
2558 Color::Muted
2559 } else {
2560 Color::Default
2561 };
2562
2563 let controls = [
2564 IconButton::new("reject-invite", IconName::Close)
2565 .on_click(cx.listener(move |this, _, _, cx| {
2566 this.respond_to_channel_invite(channel_id, false, cx);
2567 }))
2568 .icon_color(color)
2569 .tooltip(Tooltip::text("Decline invite")),
2570 IconButton::new("accept-invite", IconName::Check)
2571 .on_click(cx.listener(move |this, _, _, cx| {
2572 this.respond_to_channel_invite(channel_id, true, cx);
2573 }))
2574 .icon_color(color)
2575 .tooltip(Tooltip::text("Accept invite")),
2576 ];
2577
2578 ListItem::new(("channel-invite", channel.id.0 as usize))
2579 .toggle_state(is_selected)
2580 .child(
2581 h_flex()
2582 .w_full()
2583 .justify_between()
2584 .child(Label::new(channel.name.clone()))
2585 .child(h_flex().children(controls)),
2586 )
2587 .start_slot(
2588 Icon::new(IconName::Hash)
2589 .size(IconSize::Small)
2590 .color(Color::Muted),
2591 )
2592 }
2593
2594 fn render_contact_placeholder(&self, is_selected: bool, cx: &mut Context<Self>) -> ListItem {
2595 ListItem::new("contact-placeholder")
2596 .child(Icon::new(IconName::Plus))
2597 .child(Label::new("Add a Contact"))
2598 .toggle_state(is_selected)
2599 .on_click(cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)))
2600 }
2601
2602 fn render_channel(
2603 &self,
2604 channel: &Channel,
2605 depth: usize,
2606 has_children: bool,
2607 is_selected: bool,
2608 ix: usize,
2609 cx: &mut Context<Self>,
2610 ) -> impl IntoElement {
2611 let channel_id = channel.id;
2612
2613 let is_active = maybe!({
2614 let call_channel = ActiveCall::global(cx)
2615 .read(cx)
2616 .room()?
2617 .read(cx)
2618 .channel_id()?;
2619 Some(call_channel == channel_id)
2620 })
2621 .unwrap_or(false);
2622 let channel_store = self.channel_store.read(cx);
2623 let is_public = channel_store
2624 .channel_for_id(channel_id)
2625 .map(|channel| channel.visibility)
2626 == Some(proto::ChannelVisibility::Public);
2627 let disclosed =
2628 has_children.then(|| self.collapsed_channels.binary_search(&channel.id).is_err());
2629
2630 let has_messages_notification = channel_store.has_new_messages(channel_id);
2631 let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id);
2632
2633 const FACEPILE_LIMIT: usize = 3;
2634 let participants = self.channel_store.read(cx).channel_participants(channel_id);
2635
2636 let face_pile = if participants.is_empty() {
2637 None
2638 } else {
2639 let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
2640 let result = Facepile::new(
2641 participants
2642 .iter()
2643 .map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
2644 .take(FACEPILE_LIMIT)
2645 .chain(if extra_count > 0 {
2646 Some(
2647 Label::new(format!("+{extra_count}"))
2648 .ml_2()
2649 .into_any_element(),
2650 )
2651 } else {
2652 None
2653 })
2654 .collect::<SmallVec<_>>(),
2655 );
2656
2657 Some(result)
2658 };
2659
2660 let width = self.width.unwrap_or(px(240.));
2661 let root_id = channel.root_id();
2662
2663 div()
2664 .h_6()
2665 .id(channel_id.0 as usize)
2666 .group("")
2667 .flex()
2668 .w_full()
2669 .when(!channel.is_root_channel(), |el| {
2670 el.on_drag(channel.clone(), move |channel, _, _, cx| {
2671 cx.new(|_| DraggedChannelView {
2672 channel: channel.clone(),
2673 width,
2674 })
2675 })
2676 })
2677 .drag_over::<Channel>({
2678 move |style, dragged_channel: &Channel, _window, cx| {
2679 if dragged_channel.root_id() == root_id {
2680 style.bg(cx.theme().colors().ghost_element_hover)
2681 } else {
2682 style
2683 }
2684 }
2685 })
2686 .on_drop(
2687 cx.listener(move |this, dragged_channel: &Channel, window, cx| {
2688 if dragged_channel.root_id() != root_id {
2689 return;
2690 }
2691 this.move_channel(dragged_channel.id, channel_id, window, cx);
2692 }),
2693 )
2694 .child(
2695 ListItem::new(channel_id.0 as usize)
2696 // Add one level of depth for the disclosure arrow.
2697 .indent_level(depth + 1)
2698 .indent_step_size(px(20.))
2699 .toggle_state(is_selected || is_active)
2700 .toggle(disclosed)
2701 .on_toggle(cx.listener(move |this, _, window, cx| {
2702 this.toggle_channel_collapsed(channel_id, window, cx)
2703 }))
2704 .on_click(cx.listener(move |this, _, window, cx| {
2705 if is_active {
2706 this.open_channel_notes(channel_id, window, cx)
2707 } else {
2708 this.join_channel(channel_id, window, cx)
2709 }
2710 }))
2711 .on_secondary_mouse_down(cx.listener(
2712 move |this, event: &MouseDownEvent, window, cx| {
2713 this.deploy_channel_context_menu(
2714 event.position,
2715 channel_id,
2716 ix,
2717 window,
2718 cx,
2719 )
2720 },
2721 ))
2722 .start_slot(
2723 div()
2724 .relative()
2725 .child(
2726 Icon::new(if is_public {
2727 IconName::Public
2728 } else {
2729 IconName::Hash
2730 })
2731 .size(IconSize::Small)
2732 .color(Color::Muted),
2733 )
2734 .children(has_notes_notification.then(|| {
2735 div()
2736 .w_1p5()
2737 .absolute()
2738 .right(px(-1.))
2739 .top(px(-1.))
2740 .child(Indicator::dot().color(Color::Info))
2741 })),
2742 )
2743 .child(
2744 h_flex()
2745 .id(channel_id.0 as usize)
2746 .child(Label::new(channel.name.clone()))
2747 .children(face_pile.map(|face_pile| face_pile.p_1())),
2748 ),
2749 )
2750 .child(
2751 h_flex().absolute().right(rems(0.)).h_full().child(
2752 h_flex()
2753 .h_full()
2754 .gap_1()
2755 .px_1()
2756 .child(
2757 IconButton::new("channel_chat", IconName::MessageBubbles)
2758 .style(ButtonStyle::Filled)
2759 .shape(ui::IconButtonShape::Square)
2760 .icon_size(IconSize::Small)
2761 .icon_color(if has_messages_notification {
2762 Color::Default
2763 } else {
2764 Color::Muted
2765 })
2766 .on_click(cx.listener(move |this, _, window, cx| {
2767 this.join_channel_chat(channel_id, window, cx)
2768 }))
2769 .tooltip(Tooltip::text("Open channel chat"))
2770 .visible_on_hover(""),
2771 )
2772 .child(
2773 IconButton::new("channel_notes", IconName::File)
2774 .style(ButtonStyle::Filled)
2775 .shape(ui::IconButtonShape::Square)
2776 .icon_size(IconSize::Small)
2777 .icon_color(if has_notes_notification {
2778 Color::Default
2779 } else {
2780 Color::Muted
2781 })
2782 .on_click(cx.listener(move |this, _, window, cx| {
2783 this.open_channel_notes(channel_id, window, cx)
2784 }))
2785 .tooltip(Tooltip::text("Open channel notes"))
2786 .visible_on_hover(""),
2787 ),
2788 ),
2789 )
2790 .tooltip({
2791 let channel_store = self.channel_store.clone();
2792 move |_window, cx| {
2793 cx.new(|_| JoinChannelTooltip {
2794 channel_store: channel_store.clone(),
2795 channel_id,
2796 has_notes_notification,
2797 })
2798 .into()
2799 }
2800 })
2801 }
2802
2803 fn render_channel_editor(
2804 &self,
2805 depth: usize,
2806 _window: &mut Window,
2807 _cx: &mut Context<Self>,
2808 ) -> impl IntoElement {
2809 let item = ListItem::new("channel-editor")
2810 .inset(false)
2811 // Add one level of depth for the disclosure arrow.
2812 .indent_level(depth + 1)
2813 .indent_step_size(px(20.))
2814 .start_slot(
2815 Icon::new(IconName::Hash)
2816 .size(IconSize::Small)
2817 .color(Color::Muted),
2818 );
2819
2820 if let Some(pending_name) = self
2821 .channel_editing_state
2822 .as_ref()
2823 .and_then(|state| state.pending_name())
2824 {
2825 item.child(Label::new(pending_name))
2826 } else {
2827 item.child(self.channel_name_editor.clone())
2828 }
2829 }
2830}
2831
2832fn render_tree_branch(
2833 is_last: bool,
2834 overdraw: bool,
2835 window: &mut Window,
2836 cx: &mut App,
2837) -> impl IntoElement {
2838 let rem_size = window.rem_size();
2839 let line_height = window.text_style().line_height_in_pixels(rem_size);
2840 let width = rem_size * 1.5;
2841 let thickness = px(1.);
2842 let color = cx.theme().colors().text;
2843
2844 canvas(
2845 |_, _, _| {},
2846 move |bounds, _, window, _| {
2847 let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
2848 let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
2849 let right = bounds.right();
2850 let top = bounds.top();
2851
2852 window.paint_quad(fill(
2853 Bounds::from_corners(
2854 point(start_x, top),
2855 point(
2856 start_x + thickness,
2857 if is_last {
2858 start_y
2859 } else {
2860 bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
2861 },
2862 ),
2863 ),
2864 color,
2865 ));
2866 window.paint_quad(fill(
2867 Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
2868 color,
2869 ));
2870 },
2871 )
2872 .w(width)
2873 .h(line_height)
2874}
2875
2876impl Render for CollabPanel {
2877 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2878 v_flex()
2879 .key_context("CollabPanel")
2880 .on_action(cx.listener(CollabPanel::cancel))
2881 .on_action(cx.listener(CollabPanel::select_next))
2882 .on_action(cx.listener(CollabPanel::select_previous))
2883 .on_action(cx.listener(CollabPanel::confirm))
2884 .on_action(cx.listener(CollabPanel::insert_space))
2885 .on_action(cx.listener(CollabPanel::remove_selected_channel))
2886 .on_action(cx.listener(CollabPanel::show_inline_context_menu))
2887 .on_action(cx.listener(CollabPanel::rename_selected_channel))
2888 .on_action(cx.listener(CollabPanel::collapse_selected_channel))
2889 .on_action(cx.listener(CollabPanel::expand_selected_channel))
2890 .on_action(cx.listener(CollabPanel::start_move_selected_channel))
2891 .track_focus(&self.focus_handle(cx))
2892 .size_full()
2893 .child(if self.user_store.read(cx).current_user().is_none() {
2894 self.render_signed_out(cx)
2895 } else {
2896 self.render_signed_in(window, cx)
2897 })
2898 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2899 deferred(
2900 anchored()
2901 .position(*position)
2902 .anchor(gpui::Corner::TopLeft)
2903 .child(menu.clone()),
2904 )
2905 .with_priority(1)
2906 }))
2907 }
2908}
2909
2910impl EventEmitter<PanelEvent> for CollabPanel {}
2911
2912impl Panel for CollabPanel {
2913 fn position(&self, _window: &Window, cx: &App) -> DockPosition {
2914 CollaborationPanelSettings::get_global(cx).dock
2915 }
2916
2917 fn position_is_valid(&self, position: DockPosition) -> bool {
2918 matches!(position, DockPosition::Left | DockPosition::Right)
2919 }
2920
2921 fn set_position(
2922 &mut self,
2923 position: DockPosition,
2924 _window: &mut Window,
2925 cx: &mut Context<Self>,
2926 ) {
2927 settings::update_settings_file::<CollaborationPanelSettings>(
2928 self.fs.clone(),
2929 cx,
2930 move |settings, _| settings.dock = Some(position),
2931 );
2932 }
2933
2934 fn size(&self, _window: &Window, cx: &App) -> Pixels {
2935 self.width
2936 .unwrap_or_else(|| CollaborationPanelSettings::get_global(cx).default_width)
2937 }
2938
2939 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
2940 self.width = size;
2941 self.serialize(cx);
2942 cx.notify();
2943 }
2944
2945 fn icon(&self, _window: &Window, cx: &App) -> Option<ui::IconName> {
2946 CollaborationPanelSettings::get_global(cx)
2947 .button
2948 .then_some(ui::IconName::UserGroup)
2949 }
2950
2951 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2952 Some("Collab Panel")
2953 }
2954
2955 fn toggle_action(&self) -> Box<dyn gpui::Action> {
2956 Box::new(ToggleFocus)
2957 }
2958
2959 fn persistent_name() -> &'static str {
2960 "CollabPanel"
2961 }
2962
2963 fn activation_priority(&self) -> u32 {
2964 6
2965 }
2966}
2967
2968impl Focusable for CollabPanel {
2969 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
2970 self.filter_editor.focus_handle(cx).clone()
2971 }
2972}
2973
2974impl PartialEq for ListEntry {
2975 fn eq(&self, other: &Self) -> bool {
2976 match self {
2977 ListEntry::Header(section_1) => {
2978 if let ListEntry::Header(section_2) = other {
2979 return section_1 == section_2;
2980 }
2981 }
2982 ListEntry::CallParticipant { user: user_1, .. } => {
2983 if let ListEntry::CallParticipant { user: user_2, .. } = other {
2984 return user_1.id == user_2.id;
2985 }
2986 }
2987 ListEntry::ParticipantProject {
2988 project_id: project_id_1,
2989 ..
2990 } => {
2991 if let ListEntry::ParticipantProject {
2992 project_id: project_id_2,
2993 ..
2994 } = other
2995 {
2996 return project_id_1 == project_id_2;
2997 }
2998 }
2999 ListEntry::ParticipantScreen {
3000 peer_id: peer_id_1, ..
3001 } => {
3002 if let ListEntry::ParticipantScreen {
3003 peer_id: peer_id_2, ..
3004 } = other
3005 {
3006 return peer_id_1 == peer_id_2;
3007 }
3008 }
3009 ListEntry::Channel {
3010 channel: channel_1, ..
3011 } => {
3012 if let ListEntry::Channel {
3013 channel: channel_2, ..
3014 } = other
3015 {
3016 return channel_1.id == channel_2.id;
3017 }
3018 }
3019 ListEntry::ChannelNotes { channel_id } => {
3020 if let ListEntry::ChannelNotes {
3021 channel_id: other_id,
3022 } = other
3023 {
3024 return channel_id == other_id;
3025 }
3026 }
3027 ListEntry::ChannelChat { channel_id } => {
3028 if let ListEntry::ChannelChat {
3029 channel_id: other_id,
3030 } = other
3031 {
3032 return channel_id == other_id;
3033 }
3034 }
3035 ListEntry::ChannelInvite(channel_1) => {
3036 if let ListEntry::ChannelInvite(channel_2) = other {
3037 return channel_1.id == channel_2.id;
3038 }
3039 }
3040 ListEntry::IncomingRequest(user_1) => {
3041 if let ListEntry::IncomingRequest(user_2) = other {
3042 return user_1.id == user_2.id;
3043 }
3044 }
3045 ListEntry::OutgoingRequest(user_1) => {
3046 if let ListEntry::OutgoingRequest(user_2) = other {
3047 return user_1.id == user_2.id;
3048 }
3049 }
3050 ListEntry::Contact {
3051 contact: contact_1, ..
3052 } => {
3053 if let ListEntry::Contact {
3054 contact: contact_2, ..
3055 } = other
3056 {
3057 return contact_1.user.id == contact_2.user.id;
3058 }
3059 }
3060 ListEntry::ChannelEditor { depth } => {
3061 if let ListEntry::ChannelEditor { depth: other_depth } = other {
3062 return depth == other_depth;
3063 }
3064 }
3065 ListEntry::ContactPlaceholder => {
3066 if let ListEntry::ContactPlaceholder = other {
3067 return true;
3068 }
3069 }
3070 }
3071 false
3072 }
3073}
3074
3075struct DraggedChannelView {
3076 channel: Channel,
3077 width: Pixels,
3078}
3079
3080impl Render for DraggedChannelView {
3081 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3082 let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
3083 h_flex()
3084 .font_family(ui_font)
3085 .bg(cx.theme().colors().background)
3086 .w(self.width)
3087 .p_1()
3088 .gap_1()
3089 .child(
3090 Icon::new(
3091 if self.channel.visibility == proto::ChannelVisibility::Public {
3092 IconName::Public
3093 } else {
3094 IconName::Hash
3095 },
3096 )
3097 .size(IconSize::Small)
3098 .color(Color::Muted),
3099 )
3100 .child(Label::new(self.channel.name.clone()))
3101 }
3102}
3103
3104struct JoinChannelTooltip {
3105 channel_store: Entity<ChannelStore>,
3106 channel_id: ChannelId,
3107 #[allow(unused)]
3108 has_notes_notification: bool,
3109}
3110
3111impl Render for JoinChannelTooltip {
3112 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3113 tooltip_container(window, cx, |container, _, cx| {
3114 let participants = self
3115 .channel_store
3116 .read(cx)
3117 .channel_participants(self.channel_id);
3118
3119 container
3120 .child(Label::new("Join channel"))
3121 .children(participants.iter().map(|participant| {
3122 h_flex()
3123 .gap_2()
3124 .child(Avatar::new(participant.avatar_uri.clone()))
3125 .child(Label::new(participant.github_login.clone()))
3126 }))
3127 })
3128 }
3129}