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, WhiteSpace,
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 background_color: None,
2198 underline: None,
2199 strikethrough: None,
2200 white_space: WhiteSpace::Normal,
2201 };
2202
2203 EditorElement::new(
2204 editor,
2205 EditorStyle {
2206 local_player: cx.theme().players().local(),
2207 text: text_style,
2208 ..Default::default()
2209 },
2210 )
2211 }
2212
2213 fn render_header(
2214 &self,
2215 section: Section,
2216 is_selected: bool,
2217 is_collapsed: bool,
2218 cx: &ViewContext<Self>,
2219 ) -> impl IntoElement {
2220 let mut channel_link = None;
2221 let mut channel_tooltip_text = None;
2222 let mut channel_icon = None;
2223
2224 let text = match section {
2225 Section::ActiveCall => {
2226 let channel_name = maybe!({
2227 let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
2228
2229 let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
2230
2231 channel_link = Some(channel.link(cx));
2232 (channel_icon, channel_tooltip_text) = match channel.visibility {
2233 proto::ChannelVisibility::Public => {
2234 (Some("icons/public.svg"), Some("Copy public channel link."))
2235 }
2236 proto::ChannelVisibility::Members => {
2237 (Some("icons/hash.svg"), Some("Copy private channel link."))
2238 }
2239 };
2240
2241 Some(channel.name.as_ref())
2242 });
2243
2244 if let Some(name) = channel_name {
2245 SharedString::from(name.to_string())
2246 } else {
2247 SharedString::from("Current Call")
2248 }
2249 }
2250 Section::ContactRequests => SharedString::from("Requests"),
2251 Section::Contacts => SharedString::from("Contacts"),
2252 Section::Channels => SharedString::from("Channels"),
2253 Section::ChannelInvites => SharedString::from("Invites"),
2254 Section::Online => SharedString::from("Online"),
2255 Section::Offline => SharedString::from("Offline"),
2256 };
2257
2258 let button = match section {
2259 Section::ActiveCall => channel_link.map(|channel_link| {
2260 let channel_link_copy = channel_link.clone();
2261 IconButton::new("channel-link", IconName::Copy)
2262 .icon_size(IconSize::Small)
2263 .size(ButtonSize::None)
2264 .visible_on_hover("section-header")
2265 .on_click(move |_, cx| {
2266 let item = ClipboardItem::new(channel_link_copy.clone());
2267 cx.write_to_clipboard(item)
2268 })
2269 .tooltip(|cx| Tooltip::text("Copy channel link", cx))
2270 .into_any_element()
2271 }),
2272 Section::Contacts => Some(
2273 IconButton::new("add-contact", IconName::Plus)
2274 .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
2275 .tooltip(|cx| Tooltip::text("Search for new contact", cx))
2276 .into_any_element(),
2277 ),
2278 Section::Channels => Some(
2279 IconButton::new("add-channel", IconName::Plus)
2280 .on_click(cx.listener(|this, _, cx| this.new_root_channel(cx)))
2281 .tooltip(|cx| Tooltip::text("Create a channel", cx))
2282 .into_any_element(),
2283 ),
2284 _ => None,
2285 };
2286
2287 let can_collapse = match section {
2288 Section::ActiveCall | Section::Channels | Section::Contacts => false,
2289 Section::ChannelInvites
2290 | Section::ContactRequests
2291 | Section::Online
2292 | Section::Offline => true,
2293 };
2294
2295 h_flex().w_full().group("section-header").child(
2296 ListHeader::new(text)
2297 .when(can_collapse, |header| {
2298 header
2299 .toggle(Some(!is_collapsed))
2300 .on_toggle(cx.listener(move |this, _, cx| {
2301 this.toggle_section_expanded(section, cx);
2302 }))
2303 })
2304 .inset(true)
2305 .end_slot::<AnyElement>(button)
2306 .selected(is_selected),
2307 )
2308 }
2309
2310 fn render_contact(
2311 &self,
2312 contact: &Arc<Contact>,
2313 calling: bool,
2314 is_selected: bool,
2315 cx: &mut ViewContext<Self>,
2316 ) -> impl IntoElement {
2317 let online = contact.online;
2318 let busy = contact.busy || calling;
2319 let github_login = SharedString::from(contact.user.github_login.clone());
2320 let item = ListItem::new(github_login.clone())
2321 .indent_level(1)
2322 .indent_step_size(px(20.))
2323 .selected(is_selected)
2324 .child(
2325 h_flex()
2326 .w_full()
2327 .justify_between()
2328 .child(Label::new(github_login.clone()))
2329 .when(calling, |el| {
2330 el.child(Label::new("Calling").color(Color::Muted))
2331 })
2332 .when(!calling, |el| {
2333 el.child(
2334 IconButton::new("contact context menu", IconName::Ellipsis)
2335 .icon_color(Color::Muted)
2336 .visible_on_hover("")
2337 .on_click(cx.listener({
2338 let contact = contact.clone();
2339 move |this, event: &ClickEvent, cx| {
2340 this.deploy_contact_context_menu(
2341 event.down.position,
2342 contact.clone(),
2343 cx,
2344 );
2345 }
2346 })),
2347 )
2348 }),
2349 )
2350 .on_secondary_mouse_down(cx.listener({
2351 let contact = contact.clone();
2352 move |this, event: &MouseDownEvent, cx| {
2353 this.deploy_contact_context_menu(event.position, contact.clone(), cx);
2354 }
2355 }))
2356 .start_slot(
2357 // todo handle contacts with no avatar
2358 Avatar::new(contact.user.avatar_uri.clone())
2359 .indicator::<AvatarAvailabilityIndicator>(if online {
2360 Some(AvatarAvailabilityIndicator::new(match busy {
2361 true => ui::Availability::Busy,
2362 false => ui::Availability::Free,
2363 }))
2364 } else {
2365 None
2366 }),
2367 );
2368
2369 div()
2370 .id(github_login.clone())
2371 .group("")
2372 .child(item)
2373 .tooltip(move |cx| {
2374 let text = if !online {
2375 format!(" {} is offline", &github_login)
2376 } else if busy {
2377 format!(" {} is on a call", &github_login)
2378 } else {
2379 let room = ActiveCall::global(cx).read(cx).room();
2380 if room.is_some() {
2381 format!("Invite {} to join call", &github_login)
2382 } else {
2383 format!("Call {}", &github_login)
2384 }
2385 };
2386 Tooltip::text(text, cx)
2387 })
2388 }
2389
2390 fn render_contact_request(
2391 &self,
2392 user: &Arc<User>,
2393 is_incoming: bool,
2394 is_selected: bool,
2395 cx: &mut ViewContext<Self>,
2396 ) -> impl IntoElement {
2397 let github_login = SharedString::from(user.github_login.clone());
2398 let user_id = user.id;
2399 let is_response_pending = self.user_store.read(cx).is_contact_request_pending(&user);
2400 let color = if is_response_pending {
2401 Color::Muted
2402 } else {
2403 Color::Default
2404 };
2405
2406 let controls = if is_incoming {
2407 vec![
2408 IconButton::new("decline-contact", IconName::Close)
2409 .on_click(cx.listener(move |this, _, cx| {
2410 this.respond_to_contact_request(user_id, false, cx);
2411 }))
2412 .icon_color(color)
2413 .tooltip(|cx| Tooltip::text("Decline invite", cx)),
2414 IconButton::new("accept-contact", IconName::Check)
2415 .on_click(cx.listener(move |this, _, cx| {
2416 this.respond_to_contact_request(user_id, true, cx);
2417 }))
2418 .icon_color(color)
2419 .tooltip(|cx| Tooltip::text("Accept invite", cx)),
2420 ]
2421 } else {
2422 let github_login = github_login.clone();
2423 vec![IconButton::new("remove_contact", IconName::Close)
2424 .on_click(cx.listener(move |this, _, cx| {
2425 this.remove_contact(user_id, &github_login, cx);
2426 }))
2427 .icon_color(color)
2428 .tooltip(|cx| Tooltip::text("Cancel invite", cx))]
2429 };
2430
2431 ListItem::new(github_login.clone())
2432 .indent_level(1)
2433 .indent_step_size(px(20.))
2434 .selected(is_selected)
2435 .child(
2436 h_flex()
2437 .w_full()
2438 .justify_between()
2439 .child(Label::new(github_login.clone()))
2440 .child(h_flex().children(controls)),
2441 )
2442 .start_slot(Avatar::new(user.avatar_uri.clone()))
2443 }
2444
2445 fn render_channel_invite(
2446 &self,
2447 channel: &Arc<Channel>,
2448 is_selected: bool,
2449 cx: &mut ViewContext<Self>,
2450 ) -> ListItem {
2451 let channel_id = channel.id;
2452 let response_is_pending = self
2453 .channel_store
2454 .read(cx)
2455 .has_pending_channel_invite_response(&channel);
2456 let color = if response_is_pending {
2457 Color::Muted
2458 } else {
2459 Color::Default
2460 };
2461
2462 let controls = [
2463 IconButton::new("reject-invite", IconName::Close)
2464 .on_click(cx.listener(move |this, _, cx| {
2465 this.respond_to_channel_invite(channel_id, false, cx);
2466 }))
2467 .icon_color(color)
2468 .tooltip(|cx| Tooltip::text("Decline invite", cx)),
2469 IconButton::new("accept-invite", IconName::Check)
2470 .on_click(cx.listener(move |this, _, cx| {
2471 this.respond_to_channel_invite(channel_id, true, cx);
2472 }))
2473 .icon_color(color)
2474 .tooltip(|cx| Tooltip::text("Accept invite", cx)),
2475 ];
2476
2477 ListItem::new(("channel-invite", channel.id.0 as usize))
2478 .selected(is_selected)
2479 .child(
2480 h_flex()
2481 .w_full()
2482 .justify_between()
2483 .child(Label::new(channel.name.clone()))
2484 .child(h_flex().children(controls)),
2485 )
2486 .start_slot(
2487 Icon::new(IconName::Hash)
2488 .size(IconSize::Small)
2489 .color(Color::Muted),
2490 )
2491 }
2492
2493 fn render_contact_placeholder(
2494 &self,
2495 is_selected: bool,
2496 cx: &mut ViewContext<Self>,
2497 ) -> ListItem {
2498 ListItem::new("contact-placeholder")
2499 .child(Icon::new(IconName::Plus))
2500 .child(Label::new("Add a Contact"))
2501 .selected(is_selected)
2502 .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
2503 }
2504
2505 fn render_channel(
2506 &self,
2507 channel: &Channel,
2508 depth: usize,
2509 has_children: bool,
2510 is_selected: bool,
2511 ix: usize,
2512 cx: &mut ViewContext<Self>,
2513 ) -> impl IntoElement {
2514 let channel_id = channel.id;
2515
2516 let is_active = maybe!({
2517 let call_channel = ActiveCall::global(cx)
2518 .read(cx)
2519 .room()?
2520 .read(cx)
2521 .channel_id()?;
2522 Some(call_channel == channel_id)
2523 })
2524 .unwrap_or(false);
2525 let channel_store = self.channel_store.read(cx);
2526 let is_public = channel_store
2527 .channel_for_id(channel_id)
2528 .map(|channel| channel.visibility)
2529 == Some(proto::ChannelVisibility::Public);
2530 let disclosed =
2531 has_children.then(|| self.collapsed_channels.binary_search(&channel.id).is_err());
2532
2533 let has_messages_notification = channel_store.has_new_messages(channel_id);
2534 let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id);
2535
2536 const FACEPILE_LIMIT: usize = 3;
2537 let participants = self.channel_store.read(cx).channel_participants(channel_id);
2538
2539 let face_pile = if participants.is_empty() {
2540 None
2541 } else {
2542 let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
2543 let result = Facepile::new(
2544 participants
2545 .iter()
2546 .map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
2547 .take(FACEPILE_LIMIT)
2548 .chain(if extra_count > 0 {
2549 Some(
2550 Label::new(format!("+{extra_count}"))
2551 .ml_2()
2552 .into_any_element(),
2553 )
2554 } else {
2555 None
2556 })
2557 .collect::<SmallVec<_>>(),
2558 );
2559
2560 Some(result)
2561 };
2562
2563 let width = self.width.unwrap_or(px(240.));
2564 let root_id = channel.root_id();
2565
2566 div()
2567 .h_6()
2568 .id(channel_id.0 as usize)
2569 .group("")
2570 .flex()
2571 .w_full()
2572 .when(!channel.is_root_channel(), |el| {
2573 el.on_drag(channel.clone(), move |channel, cx| {
2574 cx.new_view(|_| DraggedChannelView {
2575 channel: channel.clone(),
2576 width,
2577 })
2578 })
2579 })
2580 .drag_over::<Channel>({
2581 move |style, dragged_channel: &Channel, cx| {
2582 if dragged_channel.root_id() == root_id {
2583 style.bg(cx.theme().colors().ghost_element_hover)
2584 } else {
2585 style
2586 }
2587 }
2588 })
2589 .on_drop(cx.listener(move |this, dragged_channel: &Channel, cx| {
2590 if dragged_channel.root_id() != root_id {
2591 return;
2592 }
2593 this.move_channel(dragged_channel.id, channel_id, cx);
2594 }))
2595 .child(
2596 ListItem::new(channel_id.0 as usize)
2597 // Add one level of depth for the disclosure arrow.
2598 .indent_level(depth + 1)
2599 .indent_step_size(px(20.))
2600 .selected(is_selected || is_active)
2601 .toggle(disclosed)
2602 .on_toggle(
2603 cx.listener(move |this, _, cx| {
2604 this.toggle_channel_collapsed(channel_id, cx)
2605 }),
2606 )
2607 .on_click(cx.listener(move |this, _, cx| {
2608 if is_active {
2609 this.open_channel_notes(channel_id, cx)
2610 } else {
2611 this.join_channel(channel_id, cx)
2612 }
2613 }))
2614 .on_secondary_mouse_down(cx.listener(
2615 move |this, event: &MouseDownEvent, cx| {
2616 this.deploy_channel_context_menu(event.position, channel_id, ix, cx)
2617 },
2618 ))
2619 .start_slot(
2620 div()
2621 .relative()
2622 .child(
2623 Icon::new(if is_public {
2624 IconName::Public
2625 } else {
2626 IconName::Hash
2627 })
2628 .size(IconSize::Small)
2629 .color(Color::Muted),
2630 )
2631 .children(has_notes_notification.then(|| {
2632 div()
2633 .w_1p5()
2634 .absolute()
2635 .right(px(-1.))
2636 .top(px(-1.))
2637 .child(Indicator::dot().color(Color::Info))
2638 })),
2639 )
2640 .child(
2641 h_flex()
2642 .id(channel_id.0 as usize)
2643 .child(Label::new(channel.name.clone()))
2644 .children(face_pile.map(|face_pile| face_pile.p_1())),
2645 ),
2646 )
2647 .child(
2648 h_flex().absolute().right(rems(0.)).h_full().child(
2649 h_flex()
2650 .h_full()
2651 .gap_1()
2652 .px_1()
2653 .child(
2654 IconButton::new("channel_chat", IconName::MessageBubbles)
2655 .style(ButtonStyle::Filled)
2656 .shape(ui::IconButtonShape::Square)
2657 .icon_size(IconSize::Small)
2658 .icon_color(if has_messages_notification {
2659 Color::Default
2660 } else {
2661 Color::Muted
2662 })
2663 .on_click(cx.listener(move |this, _, cx| {
2664 this.join_channel_chat(channel_id, cx)
2665 }))
2666 .tooltip(|cx| Tooltip::text("Open channel chat", cx))
2667 .visible_on_hover(""),
2668 )
2669 .child(
2670 IconButton::new("channel_notes", IconName::File)
2671 .style(ButtonStyle::Filled)
2672 .shape(ui::IconButtonShape::Square)
2673 .icon_size(IconSize::Small)
2674 .icon_color(if has_notes_notification {
2675 Color::Default
2676 } else {
2677 Color::Muted
2678 })
2679 .on_click(cx.listener(move |this, _, cx| {
2680 this.open_channel_notes(channel_id, cx)
2681 }))
2682 .tooltip(|cx| Tooltip::text("Open channel notes", cx))
2683 .visible_on_hover(""),
2684 ),
2685 ),
2686 )
2687 .tooltip({
2688 let channel_store = self.channel_store.clone();
2689 move |cx| {
2690 cx.new_view(|_| JoinChannelTooltip {
2691 channel_store: channel_store.clone(),
2692 channel_id,
2693 has_notes_notification,
2694 })
2695 .into()
2696 }
2697 })
2698 }
2699
2700 fn render_channel_editor(&self, depth: usize, _cx: &mut ViewContext<Self>) -> impl IntoElement {
2701 let item = ListItem::new("channel-editor")
2702 .inset(false)
2703 // Add one level of depth for the disclosure arrow.
2704 .indent_level(depth + 1)
2705 .indent_step_size(px(20.))
2706 .start_slot(
2707 Icon::new(IconName::Hash)
2708 .size(IconSize::Small)
2709 .color(Color::Muted),
2710 );
2711
2712 if let Some(pending_name) = self
2713 .channel_editing_state
2714 .as_ref()
2715 .and_then(|state| state.pending_name())
2716 {
2717 item.child(Label::new(pending_name))
2718 } else {
2719 item.child(self.channel_name_editor.clone())
2720 }
2721 }
2722}
2723
2724fn render_tree_branch(is_last: bool, overdraw: bool, cx: &mut WindowContext) -> impl IntoElement {
2725 let rem_size = cx.rem_size();
2726 let line_height = cx.text_style().line_height_in_pixels(rem_size);
2727 let width = rem_size * 1.5;
2728 let thickness = px(1.);
2729 let color = cx.theme().colors().text;
2730
2731 canvas(
2732 |_, _| {},
2733 move |bounds, _, cx| {
2734 let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
2735 let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
2736 let right = bounds.right();
2737 let top = bounds.top();
2738
2739 cx.paint_quad(fill(
2740 Bounds::from_corners(
2741 point(start_x, top),
2742 point(
2743 start_x + thickness,
2744 if is_last {
2745 start_y
2746 } else {
2747 bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
2748 },
2749 ),
2750 ),
2751 color,
2752 ));
2753 cx.paint_quad(fill(
2754 Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
2755 color,
2756 ));
2757 },
2758 )
2759 .w(width)
2760 .h(line_height)
2761}
2762
2763impl Render for CollabPanel {
2764 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2765 v_flex()
2766 .key_context("CollabPanel")
2767 .on_action(cx.listener(CollabPanel::cancel))
2768 .on_action(cx.listener(CollabPanel::select_next))
2769 .on_action(cx.listener(CollabPanel::select_prev))
2770 .on_action(cx.listener(CollabPanel::confirm))
2771 .on_action(cx.listener(CollabPanel::insert_space))
2772 .on_action(cx.listener(CollabPanel::remove_selected_channel))
2773 .on_action(cx.listener(CollabPanel::show_inline_context_menu))
2774 .on_action(cx.listener(CollabPanel::rename_selected_channel))
2775 .on_action(cx.listener(CollabPanel::collapse_selected_channel))
2776 .on_action(cx.listener(CollabPanel::expand_selected_channel))
2777 .on_action(cx.listener(CollabPanel::start_move_selected_channel))
2778 .track_focus(&self.focus_handle)
2779 .size_full()
2780 .child(if self.user_store.read(cx).current_user().is_none() {
2781 self.render_signed_out(cx)
2782 } else {
2783 self.render_signed_in(cx)
2784 })
2785 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2786 deferred(
2787 anchored()
2788 .position(*position)
2789 .anchor(gpui::AnchorCorner::TopLeft)
2790 .child(menu.clone()),
2791 )
2792 .with_priority(1)
2793 }))
2794 }
2795}
2796
2797impl EventEmitter<PanelEvent> for CollabPanel {}
2798
2799impl Panel for CollabPanel {
2800 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
2801 CollaborationPanelSettings::get_global(cx).dock
2802 }
2803
2804 fn position_is_valid(&self, position: DockPosition) -> bool {
2805 matches!(position, DockPosition::Left | DockPosition::Right)
2806 }
2807
2808 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2809 settings::update_settings_file::<CollaborationPanelSettings>(
2810 self.fs.clone(),
2811 cx,
2812 move |settings| settings.dock = Some(position),
2813 );
2814 }
2815
2816 fn size(&self, cx: &gpui::WindowContext) -> Pixels {
2817 self.width
2818 .unwrap_or_else(|| CollaborationPanelSettings::get_global(cx).default_width)
2819 }
2820
2821 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
2822 self.width = size;
2823 self.serialize(cx);
2824 cx.notify();
2825 }
2826
2827 fn icon(&self, cx: &gpui::WindowContext) -> Option<ui::IconName> {
2828 CollaborationPanelSettings::get_global(cx)
2829 .button
2830 .then(|| ui::IconName::Collab)
2831 }
2832
2833 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
2834 Some("Collab Panel")
2835 }
2836
2837 fn toggle_action(&self) -> Box<dyn gpui::Action> {
2838 Box::new(ToggleFocus)
2839 }
2840
2841 fn persistent_name() -> &'static str {
2842 "CollabPanel"
2843 }
2844}
2845
2846impl FocusableView for CollabPanel {
2847 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
2848 self.filter_editor.focus_handle(cx).clone()
2849 }
2850}
2851
2852impl PartialEq for ListEntry {
2853 fn eq(&self, other: &Self) -> bool {
2854 match self {
2855 ListEntry::Header(section_1) => {
2856 if let ListEntry::Header(section_2) = other {
2857 return section_1 == section_2;
2858 }
2859 }
2860 ListEntry::CallParticipant { user: user_1, .. } => {
2861 if let ListEntry::CallParticipant { user: user_2, .. } = other {
2862 return user_1.id == user_2.id;
2863 }
2864 }
2865 ListEntry::ParticipantProject {
2866 project_id: project_id_1,
2867 ..
2868 } => {
2869 if let ListEntry::ParticipantProject {
2870 project_id: project_id_2,
2871 ..
2872 } = other
2873 {
2874 return project_id_1 == project_id_2;
2875 }
2876 }
2877 ListEntry::ParticipantScreen {
2878 peer_id: peer_id_1, ..
2879 } => {
2880 if let ListEntry::ParticipantScreen {
2881 peer_id: peer_id_2, ..
2882 } = other
2883 {
2884 return peer_id_1 == peer_id_2;
2885 }
2886 }
2887 ListEntry::Channel {
2888 channel: channel_1, ..
2889 } => {
2890 if let ListEntry::Channel {
2891 channel: channel_2, ..
2892 } = other
2893 {
2894 return channel_1.id == channel_2.id;
2895 }
2896 }
2897 ListEntry::HostedProject { id, .. } => {
2898 if let ListEntry::HostedProject { id: other_id, .. } = other {
2899 return id == other_id;
2900 }
2901 }
2902 ListEntry::ChannelNotes { channel_id } => {
2903 if let ListEntry::ChannelNotes {
2904 channel_id: other_id,
2905 } = other
2906 {
2907 return channel_id == other_id;
2908 }
2909 }
2910 ListEntry::ChannelChat { channel_id } => {
2911 if let ListEntry::ChannelChat {
2912 channel_id: other_id,
2913 } = other
2914 {
2915 return channel_id == other_id;
2916 }
2917 }
2918 ListEntry::ChannelInvite(channel_1) => {
2919 if let ListEntry::ChannelInvite(channel_2) = other {
2920 return channel_1.id == channel_2.id;
2921 }
2922 }
2923 ListEntry::IncomingRequest(user_1) => {
2924 if let ListEntry::IncomingRequest(user_2) = other {
2925 return user_1.id == user_2.id;
2926 }
2927 }
2928 ListEntry::OutgoingRequest(user_1) => {
2929 if let ListEntry::OutgoingRequest(user_2) = other {
2930 return user_1.id == user_2.id;
2931 }
2932 }
2933 ListEntry::Contact {
2934 contact: contact_1, ..
2935 } => {
2936 if let ListEntry::Contact {
2937 contact: contact_2, ..
2938 } = other
2939 {
2940 return contact_1.user.id == contact_2.user.id;
2941 }
2942 }
2943 ListEntry::ChannelEditor { depth } => {
2944 if let ListEntry::ChannelEditor { depth: other_depth } = other {
2945 return depth == other_depth;
2946 }
2947 }
2948 ListEntry::ContactPlaceholder => {
2949 if let ListEntry::ContactPlaceholder = other {
2950 return true;
2951 }
2952 }
2953 }
2954 false
2955 }
2956}
2957
2958struct DraggedChannelView {
2959 channel: Channel,
2960 width: Pixels,
2961}
2962
2963impl Render for DraggedChannelView {
2964 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2965 let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
2966 h_flex()
2967 .font_family(ui_font)
2968 .bg(cx.theme().colors().background)
2969 .w(self.width)
2970 .p_1()
2971 .gap_1()
2972 .child(
2973 Icon::new(
2974 if self.channel.visibility == proto::ChannelVisibility::Public {
2975 IconName::Public
2976 } else {
2977 IconName::Hash
2978 },
2979 )
2980 .size(IconSize::Small)
2981 .color(Color::Muted),
2982 )
2983 .child(Label::new(self.channel.name.clone()))
2984 }
2985}
2986
2987struct JoinChannelTooltip {
2988 channel_store: Model<ChannelStore>,
2989 channel_id: ChannelId,
2990 #[allow(unused)]
2991 has_notes_notification: bool,
2992}
2993
2994impl Render for JoinChannelTooltip {
2995 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2996 tooltip_container(cx, |container, cx| {
2997 let participants = self
2998 .channel_store
2999 .read(cx)
3000 .channel_participants(self.channel_id);
3001
3002 container
3003 .child(Label::new("Join channel"))
3004 .children(participants.iter().map(|participant| {
3005 h_flex()
3006 .gap_2()
3007 .child(Avatar::new(participant.avatar_uri.clone()))
3008 .child(Label::new(participant.github_login.clone()))
3009 }))
3010 })
3011 }
3012}