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