1mod channel_modal;
2mod contact_finder;
3mod panel_settings;
4
5use anyhow::Result;
6use call::ActiveCall;
7use channel::{Channel, ChannelEvent, ChannelId, ChannelStore};
8use client::{proto::PeerId, Client, Contact, User, UserStore};
9use context_menu::{ContextMenu, ContextMenuItem};
10use db::kvp::KEY_VALUE_STORE;
11use editor::{Cancel, Editor};
12use futures::StreamExt;
13use fuzzy::{match_strings, StringMatchCandidate};
14use gpui::{
15 actions,
16 elements::{
17 Canvas, ChildView, Component, Empty, Flex, Image, Label, List, ListOffset, ListState,
18 MouseEventHandler, Orientation, OverlayPositionMode, Padding, ParentElement, SafeStylable,
19 Stack, Svg,
20 },
21 geometry::{
22 rect::RectF,
23 vector::{vec2f, Vector2F},
24 },
25 impl_actions,
26 platform::{CursorStyle, MouseButton, PromptLevel},
27 serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, ModelHandle,
28 Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
29};
30use menu::{Confirm, SelectNext, SelectPrev};
31use panel_settings::{CollaborationPanelDockPosition, CollaborationPanelSettings};
32use project::{Fs, Project};
33use serde_derive::{Deserialize, Serialize};
34use settings::SettingsStore;
35use staff_mode::StaffMode;
36use std::{borrow::Cow, mem, sync::Arc};
37use theme::{components::ComponentExt, IconButton};
38use util::{iife, ResultExt, TryFutureExt};
39use workspace::{
40 dock::{DockPosition, Panel},
41 item::ItemHandle,
42 Workspace,
43};
44
45use crate::face_pile::FacePile;
46use channel_modal::ChannelModal;
47
48use self::contact_finder::ContactFinder;
49
50#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
51struct RemoveChannel {
52 channel_id: u64,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
56struct ToggleCollapse {
57 channel_id: u64,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
61struct NewChannel {
62 channel_id: u64,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
66struct InviteMembers {
67 channel_id: u64,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
71struct ManageMembers {
72 channel_id: u64,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
76struct RenameChannel {
77 channel_id: u64,
78}
79
80actions!(
81 collab_panel,
82 [
83 ToggleFocus,
84 Remove,
85 Secondary,
86 CollapseSelectedChannel,
87 ExpandSelectedChannel
88 ]
89);
90
91impl_actions!(
92 collab_panel,
93 [
94 RemoveChannel,
95 NewChannel,
96 InviteMembers,
97 ManageMembers,
98 RenameChannel,
99 ToggleCollapse
100 ]
101);
102
103const COLLABORATION_PANEL_KEY: &'static str = "CollaborationPanel";
104
105pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
106 settings::register::<panel_settings::CollaborationPanelSettings>(cx);
107 contact_finder::init(cx);
108 channel_modal::init(cx);
109
110 cx.add_action(CollabPanel::cancel);
111 cx.add_action(CollabPanel::select_next);
112 cx.add_action(CollabPanel::select_prev);
113 cx.add_action(CollabPanel::confirm);
114 cx.add_action(CollabPanel::remove);
115 cx.add_action(CollabPanel::remove_selected_channel);
116 cx.add_action(CollabPanel::show_inline_context_menu);
117 cx.add_action(CollabPanel::new_subchannel);
118 cx.add_action(CollabPanel::invite_members);
119 cx.add_action(CollabPanel::manage_members);
120 cx.add_action(CollabPanel::rename_selected_channel);
121 cx.add_action(CollabPanel::rename_channel);
122 cx.add_action(CollabPanel::toggle_channel_collapsed);
123 cx.add_action(CollabPanel::collapse_selected_channel);
124 cx.add_action(CollabPanel::expand_selected_channel)
125}
126
127#[derive(Debug)]
128pub enum ChannelEditingState {
129 Create {
130 parent_id: Option<u64>,
131 pending_name: Option<String>,
132 },
133 Rename {
134 channel_id: u64,
135 pending_name: Option<String>,
136 },
137}
138
139impl ChannelEditingState {
140 fn pending_name(&self) -> Option<&str> {
141 match self {
142 ChannelEditingState::Create { pending_name, .. } => pending_name.as_deref(),
143 ChannelEditingState::Rename { pending_name, .. } => pending_name.as_deref(),
144 }
145 }
146}
147
148pub struct CollabPanel {
149 width: Option<f32>,
150 fs: Arc<dyn Fs>,
151 has_focus: bool,
152 pending_serialization: Task<Option<()>>,
153 context_menu: ViewHandle<ContextMenu>,
154 filter_editor: ViewHandle<Editor>,
155 channel_name_editor: ViewHandle<Editor>,
156 channel_editing_state: Option<ChannelEditingState>,
157 entries: Vec<ListEntry>,
158 selection: Option<usize>,
159 user_store: ModelHandle<UserStore>,
160 client: Arc<Client>,
161 channel_store: ModelHandle<ChannelStore>,
162 project: ModelHandle<Project>,
163 match_candidates: Vec<StringMatchCandidate>,
164 list_state: ListState<Self>,
165 subscriptions: Vec<Subscription>,
166 collapsed_sections: Vec<Section>,
167 collapsed_channels: Vec<ChannelId>,
168 workspace: WeakViewHandle<Workspace>,
169 context_menu_on_selected: bool,
170}
171
172#[derive(Serialize, Deserialize)]
173struct SerializedChannelsPanel {
174 width: Option<f32>,
175}
176
177#[derive(Debug)]
178pub enum Event {
179 DockPositionChanged,
180 Focus,
181 Dismissed,
182}
183
184#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
185enum Section {
186 ActiveCall,
187 Channels,
188 ChannelInvites,
189 ContactRequests,
190 Contacts,
191 Online,
192 Offline,
193}
194
195#[derive(Clone, Debug)]
196enum ListEntry {
197 Header(Section, usize),
198 CallParticipant {
199 user: Arc<User>,
200 is_pending: bool,
201 },
202 ParticipantProject {
203 project_id: u64,
204 worktree_root_names: Vec<String>,
205 host_user_id: u64,
206 is_last: bool,
207 },
208 ParticipantScreen {
209 peer_id: PeerId,
210 is_last: bool,
211 },
212 IncomingRequest(Arc<User>),
213 OutgoingRequest(Arc<User>),
214 ChannelInvite(Arc<Channel>),
215 Channel {
216 channel: Arc<Channel>,
217 depth: usize,
218 },
219 ChannelEditor {
220 depth: usize,
221 },
222 Contact {
223 contact: Arc<Contact>,
224 calling: bool,
225 },
226 ContactPlaceholder,
227}
228
229impl Entity for CollabPanel {
230 type Event = Event;
231}
232
233impl CollabPanel {
234 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
235 cx.add_view::<Self, _>(|cx| {
236 let view_id = cx.view_id();
237
238 let filter_editor = cx.add_view(|cx| {
239 let mut editor = Editor::single_line(
240 Some(Arc::new(|theme| {
241 theme.collab_panel.user_query_editor.clone()
242 })),
243 cx,
244 );
245 editor.set_placeholder_text("Filter channels, contacts", cx);
246 editor
247 });
248
249 cx.subscribe(&filter_editor, |this, _, event, cx| {
250 if let editor::Event::BufferEdited = event {
251 let query = this.filter_editor.read(cx).text(cx);
252 if !query.is_empty() {
253 this.selection.take();
254 }
255 this.update_entries(true, cx);
256 if !query.is_empty() {
257 this.selection = this
258 .entries
259 .iter()
260 .position(|entry| !matches!(entry, ListEntry::Header(_, _)));
261 }
262 }
263 })
264 .detach();
265
266 let channel_name_editor = cx.add_view(|cx| {
267 Editor::single_line(
268 Some(Arc::new(|theme| {
269 theme.collab_panel.user_query_editor.clone()
270 })),
271 cx,
272 )
273 });
274
275 cx.subscribe(&channel_name_editor, |this, _, event, cx| {
276 if let editor::Event::Blurred = event {
277 if let Some(state) = &this.channel_editing_state {
278 if state.pending_name().is_some() {
279 return;
280 }
281 }
282 this.take_editing_state(cx);
283 this.update_entries(false, cx);
284 cx.notify();
285 }
286 })
287 .detach();
288
289 let list_state =
290 ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
291 let theme = theme::current(cx).clone();
292 let is_selected = this.selection == Some(ix);
293 let current_project_id = this.project.read(cx).remote_id();
294
295 match &this.entries[ix] {
296 ListEntry::Header(section, depth) => {
297 let is_collapsed = this.collapsed_sections.contains(section);
298 this.render_header(
299 *section,
300 &theme,
301 *depth,
302 is_selected,
303 is_collapsed,
304 cx,
305 )
306 }
307 ListEntry::CallParticipant { user, is_pending } => {
308 Self::render_call_participant(
309 user,
310 *is_pending,
311 is_selected,
312 &theme.collab_panel,
313 )
314 }
315 ListEntry::ParticipantProject {
316 project_id,
317 worktree_root_names,
318 host_user_id,
319 is_last,
320 } => Self::render_participant_project(
321 *project_id,
322 worktree_root_names,
323 *host_user_id,
324 Some(*project_id) == current_project_id,
325 *is_last,
326 is_selected,
327 &theme.collab_panel,
328 cx,
329 ),
330 ListEntry::ParticipantScreen { peer_id, is_last } => {
331 Self::render_participant_screen(
332 *peer_id,
333 *is_last,
334 is_selected,
335 &theme.collab_panel,
336 cx,
337 )
338 }
339 ListEntry::Channel { channel, depth } => {
340 let channel_row = this.render_channel(
341 &*channel,
342 *depth,
343 &theme.collab_panel,
344 is_selected,
345 cx,
346 );
347
348 if is_selected && this.context_menu_on_selected {
349 Stack::new()
350 .with_child(channel_row)
351 .with_child(
352 ChildView::new(&this.context_menu, cx)
353 .aligned()
354 .bottom()
355 .right(),
356 )
357 .into_any()
358 } else {
359 return channel_row;
360 }
361 }
362 ListEntry::ChannelInvite(channel) => Self::render_channel_invite(
363 channel.clone(),
364 this.channel_store.clone(),
365 &theme.collab_panel,
366 is_selected,
367 cx,
368 ),
369 ListEntry::IncomingRequest(user) => Self::render_contact_request(
370 user.clone(),
371 this.user_store.clone(),
372 &theme.collab_panel,
373 true,
374 is_selected,
375 cx,
376 ),
377 ListEntry::OutgoingRequest(user) => Self::render_contact_request(
378 user.clone(),
379 this.user_store.clone(),
380 &theme.collab_panel,
381 false,
382 is_selected,
383 cx,
384 ),
385 ListEntry::Contact { contact, calling } => Self::render_contact(
386 contact,
387 *calling,
388 &this.project,
389 &theme.collab_panel,
390 is_selected,
391 cx,
392 ),
393 ListEntry::ChannelEditor { depth } => {
394 this.render_channel_editor(&theme, *depth, cx)
395 }
396 ListEntry::ContactPlaceholder => {
397 this.render_contact_placeholder(&theme.collab_panel, is_selected, cx)
398 }
399 }
400 });
401
402 let mut this = Self {
403 width: None,
404 has_focus: false,
405 fs: workspace.app_state().fs.clone(),
406 pending_serialization: Task::ready(None),
407 context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
408 channel_name_editor,
409 filter_editor,
410 entries: Vec::default(),
411 channel_editing_state: None,
412 selection: None,
413 user_store: workspace.user_store().clone(),
414 channel_store: workspace.app_state().channel_store.clone(),
415 project: workspace.project().clone(),
416 subscriptions: Vec::default(),
417 match_candidates: Vec::default(),
418 collapsed_sections: vec![Section::Offline],
419 collapsed_channels: Vec::default(),
420 workspace: workspace.weak_handle(),
421 client: workspace.app_state().client.clone(),
422 context_menu_on_selected: true,
423 list_state,
424 };
425
426 this.update_entries(false, cx);
427
428 // Update the dock position when the setting changes.
429 let mut old_dock_position = this.position(cx);
430 this.subscriptions
431 .push(
432 cx.observe_global::<SettingsStore, _>(move |this: &mut CollabPanel, cx| {
433 let new_dock_position = this.position(cx);
434 if new_dock_position != old_dock_position {
435 old_dock_position = new_dock_position;
436 cx.emit(Event::DockPositionChanged);
437 }
438 cx.notify();
439 }),
440 );
441
442 let active_call = ActiveCall::global(cx);
443 this.subscriptions
444 .push(cx.observe(&this.user_store, |this, _, cx| {
445 this.update_entries(true, cx)
446 }));
447 this.subscriptions
448 .push(cx.observe(&this.channel_store, |this, _, cx| {
449 this.update_entries(true, cx)
450 }));
451 this.subscriptions
452 .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
453 this.subscriptions.push(
454 cx.observe_global::<StaffMode, _>(move |this, cx| this.update_entries(true, cx)),
455 );
456 this.subscriptions.push(cx.subscribe(
457 &this.channel_store,
458 |this, _channel_store, e, cx| match e {
459 ChannelEvent::ChannelCreated(channel_id)
460 | ChannelEvent::ChannelRenamed(channel_id) => {
461 if this.take_editing_state(cx) {
462 this.update_entries(false, cx);
463 this.selection = this.entries.iter().position(|entry| {
464 if let ListEntry::Channel { channel, .. } = entry {
465 channel.id == *channel_id
466 } else {
467 false
468 }
469 });
470 }
471 }
472 },
473 ));
474
475 this
476 })
477 }
478
479 pub fn load(
480 workspace: WeakViewHandle<Workspace>,
481 cx: AsyncAppContext,
482 ) -> Task<Result<ViewHandle<Self>>> {
483 cx.spawn(|mut cx| async move {
484 let serialized_panel = if let Some(panel) = cx
485 .background()
486 .spawn(async move { KEY_VALUE_STORE.read_kvp(COLLABORATION_PANEL_KEY) })
487 .await
488 .log_err()
489 .flatten()
490 {
491 Some(serde_json::from_str::<SerializedChannelsPanel>(&panel)?)
492 } else {
493 None
494 };
495
496 workspace.update(&mut cx, |workspace, cx| {
497 let panel = CollabPanel::new(workspace, cx);
498 if let Some(serialized_panel) = serialized_panel {
499 panel.update(cx, |panel, cx| {
500 panel.width = serialized_panel.width;
501 cx.notify();
502 });
503 }
504 panel
505 })
506 })
507 }
508
509 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
510 let width = self.width;
511 self.pending_serialization = cx.background().spawn(
512 async move {
513 KEY_VALUE_STORE
514 .write_kvp(
515 COLLABORATION_PANEL_KEY.into(),
516 serde_json::to_string(&SerializedChannelsPanel { width })?,
517 )
518 .await?;
519 anyhow::Ok(())
520 }
521 .log_err(),
522 );
523 }
524
525 fn update_entries(&mut self, select_same_item: bool, cx: &mut ViewContext<Self>) {
526 let channel_store = self.channel_store.read(cx);
527 let user_store = self.user_store.read(cx);
528 let query = self.filter_editor.read(cx).text(cx);
529 let executor = cx.background().clone();
530
531 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
532 let old_entries = mem::take(&mut self.entries);
533
534 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
535 self.entries.push(ListEntry::Header(Section::ActiveCall, 0));
536
537 if !self.collapsed_sections.contains(&Section::ActiveCall) {
538 let room = room.read(cx);
539
540 // Populate the active user.
541 if let Some(user) = user_store.current_user() {
542 self.match_candidates.clear();
543 self.match_candidates.push(StringMatchCandidate {
544 id: 0,
545 string: user.github_login.clone(),
546 char_bag: user.github_login.chars().collect(),
547 });
548 let matches = executor.block(match_strings(
549 &self.match_candidates,
550 &query,
551 true,
552 usize::MAX,
553 &Default::default(),
554 executor.clone(),
555 ));
556 if !matches.is_empty() {
557 let user_id = user.id;
558 self.entries.push(ListEntry::CallParticipant {
559 user,
560 is_pending: false,
561 });
562 let mut projects = room.local_participant().projects.iter().peekable();
563 while let Some(project) = projects.next() {
564 self.entries.push(ListEntry::ParticipantProject {
565 project_id: project.id,
566 worktree_root_names: project.worktree_root_names.clone(),
567 host_user_id: user_id,
568 is_last: projects.peek().is_none(),
569 });
570 }
571 }
572 }
573
574 // Populate remote participants.
575 self.match_candidates.clear();
576 self.match_candidates
577 .extend(room.remote_participants().iter().map(|(_, participant)| {
578 StringMatchCandidate {
579 id: participant.user.id as usize,
580 string: participant.user.github_login.clone(),
581 char_bag: participant.user.github_login.chars().collect(),
582 }
583 }));
584 let matches = executor.block(match_strings(
585 &self.match_candidates,
586 &query,
587 true,
588 usize::MAX,
589 &Default::default(),
590 executor.clone(),
591 ));
592 for mat in matches {
593 let user_id = mat.candidate_id as u64;
594 let participant = &room.remote_participants()[&user_id];
595 self.entries.push(ListEntry::CallParticipant {
596 user: participant.user.clone(),
597 is_pending: false,
598 });
599 let mut projects = participant.projects.iter().peekable();
600 while let Some(project) = projects.next() {
601 self.entries.push(ListEntry::ParticipantProject {
602 project_id: project.id,
603 worktree_root_names: project.worktree_root_names.clone(),
604 host_user_id: participant.user.id,
605 is_last: projects.peek().is_none()
606 && participant.video_tracks.is_empty(),
607 });
608 }
609 if !participant.video_tracks.is_empty() {
610 self.entries.push(ListEntry::ParticipantScreen {
611 peer_id: participant.peer_id,
612 is_last: true,
613 });
614 }
615 }
616
617 // Populate pending participants.
618 self.match_candidates.clear();
619 self.match_candidates
620 .extend(room.pending_participants().iter().enumerate().map(
621 |(id, participant)| StringMatchCandidate {
622 id,
623 string: participant.github_login.clone(),
624 char_bag: participant.github_login.chars().collect(),
625 },
626 ));
627 let matches = executor.block(match_strings(
628 &self.match_candidates,
629 &query,
630 true,
631 usize::MAX,
632 &Default::default(),
633 executor.clone(),
634 ));
635 self.entries
636 .extend(matches.iter().map(|mat| ListEntry::CallParticipant {
637 user: room.pending_participants()[mat.candidate_id].clone(),
638 is_pending: true,
639 }));
640 }
641 }
642
643 let mut request_entries = Vec::new();
644 if self.include_channels_section(cx) {
645 self.entries.push(ListEntry::Header(Section::Channels, 0));
646
647 if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
648 self.match_candidates.clear();
649 self.match_candidates
650 .extend(
651 channel_store
652 .channels()
653 .enumerate()
654 .map(|(ix, (_, channel))| StringMatchCandidate {
655 id: ix,
656 string: channel.name.clone(),
657 char_bag: channel.name.chars().collect(),
658 }),
659 );
660 let matches = executor.block(match_strings(
661 &self.match_candidates,
662 &query,
663 true,
664 usize::MAX,
665 &Default::default(),
666 executor.clone(),
667 ));
668 if let Some(state) = &self.channel_editing_state {
669 if matches!(
670 state,
671 ChannelEditingState::Create {
672 parent_id: None,
673 ..
674 }
675 ) {
676 self.entries.push(ListEntry::ChannelEditor { depth: 0 });
677 }
678 }
679 let mut collapse_depth = None;
680 for mat in matches {
681 let (depth, channel) =
682 channel_store.channel_at_index(mat.candidate_id).unwrap();
683
684 if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) {
685 collapse_depth = Some(depth);
686 } else if let Some(collapsed_depth) = collapse_depth {
687 if depth > collapsed_depth {
688 continue;
689 }
690 if self.is_channel_collapsed(channel.id) {
691 collapse_depth = Some(depth);
692 } else {
693 collapse_depth = None;
694 }
695 }
696
697 match &self.channel_editing_state {
698 Some(ChannelEditingState::Create { parent_id, .. })
699 if *parent_id == Some(channel.id) =>
700 {
701 self.entries.push(ListEntry::Channel {
702 channel: channel.clone(),
703 depth,
704 });
705 self.entries
706 .push(ListEntry::ChannelEditor { depth: depth + 1 });
707 }
708 Some(ChannelEditingState::Rename { channel_id, .. })
709 if *channel_id == channel.id =>
710 {
711 self.entries.push(ListEntry::ChannelEditor { depth });
712 }
713 _ => {
714 self.entries.push(ListEntry::Channel {
715 channel: channel.clone(),
716 depth,
717 });
718 }
719 }
720 }
721 }
722
723 let channel_invites = channel_store.channel_invitations();
724 if !channel_invites.is_empty() {
725 self.match_candidates.clear();
726 self.match_candidates
727 .extend(channel_invites.iter().enumerate().map(|(ix, channel)| {
728 StringMatchCandidate {
729 id: ix,
730 string: channel.name.clone(),
731 char_bag: channel.name.chars().collect(),
732 }
733 }));
734 let matches = executor.block(match_strings(
735 &self.match_candidates,
736 &query,
737 true,
738 usize::MAX,
739 &Default::default(),
740 executor.clone(),
741 ));
742 request_entries.extend(matches.iter().map(|mat| {
743 ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())
744 }));
745
746 if !request_entries.is_empty() {
747 self.entries
748 .push(ListEntry::Header(Section::ChannelInvites, 1));
749 if !self.collapsed_sections.contains(&Section::ChannelInvites) {
750 self.entries.append(&mut request_entries);
751 }
752 }
753 }
754 }
755
756 self.entries.push(ListEntry::Header(Section::Contacts, 0));
757
758 request_entries.clear();
759 let incoming = user_store.incoming_contact_requests();
760 if !incoming.is_empty() {
761 self.match_candidates.clear();
762 self.match_candidates
763 .extend(
764 incoming
765 .iter()
766 .enumerate()
767 .map(|(ix, user)| StringMatchCandidate {
768 id: ix,
769 string: user.github_login.clone(),
770 char_bag: user.github_login.chars().collect(),
771 }),
772 );
773 let matches = executor.block(match_strings(
774 &self.match_candidates,
775 &query,
776 true,
777 usize::MAX,
778 &Default::default(),
779 executor.clone(),
780 ));
781 request_entries.extend(
782 matches
783 .iter()
784 .map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
785 );
786 }
787
788 let outgoing = user_store.outgoing_contact_requests();
789 if !outgoing.is_empty() {
790 self.match_candidates.clear();
791 self.match_candidates
792 .extend(
793 outgoing
794 .iter()
795 .enumerate()
796 .map(|(ix, user)| StringMatchCandidate {
797 id: ix,
798 string: user.github_login.clone(),
799 char_bag: user.github_login.chars().collect(),
800 }),
801 );
802 let matches = executor.block(match_strings(
803 &self.match_candidates,
804 &query,
805 true,
806 usize::MAX,
807 &Default::default(),
808 executor.clone(),
809 ));
810 request_entries.extend(
811 matches
812 .iter()
813 .map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
814 );
815 }
816
817 if !request_entries.is_empty() {
818 self.entries
819 .push(ListEntry::Header(Section::ContactRequests, 1));
820 if !self.collapsed_sections.contains(&Section::ContactRequests) {
821 self.entries.append(&mut request_entries);
822 }
823 }
824
825 let contacts = user_store.contacts();
826 if !contacts.is_empty() {
827 self.match_candidates.clear();
828 self.match_candidates
829 .extend(
830 contacts
831 .iter()
832 .enumerate()
833 .map(|(ix, contact)| StringMatchCandidate {
834 id: ix,
835 string: contact.user.github_login.clone(),
836 char_bag: contact.user.github_login.chars().collect(),
837 }),
838 );
839
840 let matches = executor.block(match_strings(
841 &self.match_candidates,
842 &query,
843 true,
844 usize::MAX,
845 &Default::default(),
846 executor.clone(),
847 ));
848
849 let (online_contacts, offline_contacts) = matches
850 .iter()
851 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
852
853 for (matches, section) in [
854 (online_contacts, Section::Online),
855 (offline_contacts, Section::Offline),
856 ] {
857 if !matches.is_empty() {
858 self.entries.push(ListEntry::Header(section, 1));
859 if !self.collapsed_sections.contains(§ion) {
860 let active_call = &ActiveCall::global(cx).read(cx);
861 for mat in matches {
862 let contact = &contacts[mat.candidate_id];
863 self.entries.push(ListEntry::Contact {
864 contact: contact.clone(),
865 calling: active_call.pending_invites().contains(&contact.user.id),
866 });
867 }
868 }
869 }
870 }
871 }
872
873 if incoming.is_empty() && outgoing.is_empty() && contacts.is_empty() {
874 self.entries.push(ListEntry::ContactPlaceholder);
875 }
876
877 if select_same_item {
878 if let Some(prev_selected_entry) = prev_selected_entry {
879 self.selection.take();
880 for (ix, entry) in self.entries.iter().enumerate() {
881 if *entry == prev_selected_entry {
882 self.selection = Some(ix);
883 break;
884 }
885 }
886 }
887 } else {
888 self.selection = self.selection.and_then(|prev_selection| {
889 if self.entries.is_empty() {
890 None
891 } else {
892 Some(prev_selection.min(self.entries.len() - 1))
893 }
894 });
895 }
896
897 let old_scroll_top = self.list_state.logical_scroll_top();
898 self.list_state.reset(self.entries.len());
899
900 // Attempt to maintain the same scroll position.
901 if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
902 let new_scroll_top = self
903 .entries
904 .iter()
905 .position(|entry| entry == old_top_entry)
906 .map(|item_ix| ListOffset {
907 item_ix,
908 offset_in_item: old_scroll_top.offset_in_item,
909 })
910 .or_else(|| {
911 let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
912 let item_ix = self
913 .entries
914 .iter()
915 .position(|entry| entry == entry_after_old_top)?;
916 Some(ListOffset {
917 item_ix,
918 offset_in_item: 0.,
919 })
920 })
921 .or_else(|| {
922 let entry_before_old_top =
923 old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
924 let item_ix = self
925 .entries
926 .iter()
927 .position(|entry| entry == entry_before_old_top)?;
928 Some(ListOffset {
929 item_ix,
930 offset_in_item: 0.,
931 })
932 });
933
934 self.list_state
935 .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
936 }
937
938 cx.notify();
939 }
940
941 fn render_call_participant(
942 user: &User,
943 is_pending: bool,
944 is_selected: bool,
945 theme: &theme::CollabPanel,
946 ) -> AnyElement<Self> {
947 Flex::row()
948 .with_children(user.avatar.clone().map(|avatar| {
949 Image::from_data(avatar)
950 .with_style(theme.contact_avatar)
951 .aligned()
952 .left()
953 }))
954 .with_child(
955 Label::new(
956 user.github_login.clone(),
957 theme.contact_username.text.clone(),
958 )
959 .contained()
960 .with_style(theme.contact_username.container)
961 .aligned()
962 .left()
963 .flex(1., true),
964 )
965 .with_children(if is_pending {
966 Some(
967 Label::new("Calling", theme.calling_indicator.text.clone())
968 .contained()
969 .with_style(theme.calling_indicator.container)
970 .aligned(),
971 )
972 } else {
973 None
974 })
975 .constrained()
976 .with_height(theme.row_height)
977 .contained()
978 .with_style(
979 *theme
980 .contact_row
981 .in_state(is_selected)
982 .style_for(&mut Default::default()),
983 )
984 .into_any()
985 }
986
987 fn render_participant_project(
988 project_id: u64,
989 worktree_root_names: &[String],
990 host_user_id: u64,
991 is_current: bool,
992 is_last: bool,
993 is_selected: bool,
994 theme: &theme::CollabPanel,
995 cx: &mut ViewContext<Self>,
996 ) -> AnyElement<Self> {
997 enum JoinProject {}
998
999 let font_cache = cx.font_cache();
1000 let host_avatar_height = theme
1001 .contact_avatar
1002 .width
1003 .or(theme.contact_avatar.height)
1004 .unwrap_or(0.);
1005 let row = &theme.project_row.inactive_state().default;
1006 let tree_branch = theme.tree_branch;
1007 let line_height = row.name.text.line_height(font_cache);
1008 let cap_height = row.name.text.cap_height(font_cache);
1009 let baseline_offset =
1010 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
1011 let project_name = if worktree_root_names.is_empty() {
1012 "untitled".to_string()
1013 } else {
1014 worktree_root_names.join(", ")
1015 };
1016
1017 MouseEventHandler::new::<JoinProject, _>(project_id as usize, cx, |mouse_state, _| {
1018 let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
1019 let row = theme
1020 .project_row
1021 .in_state(is_selected)
1022 .style_for(mouse_state);
1023
1024 Flex::row()
1025 .with_child(
1026 Stack::new()
1027 .with_child(Canvas::new(move |scene, bounds, _, _, _| {
1028 let start_x =
1029 bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
1030 let end_x = bounds.max_x();
1031 let start_y = bounds.min_y();
1032 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
1033
1034 scene.push_quad(gpui::Quad {
1035 bounds: RectF::from_points(
1036 vec2f(start_x, start_y),
1037 vec2f(
1038 start_x + tree_branch.width,
1039 if is_last { end_y } else { bounds.max_y() },
1040 ),
1041 ),
1042 background: Some(tree_branch.color),
1043 border: gpui::Border::default(),
1044 corner_radii: (0.).into(),
1045 });
1046 scene.push_quad(gpui::Quad {
1047 bounds: RectF::from_points(
1048 vec2f(start_x, end_y),
1049 vec2f(end_x, end_y + tree_branch.width),
1050 ),
1051 background: Some(tree_branch.color),
1052 border: gpui::Border::default(),
1053 corner_radii: (0.).into(),
1054 });
1055 }))
1056 .constrained()
1057 .with_width(host_avatar_height),
1058 )
1059 .with_child(
1060 Label::new(project_name, row.name.text.clone())
1061 .aligned()
1062 .left()
1063 .contained()
1064 .with_style(row.name.container)
1065 .flex(1., false),
1066 )
1067 .constrained()
1068 .with_height(theme.row_height)
1069 .contained()
1070 .with_style(row.container)
1071 })
1072 .with_cursor_style(if !is_current {
1073 CursorStyle::PointingHand
1074 } else {
1075 CursorStyle::Arrow
1076 })
1077 .on_click(MouseButton::Left, move |_, this, cx| {
1078 if !is_current {
1079 if let Some(workspace) = this.workspace.upgrade(cx) {
1080 let app_state = workspace.read(cx).app_state().clone();
1081 workspace::join_remote_project(project_id, host_user_id, app_state, cx)
1082 .detach_and_log_err(cx);
1083 }
1084 }
1085 })
1086 .into_any()
1087 }
1088
1089 fn render_participant_screen(
1090 peer_id: PeerId,
1091 is_last: bool,
1092 is_selected: bool,
1093 theme: &theme::CollabPanel,
1094 cx: &mut ViewContext<Self>,
1095 ) -> AnyElement<Self> {
1096 enum OpenSharedScreen {}
1097
1098 let font_cache = cx.font_cache();
1099 let host_avatar_height = theme
1100 .contact_avatar
1101 .width
1102 .or(theme.contact_avatar.height)
1103 .unwrap_or(0.);
1104 let row = &theme.project_row.inactive_state().default;
1105 let tree_branch = theme.tree_branch;
1106 let line_height = row.name.text.line_height(font_cache);
1107 let cap_height = row.name.text.cap_height(font_cache);
1108 let baseline_offset =
1109 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
1110
1111 MouseEventHandler::new::<OpenSharedScreen, _>(
1112 peer_id.as_u64() as usize,
1113 cx,
1114 |mouse_state, _| {
1115 let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
1116 let row = theme
1117 .project_row
1118 .in_state(is_selected)
1119 .style_for(mouse_state);
1120
1121 Flex::row()
1122 .with_child(
1123 Stack::new()
1124 .with_child(Canvas::new(move |scene, bounds, _, _, _| {
1125 let start_x = bounds.min_x() + (bounds.width() / 2.)
1126 - (tree_branch.width / 2.);
1127 let end_x = bounds.max_x();
1128 let start_y = bounds.min_y();
1129 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
1130
1131 scene.push_quad(gpui::Quad {
1132 bounds: RectF::from_points(
1133 vec2f(start_x, start_y),
1134 vec2f(
1135 start_x + tree_branch.width,
1136 if is_last { end_y } else { bounds.max_y() },
1137 ),
1138 ),
1139 background: Some(tree_branch.color),
1140 border: gpui::Border::default(),
1141 corner_radii: (0.).into(),
1142 });
1143 scene.push_quad(gpui::Quad {
1144 bounds: RectF::from_points(
1145 vec2f(start_x, end_y),
1146 vec2f(end_x, end_y + tree_branch.width),
1147 ),
1148 background: Some(tree_branch.color),
1149 border: gpui::Border::default(),
1150 corner_radii: (0.).into(),
1151 });
1152 }))
1153 .constrained()
1154 .with_width(host_avatar_height),
1155 )
1156 .with_child(
1157 Svg::new("icons/disable_screen_sharing_12.svg")
1158 .with_color(row.icon.color)
1159 .constrained()
1160 .with_width(row.icon.width)
1161 .aligned()
1162 .left()
1163 .contained()
1164 .with_style(row.icon.container),
1165 )
1166 .with_child(
1167 Label::new("Screen", row.name.text.clone())
1168 .aligned()
1169 .left()
1170 .contained()
1171 .with_style(row.name.container)
1172 .flex(1., false),
1173 )
1174 .constrained()
1175 .with_height(theme.row_height)
1176 .contained()
1177 .with_style(row.container)
1178 },
1179 )
1180 .with_cursor_style(CursorStyle::PointingHand)
1181 .on_click(MouseButton::Left, move |_, this, cx| {
1182 if let Some(workspace) = this.workspace.upgrade(cx) {
1183 workspace.update(cx, |workspace, cx| {
1184 workspace.open_shared_screen(peer_id, cx)
1185 });
1186 }
1187 })
1188 .into_any()
1189 }
1190
1191 fn take_editing_state(&mut self, cx: &mut ViewContext<Self>) -> bool {
1192 if let Some(_) = self.channel_editing_state.take() {
1193 self.channel_name_editor.update(cx, |editor, cx| {
1194 editor.set_text("", cx);
1195 });
1196 true
1197 } else {
1198 false
1199 }
1200 }
1201
1202 fn render_header(
1203 &self,
1204 section: Section,
1205 theme: &theme::Theme,
1206 depth: usize,
1207 is_selected: bool,
1208 is_collapsed: bool,
1209 cx: &mut ViewContext<Self>,
1210 ) -> AnyElement<Self> {
1211 enum Header {}
1212 enum LeaveCallContactList {}
1213 enum AddChannel {}
1214
1215 let tooltip_style = &theme.tooltip;
1216 let text = match section {
1217 Section::ActiveCall => {
1218 let channel_name = iife!({
1219 let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
1220
1221 let name = self
1222 .channel_store
1223 .read(cx)
1224 .channel_for_id(channel_id)?
1225 .name
1226 .as_str();
1227
1228 Some(name)
1229 });
1230
1231 if let Some(name) = channel_name {
1232 Cow::Owned(format!("Current Call - #{}", name))
1233 } else {
1234 Cow::Borrowed("Current Call")
1235 }
1236 }
1237 Section::ContactRequests => Cow::Borrowed("Requests"),
1238 Section::Contacts => Cow::Borrowed("Contacts"),
1239 Section::Channels => Cow::Borrowed("Channels"),
1240 Section::ChannelInvites => Cow::Borrowed("Invites"),
1241 Section::Online => Cow::Borrowed("Online"),
1242 Section::Offline => Cow::Borrowed("Offline"),
1243 };
1244
1245 enum AddContact {}
1246 let button = match section {
1247 Section::ActiveCall => Some(
1248 MouseEventHandler::new::<AddContact, _>(0, cx, |state, _| {
1249 render_icon_button(
1250 theme
1251 .collab_panel
1252 .leave_call_button
1253 .style_for(is_selected, state),
1254 "icons/exit.svg",
1255 )
1256 })
1257 .with_cursor_style(CursorStyle::PointingHand)
1258 .on_click(MouseButton::Left, |_, _, cx| {
1259 Self::leave_call(cx);
1260 })
1261 .with_tooltip::<AddContact>(
1262 0,
1263 "Leave call",
1264 None,
1265 tooltip_style.clone(),
1266 cx,
1267 ),
1268 ),
1269 Section::Contacts => Some(
1270 MouseEventHandler::new::<LeaveCallContactList, _>(0, cx, |state, _| {
1271 render_icon_button(
1272 theme
1273 .collab_panel
1274 .add_contact_button
1275 .style_for(is_selected, state),
1276 "icons/plus_16.svg",
1277 )
1278 })
1279 .with_cursor_style(CursorStyle::PointingHand)
1280 .on_click(MouseButton::Left, |_, this, cx| {
1281 this.toggle_contact_finder(cx);
1282 })
1283 .with_tooltip::<LeaveCallContactList>(
1284 0,
1285 "Search for new contact",
1286 None,
1287 tooltip_style.clone(),
1288 cx,
1289 ),
1290 ),
1291 Section::Channels => Some(
1292 MouseEventHandler::new::<AddChannel, _>(0, cx, |state, _| {
1293 render_icon_button(
1294 theme
1295 .collab_panel
1296 .add_contact_button
1297 .style_for(is_selected, state),
1298 "icons/plus.svg",
1299 )
1300 })
1301 .with_cursor_style(CursorStyle::PointingHand)
1302 .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx))
1303 .with_tooltip::<AddChannel>(
1304 0,
1305 "Create a channel",
1306 None,
1307 tooltip_style.clone(),
1308 cx,
1309 ),
1310 ),
1311 _ => None,
1312 };
1313
1314 let can_collapse = depth > 0;
1315 let icon_size = (&theme.collab_panel).section_icon_size;
1316 let mut result = MouseEventHandler::new::<Header, _>(section as usize, cx, |state, _| {
1317 let header_style = if can_collapse {
1318 theme
1319 .collab_panel
1320 .subheader_row
1321 .in_state(is_selected)
1322 .style_for(state)
1323 } else {
1324 &theme.collab_panel.header_row
1325 };
1326
1327 Flex::row()
1328 .with_children(if can_collapse {
1329 Some(
1330 Svg::new(if is_collapsed {
1331 "icons/chevron_right.svg"
1332 } else {
1333 "icons/chevron_down.svg"
1334 })
1335 .with_color(header_style.text.color)
1336 .constrained()
1337 .with_max_width(icon_size)
1338 .with_max_height(icon_size)
1339 .aligned()
1340 .constrained()
1341 .with_width(icon_size)
1342 .contained()
1343 .with_margin_right(
1344 theme.collab_panel.contact_username.container.margin.left,
1345 ),
1346 )
1347 } else {
1348 None
1349 })
1350 .with_child(
1351 Label::new(text, header_style.text.clone())
1352 .aligned()
1353 .left()
1354 .flex(1., true),
1355 )
1356 .with_children(button.map(|button| button.aligned().right()))
1357 .constrained()
1358 .with_height(theme.collab_panel.row_height)
1359 .contained()
1360 .with_style(header_style.container)
1361 });
1362
1363 if can_collapse {
1364 result = result
1365 .with_cursor_style(CursorStyle::PointingHand)
1366 .on_click(MouseButton::Left, move |_, this, cx| {
1367 if can_collapse {
1368 this.toggle_section_expanded(section, cx);
1369 }
1370 })
1371 }
1372
1373 result.into_any()
1374 }
1375
1376 fn render_contact(
1377 contact: &Contact,
1378 calling: bool,
1379 project: &ModelHandle<Project>,
1380 theme: &theme::CollabPanel,
1381 is_selected: bool,
1382 cx: &mut ViewContext<Self>,
1383 ) -> AnyElement<Self> {
1384 let online = contact.online;
1385 let busy = contact.busy || calling;
1386 let user_id = contact.user.id;
1387 let github_login = contact.user.github_login.clone();
1388 let initial_project = project.clone();
1389 let mut event_handler =
1390 MouseEventHandler::new::<Contact, _>(contact.user.id as usize, cx, |state, cx| {
1391 Flex::row()
1392 .with_children(contact.user.avatar.clone().map(|avatar| {
1393 let status_badge = if contact.online {
1394 Some(
1395 Empty::new()
1396 .collapsed()
1397 .contained()
1398 .with_style(if busy {
1399 theme.contact_status_busy
1400 } else {
1401 theme.contact_status_free
1402 })
1403 .aligned(),
1404 )
1405 } else {
1406 None
1407 };
1408 Stack::new()
1409 .with_child(
1410 Image::from_data(avatar)
1411 .with_style(theme.contact_avatar)
1412 .aligned()
1413 .left(),
1414 )
1415 .with_children(status_badge)
1416 }))
1417 .with_child(
1418 Label::new(
1419 contact.user.github_login.clone(),
1420 theme.contact_username.text.clone(),
1421 )
1422 .contained()
1423 .with_style(theme.contact_username.container)
1424 .aligned()
1425 .left()
1426 .flex(1., true),
1427 )
1428 .with_child(
1429 MouseEventHandler::new::<Cancel, _>(
1430 contact.user.id as usize,
1431 cx,
1432 |mouse_state, _| {
1433 let button_style = theme.contact_button.style_for(mouse_state);
1434 render_icon_button(button_style, "icons/x.svg")
1435 .aligned()
1436 .flex_float()
1437 },
1438 )
1439 .with_padding(Padding::uniform(2.))
1440 .with_cursor_style(CursorStyle::PointingHand)
1441 .on_click(MouseButton::Left, move |_, this, cx| {
1442 this.remove_contact(user_id, &github_login, cx);
1443 })
1444 .flex_float(),
1445 )
1446 .with_children(if calling {
1447 Some(
1448 Label::new("Calling", theme.calling_indicator.text.clone())
1449 .contained()
1450 .with_style(theme.calling_indicator.container)
1451 .aligned(),
1452 )
1453 } else {
1454 None
1455 })
1456 .constrained()
1457 .with_height(theme.row_height)
1458 .contained()
1459 .with_style(*theme.contact_row.in_state(is_selected).style_for(state))
1460 })
1461 .on_click(MouseButton::Left, move |_, this, cx| {
1462 if online && !busy {
1463 this.call(user_id, Some(initial_project.clone()), cx);
1464 }
1465 });
1466
1467 if online {
1468 event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand);
1469 }
1470
1471 event_handler.into_any()
1472 }
1473
1474 fn render_contact_placeholder(
1475 &self,
1476 theme: &theme::CollabPanel,
1477 is_selected: bool,
1478 cx: &mut ViewContext<Self>,
1479 ) -> AnyElement<Self> {
1480 enum AddContacts {}
1481 MouseEventHandler::new::<AddContacts, _>(0, cx, |state, _| {
1482 let style = theme.list_empty_state.style_for(is_selected, state);
1483 Flex::row()
1484 .with_child(
1485 Svg::new("icons/plus.svg")
1486 .with_color(theme.list_empty_icon.color)
1487 .constrained()
1488 .with_width(theme.list_empty_icon.width)
1489 .aligned()
1490 .left(),
1491 )
1492 .with_child(
1493 Label::new("Add a contact", style.text.clone())
1494 .contained()
1495 .with_style(theme.list_empty_label_container),
1496 )
1497 .align_children_center()
1498 .contained()
1499 .with_style(style.container)
1500 .into_any()
1501 })
1502 .on_click(MouseButton::Left, |_, this, cx| {
1503 this.toggle_contact_finder(cx);
1504 })
1505 .into_any()
1506 }
1507
1508 fn render_channel_editor(
1509 &self,
1510 theme: &theme::Theme,
1511 depth: usize,
1512 cx: &AppContext,
1513 ) -> AnyElement<Self> {
1514 Flex::row()
1515 .with_child(
1516 Empty::new()
1517 .constrained()
1518 .with_width(theme.collab_panel.disclosure.button_space()),
1519 )
1520 .with_child(
1521 Svg::new("icons/hash.svg")
1522 .with_color(theme.collab_panel.channel_hash.color)
1523 .constrained()
1524 .with_width(theme.collab_panel.channel_hash.width)
1525 .aligned()
1526 .left(),
1527 )
1528 .with_child(
1529 if let Some(pending_name) = self
1530 .channel_editing_state
1531 .as_ref()
1532 .and_then(|state| state.pending_name())
1533 {
1534 Label::new(
1535 pending_name.to_string(),
1536 theme.collab_panel.contact_username.text.clone(),
1537 )
1538 .contained()
1539 .with_style(theme.collab_panel.contact_username.container)
1540 .aligned()
1541 .left()
1542 .flex(1., true)
1543 .into_any()
1544 } else {
1545 ChildView::new(&self.channel_name_editor, cx)
1546 .aligned()
1547 .left()
1548 .contained()
1549 .with_style(theme.collab_panel.channel_editor)
1550 .flex(1.0, true)
1551 .into_any()
1552 },
1553 )
1554 .align_children_center()
1555 .constrained()
1556 .with_height(theme.collab_panel.row_height)
1557 .contained()
1558 .with_style(gpui::elements::ContainerStyle {
1559 background_color: Some(theme.editor.background),
1560 ..*theme.collab_panel.contact_row.default_style()
1561 })
1562 .with_padding_left(
1563 theme.collab_panel.contact_row.default_style().padding.left
1564 + theme.collab_panel.channel_indent * depth as f32,
1565 )
1566 .into_any()
1567 }
1568
1569 fn render_channel(
1570 &self,
1571 channel: &Channel,
1572 depth: usize,
1573 theme: &theme::CollabPanel,
1574 is_selected: bool,
1575 cx: &mut ViewContext<Self>,
1576 ) -> AnyElement<Self> {
1577 let channel_id = channel.id;
1578 let has_children = self.channel_store.read(cx).has_children(channel_id);
1579 let disclosed =
1580 has_children.then(|| !self.collapsed_channels.binary_search(&channel_id).is_ok());
1581
1582 let is_active = iife!({
1583 let call_channel = ActiveCall::global(cx)
1584 .read(cx)
1585 .room()?
1586 .read(cx)
1587 .channel_id()?;
1588 Some(call_channel == channel_id)
1589 })
1590 .unwrap_or(false);
1591
1592 const FACEPILE_LIMIT: usize = 3;
1593
1594 MouseEventHandler::new::<Channel, _>(channel.id as usize, cx, |state, cx| {
1595 Flex::<Self>::row()
1596 .with_child(
1597 Svg::new("icons/hash.svg")
1598 .with_color(theme.channel_hash.color)
1599 .constrained()
1600 .with_width(theme.channel_hash.width)
1601 .aligned()
1602 .left(),
1603 )
1604 .with_child(
1605 Label::new(channel.name.clone(), theme.channel_name.text.clone())
1606 .contained()
1607 .with_style(theme.channel_name.container)
1608 .aligned()
1609 .left()
1610 .flex(1., true),
1611 )
1612 .with_children({
1613 let participants = self.channel_store.read(cx).channel_participants(channel_id);
1614 if !participants.is_empty() {
1615 let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
1616
1617 Some(
1618 FacePile::new(theme.face_overlap)
1619 .with_children(
1620 participants
1621 .iter()
1622 .filter_map(|user| {
1623 Some(
1624 Image::from_data(user.avatar.clone()?)
1625 .with_style(theme.channel_avatar),
1626 )
1627 })
1628 .take(FACEPILE_LIMIT),
1629 )
1630 .with_children((extra_count > 0).then(|| {
1631 Label::new(
1632 format!("+{}", extra_count),
1633 theme.extra_participant_label.text.clone(),
1634 )
1635 .contained()
1636 .with_style(theme.extra_participant_label.container)
1637 })),
1638 )
1639 } else {
1640 None
1641 }
1642 })
1643 .align_children_center()
1644 .styleable_component()
1645 .disclosable(disclosed, Box::new(ToggleCollapse { channel_id }))
1646 .with_id(channel_id as usize)
1647 .with_style(theme.disclosure.clone())
1648 .element()
1649 .constrained()
1650 .with_height(theme.row_height)
1651 .contained()
1652 .with_style(*theme.channel_row.style_for(is_selected || is_active, state))
1653 .with_padding_left(
1654 theme.channel_row.default_style().padding.left
1655 + theme.channel_indent * depth as f32,
1656 )
1657 })
1658 .on_click(MouseButton::Left, move |_, this, cx| {
1659 this.join_channel(channel_id, cx);
1660 })
1661 .on_click(MouseButton::Right, move |e, this, cx| {
1662 this.deploy_channel_context_menu(Some(e.position), channel_id, cx);
1663 })
1664 .with_cursor_style(CursorStyle::PointingHand)
1665 .into_any()
1666 }
1667
1668 fn render_channel_invite(
1669 channel: Arc<Channel>,
1670 channel_store: ModelHandle<ChannelStore>,
1671 theme: &theme::CollabPanel,
1672 is_selected: bool,
1673 cx: &mut ViewContext<Self>,
1674 ) -> AnyElement<Self> {
1675 enum Decline {}
1676 enum Accept {}
1677
1678 let channel_id = channel.id;
1679 let is_invite_pending = channel_store
1680 .read(cx)
1681 .has_pending_channel_invite_response(&channel);
1682 let button_spacing = theme.contact_button_spacing;
1683
1684 Flex::row()
1685 .with_child(
1686 Svg::new("icons/hash.svg")
1687 .with_color(theme.channel_hash.color)
1688 .constrained()
1689 .with_width(theme.channel_hash.width)
1690 .aligned()
1691 .left(),
1692 )
1693 .with_child(
1694 Label::new(channel.name.clone(), theme.contact_username.text.clone())
1695 .contained()
1696 .with_style(theme.contact_username.container)
1697 .aligned()
1698 .left()
1699 .flex(1., true),
1700 )
1701 .with_child(
1702 MouseEventHandler::new::<Decline, _>(channel.id as usize, cx, |mouse_state, _| {
1703 let button_style = if is_invite_pending {
1704 &theme.disabled_button
1705 } else {
1706 theme.contact_button.style_for(mouse_state)
1707 };
1708 render_icon_button(button_style, "icons/x.svg").aligned()
1709 })
1710 .with_cursor_style(CursorStyle::PointingHand)
1711 .on_click(MouseButton::Left, move |_, this, cx| {
1712 this.respond_to_channel_invite(channel_id, false, cx);
1713 })
1714 .contained()
1715 .with_margin_right(button_spacing),
1716 )
1717 .with_child(
1718 MouseEventHandler::new::<Accept, _>(channel.id as usize, cx, |mouse_state, _| {
1719 let button_style = if is_invite_pending {
1720 &theme.disabled_button
1721 } else {
1722 theme.contact_button.style_for(mouse_state)
1723 };
1724 render_icon_button(button_style, "icons/check.svg")
1725 .aligned()
1726 .flex_float()
1727 })
1728 .with_cursor_style(CursorStyle::PointingHand)
1729 .on_click(MouseButton::Left, move |_, this, cx| {
1730 this.respond_to_channel_invite(channel_id, true, cx);
1731 }),
1732 )
1733 .constrained()
1734 .with_height(theme.row_height)
1735 .contained()
1736 .with_style(
1737 *theme
1738 .contact_row
1739 .in_state(is_selected)
1740 .style_for(&mut Default::default()),
1741 )
1742 .with_padding_left(
1743 theme.contact_row.default_style().padding.left + theme.channel_indent,
1744 )
1745 .into_any()
1746 }
1747
1748 fn render_contact_request(
1749 user: Arc<User>,
1750 user_store: ModelHandle<UserStore>,
1751 theme: &theme::CollabPanel,
1752 is_incoming: bool,
1753 is_selected: bool,
1754 cx: &mut ViewContext<Self>,
1755 ) -> AnyElement<Self> {
1756 enum Decline {}
1757 enum Accept {}
1758 enum Cancel {}
1759
1760 let mut row = Flex::row()
1761 .with_children(user.avatar.clone().map(|avatar| {
1762 Image::from_data(avatar)
1763 .with_style(theme.contact_avatar)
1764 .aligned()
1765 .left()
1766 }))
1767 .with_child(
1768 Label::new(
1769 user.github_login.clone(),
1770 theme.contact_username.text.clone(),
1771 )
1772 .contained()
1773 .with_style(theme.contact_username.container)
1774 .aligned()
1775 .left()
1776 .flex(1., true),
1777 );
1778
1779 let user_id = user.id;
1780 let github_login = user.github_login.clone();
1781 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
1782 let button_spacing = theme.contact_button_spacing;
1783
1784 if is_incoming {
1785 row.add_child(
1786 MouseEventHandler::new::<Decline, _>(user.id as usize, cx, |mouse_state, _| {
1787 let button_style = if is_contact_request_pending {
1788 &theme.disabled_button
1789 } else {
1790 theme.contact_button.style_for(mouse_state)
1791 };
1792 render_icon_button(button_style, "icons/x.svg").aligned()
1793 })
1794 .with_cursor_style(CursorStyle::PointingHand)
1795 .on_click(MouseButton::Left, move |_, this, cx| {
1796 this.respond_to_contact_request(user_id, false, cx);
1797 })
1798 .contained()
1799 .with_margin_right(button_spacing),
1800 );
1801
1802 row.add_child(
1803 MouseEventHandler::new::<Accept, _>(user.id as usize, cx, |mouse_state, _| {
1804 let button_style = if is_contact_request_pending {
1805 &theme.disabled_button
1806 } else {
1807 theme.contact_button.style_for(mouse_state)
1808 };
1809 render_icon_button(button_style, "icons/check.svg")
1810 .aligned()
1811 .flex_float()
1812 })
1813 .with_cursor_style(CursorStyle::PointingHand)
1814 .on_click(MouseButton::Left, move |_, this, cx| {
1815 this.respond_to_contact_request(user_id, true, cx);
1816 }),
1817 );
1818 } else {
1819 row.add_child(
1820 MouseEventHandler::new::<Cancel, _>(user.id as usize, cx, |mouse_state, _| {
1821 let button_style = if is_contact_request_pending {
1822 &theme.disabled_button
1823 } else {
1824 theme.contact_button.style_for(mouse_state)
1825 };
1826 render_icon_button(button_style, "icons/x.svg")
1827 .aligned()
1828 .flex_float()
1829 })
1830 .with_padding(Padding::uniform(2.))
1831 .with_cursor_style(CursorStyle::PointingHand)
1832 .on_click(MouseButton::Left, move |_, this, cx| {
1833 this.remove_contact(user_id, &github_login, cx);
1834 })
1835 .flex_float(),
1836 );
1837 }
1838
1839 row.constrained()
1840 .with_height(theme.row_height)
1841 .contained()
1842 .with_style(
1843 *theme
1844 .contact_row
1845 .in_state(is_selected)
1846 .style_for(&mut Default::default()),
1847 )
1848 .into_any()
1849 }
1850
1851 fn include_channels_section(&self, cx: &AppContext) -> bool {
1852 if cx.has_global::<StaffMode>() {
1853 cx.global::<StaffMode>().0
1854 } else {
1855 false
1856 }
1857 }
1858
1859 fn deploy_channel_context_menu(
1860 &mut self,
1861 position: Option<Vector2F>,
1862 channel_id: u64,
1863 cx: &mut ViewContext<Self>,
1864 ) {
1865 if self.channel_store.read(cx).is_user_admin(channel_id) {
1866 self.context_menu_on_selected = position.is_none();
1867
1868 self.context_menu.update(cx, |context_menu, cx| {
1869 context_menu.set_position_mode(if self.context_menu_on_selected {
1870 OverlayPositionMode::Local
1871 } else {
1872 OverlayPositionMode::Window
1873 });
1874
1875 let expand_action_name = if self.is_channel_collapsed(channel_id) {
1876 "Expand Subchannels"
1877 } else {
1878 "Collapse Subchannels"
1879 };
1880
1881 context_menu.show(
1882 position.unwrap_or_default(),
1883 if self.context_menu_on_selected {
1884 gpui::elements::AnchorCorner::TopRight
1885 } else {
1886 gpui::elements::AnchorCorner::BottomLeft
1887 },
1888 vec![
1889 ContextMenuItem::action(expand_action_name, ToggleCollapse { channel_id }),
1890 ContextMenuItem::action("New Subchannel", NewChannel { channel_id }),
1891 ContextMenuItem::Separator,
1892 ContextMenuItem::action("Invite to Channel", InviteMembers { channel_id }),
1893 ContextMenuItem::Separator,
1894 ContextMenuItem::action("Rename", RenameChannel { channel_id }),
1895 ContextMenuItem::action("Manage", ManageMembers { channel_id }),
1896 ContextMenuItem::Separator,
1897 ContextMenuItem::action("Delete", RemoveChannel { channel_id }),
1898 ],
1899 cx,
1900 );
1901 });
1902
1903 cx.notify();
1904 }
1905 }
1906
1907 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
1908 if self.take_editing_state(cx) {
1909 cx.focus(&self.filter_editor);
1910 } else {
1911 self.filter_editor.update(cx, |editor, cx| {
1912 if editor.buffer().read(cx).len(cx) > 0 {
1913 editor.set_text("", cx);
1914 }
1915 });
1916 }
1917
1918 self.update_entries(false, cx);
1919 }
1920
1921 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1922 let ix = self.selection.map_or(0, |ix| ix + 1);
1923 if ix < self.entries.len() {
1924 self.selection = Some(ix);
1925 }
1926
1927 self.list_state.reset(self.entries.len());
1928 if let Some(ix) = self.selection {
1929 self.list_state.scroll_to(ListOffset {
1930 item_ix: ix,
1931 offset_in_item: 0.,
1932 });
1933 }
1934 cx.notify();
1935 }
1936
1937 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
1938 let ix = self.selection.take().unwrap_or(0);
1939 if ix > 0 {
1940 self.selection = Some(ix - 1);
1941 }
1942
1943 self.list_state.reset(self.entries.len());
1944 if let Some(ix) = self.selection {
1945 self.list_state.scroll_to(ListOffset {
1946 item_ix: ix,
1947 offset_in_item: 0.,
1948 });
1949 }
1950 cx.notify();
1951 }
1952
1953 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1954 if self.confirm_channel_edit(cx) {
1955 return;
1956 }
1957
1958 if let Some(selection) = self.selection {
1959 if let Some(entry) = self.entries.get(selection) {
1960 match entry {
1961 ListEntry::Header(section, _) => match section {
1962 Section::ActiveCall => Self::leave_call(cx),
1963 Section::Channels => self.new_root_channel(cx),
1964 Section::Contacts => self.toggle_contact_finder(cx),
1965 Section::ContactRequests
1966 | Section::Online
1967 | Section::Offline
1968 | Section::ChannelInvites => {
1969 self.toggle_section_expanded(*section, cx);
1970 }
1971 },
1972 ListEntry::Contact { contact, calling } => {
1973 if contact.online && !contact.busy && !calling {
1974 self.call(contact.user.id, Some(self.project.clone()), cx);
1975 }
1976 }
1977 ListEntry::ParticipantProject {
1978 project_id,
1979 host_user_id,
1980 ..
1981 } => {
1982 if let Some(workspace) = self.workspace.upgrade(cx) {
1983 let app_state = workspace.read(cx).app_state().clone();
1984 workspace::join_remote_project(
1985 *project_id,
1986 *host_user_id,
1987 app_state,
1988 cx,
1989 )
1990 .detach_and_log_err(cx);
1991 }
1992 }
1993 ListEntry::ParticipantScreen { peer_id, .. } => {
1994 if let Some(workspace) = self.workspace.upgrade(cx) {
1995 workspace.update(cx, |workspace, cx| {
1996 workspace.open_shared_screen(*peer_id, cx)
1997 });
1998 }
1999 }
2000 ListEntry::Channel { channel, .. } => {
2001 self.join_channel(channel.id, cx);
2002 }
2003 ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
2004 _ => {}
2005 }
2006 }
2007 }
2008 }
2009
2010 fn confirm_channel_edit(&mut self, cx: &mut ViewContext<CollabPanel>) -> bool {
2011 if let Some(editing_state) = &mut self.channel_editing_state {
2012 match editing_state {
2013 ChannelEditingState::Create {
2014 parent_id,
2015 pending_name,
2016 ..
2017 } => {
2018 if pending_name.is_some() {
2019 return false;
2020 }
2021 let channel_name = self.channel_name_editor.read(cx).text(cx);
2022
2023 *pending_name = Some(channel_name.clone());
2024
2025 self.channel_store
2026 .update(cx, |channel_store, cx| {
2027 channel_store.create_channel(&channel_name, *parent_id, cx)
2028 })
2029 .detach();
2030 cx.notify();
2031 }
2032 ChannelEditingState::Rename {
2033 channel_id,
2034 pending_name,
2035 } => {
2036 if pending_name.is_some() {
2037 return false;
2038 }
2039 let channel_name = self.channel_name_editor.read(cx).text(cx);
2040 *pending_name = Some(channel_name.clone());
2041
2042 self.channel_store
2043 .update(cx, |channel_store, cx| {
2044 channel_store.rename(*channel_id, &channel_name, cx)
2045 })
2046 .detach();
2047 cx.notify();
2048 }
2049 }
2050 cx.focus_self();
2051 true
2052 } else {
2053 false
2054 }
2055 }
2056
2057 fn toggle_section_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
2058 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
2059 self.collapsed_sections.remove(ix);
2060 } else {
2061 self.collapsed_sections.push(section);
2062 }
2063 self.update_entries(false, cx);
2064 }
2065
2066 fn collapse_selected_channel(
2067 &mut self,
2068 _: &CollapseSelectedChannel,
2069 cx: &mut ViewContext<Self>,
2070 ) {
2071 let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
2072 return;
2073 };
2074
2075 if self.is_channel_collapsed(channel_id) {
2076 return;
2077 }
2078
2079 self.toggle_channel_collapsed(&ToggleCollapse { channel_id }, cx)
2080 }
2081
2082 fn expand_selected_channel(&mut self, _: &ExpandSelectedChannel, cx: &mut ViewContext<Self>) {
2083 let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
2084 return;
2085 };
2086
2087 if !self.is_channel_collapsed(channel_id) {
2088 return;
2089 }
2090
2091 self.toggle_channel_collapsed(&ToggleCollapse { channel_id }, cx)
2092 }
2093
2094 fn toggle_channel_collapsed(&mut self, action: &ToggleCollapse, cx: &mut ViewContext<Self>) {
2095 let channel_id = action.channel_id;
2096
2097 match self.collapsed_channels.binary_search(&channel_id) {
2098 Ok(ix) => {
2099 self.collapsed_channels.remove(ix);
2100 }
2101 Err(ix) => {
2102 self.collapsed_channels.insert(ix, channel_id);
2103 }
2104 };
2105 self.update_entries(true, cx);
2106 cx.notify();
2107 cx.focus_self();
2108 }
2109
2110 fn is_channel_collapsed(&self, channel: ChannelId) -> bool {
2111 self.collapsed_channels.binary_search(&channel).is_ok()
2112 }
2113
2114 fn leave_call(cx: &mut ViewContext<Self>) {
2115 ActiveCall::global(cx)
2116 .update(cx, |call, cx| call.hang_up(cx))
2117 .detach_and_log_err(cx);
2118 }
2119
2120 fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
2121 if let Some(workspace) = self.workspace.upgrade(cx) {
2122 workspace.update(cx, |workspace, cx| {
2123 workspace.toggle_modal(cx, |_, cx| {
2124 cx.add_view(|cx| {
2125 let mut finder = ContactFinder::new(self.user_store.clone(), cx);
2126 finder.set_query(self.filter_editor.read(cx).text(cx), cx);
2127 finder
2128 })
2129 });
2130 });
2131 }
2132 }
2133
2134 fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
2135 self.channel_editing_state = Some(ChannelEditingState::Create {
2136 parent_id: None,
2137 pending_name: None,
2138 });
2139 self.update_entries(false, cx);
2140 self.select_channel_editor();
2141 cx.focus(self.channel_name_editor.as_any());
2142 cx.notify();
2143 }
2144
2145 fn select_channel_editor(&mut self) {
2146 self.selection = self.entries.iter().position(|entry| match entry {
2147 ListEntry::ChannelEditor { .. } => true,
2148 _ => false,
2149 });
2150 }
2151
2152 fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext<Self>) {
2153 self.collapsed_channels
2154 .retain(|&channel| channel != action.channel_id);
2155 self.channel_editing_state = Some(ChannelEditingState::Create {
2156 parent_id: Some(action.channel_id),
2157 pending_name: None,
2158 });
2159 self.update_entries(false, cx);
2160 self.select_channel_editor();
2161 cx.focus(self.channel_name_editor.as_any());
2162 cx.notify();
2163 }
2164
2165 fn invite_members(&mut self, action: &InviteMembers, cx: &mut ViewContext<Self>) {
2166 self.show_channel_modal(action.channel_id, channel_modal::Mode::InviteMembers, cx);
2167 }
2168
2169 fn manage_members(&mut self, action: &ManageMembers, cx: &mut ViewContext<Self>) {
2170 self.show_channel_modal(action.channel_id, channel_modal::Mode::ManageMembers, cx);
2171 }
2172
2173 fn remove(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
2174 if let Some(channel) = self.selected_channel() {
2175 self.remove_channel(channel.id, cx)
2176 }
2177 }
2178
2179 fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
2180 if let Some(channel) = self.selected_channel() {
2181 self.rename_channel(
2182 &RenameChannel {
2183 channel_id: channel.id,
2184 },
2185 cx,
2186 );
2187 }
2188 }
2189
2190 fn rename_channel(&mut self, action: &RenameChannel, cx: &mut ViewContext<Self>) {
2191 let channel_store = self.channel_store.read(cx);
2192 if !channel_store.is_user_admin(action.channel_id) {
2193 return;
2194 }
2195 if let Some(channel) = channel_store.channel_for_id(action.channel_id).cloned() {
2196 self.channel_editing_state = Some(ChannelEditingState::Rename {
2197 channel_id: action.channel_id,
2198 pending_name: None,
2199 });
2200 self.channel_name_editor.update(cx, |editor, cx| {
2201 editor.set_text(channel.name.clone(), cx);
2202 editor.select_all(&Default::default(), cx);
2203 });
2204 cx.focus(self.channel_name_editor.as_any());
2205 self.update_entries(false, cx);
2206 self.select_channel_editor();
2207 }
2208 }
2209
2210 fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
2211 let Some(channel) = self.selected_channel() else {
2212 return;
2213 };
2214
2215 self.deploy_channel_context_menu(None, channel.id, cx);
2216 }
2217
2218 fn selected_channel(&self) -> Option<&Arc<Channel>> {
2219 self.selection
2220 .and_then(|ix| self.entries.get(ix))
2221 .and_then(|entry| match entry {
2222 ListEntry::Channel { channel, .. } => Some(channel),
2223 _ => None,
2224 })
2225 }
2226
2227 fn show_channel_modal(
2228 &mut self,
2229 channel_id: ChannelId,
2230 mode: channel_modal::Mode,
2231 cx: &mut ViewContext<Self>,
2232 ) {
2233 let workspace = self.workspace.clone();
2234 let user_store = self.user_store.clone();
2235 let channel_store = self.channel_store.clone();
2236 let members = self.channel_store.update(cx, |channel_store, cx| {
2237 channel_store.get_channel_member_details(channel_id, cx)
2238 });
2239
2240 cx.spawn(|_, mut cx| async move {
2241 let members = members.await?;
2242 workspace.update(&mut cx, |workspace, cx| {
2243 workspace.toggle_modal(cx, |_, cx| {
2244 cx.add_view(|cx| {
2245 ChannelModal::new(
2246 user_store.clone(),
2247 channel_store.clone(),
2248 channel_id,
2249 mode,
2250 members,
2251 cx,
2252 )
2253 })
2254 });
2255 })
2256 })
2257 .detach();
2258 }
2259
2260 fn remove_selected_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext<Self>) {
2261 self.remove_channel(action.channel_id, cx)
2262 }
2263
2264 fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
2265 let channel_store = self.channel_store.clone();
2266 if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
2267 let prompt_message = format!(
2268 "Are you sure you want to remove the channel \"{}\"?",
2269 channel.name
2270 );
2271 let mut answer =
2272 cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
2273 let window = cx.window();
2274 cx.spawn(|this, mut cx| async move {
2275 if answer.next().await == Some(0) {
2276 if let Err(e) = channel_store
2277 .update(&mut cx, |channels, _| channels.remove_channel(channel_id))
2278 .await
2279 {
2280 window.prompt(
2281 PromptLevel::Info,
2282 &format!("Failed to remove channel: {}", e),
2283 &["Ok"],
2284 &mut cx,
2285 );
2286 }
2287 this.update(&mut cx, |_, cx| cx.focus_self()).ok();
2288 }
2289 })
2290 .detach();
2291 }
2292 }
2293
2294 // Should move to the filter editor if clicking on it
2295 // Should move selection to the channel editor if activating it
2296
2297 fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
2298 let user_store = self.user_store.clone();
2299 let prompt_message = format!(
2300 "Are you sure you want to remove \"{}\" from your contacts?",
2301 github_login
2302 );
2303 let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
2304 let window = cx.window();
2305 cx.spawn(|_, mut cx| async move {
2306 if answer.next().await == Some(0) {
2307 if let Err(e) = user_store
2308 .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
2309 .await
2310 {
2311 window.prompt(
2312 PromptLevel::Info,
2313 &format!("Failed to remove contact: {}", e),
2314 &["Ok"],
2315 &mut cx,
2316 );
2317 }
2318 }
2319 })
2320 .detach();
2321 }
2322
2323 fn respond_to_contact_request(
2324 &mut self,
2325 user_id: u64,
2326 accept: bool,
2327 cx: &mut ViewContext<Self>,
2328 ) {
2329 self.user_store
2330 .update(cx, |store, cx| {
2331 store.respond_to_contact_request(user_id, accept, cx)
2332 })
2333 .detach();
2334 }
2335
2336 fn respond_to_channel_invite(
2337 &mut self,
2338 channel_id: u64,
2339 accept: bool,
2340 cx: &mut ViewContext<Self>,
2341 ) {
2342 let respond = self.channel_store.update(cx, |store, _| {
2343 store.respond_to_channel_invite(channel_id, accept)
2344 });
2345 cx.foreground().spawn(respond).detach();
2346 }
2347
2348 fn call(
2349 &mut self,
2350 recipient_user_id: u64,
2351 initial_project: Option<ModelHandle<Project>>,
2352 cx: &mut ViewContext<Self>,
2353 ) {
2354 ActiveCall::global(cx)
2355 .update(cx, |call, cx| {
2356 call.invite(recipient_user_id, initial_project, cx)
2357 })
2358 .detach_and_log_err(cx);
2359 }
2360
2361 fn join_channel(&self, channel: u64, cx: &mut ViewContext<Self>) {
2362 ActiveCall::global(cx)
2363 .update(cx, |call, cx| call.join_channel(channel, cx))
2364 .detach_and_log_err(cx);
2365 }
2366}
2367
2368impl View for CollabPanel {
2369 fn ui_name() -> &'static str {
2370 "CollabPanel"
2371 }
2372
2373 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
2374 if !self.has_focus {
2375 self.has_focus = true;
2376 if !self.context_menu.is_focused(cx) {
2377 if let Some(editing_state) = &self.channel_editing_state {
2378 if editing_state.pending_name().is_none() {
2379 cx.focus(&self.channel_name_editor);
2380 } else {
2381 cx.focus(&self.filter_editor);
2382 }
2383 } else {
2384 cx.focus(&self.filter_editor);
2385 }
2386 }
2387 cx.emit(Event::Focus);
2388 }
2389 }
2390
2391 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
2392 self.has_focus = false;
2393 }
2394
2395 fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement<Self> {
2396 let theme = &theme::current(cx).collab_panel;
2397
2398 if self.user_store.read(cx).current_user().is_none() {
2399 enum LogInButton {}
2400
2401 return Flex::column()
2402 .with_child(
2403 MouseEventHandler::new::<LogInButton, _>(0, cx, |state, _| {
2404 let button = theme.log_in_button.style_for(state);
2405 Label::new("Sign in to collaborate", button.text.clone())
2406 .aligned()
2407 .left()
2408 .contained()
2409 .with_style(button.container)
2410 })
2411 .on_click(MouseButton::Left, |_, this, cx| {
2412 let client = this.client.clone();
2413 cx.spawn(|_, cx| async move {
2414 client.authenticate_and_connect(true, &cx).await.log_err();
2415 })
2416 .detach();
2417 })
2418 .with_cursor_style(CursorStyle::PointingHand),
2419 )
2420 .contained()
2421 .with_style(theme.container)
2422 .into_any();
2423 }
2424
2425 enum PanelFocus {}
2426 MouseEventHandler::new::<PanelFocus, _>(0, cx, |_, cx| {
2427 Stack::new()
2428 .with_child(
2429 Flex::column()
2430 .with_child(
2431 Flex::row()
2432 .with_child(
2433 ChildView::new(&self.filter_editor, cx)
2434 .contained()
2435 .with_style(theme.user_query_editor.container)
2436 .flex(1.0, true),
2437 )
2438 .constrained()
2439 .with_width(self.size(cx)),
2440 )
2441 .with_child(
2442 List::new(self.list_state.clone())
2443 .constrained()
2444 .with_width(self.size(cx))
2445 .flex(1., true)
2446 .into_any(),
2447 )
2448 .contained()
2449 .with_style(theme.container)
2450 .constrained()
2451 .with_width(self.size(cx))
2452 .into_any(),
2453 )
2454 .with_children(
2455 (!self.context_menu_on_selected)
2456 .then(|| ChildView::new(&self.context_menu, cx)),
2457 )
2458 .into_any()
2459 })
2460 .on_click(MouseButton::Left, |_, _, cx| cx.focus_self())
2461 .into_any_named("collab panel")
2462 }
2463}
2464
2465impl Panel for CollabPanel {
2466 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
2467 match settings::get::<CollaborationPanelSettings>(cx).dock {
2468 CollaborationPanelDockPosition::Left => DockPosition::Left,
2469 CollaborationPanelDockPosition::Right => DockPosition::Right,
2470 }
2471 }
2472
2473 fn position_is_valid(&self, position: DockPosition) -> bool {
2474 matches!(position, DockPosition::Left | DockPosition::Right)
2475 }
2476
2477 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2478 settings::update_settings_file::<CollaborationPanelSettings>(
2479 self.fs.clone(),
2480 cx,
2481 move |settings| {
2482 let dock = match position {
2483 DockPosition::Left | DockPosition::Bottom => {
2484 CollaborationPanelDockPosition::Left
2485 }
2486 DockPosition::Right => CollaborationPanelDockPosition::Right,
2487 };
2488 settings.dock = Some(dock);
2489 },
2490 );
2491 }
2492
2493 fn size(&self, cx: &gpui::WindowContext) -> f32 {
2494 self.width
2495 .unwrap_or_else(|| settings::get::<CollaborationPanelSettings>(cx).default_width)
2496 }
2497
2498 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
2499 self.width = size;
2500 self.serialize(cx);
2501 cx.notify();
2502 }
2503
2504 fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
2505 settings::get::<CollaborationPanelSettings>(cx)
2506 .button
2507 .then(|| "icons/conversations.svg")
2508 }
2509
2510 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
2511 (
2512 "Collaboration Panel".to_string(),
2513 Some(Box::new(ToggleFocus)),
2514 )
2515 }
2516
2517 fn should_change_position_on_event(event: &Self::Event) -> bool {
2518 matches!(event, Event::DockPositionChanged)
2519 }
2520
2521 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
2522 self.has_focus
2523 }
2524
2525 fn is_focus_event(event: &Self::Event) -> bool {
2526 matches!(event, Event::Focus)
2527 }
2528}
2529
2530impl PartialEq for ListEntry {
2531 fn eq(&self, other: &Self) -> bool {
2532 match self {
2533 ListEntry::Header(section_1, depth_1) => {
2534 if let ListEntry::Header(section_2, depth_2) = other {
2535 return section_1 == section_2 && depth_1 == depth_2;
2536 }
2537 }
2538 ListEntry::CallParticipant { user: user_1, .. } => {
2539 if let ListEntry::CallParticipant { user: user_2, .. } = other {
2540 return user_1.id == user_2.id;
2541 }
2542 }
2543 ListEntry::ParticipantProject {
2544 project_id: project_id_1,
2545 ..
2546 } => {
2547 if let ListEntry::ParticipantProject {
2548 project_id: project_id_2,
2549 ..
2550 } = other
2551 {
2552 return project_id_1 == project_id_2;
2553 }
2554 }
2555 ListEntry::ParticipantScreen {
2556 peer_id: peer_id_1, ..
2557 } => {
2558 if let ListEntry::ParticipantScreen {
2559 peer_id: peer_id_2, ..
2560 } = other
2561 {
2562 return peer_id_1 == peer_id_2;
2563 }
2564 }
2565 ListEntry::Channel {
2566 channel: channel_1,
2567 depth: depth_1,
2568 } => {
2569 if let ListEntry::Channel {
2570 channel: channel_2,
2571 depth: depth_2,
2572 } = other
2573 {
2574 return channel_1.id == channel_2.id && depth_1 == depth_2;
2575 }
2576 }
2577 ListEntry::ChannelInvite(channel_1) => {
2578 if let ListEntry::ChannelInvite(channel_2) = other {
2579 return channel_1.id == channel_2.id;
2580 }
2581 }
2582 ListEntry::IncomingRequest(user_1) => {
2583 if let ListEntry::IncomingRequest(user_2) = other {
2584 return user_1.id == user_2.id;
2585 }
2586 }
2587 ListEntry::OutgoingRequest(user_1) => {
2588 if let ListEntry::OutgoingRequest(user_2) = other {
2589 return user_1.id == user_2.id;
2590 }
2591 }
2592 ListEntry::Contact {
2593 contact: contact_1, ..
2594 } => {
2595 if let ListEntry::Contact {
2596 contact: contact_2, ..
2597 } = other
2598 {
2599 return contact_1.user.id == contact_2.user.id;
2600 }
2601 }
2602 ListEntry::ChannelEditor { depth } => {
2603 if let ListEntry::ChannelEditor { depth: other_depth } = other {
2604 return depth == other_depth;
2605 }
2606 }
2607 ListEntry::ContactPlaceholder => {
2608 if let ListEntry::ContactPlaceholder = other {
2609 return true;
2610 }
2611 }
2612 }
2613 false
2614 }
2615}
2616
2617fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<CollabPanel> {
2618 Svg::new(svg_path)
2619 .with_color(style.color)
2620 .constrained()
2621 .with_width(style.icon_width)
2622 .aligned()
2623 .constrained()
2624 .with_width(style.button_width)
2625 .with_height(style.button_width)
2626 .contained()
2627 .with_style(style.container)
2628}