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