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