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(Editor::single_line);
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 self.channel_editing_state.take().is_some() {
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 reset_filter_editor_text(&mut self, cx: &mut ViewContext<Self>) -> bool {
1399 self.filter_editor.update(cx, |editor, cx| {
1400 if editor.buffer().read(cx).len(cx) > 0 {
1401 editor.set_text("", cx);
1402 true
1403 } else {
1404 false
1405 }
1406 })
1407 }
1408
1409 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
1410 if self.take_editing_state(cx) {
1411 cx.focus_view(&self.filter_editor);
1412 } else if !self.reset_filter_editor_text(cx) {
1413 self.focus_handle.focus(cx);
1414 }
1415
1416 if self.context_menu.is_some() {
1417 self.context_menu.take();
1418 cx.notify();
1419 }
1420
1421 self.update_entries(false, cx);
1422 }
1423
1424 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1425 let ix = self.selection.map_or(0, |ix| ix + 1);
1426 if ix < self.entries.len() {
1427 self.selection = Some(ix);
1428 }
1429
1430 if let Some(ix) = self.selection {
1431 self.scroll_to_item(ix)
1432 }
1433 cx.notify();
1434 }
1435
1436 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
1437 let ix = self.selection.take().unwrap_or(0);
1438 if ix > 0 {
1439 self.selection = Some(ix - 1);
1440 }
1441
1442 if let Some(ix) = self.selection {
1443 self.scroll_to_item(ix)
1444 }
1445 cx.notify();
1446 }
1447
1448 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1449 if self.confirm_channel_edit(cx) {
1450 return;
1451 }
1452
1453 if let Some(selection) = self.selection {
1454 if let Some(entry) = self.entries.get(selection) {
1455 match entry {
1456 ListEntry::Header(section) => match section {
1457 Section::ActiveCall => Self::leave_call(cx),
1458 Section::Channels => self.new_root_channel(cx),
1459 Section::Contacts => self.toggle_contact_finder(cx),
1460 Section::ContactRequests
1461 | Section::Online
1462 | Section::Offline
1463 | Section::ChannelInvites => {
1464 self.toggle_section_expanded(*section, cx);
1465 }
1466 },
1467 ListEntry::Contact { contact, calling } => {
1468 if contact.online && !contact.busy && !calling {
1469 self.call(contact.user.id, cx);
1470 }
1471 }
1472 ListEntry::ParticipantProject {
1473 project_id,
1474 host_user_id,
1475 ..
1476 } => {
1477 if let Some(workspace) = self.workspace.upgrade() {
1478 let app_state = workspace.read(cx).app_state().clone();
1479 workspace::join_in_room_project(
1480 *project_id,
1481 *host_user_id,
1482 app_state,
1483 cx,
1484 )
1485 .detach_and_prompt_err(
1486 "Failed to join project",
1487 cx,
1488 |_, _| None,
1489 );
1490 }
1491 }
1492 ListEntry::ParticipantScreen { peer_id, .. } => {
1493 let Some(peer_id) = peer_id else {
1494 return;
1495 };
1496 if let Some(workspace) = self.workspace.upgrade() {
1497 workspace.update(cx, |workspace, cx| {
1498 workspace.open_shared_screen(*peer_id, cx)
1499 });
1500 }
1501 }
1502 ListEntry::Channel { channel, .. } => {
1503 let is_active = maybe!({
1504 let call_channel = ActiveCall::global(cx)
1505 .read(cx)
1506 .room()?
1507 .read(cx)
1508 .channel_id()?;
1509
1510 Some(call_channel == channel.id)
1511 })
1512 .unwrap_or(false);
1513 if is_active {
1514 self.open_channel_notes(channel.id, cx)
1515 } else {
1516 self.join_channel(channel.id, cx)
1517 }
1518 }
1519 ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
1520 ListEntry::CallParticipant { user, peer_id, .. } => {
1521 if Some(user) == self.user_store.read(cx).current_user().as_ref() {
1522 Self::leave_call(cx);
1523 } else if let Some(peer_id) = peer_id {
1524 self.workspace
1525 .update(cx, |workspace, cx| workspace.follow(*peer_id, cx))
1526 .ok();
1527 }
1528 }
1529 ListEntry::IncomingRequest(user) => {
1530 self.respond_to_contact_request(user.id, true, cx)
1531 }
1532 ListEntry::ChannelInvite(channel) => {
1533 self.respond_to_channel_invite(channel.id, true, cx)
1534 }
1535 ListEntry::ChannelNotes { channel_id } => {
1536 self.open_channel_notes(*channel_id, cx)
1537 }
1538 ListEntry::ChannelChat { channel_id } => {
1539 self.join_channel_chat(*channel_id, cx)
1540 }
1541 ListEntry::HostedProject {
1542 id: _id,
1543 name: _name,
1544 } => {
1545 // todo()
1546 }
1547 ListEntry::OutgoingRequest(_) => {}
1548 ListEntry::ChannelEditor { .. } => {}
1549 }
1550 }
1551 }
1552 }
1553
1554 fn insert_space(&mut self, _: &InsertSpace, cx: &mut ViewContext<Self>) {
1555 if self.channel_editing_state.is_some() {
1556 self.channel_name_editor.update(cx, |editor, cx| {
1557 editor.insert(" ", cx);
1558 });
1559 }
1560 }
1561
1562 fn confirm_channel_edit(&mut self, cx: &mut ViewContext<CollabPanel>) -> bool {
1563 if let Some(editing_state) = &mut self.channel_editing_state {
1564 match editing_state {
1565 ChannelEditingState::Create {
1566 location,
1567 pending_name,
1568 ..
1569 } => {
1570 if pending_name.is_some() {
1571 return false;
1572 }
1573 let channel_name = self.channel_name_editor.read(cx).text(cx);
1574
1575 *pending_name = Some(channel_name.clone());
1576
1577 let create = self.channel_store.update(cx, |channel_store, cx| {
1578 channel_store.create_channel(&channel_name, *location, cx)
1579 });
1580 if location.is_none() {
1581 cx.spawn(|this, mut cx| async move {
1582 let channel_id = create.await?;
1583 this.update(&mut cx, |this, cx| {
1584 this.show_channel_modal(
1585 channel_id,
1586 channel_modal::Mode::InviteMembers,
1587 cx,
1588 )
1589 })
1590 })
1591 .detach_and_prompt_err(
1592 "Failed to create channel",
1593 cx,
1594 |_, _| None,
1595 );
1596 } else {
1597 create.detach_and_prompt_err("Failed to create channel", cx, |_, _| None);
1598 }
1599 cx.notify();
1600 }
1601 ChannelEditingState::Rename {
1602 location,
1603 pending_name,
1604 } => {
1605 if pending_name.is_some() {
1606 return false;
1607 }
1608 let channel_name = self.channel_name_editor.read(cx).text(cx);
1609 *pending_name = Some(channel_name.clone());
1610
1611 self.channel_store
1612 .update(cx, |channel_store, cx| {
1613 channel_store.rename(*location, &channel_name, cx)
1614 })
1615 .detach();
1616 cx.notify();
1617 }
1618 }
1619 cx.focus_self();
1620 true
1621 } else {
1622 false
1623 }
1624 }
1625
1626 fn toggle_section_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
1627 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
1628 self.collapsed_sections.remove(ix);
1629 } else {
1630 self.collapsed_sections.push(section);
1631 }
1632 self.update_entries(false, cx);
1633 }
1634
1635 fn collapse_selected_channel(
1636 &mut self,
1637 _: &CollapseSelectedChannel,
1638 cx: &mut ViewContext<Self>,
1639 ) {
1640 let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
1641 return;
1642 };
1643
1644 if self.is_channel_collapsed(channel_id) {
1645 return;
1646 }
1647
1648 self.toggle_channel_collapsed(channel_id, cx);
1649 }
1650
1651 fn expand_selected_channel(&mut self, _: &ExpandSelectedChannel, cx: &mut ViewContext<Self>) {
1652 let Some(id) = self.selected_channel().map(|channel| channel.id) else {
1653 return;
1654 };
1655
1656 if !self.is_channel_collapsed(id) {
1657 return;
1658 }
1659
1660 self.toggle_channel_collapsed(id, cx)
1661 }
1662
1663 fn toggle_channel_collapsed(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1664 match self.collapsed_channels.binary_search(&channel_id) {
1665 Ok(ix) => {
1666 self.collapsed_channels.remove(ix);
1667 }
1668 Err(ix) => {
1669 self.collapsed_channels.insert(ix, channel_id);
1670 }
1671 };
1672 self.serialize(cx);
1673 self.update_entries(true, cx);
1674 cx.notify();
1675 cx.focus_self();
1676 }
1677
1678 fn is_channel_collapsed(&self, channel_id: ChannelId) -> bool {
1679 self.collapsed_channels.binary_search(&channel_id).is_ok()
1680 }
1681
1682 fn leave_call(cx: &mut WindowContext) {
1683 ActiveCall::global(cx)
1684 .update(cx, |call, cx| call.hang_up(cx))
1685 .detach_and_prompt_err("Failed to hang up", cx, |_, _| None);
1686 }
1687
1688 fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
1689 if let Some(workspace) = self.workspace.upgrade() {
1690 workspace.update(cx, |workspace, cx| {
1691 workspace.toggle_modal(cx, |cx| {
1692 let mut finder = ContactFinder::new(self.user_store.clone(), cx);
1693 finder.set_query(self.filter_editor.read(cx).text(cx), cx);
1694 finder
1695 });
1696 });
1697 }
1698 }
1699
1700 fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
1701 self.channel_editing_state = Some(ChannelEditingState::Create {
1702 location: None,
1703 pending_name: None,
1704 });
1705 self.update_entries(false, cx);
1706 self.select_channel_editor();
1707 cx.focus_view(&self.channel_name_editor);
1708 cx.notify();
1709 }
1710
1711 fn select_channel_editor(&mut self) {
1712 self.selection = self.entries.iter().position(|entry| match entry {
1713 ListEntry::ChannelEditor { .. } => true,
1714 _ => false,
1715 });
1716 }
1717
1718 fn new_subchannel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1719 self.collapsed_channels
1720 .retain(|channel| *channel != channel_id);
1721 self.channel_editing_state = Some(ChannelEditingState::Create {
1722 location: Some(channel_id),
1723 pending_name: None,
1724 });
1725 self.update_entries(false, cx);
1726 self.select_channel_editor();
1727 cx.focus_view(&self.channel_name_editor);
1728 cx.notify();
1729 }
1730
1731 fn manage_members(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1732 self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, cx);
1733 }
1734
1735 fn remove_selected_channel(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
1736 if let Some(channel) = self.selected_channel() {
1737 self.remove_channel(channel.id, cx)
1738 }
1739 }
1740
1741 fn rename_selected_channel(&mut self, _: &SecondaryConfirm, cx: &mut ViewContext<Self>) {
1742 if let Some(channel) = self.selected_channel() {
1743 self.rename_channel(channel.id, cx);
1744 }
1745 }
1746
1747 fn rename_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1748 let channel_store = self.channel_store.read(cx);
1749 if !channel_store.is_channel_admin(channel_id) {
1750 return;
1751 }
1752 if let Some(channel) = channel_store.channel_for_id(channel_id).cloned() {
1753 self.channel_editing_state = Some(ChannelEditingState::Rename {
1754 location: channel_id,
1755 pending_name: None,
1756 });
1757 self.channel_name_editor.update(cx, |editor, cx| {
1758 editor.set_text(channel.name.clone(), cx);
1759 editor.select_all(&Default::default(), cx);
1760 });
1761 cx.focus_view(&self.channel_name_editor);
1762 self.update_entries(false, cx);
1763 self.select_channel_editor();
1764 }
1765 }
1766
1767 fn set_channel_visibility(
1768 &mut self,
1769 channel_id: ChannelId,
1770 visibility: ChannelVisibility,
1771 cx: &mut ViewContext<Self>,
1772 ) {
1773 self.channel_store
1774 .update(cx, |channel_store, cx| {
1775 channel_store.set_channel_visibility(channel_id, visibility, cx)
1776 })
1777 .detach_and_prompt_err("Failed to set channel visibility", cx, |e, _| match e.error_code() {
1778 ErrorCode::BadPublicNesting =>
1779 if e.error_tag("direction") == Some("parent") {
1780 Some("To make a channel public, its parent channel must be public.".to_string())
1781 } else {
1782 Some("To make a channel private, all of its subchannels must be private.".to_string())
1783 },
1784 _ => None
1785 });
1786 }
1787
1788 fn start_move_channel(&mut self, channel_id: ChannelId, _cx: &mut ViewContext<Self>) {
1789 self.channel_clipboard = Some(ChannelMoveClipboard { channel_id });
1790 }
1791
1792 fn start_move_selected_channel(&mut self, _: &StartMoveChannel, cx: &mut ViewContext<Self>) {
1793 if let Some(channel) = self.selected_channel() {
1794 self.start_move_channel(channel.id, cx);
1795 }
1796 }
1797
1798 fn move_channel_on_clipboard(
1799 &mut self,
1800 to_channel_id: ChannelId,
1801 cx: &mut ViewContext<CollabPanel>,
1802 ) {
1803 if let Some(clipboard) = self.channel_clipboard.take() {
1804 self.move_channel(clipboard.channel_id, to_channel_id, cx)
1805 }
1806 }
1807
1808 fn move_channel(&self, channel_id: ChannelId, to: ChannelId, cx: &mut ViewContext<Self>) {
1809 self.channel_store
1810 .update(cx, |channel_store, cx| {
1811 channel_store.move_channel(channel_id, to, cx)
1812 })
1813 .detach_and_prompt_err("Failed to move channel", cx, |e, _| match e.error_code() {
1814 ErrorCode::BadPublicNesting => {
1815 Some("Public channels must have public parents".into())
1816 }
1817 ErrorCode::CircularNesting => Some("You cannot move a channel into itself".into()),
1818 ErrorCode::WrongMoveTarget => {
1819 Some("You cannot move a channel into a different root channel".into())
1820 }
1821 _ => None,
1822 })
1823 }
1824
1825 fn open_channel_notes(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1826 if let Some(workspace) = self.workspace.upgrade() {
1827 ChannelView::open(channel_id, None, workspace, cx).detach();
1828 }
1829 }
1830
1831 fn show_inline_context_menu(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
1832 let Some(bounds) = self
1833 .selection
1834 .and_then(|ix| self.list_state.bounds_for_item(ix))
1835 else {
1836 return;
1837 };
1838
1839 if let Some(channel) = self.selected_channel() {
1840 self.deploy_channel_context_menu(
1841 bounds.center(),
1842 channel.id,
1843 self.selection.unwrap(),
1844 cx,
1845 );
1846 cx.stop_propagation();
1847 return;
1848 };
1849
1850 if let Some(contact) = self.selected_contact() {
1851 self.deploy_contact_context_menu(bounds.center(), contact, cx);
1852 cx.stop_propagation();
1853 }
1854 }
1855
1856 fn selected_channel(&self) -> Option<&Arc<Channel>> {
1857 self.selection
1858 .and_then(|ix| self.entries.get(ix))
1859 .and_then(|entry| match entry {
1860 ListEntry::Channel { channel, .. } => Some(channel),
1861 _ => None,
1862 })
1863 }
1864
1865 fn selected_contact(&self) -> Option<Arc<Contact>> {
1866 self.selection
1867 .and_then(|ix| self.entries.get(ix))
1868 .and_then(|entry| match entry {
1869 ListEntry::Contact { contact, .. } => Some(contact.clone()),
1870 _ => None,
1871 })
1872 }
1873
1874 fn show_channel_modal(
1875 &mut self,
1876 channel_id: ChannelId,
1877 mode: channel_modal::Mode,
1878 cx: &mut ViewContext<Self>,
1879 ) {
1880 let workspace = self.workspace.clone();
1881 let user_store = self.user_store.clone();
1882 let channel_store = self.channel_store.clone();
1883
1884 cx.spawn(|_, mut cx| async move {
1885 workspace.update(&mut cx, |workspace, cx| {
1886 workspace.toggle_modal(cx, |cx| {
1887 ChannelModal::new(
1888 user_store.clone(),
1889 channel_store.clone(),
1890 channel_id,
1891 mode,
1892 cx,
1893 )
1894 });
1895 })
1896 })
1897 .detach();
1898 }
1899
1900 fn leave_channel(&self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1901 let Some(user_id) = self.user_store.read(cx).current_user().map(|u| u.id) else {
1902 return;
1903 };
1904 let Some(channel) = self.channel_store.read(cx).channel_for_id(channel_id) else {
1905 return;
1906 };
1907 let prompt_message = format!("Are you sure you want to leave \"#{}\"?", channel.name);
1908 let answer = cx.prompt(
1909 PromptLevel::Warning,
1910 &prompt_message,
1911 None,
1912 &["Leave", "Cancel"],
1913 );
1914 cx.spawn(|this, mut cx| async move {
1915 if answer.await? != 0 {
1916 return Ok(());
1917 }
1918 this.update(&mut cx, |this, cx| {
1919 this.channel_store.update(cx, |channel_store, cx| {
1920 channel_store.remove_member(channel_id, user_id, cx)
1921 })
1922 })?
1923 .await
1924 })
1925 .detach_and_prompt_err("Failed to leave channel", cx, |_, _| None)
1926 }
1927
1928 fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1929 let channel_store = self.channel_store.clone();
1930 if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
1931 let prompt_message = format!(
1932 "Are you sure you want to remove the channel \"{}\"?",
1933 channel.name
1934 );
1935 let answer = cx.prompt(
1936 PromptLevel::Warning,
1937 &prompt_message,
1938 None,
1939 &["Remove", "Cancel"],
1940 );
1941 cx.spawn(|this, mut cx| async move {
1942 if answer.await? == 0 {
1943 channel_store
1944 .update(&mut cx, |channels, _| channels.remove_channel(channel_id))?
1945 .await
1946 .notify_async_err(&mut cx);
1947 this.update(&mut cx, |_, cx| cx.focus_self()).ok();
1948 }
1949 anyhow::Ok(())
1950 })
1951 .detach();
1952 }
1953 }
1954
1955 fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
1956 let user_store = self.user_store.clone();
1957 let prompt_message = format!(
1958 "Are you sure you want to remove \"{}\" from your contacts?",
1959 github_login
1960 );
1961 let answer = cx.prompt(
1962 PromptLevel::Warning,
1963 &prompt_message,
1964 None,
1965 &["Remove", "Cancel"],
1966 );
1967 cx.spawn(|_, mut cx| async move {
1968 if answer.await? == 0 {
1969 user_store
1970 .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))?
1971 .await
1972 .notify_async_err(&mut cx);
1973 }
1974 anyhow::Ok(())
1975 })
1976 .detach_and_prompt_err("Failed to remove contact", cx, |_, _| None);
1977 }
1978
1979 fn respond_to_contact_request(
1980 &mut self,
1981 user_id: u64,
1982 accept: bool,
1983 cx: &mut ViewContext<Self>,
1984 ) {
1985 self.user_store
1986 .update(cx, |store, cx| {
1987 store.respond_to_contact_request(user_id, accept, cx)
1988 })
1989 .detach_and_prompt_err("Failed to respond to contact request", cx, |_, _| None);
1990 }
1991
1992 fn respond_to_channel_invite(
1993 &mut self,
1994 channel_id: ChannelId,
1995 accept: bool,
1996 cx: &mut ViewContext<Self>,
1997 ) {
1998 self.channel_store
1999 .update(cx, |store, cx| {
2000 store.respond_to_channel_invite(channel_id, accept, cx)
2001 })
2002 .detach();
2003 }
2004
2005 fn call(&mut self, recipient_user_id: u64, cx: &mut ViewContext<Self>) {
2006 ActiveCall::global(cx)
2007 .update(cx, |call, cx| {
2008 call.invite(recipient_user_id, Some(self.project.clone()), cx)
2009 })
2010 .detach_and_prompt_err("Call failed", cx, |_, _| None);
2011 }
2012
2013 fn join_channel(&self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
2014 let Some(workspace) = self.workspace.upgrade() else {
2015 return;
2016 };
2017 let Some(handle) = cx.window_handle().downcast::<Workspace>() else {
2018 return;
2019 };
2020 workspace::join_channel(
2021 channel_id,
2022 workspace.read(cx).app_state().clone(),
2023 Some(handle),
2024 cx,
2025 )
2026 .detach_and_prompt_err("Failed to join channel", cx, |_, _| None)
2027 }
2028
2029 fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
2030 let Some(workspace) = self.workspace.upgrade() else {
2031 return;
2032 };
2033 cx.window_context().defer(move |cx| {
2034 workspace.update(cx, |workspace, cx| {
2035 if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
2036 panel.update(cx, |panel, cx| {
2037 panel
2038 .select_channel(channel_id, None, cx)
2039 .detach_and_notify_err(cx);
2040 });
2041 }
2042 });
2043 });
2044 }
2045
2046 fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
2047 let channel_store = self.channel_store.read(cx);
2048 let Some(channel) = channel_store.channel_for_id(channel_id) else {
2049 return;
2050 };
2051 let item = ClipboardItem::new_string(channel.link(cx));
2052 cx.write_to_clipboard(item)
2053 }
2054
2055 fn render_signed_out(&mut self, cx: &mut ViewContext<Self>) -> Div {
2056 let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more.";
2057
2058 v_flex()
2059 .gap_6()
2060 .p_4()
2061 .child(Label::new(collab_blurb))
2062 .child(
2063 v_flex()
2064 .gap_2()
2065 .child(
2066 Button::new("sign_in", "Sign in")
2067 .icon_color(Color::Muted)
2068 .icon(IconName::Github)
2069 .icon_position(IconPosition::Start)
2070 .style(ButtonStyle::Filled)
2071 .full_width()
2072 .on_click(cx.listener(|this, _, cx| {
2073 let client = this.client.clone();
2074 cx.spawn(|_, mut cx| async move {
2075 client
2076 .authenticate_and_connect(true, &cx)
2077 .await
2078 .notify_async_err(&mut cx);
2079 })
2080 .detach()
2081 })),
2082 )
2083 .child(
2084 div().flex().w_full().items_center().child(
2085 Label::new("Sign in to enable collaboration.")
2086 .color(Color::Muted)
2087 .size(LabelSize::Small),
2088 ),
2089 ),
2090 )
2091 }
2092
2093 fn render_list_entry(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement {
2094 let entry = &self.entries[ix];
2095
2096 let is_selected = self.selection == Some(ix);
2097 match entry {
2098 ListEntry::Header(section) => {
2099 let is_collapsed = self.collapsed_sections.contains(section);
2100 self.render_header(*section, is_selected, is_collapsed, cx)
2101 .into_any_element()
2102 }
2103 ListEntry::Contact { contact, calling } => self
2104 .render_contact(contact, *calling, is_selected, cx)
2105 .into_any_element(),
2106 ListEntry::ContactPlaceholder => self
2107 .render_contact_placeholder(is_selected, cx)
2108 .into_any_element(),
2109 ListEntry::IncomingRequest(user) => self
2110 .render_contact_request(user, true, is_selected, cx)
2111 .into_any_element(),
2112 ListEntry::OutgoingRequest(user) => self
2113 .render_contact_request(user, false, is_selected, cx)
2114 .into_any_element(),
2115 ListEntry::Channel {
2116 channel,
2117 depth,
2118 has_children,
2119 } => self
2120 .render_channel(channel, *depth, *has_children, is_selected, ix, cx)
2121 .into_any_element(),
2122 ListEntry::ChannelEditor { depth } => {
2123 self.render_channel_editor(*depth, cx).into_any_element()
2124 }
2125 ListEntry::ChannelInvite(channel) => self
2126 .render_channel_invite(channel, is_selected, cx)
2127 .into_any_element(),
2128 ListEntry::CallParticipant {
2129 user,
2130 peer_id,
2131 is_pending,
2132 role,
2133 } => self
2134 .render_call_participant(user, *peer_id, *is_pending, *role, is_selected, cx)
2135 .into_any_element(),
2136 ListEntry::ParticipantProject {
2137 project_id,
2138 worktree_root_names,
2139 host_user_id,
2140 is_last,
2141 } => self
2142 .render_participant_project(
2143 *project_id,
2144 worktree_root_names,
2145 *host_user_id,
2146 *is_last,
2147 is_selected,
2148 cx,
2149 )
2150 .into_any_element(),
2151 ListEntry::ParticipantScreen { peer_id, is_last } => self
2152 .render_participant_screen(*peer_id, *is_last, is_selected, cx)
2153 .into_any_element(),
2154 ListEntry::ChannelNotes { channel_id } => self
2155 .render_channel_notes(*channel_id, is_selected, cx)
2156 .into_any_element(),
2157 ListEntry::ChannelChat { channel_id } => self
2158 .render_channel_chat(*channel_id, is_selected, cx)
2159 .into_any_element(),
2160
2161 ListEntry::HostedProject { id, name } => self
2162 .render_channel_project(*id, name, is_selected, cx)
2163 .into_any_element(),
2164 }
2165 }
2166
2167 fn render_signed_in(&mut self, cx: &mut ViewContext<Self>) -> Div {
2168 self.channel_store.update(cx, |channel_store, _| {
2169 channel_store.initialize();
2170 });
2171 v_flex()
2172 .size_full()
2173 .child(list(self.list_state.clone()).size_full())
2174 .child(
2175 v_flex()
2176 .child(div().mx_2().border_primary(cx).border_t_1())
2177 .child(
2178 v_flex()
2179 .p_2()
2180 .child(self.render_filter_input(&self.filter_editor, cx)),
2181 ),
2182 )
2183 }
2184
2185 fn render_filter_input(
2186 &self,
2187 editor: &View<Editor>,
2188 cx: &mut ViewContext<Self>,
2189 ) -> impl IntoElement {
2190 let settings = ThemeSettings::get_global(cx);
2191 let text_style = TextStyle {
2192 color: if editor.read(cx).read_only(cx) {
2193 cx.theme().colors().text_disabled
2194 } else {
2195 cx.theme().colors().text
2196 },
2197 font_family: settings.ui_font.family.clone(),
2198 font_features: settings.ui_font.features.clone(),
2199 font_fallbacks: settings.ui_font.fallbacks.clone(),
2200 font_size: rems(0.875).into(),
2201 font_weight: settings.ui_font.weight,
2202 font_style: FontStyle::Normal,
2203 line_height: relative(1.3),
2204 ..Default::default()
2205 };
2206
2207 EditorElement::new(
2208 editor,
2209 EditorStyle {
2210 local_player: cx.theme().players().local(),
2211 text: text_style,
2212 ..Default::default()
2213 },
2214 )
2215 }
2216
2217 fn render_header(
2218 &self,
2219 section: Section,
2220 is_selected: bool,
2221 is_collapsed: bool,
2222 cx: &ViewContext<Self>,
2223 ) -> impl IntoElement {
2224 let mut channel_link = None;
2225 let mut channel_tooltip_text = None;
2226 let mut channel_icon = None;
2227
2228 let text = match section {
2229 Section::ActiveCall => {
2230 let channel_name = maybe!({
2231 let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
2232
2233 let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
2234
2235 channel_link = Some(channel.link(cx));
2236 (channel_icon, channel_tooltip_text) = match channel.visibility {
2237 proto::ChannelVisibility::Public => {
2238 (Some("icons/public.svg"), Some("Copy public channel link."))
2239 }
2240 proto::ChannelVisibility::Members => {
2241 (Some("icons/hash.svg"), Some("Copy private channel link."))
2242 }
2243 };
2244
2245 Some(channel.name.as_ref())
2246 });
2247
2248 if let Some(name) = channel_name {
2249 SharedString::from(name.to_string())
2250 } else {
2251 SharedString::from("Current Call")
2252 }
2253 }
2254 Section::ContactRequests => SharedString::from("Requests"),
2255 Section::Contacts => SharedString::from("Contacts"),
2256 Section::Channels => SharedString::from("Channels"),
2257 Section::ChannelInvites => SharedString::from("Invites"),
2258 Section::Online => SharedString::from("Online"),
2259 Section::Offline => SharedString::from("Offline"),
2260 };
2261
2262 let button = match section {
2263 Section::ActiveCall => channel_link.map(|channel_link| {
2264 let channel_link_copy = channel_link.clone();
2265 IconButton::new("channel-link", IconName::Copy)
2266 .icon_size(IconSize::Small)
2267 .size(ButtonSize::None)
2268 .visible_on_hover("section-header")
2269 .on_click(move |_, cx| {
2270 let item = ClipboardItem::new_string(channel_link_copy.clone());
2271 cx.write_to_clipboard(item)
2272 })
2273 .tooltip(|cx| Tooltip::text("Copy channel link", cx))
2274 .into_any_element()
2275 }),
2276 Section::Contacts => Some(
2277 IconButton::new("add-contact", IconName::Plus)
2278 .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
2279 .tooltip(|cx| Tooltip::text("Search for new contact", cx))
2280 .into_any_element(),
2281 ),
2282 Section::Channels => Some(
2283 IconButton::new("add-channel", IconName::Plus)
2284 .on_click(cx.listener(|this, _, cx| this.new_root_channel(cx)))
2285 .tooltip(|cx| Tooltip::text("Create a channel", cx))
2286 .into_any_element(),
2287 ),
2288 _ => None,
2289 };
2290
2291 let can_collapse = match section {
2292 Section::ActiveCall | Section::Channels | Section::Contacts => false,
2293 Section::ChannelInvites
2294 | Section::ContactRequests
2295 | Section::Online
2296 | Section::Offline => true,
2297 };
2298
2299 h_flex().w_full().group("section-header").child(
2300 ListHeader::new(text)
2301 .when(can_collapse, |header| {
2302 header
2303 .toggle(Some(!is_collapsed))
2304 .on_toggle(cx.listener(move |this, _, cx| {
2305 this.toggle_section_expanded(section, cx);
2306 }))
2307 })
2308 .inset(true)
2309 .end_slot::<AnyElement>(button)
2310 .selected(is_selected),
2311 )
2312 }
2313
2314 fn render_contact(
2315 &self,
2316 contact: &Arc<Contact>,
2317 calling: bool,
2318 is_selected: bool,
2319 cx: &mut ViewContext<Self>,
2320 ) -> impl IntoElement {
2321 let online = contact.online;
2322 let busy = contact.busy || calling;
2323 let github_login = SharedString::from(contact.user.github_login.clone());
2324 let item = ListItem::new(github_login.clone())
2325 .indent_level(1)
2326 .indent_step_size(px(20.))
2327 .selected(is_selected)
2328 .child(
2329 h_flex()
2330 .w_full()
2331 .justify_between()
2332 .child(Label::new(github_login.clone()))
2333 .when(calling, |el| {
2334 el.child(Label::new("Calling").color(Color::Muted))
2335 })
2336 .when(!calling, |el| {
2337 el.child(
2338 IconButton::new("contact context menu", IconName::Ellipsis)
2339 .icon_color(Color::Muted)
2340 .visible_on_hover("")
2341 .on_click(cx.listener({
2342 let contact = contact.clone();
2343 move |this, event: &ClickEvent, cx| {
2344 this.deploy_contact_context_menu(
2345 event.down.position,
2346 contact.clone(),
2347 cx,
2348 );
2349 }
2350 })),
2351 )
2352 }),
2353 )
2354 .on_secondary_mouse_down(cx.listener({
2355 let contact = contact.clone();
2356 move |this, event: &MouseDownEvent, cx| {
2357 this.deploy_contact_context_menu(event.position, contact.clone(), cx);
2358 }
2359 }))
2360 .start_slot(
2361 // todo handle contacts with no avatar
2362 Avatar::new(contact.user.avatar_uri.clone())
2363 .indicator::<AvatarAvailabilityIndicator>(if online {
2364 Some(AvatarAvailabilityIndicator::new(match busy {
2365 true => ui::Availability::Busy,
2366 false => ui::Availability::Free,
2367 }))
2368 } else {
2369 None
2370 }),
2371 );
2372
2373 div()
2374 .id(github_login.clone())
2375 .group("")
2376 .child(item)
2377 .tooltip(move |cx| {
2378 let text = if !online {
2379 format!(" {} is offline", &github_login)
2380 } else if busy {
2381 format!(" {} is on a call", &github_login)
2382 } else {
2383 let room = ActiveCall::global(cx).read(cx).room();
2384 if room.is_some() {
2385 format!("Invite {} to join call", &github_login)
2386 } else {
2387 format!("Call {}", &github_login)
2388 }
2389 };
2390 Tooltip::text(text, cx)
2391 })
2392 }
2393
2394 fn render_contact_request(
2395 &self,
2396 user: &Arc<User>,
2397 is_incoming: bool,
2398 is_selected: bool,
2399 cx: &mut ViewContext<Self>,
2400 ) -> impl IntoElement {
2401 let github_login = SharedString::from(user.github_login.clone());
2402 let user_id = user.id;
2403 let is_response_pending = self.user_store.read(cx).is_contact_request_pending(user);
2404 let color = if is_response_pending {
2405 Color::Muted
2406 } else {
2407 Color::Default
2408 };
2409
2410 let controls = if is_incoming {
2411 vec![
2412 IconButton::new("decline-contact", IconName::Close)
2413 .on_click(cx.listener(move |this, _, cx| {
2414 this.respond_to_contact_request(user_id, false, cx);
2415 }))
2416 .icon_color(color)
2417 .tooltip(|cx| Tooltip::text("Decline invite", cx)),
2418 IconButton::new("accept-contact", IconName::Check)
2419 .on_click(cx.listener(move |this, _, cx| {
2420 this.respond_to_contact_request(user_id, true, cx);
2421 }))
2422 .icon_color(color)
2423 .tooltip(|cx| Tooltip::text("Accept invite", cx)),
2424 ]
2425 } else {
2426 let github_login = github_login.clone();
2427 vec![IconButton::new("remove_contact", IconName::Close)
2428 .on_click(cx.listener(move |this, _, cx| {
2429 this.remove_contact(user_id, &github_login, cx);
2430 }))
2431 .icon_color(color)
2432 .tooltip(|cx| Tooltip::text("Cancel invite", cx))]
2433 };
2434
2435 ListItem::new(github_login.clone())
2436 .indent_level(1)
2437 .indent_step_size(px(20.))
2438 .selected(is_selected)
2439 .child(
2440 h_flex()
2441 .w_full()
2442 .justify_between()
2443 .child(Label::new(github_login.clone()))
2444 .child(h_flex().children(controls)),
2445 )
2446 .start_slot(Avatar::new(user.avatar_uri.clone()))
2447 }
2448
2449 fn render_channel_invite(
2450 &self,
2451 channel: &Arc<Channel>,
2452 is_selected: bool,
2453 cx: &mut ViewContext<Self>,
2454 ) -> ListItem {
2455 let channel_id = channel.id;
2456 let response_is_pending = self
2457 .channel_store
2458 .read(cx)
2459 .has_pending_channel_invite_response(channel);
2460 let color = if response_is_pending {
2461 Color::Muted
2462 } else {
2463 Color::Default
2464 };
2465
2466 let controls = [
2467 IconButton::new("reject-invite", IconName::Close)
2468 .on_click(cx.listener(move |this, _, cx| {
2469 this.respond_to_channel_invite(channel_id, false, cx);
2470 }))
2471 .icon_color(color)
2472 .tooltip(|cx| Tooltip::text("Decline invite", cx)),
2473 IconButton::new("accept-invite", IconName::Check)
2474 .on_click(cx.listener(move |this, _, cx| {
2475 this.respond_to_channel_invite(channel_id, true, cx);
2476 }))
2477 .icon_color(color)
2478 .tooltip(|cx| Tooltip::text("Accept invite", cx)),
2479 ];
2480
2481 ListItem::new(("channel-invite", channel.id.0 as usize))
2482 .selected(is_selected)
2483 .child(
2484 h_flex()
2485 .w_full()
2486 .justify_between()
2487 .child(Label::new(channel.name.clone()))
2488 .child(h_flex().children(controls)),
2489 )
2490 .start_slot(
2491 Icon::new(IconName::Hash)
2492 .size(IconSize::Small)
2493 .color(Color::Muted),
2494 )
2495 }
2496
2497 fn render_contact_placeholder(
2498 &self,
2499 is_selected: bool,
2500 cx: &mut ViewContext<Self>,
2501 ) -> ListItem {
2502 ListItem::new("contact-placeholder")
2503 .child(Icon::new(IconName::Plus))
2504 .child(Label::new("Add a Contact"))
2505 .selected(is_selected)
2506 .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
2507 }
2508
2509 fn render_channel(
2510 &self,
2511 channel: &Channel,
2512 depth: usize,
2513 has_children: bool,
2514 is_selected: bool,
2515 ix: usize,
2516 cx: &mut ViewContext<Self>,
2517 ) -> impl IntoElement {
2518 let channel_id = channel.id;
2519
2520 let is_active = maybe!({
2521 let call_channel = ActiveCall::global(cx)
2522 .read(cx)
2523 .room()?
2524 .read(cx)
2525 .channel_id()?;
2526 Some(call_channel == channel_id)
2527 })
2528 .unwrap_or(false);
2529 let channel_store = self.channel_store.read(cx);
2530 let is_public = channel_store
2531 .channel_for_id(channel_id)
2532 .map(|channel| channel.visibility)
2533 == Some(proto::ChannelVisibility::Public);
2534 let disclosed =
2535 has_children.then(|| self.collapsed_channels.binary_search(&channel.id).is_err());
2536
2537 let has_messages_notification = channel_store.has_new_messages(channel_id);
2538 let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id);
2539
2540 const FACEPILE_LIMIT: usize = 3;
2541 let participants = self.channel_store.read(cx).channel_participants(channel_id);
2542
2543 let face_pile = if participants.is_empty() {
2544 None
2545 } else {
2546 let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
2547 let result = Facepile::new(
2548 participants
2549 .iter()
2550 .map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
2551 .take(FACEPILE_LIMIT)
2552 .chain(if extra_count > 0 {
2553 Some(
2554 Label::new(format!("+{extra_count}"))
2555 .ml_2()
2556 .into_any_element(),
2557 )
2558 } else {
2559 None
2560 })
2561 .collect::<SmallVec<_>>(),
2562 );
2563
2564 Some(result)
2565 };
2566
2567 let width = self.width.unwrap_or(px(240.));
2568 let root_id = channel.root_id();
2569
2570 div()
2571 .h_6()
2572 .id(channel_id.0 as usize)
2573 .group("")
2574 .flex()
2575 .w_full()
2576 .when(!channel.is_root_channel(), |el| {
2577 el.on_drag(channel.clone(), move |channel, cx| {
2578 cx.new_view(|_| DraggedChannelView {
2579 channel: channel.clone(),
2580 width,
2581 })
2582 })
2583 })
2584 .drag_over::<Channel>({
2585 move |style, dragged_channel: &Channel, cx| {
2586 if dragged_channel.root_id() == root_id {
2587 style.bg(cx.theme().colors().ghost_element_hover)
2588 } else {
2589 style
2590 }
2591 }
2592 })
2593 .on_drop(cx.listener(move |this, dragged_channel: &Channel, cx| {
2594 if dragged_channel.root_id() != root_id {
2595 return;
2596 }
2597 this.move_channel(dragged_channel.id, channel_id, cx);
2598 }))
2599 .child(
2600 ListItem::new(channel_id.0 as usize)
2601 // Add one level of depth for the disclosure arrow.
2602 .indent_level(depth + 1)
2603 .indent_step_size(px(20.))
2604 .selected(is_selected || is_active)
2605 .toggle(disclosed)
2606 .on_toggle(
2607 cx.listener(move |this, _, cx| {
2608 this.toggle_channel_collapsed(channel_id, cx)
2609 }),
2610 )
2611 .on_click(cx.listener(move |this, _, cx| {
2612 if is_active {
2613 this.open_channel_notes(channel_id, cx)
2614 } else {
2615 this.join_channel(channel_id, cx)
2616 }
2617 }))
2618 .on_secondary_mouse_down(cx.listener(
2619 move |this, event: &MouseDownEvent, cx| {
2620 this.deploy_channel_context_menu(event.position, channel_id, ix, cx)
2621 },
2622 ))
2623 .start_slot(
2624 div()
2625 .relative()
2626 .child(
2627 Icon::new(if is_public {
2628 IconName::Public
2629 } else {
2630 IconName::Hash
2631 })
2632 .size(IconSize::Small)
2633 .color(Color::Muted),
2634 )
2635 .children(has_notes_notification.then(|| {
2636 div()
2637 .w_1p5()
2638 .absolute()
2639 .right(px(-1.))
2640 .top(px(-1.))
2641 .child(Indicator::dot().color(Color::Info))
2642 })),
2643 )
2644 .child(
2645 h_flex()
2646 .id(channel_id.0 as usize)
2647 .child(Label::new(channel.name.clone()))
2648 .children(face_pile.map(|face_pile| face_pile.p_1())),
2649 ),
2650 )
2651 .child(
2652 h_flex().absolute().right(rems(0.)).h_full().child(
2653 h_flex()
2654 .h_full()
2655 .gap_1()
2656 .px_1()
2657 .child(
2658 IconButton::new("channel_chat", IconName::MessageBubbles)
2659 .style(ButtonStyle::Filled)
2660 .shape(ui::IconButtonShape::Square)
2661 .icon_size(IconSize::Small)
2662 .icon_color(if has_messages_notification {
2663 Color::Default
2664 } else {
2665 Color::Muted
2666 })
2667 .on_click(cx.listener(move |this, _, cx| {
2668 this.join_channel_chat(channel_id, cx)
2669 }))
2670 .tooltip(|cx| Tooltip::text("Open channel chat", cx))
2671 .visible_on_hover(""),
2672 )
2673 .child(
2674 IconButton::new("channel_notes", IconName::File)
2675 .style(ButtonStyle::Filled)
2676 .shape(ui::IconButtonShape::Square)
2677 .icon_size(IconSize::Small)
2678 .icon_color(if has_notes_notification {
2679 Color::Default
2680 } else {
2681 Color::Muted
2682 })
2683 .on_click(cx.listener(move |this, _, cx| {
2684 this.open_channel_notes(channel_id, cx)
2685 }))
2686 .tooltip(|cx| Tooltip::text("Open channel notes", cx))
2687 .visible_on_hover(""),
2688 ),
2689 ),
2690 )
2691 .tooltip({
2692 let channel_store = self.channel_store.clone();
2693 move |cx| {
2694 cx.new_view(|_| JoinChannelTooltip {
2695 channel_store: channel_store.clone(),
2696 channel_id,
2697 has_notes_notification,
2698 })
2699 .into()
2700 }
2701 })
2702 }
2703
2704 fn render_channel_editor(&self, depth: usize, _cx: &mut ViewContext<Self>) -> impl IntoElement {
2705 let item = ListItem::new("channel-editor")
2706 .inset(false)
2707 // Add one level of depth for the disclosure arrow.
2708 .indent_level(depth + 1)
2709 .indent_step_size(px(20.))
2710 .start_slot(
2711 Icon::new(IconName::Hash)
2712 .size(IconSize::Small)
2713 .color(Color::Muted),
2714 );
2715
2716 if let Some(pending_name) = self
2717 .channel_editing_state
2718 .as_ref()
2719 .and_then(|state| state.pending_name())
2720 {
2721 item.child(Label::new(pending_name))
2722 } else {
2723 item.child(self.channel_name_editor.clone())
2724 }
2725 }
2726}
2727
2728fn render_tree_branch(is_last: bool, overdraw: bool, cx: &mut WindowContext) -> impl IntoElement {
2729 let rem_size = cx.rem_size();
2730 let line_height = cx.text_style().line_height_in_pixels(rem_size);
2731 let width = rem_size * 1.5;
2732 let thickness = px(1.);
2733 let color = cx.theme().colors().text;
2734
2735 canvas(
2736 |_, _| {},
2737 move |bounds, _, cx| {
2738 let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
2739 let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
2740 let right = bounds.right();
2741 let top = bounds.top();
2742
2743 cx.paint_quad(fill(
2744 Bounds::from_corners(
2745 point(start_x, top),
2746 point(
2747 start_x + thickness,
2748 if is_last {
2749 start_y
2750 } else {
2751 bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
2752 },
2753 ),
2754 ),
2755 color,
2756 ));
2757 cx.paint_quad(fill(
2758 Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
2759 color,
2760 ));
2761 },
2762 )
2763 .w(width)
2764 .h(line_height)
2765}
2766
2767impl Render for CollabPanel {
2768 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2769 v_flex()
2770 .key_context("CollabPanel")
2771 .on_action(cx.listener(CollabPanel::cancel))
2772 .on_action(cx.listener(CollabPanel::select_next))
2773 .on_action(cx.listener(CollabPanel::select_prev))
2774 .on_action(cx.listener(CollabPanel::confirm))
2775 .on_action(cx.listener(CollabPanel::insert_space))
2776 .on_action(cx.listener(CollabPanel::remove_selected_channel))
2777 .on_action(cx.listener(CollabPanel::show_inline_context_menu))
2778 .on_action(cx.listener(CollabPanel::rename_selected_channel))
2779 .on_action(cx.listener(CollabPanel::collapse_selected_channel))
2780 .on_action(cx.listener(CollabPanel::expand_selected_channel))
2781 .on_action(cx.listener(CollabPanel::start_move_selected_channel))
2782 .track_focus(&self.focus_handle)
2783 .size_full()
2784 .child(if self.user_store.read(cx).current_user().is_none() {
2785 self.render_signed_out(cx)
2786 } else {
2787 self.render_signed_in(cx)
2788 })
2789 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2790 deferred(
2791 anchored()
2792 .position(*position)
2793 .anchor(gpui::AnchorCorner::TopLeft)
2794 .child(menu.clone()),
2795 )
2796 .with_priority(1)
2797 }))
2798 }
2799}
2800
2801impl EventEmitter<PanelEvent> for CollabPanel {}
2802
2803impl Panel for CollabPanel {
2804 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
2805 CollaborationPanelSettings::get_global(cx).dock
2806 }
2807
2808 fn position_is_valid(&self, position: DockPosition) -> bool {
2809 matches!(position, DockPosition::Left | DockPosition::Right)
2810 }
2811
2812 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2813 settings::update_settings_file::<CollaborationPanelSettings>(
2814 self.fs.clone(),
2815 cx,
2816 move |settings, _| settings.dock = Some(position),
2817 );
2818 }
2819
2820 fn size(&self, cx: &gpui::WindowContext) -> Pixels {
2821 self.width
2822 .unwrap_or_else(|| CollaborationPanelSettings::get_global(cx).default_width)
2823 }
2824
2825 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
2826 self.width = size;
2827 self.serialize(cx);
2828 cx.notify();
2829 }
2830
2831 fn icon(&self, cx: &gpui::WindowContext) -> Option<ui::IconName> {
2832 CollaborationPanelSettings::get_global(cx)
2833 .button
2834 .then_some(ui::IconName::Collab)
2835 }
2836
2837 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
2838 Some("Collab Panel")
2839 }
2840
2841 fn toggle_action(&self) -> Box<dyn gpui::Action> {
2842 Box::new(ToggleFocus)
2843 }
2844
2845 fn persistent_name() -> &'static str {
2846 "CollabPanel"
2847 }
2848}
2849
2850impl FocusableView for CollabPanel {
2851 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
2852 self.filter_editor.focus_handle(cx).clone()
2853 }
2854}
2855
2856impl PartialEq for ListEntry {
2857 fn eq(&self, other: &Self) -> bool {
2858 match self {
2859 ListEntry::Header(section_1) => {
2860 if let ListEntry::Header(section_2) = other {
2861 return section_1 == section_2;
2862 }
2863 }
2864 ListEntry::CallParticipant { user: user_1, .. } => {
2865 if let ListEntry::CallParticipant { user: user_2, .. } = other {
2866 return user_1.id == user_2.id;
2867 }
2868 }
2869 ListEntry::ParticipantProject {
2870 project_id: project_id_1,
2871 ..
2872 } => {
2873 if let ListEntry::ParticipantProject {
2874 project_id: project_id_2,
2875 ..
2876 } = other
2877 {
2878 return project_id_1 == project_id_2;
2879 }
2880 }
2881 ListEntry::ParticipantScreen {
2882 peer_id: peer_id_1, ..
2883 } => {
2884 if let ListEntry::ParticipantScreen {
2885 peer_id: peer_id_2, ..
2886 } = other
2887 {
2888 return peer_id_1 == peer_id_2;
2889 }
2890 }
2891 ListEntry::Channel {
2892 channel: channel_1, ..
2893 } => {
2894 if let ListEntry::Channel {
2895 channel: channel_2, ..
2896 } = other
2897 {
2898 return channel_1.id == channel_2.id;
2899 }
2900 }
2901 ListEntry::HostedProject { id, .. } => {
2902 if let ListEntry::HostedProject { id: other_id, .. } = other {
2903 return id == other_id;
2904 }
2905 }
2906 ListEntry::ChannelNotes { channel_id } => {
2907 if let ListEntry::ChannelNotes {
2908 channel_id: other_id,
2909 } = other
2910 {
2911 return channel_id == other_id;
2912 }
2913 }
2914 ListEntry::ChannelChat { channel_id } => {
2915 if let ListEntry::ChannelChat {
2916 channel_id: other_id,
2917 } = other
2918 {
2919 return channel_id == other_id;
2920 }
2921 }
2922 ListEntry::ChannelInvite(channel_1) => {
2923 if let ListEntry::ChannelInvite(channel_2) = other {
2924 return channel_1.id == channel_2.id;
2925 }
2926 }
2927 ListEntry::IncomingRequest(user_1) => {
2928 if let ListEntry::IncomingRequest(user_2) = other {
2929 return user_1.id == user_2.id;
2930 }
2931 }
2932 ListEntry::OutgoingRequest(user_1) => {
2933 if let ListEntry::OutgoingRequest(user_2) = other {
2934 return user_1.id == user_2.id;
2935 }
2936 }
2937 ListEntry::Contact {
2938 contact: contact_1, ..
2939 } => {
2940 if let ListEntry::Contact {
2941 contact: contact_2, ..
2942 } = other
2943 {
2944 return contact_1.user.id == contact_2.user.id;
2945 }
2946 }
2947 ListEntry::ChannelEditor { depth } => {
2948 if let ListEntry::ChannelEditor { depth: other_depth } = other {
2949 return depth == other_depth;
2950 }
2951 }
2952 ListEntry::ContactPlaceholder => {
2953 if let ListEntry::ContactPlaceholder = other {
2954 return true;
2955 }
2956 }
2957 }
2958 false
2959 }
2960}
2961
2962struct DraggedChannelView {
2963 channel: Channel,
2964 width: Pixels,
2965}
2966
2967impl Render for DraggedChannelView {
2968 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2969 let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
2970 h_flex()
2971 .font_family(ui_font)
2972 .bg(cx.theme().colors().background)
2973 .w(self.width)
2974 .p_1()
2975 .gap_1()
2976 .child(
2977 Icon::new(
2978 if self.channel.visibility == proto::ChannelVisibility::Public {
2979 IconName::Public
2980 } else {
2981 IconName::Hash
2982 },
2983 )
2984 .size(IconSize::Small)
2985 .color(Color::Muted),
2986 )
2987 .child(Label::new(self.channel.name.clone()))
2988 }
2989}
2990
2991struct JoinChannelTooltip {
2992 channel_store: Model<ChannelStore>,
2993 channel_id: ChannelId,
2994 #[allow(unused)]
2995 has_notes_notification: bool,
2996}
2997
2998impl Render for JoinChannelTooltip {
2999 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
3000 tooltip_container(cx, |container, cx| {
3001 let participants = self
3002 .channel_store
3003 .read(cx)
3004 .channel_participants(self.channel_id);
3005
3006 container
3007 .child(Label::new("Join channel"))
3008 .children(participants.iter().map(|participant| {
3009 h_flex()
3010 .gap_2()
3011 .child(Avatar::new(participant.avatar_uri.clone()))
3012 .child(Label::new(participant.github_login.clone()))
3013 }))
3014 })
3015 }
3016}