1mod contact_finder;
2mod contact_notification;
3
4use client::{Contact, ContactEventKind, User, UserStore};
5use contact_notification::ContactNotification;
6use editor::{Cancel, Editor};
7use fuzzy::{match_strings, StringMatchCandidate};
8use gpui::{
9 elements::*,
10 geometry::{rect::RectF, vector::vec2f},
11 impl_actions, impl_internal_actions,
12 platform::CursorStyle,
13 AppContext, Element, ElementBox, Entity, LayoutContext, ModelHandle, MutableAppContext,
14 RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
15};
16use serde::Deserialize;
17use settings::Settings;
18use std::sync::Arc;
19use theme::IconButton;
20use workspace::{
21 menu::{Confirm, SelectNext, SelectPrev},
22 sidebar::SidebarItem,
23 AppState, JoinProject, Workspace,
24};
25
26impl_actions!(
27 contacts_panel,
28 [RequestContact, RemoveContact, RespondToContactRequest]
29);
30
31impl_internal_actions!(contacts_panel, [ToggleExpanded]);
32
33#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
34enum Section {
35 Requests,
36 Online,
37 Offline,
38}
39
40#[derive(Clone, Debug)]
41enum ContactEntry {
42 Header(Section),
43 IncomingRequest(Arc<User>),
44 OutgoingRequest(Arc<User>),
45 Contact(Arc<Contact>),
46 ContactProject(Arc<Contact>, usize),
47}
48
49#[derive(Clone)]
50struct ToggleExpanded(Section);
51
52pub struct ContactsPanel {
53 entries: Vec<ContactEntry>,
54 match_candidates: Vec<StringMatchCandidate>,
55 list_state: ListState,
56 user_store: ModelHandle<UserStore>,
57 filter_editor: ViewHandle<Editor>,
58 collapsed_sections: Vec<Section>,
59 selection: Option<usize>,
60 app_state: Arc<AppState>,
61 _maintain_contacts: Subscription,
62}
63
64#[derive(Clone, Deserialize)]
65pub struct RequestContact(pub u64);
66
67#[derive(Clone, Deserialize)]
68pub struct RemoveContact(pub u64);
69
70#[derive(Clone, Deserialize)]
71pub struct RespondToContactRequest {
72 pub user_id: u64,
73 pub accept: bool,
74}
75
76pub fn init(cx: &mut MutableAppContext) {
77 contact_finder::init(cx);
78 contact_notification::init(cx);
79 cx.add_action(ContactsPanel::request_contact);
80 cx.add_action(ContactsPanel::remove_contact);
81 cx.add_action(ContactsPanel::respond_to_contact_request);
82 cx.add_action(ContactsPanel::clear_filter);
83 cx.add_action(ContactsPanel::select_next);
84 cx.add_action(ContactsPanel::select_prev);
85 cx.add_action(ContactsPanel::confirm);
86 cx.add_action(ContactsPanel::toggle_expanded);
87}
88
89impl ContactsPanel {
90 pub fn new(
91 app_state: Arc<AppState>,
92 workspace: WeakViewHandle<Workspace>,
93 cx: &mut ViewContext<Self>,
94 ) -> Self {
95 let filter_editor = cx.add_view(|cx| {
96 let mut editor = Editor::single_line(
97 Some(|theme| theme.contacts_panel.user_query_editor.clone()),
98 cx,
99 );
100 editor.set_placeholder_text("Filter contacts", cx);
101 editor
102 });
103
104 cx.subscribe(&filter_editor, |this, _, event, cx| {
105 if let editor::Event::BufferEdited = event {
106 let query = this.filter_editor.read(cx).text(cx);
107 if !query.is_empty() {
108 this.selection.take();
109 }
110 this.update_entries(cx);
111 if !query.is_empty() {
112 this.selection = this
113 .entries
114 .iter()
115 .position(|entry| !matches!(entry, ContactEntry::Header(_)));
116 }
117 }
118 })
119 .detach();
120
121 cx.subscribe(&app_state.user_store, {
122 let user_store = app_state.user_store.downgrade();
123 move |_, _, event, cx| {
124 if let Some((workspace, user_store)) =
125 workspace.upgrade(cx).zip(user_store.upgrade(cx))
126 {
127 workspace.update(cx, |workspace, cx| match event.kind {
128 ContactEventKind::Requested | ContactEventKind::Accepted => workspace
129 .show_notification(
130 cx.add_view(|cx| {
131 ContactNotification::new(event.clone(), user_store, cx)
132 }),
133 cx,
134 ),
135 _ => {}
136 });
137 }
138 }
139 })
140 .detach();
141
142 let mut this = Self {
143 list_state: ListState::new(0, Orientation::Top, 1000., {
144 let this = cx.weak_handle();
145 let app_state = app_state.clone();
146 move |ix, cx| {
147 let this = this.upgrade(cx).unwrap();
148 let this = this.read(cx);
149 let theme = cx.global::<Settings>().theme.clone();
150 let theme = &theme.contacts_panel;
151 let current_user_id =
152 this.user_store.read(cx).current_user().map(|user| user.id);
153 let is_selected = this.selection == Some(ix);
154
155 match &this.entries[ix] {
156 ContactEntry::Header(section) => {
157 let is_collapsed = this.collapsed_sections.contains(§ion);
158 Self::render_header(*section, theme, is_selected, is_collapsed, cx)
159 }
160 ContactEntry::IncomingRequest(user) => Self::render_contact_request(
161 user.clone(),
162 this.user_store.clone(),
163 theme,
164 true,
165 is_selected,
166 cx,
167 ),
168 ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
169 user.clone(),
170 this.user_store.clone(),
171 theme,
172 false,
173 is_selected,
174 cx,
175 ),
176 ContactEntry::Contact(contact) => {
177 Self::render_contact(contact.clone(), theme, is_selected)
178 }
179 ContactEntry::ContactProject(contact, project_ix) => {
180 let is_last_project_for_contact =
181 this.entries.get(ix + 1).map_or(true, |next| {
182 if let ContactEntry::ContactProject(next_contact, _) = next {
183 next_contact.user.id != contact.user.id
184 } else {
185 true
186 }
187 });
188 Self::render_contact_project(
189 contact.clone(),
190 current_user_id,
191 *project_ix,
192 app_state.clone(),
193 theme,
194 is_last_project_for_contact,
195 is_selected,
196 cx,
197 )
198 }
199 }
200 }
201 }),
202 selection: None,
203 collapsed_sections: Default::default(),
204 entries: Default::default(),
205 match_candidates: Default::default(),
206 filter_editor,
207 _maintain_contacts: cx
208 .observe(&app_state.user_store, |this, _, cx| this.update_entries(cx)),
209 user_store: app_state.user_store.clone(),
210 app_state,
211 };
212 this.update_entries(cx);
213 this
214 }
215
216 fn render_header(
217 section: Section,
218 theme: &theme::ContactsPanel,
219 is_selected: bool,
220 is_collapsed: bool,
221 cx: &mut LayoutContext,
222 ) -> ElementBox {
223 enum Header {}
224
225 let header_style = theme.header_row.style_for(&Default::default(), is_selected);
226 let text = match section {
227 Section::Requests => "Requests",
228 Section::Online => "Online",
229 Section::Offline => "Offline",
230 };
231 let icon_size = theme.section_icon_size;
232 MouseEventHandler::new::<Header, _, _>(section as usize, cx, |_, _| {
233 Flex::row()
234 .with_child(
235 Svg::new(if is_collapsed {
236 "icons/disclosure-closed.svg"
237 } else {
238 "icons/disclosure-open.svg"
239 })
240 .with_color(header_style.text.color)
241 .constrained()
242 .with_max_width(icon_size)
243 .with_max_height(icon_size)
244 .aligned()
245 .constrained()
246 .with_width(icon_size)
247 .boxed(),
248 )
249 .with_child(
250 Label::new(text.to_string(), header_style.text.clone())
251 .aligned()
252 .left()
253 .contained()
254 .with_margin_left(theme.contact_username.container.margin.left)
255 .flex(1., true)
256 .boxed(),
257 )
258 .constrained()
259 .with_height(theme.row_height)
260 .contained()
261 .with_style(header_style.container)
262 .boxed()
263 })
264 .with_cursor_style(CursorStyle::PointingHand)
265 .on_click(move |_, cx| cx.dispatch_action(ToggleExpanded(section)))
266 .boxed()
267 }
268
269 fn render_contact(
270 contact: Arc<Contact>,
271 theme: &theme::ContactsPanel,
272 is_selected: bool,
273 ) -> ElementBox {
274 Flex::row()
275 .with_children(contact.user.avatar.clone().map(|avatar| {
276 Image::new(avatar)
277 .with_style(theme.contact_avatar)
278 .aligned()
279 .left()
280 .boxed()
281 }))
282 .with_child(
283 Label::new(
284 contact.user.github_login.clone(),
285 theme.contact_username.text.clone(),
286 )
287 .contained()
288 .with_style(theme.contact_username.container)
289 .aligned()
290 .left()
291 .flex(1., true)
292 .boxed(),
293 )
294 .constrained()
295 .with_height(theme.row_height)
296 .contained()
297 .with_style(
298 *theme
299 .contact_row
300 .style_for(&Default::default(), is_selected),
301 )
302 .boxed()
303 }
304
305 fn render_contact_project(
306 contact: Arc<Contact>,
307 current_user_id: Option<u64>,
308 project_ix: usize,
309 app_state: Arc<AppState>,
310 theme: &theme::ContactsPanel,
311 is_last_project: bool,
312 is_selected: bool,
313 cx: &mut LayoutContext,
314 ) -> ElementBox {
315 let project = &contact.projects[project_ix];
316 let project_id = project.id;
317 let is_host = Some(contact.user.id) == current_user_id;
318 let is_guest = !is_host
319 && project
320 .guests
321 .iter()
322 .any(|guest| Some(guest.id) == current_user_id);
323 let is_shared = project.is_shared;
324
325 let font_cache = cx.font_cache();
326 let host_avatar_height = theme
327 .contact_avatar
328 .width
329 .or(theme.contact_avatar.height)
330 .unwrap_or(0.);
331 let row = &theme.unshared_project_row.default;
332 let tree_branch = theme.tree_branch.clone();
333 let line_height = row.name.text.line_height(font_cache);
334 let cap_height = row.name.text.cap_height(font_cache);
335 let baseline_offset =
336 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
337
338 MouseEventHandler::new::<JoinProject, _, _>(project_id as usize, cx, |mouse_state, _| {
339 let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
340 let row = if project.is_shared {
341 &theme.shared_project_row
342 } else {
343 &theme.unshared_project_row
344 }
345 .style_for(mouse_state, is_selected);
346
347 Flex::row()
348 .with_child(
349 Canvas::new(move |bounds, _, cx| {
350 let start_x =
351 bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
352 let end_x = bounds.max_x();
353 let start_y = bounds.min_y();
354 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
355
356 cx.scene.push_quad(gpui::Quad {
357 bounds: RectF::from_points(
358 vec2f(start_x, start_y),
359 vec2f(
360 start_x + tree_branch.width,
361 if is_last_project {
362 end_y
363 } else {
364 bounds.max_y()
365 },
366 ),
367 ),
368 background: Some(tree_branch.color),
369 border: gpui::Border::default(),
370 corner_radius: 0.,
371 });
372 cx.scene.push_quad(gpui::Quad {
373 bounds: RectF::from_points(
374 vec2f(start_x, end_y),
375 vec2f(end_x, end_y + tree_branch.width),
376 ),
377 background: Some(tree_branch.color),
378 border: gpui::Border::default(),
379 corner_radius: 0.,
380 });
381 })
382 .constrained()
383 .with_width(host_avatar_height)
384 .boxed(),
385 )
386 .with_child(
387 Label::new(
388 project.worktree_root_names.join(", "),
389 row.name.text.clone(),
390 )
391 .aligned()
392 .left()
393 .contained()
394 .with_style(row.name.container)
395 .flex(1., false)
396 .boxed(),
397 )
398 .with_children(project.guests.iter().filter_map(|participant| {
399 participant.avatar.clone().map(|avatar| {
400 Image::new(avatar)
401 .with_style(row.guest_avatar)
402 .aligned()
403 .left()
404 .contained()
405 .with_margin_right(row.guest_avatar_spacing)
406 .boxed()
407 })
408 }))
409 .constrained()
410 .with_height(theme.row_height)
411 .contained()
412 .with_style(row.container)
413 .boxed()
414 })
415 .with_cursor_style(if !is_host && is_shared {
416 CursorStyle::PointingHand
417 } else {
418 CursorStyle::Arrow
419 })
420 .on_click(move |_, cx| {
421 if !is_host && !is_guest {
422 cx.dispatch_global_action(JoinProject {
423 project_id,
424 app_state: app_state.clone(),
425 });
426 }
427 })
428 .boxed()
429 }
430
431 fn render_contact_request(
432 user: Arc<User>,
433 user_store: ModelHandle<UserStore>,
434 theme: &theme::ContactsPanel,
435 is_incoming: bool,
436 is_selected: bool,
437 cx: &mut LayoutContext,
438 ) -> ElementBox {
439 enum Decline {}
440 enum Accept {}
441 enum Cancel {}
442
443 let mut row = Flex::row()
444 .with_children(user.avatar.clone().map(|avatar| {
445 Image::new(avatar)
446 .with_style(theme.contact_avatar)
447 .aligned()
448 .left()
449 .boxed()
450 }))
451 .with_child(
452 Label::new(
453 user.github_login.clone(),
454 theme.contact_username.text.clone(),
455 )
456 .contained()
457 .with_style(theme.contact_username.container)
458 .aligned()
459 .left()
460 .flex(1., true)
461 .boxed(),
462 );
463
464 let user_id = user.id;
465 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
466 let button_spacing = theme.contact_button_spacing;
467
468 if is_incoming {
469 row.add_children([
470 MouseEventHandler::new::<Decline, _, _>(user.id as usize, cx, |mouse_state, _| {
471 let button_style = if is_contact_request_pending {
472 &theme.disabled_contact_button
473 } else {
474 &theme.contact_button.style_for(mouse_state, false)
475 };
476 render_icon_button(button_style, "icons/decline.svg")
477 .aligned()
478 // .flex_float()
479 .boxed()
480 })
481 .with_cursor_style(CursorStyle::PointingHand)
482 .on_click(move |_, cx| {
483 cx.dispatch_action(RespondToContactRequest {
484 user_id,
485 accept: false,
486 })
487 })
488 // .flex_float()
489 .contained()
490 .with_margin_right(button_spacing)
491 .boxed(),
492 MouseEventHandler::new::<Accept, _, _>(user.id as usize, cx, |mouse_state, _| {
493 let button_style = if is_contact_request_pending {
494 &theme.disabled_contact_button
495 } else {
496 &theme.contact_button.style_for(mouse_state, false)
497 };
498 render_icon_button(button_style, "icons/accept.svg")
499 .aligned()
500 .flex_float()
501 .boxed()
502 })
503 .with_cursor_style(CursorStyle::PointingHand)
504 .on_click(move |_, cx| {
505 cx.dispatch_action(RespondToContactRequest {
506 user_id,
507 accept: true,
508 })
509 })
510 .boxed(),
511 ]);
512 } else {
513 row.add_child(
514 MouseEventHandler::new::<Cancel, _, _>(user.id as usize, cx, |mouse_state, _| {
515 let button_style = if is_contact_request_pending {
516 &theme.disabled_contact_button
517 } else {
518 &theme.contact_button.style_for(mouse_state, false)
519 };
520 render_icon_button(button_style, "icons/decline.svg")
521 .aligned()
522 .flex_float()
523 .boxed()
524 })
525 .with_padding(Padding::uniform(2.))
526 .with_cursor_style(CursorStyle::PointingHand)
527 .on_click(move |_, cx| cx.dispatch_action(RemoveContact(user_id)))
528 .flex_float()
529 .boxed(),
530 );
531 }
532
533 row.constrained()
534 .with_height(theme.row_height)
535 .contained()
536 .with_style(
537 *theme
538 .contact_row
539 .style_for(&Default::default(), is_selected),
540 )
541 .boxed()
542 }
543
544 fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
545 let user_store = self.user_store.read(cx);
546 let query = self.filter_editor.read(cx).text(cx);
547 let executor = cx.background().clone();
548
549 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
550 self.entries.clear();
551
552 let mut request_entries = Vec::new();
553 let incoming = user_store.incoming_contact_requests();
554 if !incoming.is_empty() {
555 self.match_candidates.clear();
556 self.match_candidates
557 .extend(
558 incoming
559 .iter()
560 .enumerate()
561 .map(|(ix, user)| StringMatchCandidate {
562 id: ix,
563 string: user.github_login.clone(),
564 char_bag: user.github_login.chars().collect(),
565 }),
566 );
567 let matches = executor.block(match_strings(
568 &self.match_candidates,
569 &query,
570 true,
571 usize::MAX,
572 &Default::default(),
573 executor.clone(),
574 ));
575 request_entries.extend(
576 matches
577 .iter()
578 .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
579 );
580 }
581
582 let outgoing = user_store.outgoing_contact_requests();
583 if !outgoing.is_empty() {
584 self.match_candidates.clear();
585 self.match_candidates
586 .extend(
587 outgoing
588 .iter()
589 .enumerate()
590 .map(|(ix, user)| StringMatchCandidate {
591 id: ix,
592 string: user.github_login.clone(),
593 char_bag: user.github_login.chars().collect(),
594 }),
595 );
596 let matches = executor.block(match_strings(
597 &self.match_candidates,
598 &query,
599 true,
600 usize::MAX,
601 &Default::default(),
602 executor.clone(),
603 ));
604 request_entries.extend(
605 matches
606 .iter()
607 .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
608 );
609 }
610
611 if !request_entries.is_empty() {
612 self.entries.push(ContactEntry::Header(Section::Requests));
613 if !self.collapsed_sections.contains(&Section::Requests) {
614 self.entries.append(&mut request_entries);
615 }
616 }
617
618 let contacts = user_store.contacts();
619 if !contacts.is_empty() {
620 self.match_candidates.clear();
621 self.match_candidates
622 .extend(
623 contacts
624 .iter()
625 .enumerate()
626 .map(|(ix, contact)| StringMatchCandidate {
627 id: ix,
628 string: contact.user.github_login.clone(),
629 char_bag: contact.user.github_login.chars().collect(),
630 }),
631 );
632 let matches = executor.block(match_strings(
633 &self.match_candidates,
634 &query,
635 true,
636 usize::MAX,
637 &Default::default(),
638 executor.clone(),
639 ));
640
641 let (online_contacts, offline_contacts) = matches
642 .iter()
643 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
644
645 for (matches, section) in [
646 (online_contacts, Section::Online),
647 (offline_contacts, Section::Offline),
648 ] {
649 if !matches.is_empty() {
650 self.entries.push(ContactEntry::Header(section));
651 if !self.collapsed_sections.contains(§ion) {
652 for mat in matches {
653 let contact = &contacts[mat.candidate_id];
654 self.entries.push(ContactEntry::Contact(contact.clone()));
655 self.entries
656 .extend(contact.projects.iter().enumerate().filter_map(
657 |(ix, project)| {
658 if project.worktree_root_names.is_empty() {
659 None
660 } else {
661 Some(ContactEntry::ContactProject(contact.clone(), ix))
662 }
663 },
664 ));
665 }
666 }
667 }
668 }
669 }
670
671 if let Some(prev_selected_entry) = prev_selected_entry {
672 self.selection.take();
673 for (ix, entry) in self.entries.iter().enumerate() {
674 if *entry == prev_selected_entry {
675 self.selection = Some(ix);
676 break;
677 }
678 }
679 }
680
681 self.list_state.reset(self.entries.len());
682 cx.notify();
683 }
684
685 fn request_contact(&mut self, request: &RequestContact, cx: &mut ViewContext<Self>) {
686 self.user_store
687 .update(cx, |store, cx| store.request_contact(request.0, cx))
688 .detach();
689 }
690
691 fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
692 self.user_store
693 .update(cx, |store, cx| store.remove_contact(request.0, cx))
694 .detach();
695 }
696
697 fn respond_to_contact_request(
698 &mut self,
699 action: &RespondToContactRequest,
700 cx: &mut ViewContext<Self>,
701 ) {
702 self.user_store
703 .update(cx, |store, cx| {
704 store.respond_to_contact_request(action.user_id, action.accept, cx)
705 })
706 .detach();
707 }
708
709 fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
710 self.filter_editor
711 .update(cx, |editor, cx| editor.set_text("", cx));
712 }
713
714 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
715 if let Some(ix) = self.selection {
716 if self.entries.len() > ix + 1 {
717 self.selection = Some(ix + 1);
718 }
719 } else if !self.entries.is_empty() {
720 self.selection = Some(0);
721 }
722 cx.notify();
723 self.list_state.reset(self.entries.len());
724 }
725
726 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
727 if let Some(ix) = self.selection {
728 if ix > 0 {
729 self.selection = Some(ix - 1);
730 } else {
731 self.selection = None;
732 }
733 }
734 cx.notify();
735 self.list_state.reset(self.entries.len());
736 }
737
738 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
739 if let Some(selection) = self.selection {
740 if let Some(entry) = self.entries.get(selection) {
741 match entry {
742 ContactEntry::Header(section) => {
743 let section = *section;
744 self.toggle_expanded(&ToggleExpanded(section), cx);
745 }
746 ContactEntry::ContactProject(contact, project_ix) => {
747 cx.dispatch_global_action(JoinProject {
748 project_id: contact.projects[*project_ix].id,
749 app_state: self.app_state.clone(),
750 })
751 }
752 _ => {}
753 }
754 }
755 }
756 }
757
758 fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
759 let section = action.0;
760 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
761 self.collapsed_sections.remove(ix);
762 } else {
763 self.collapsed_sections.push(section);
764 }
765 self.update_entries(cx);
766 }
767}
768
769impl SidebarItem for ContactsPanel {
770 fn should_show_badge(&self, cx: &AppContext) -> bool {
771 !self
772 .user_store
773 .read(cx)
774 .incoming_contact_requests()
775 .is_empty()
776 }
777
778 fn contains_focused_view(&self, cx: &AppContext) -> bool {
779 self.filter_editor.is_focused(cx)
780 }
781}
782
783fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
784 Svg::new(svg_path)
785 .with_color(style.color)
786 .constrained()
787 .with_width(style.icon_width)
788 .aligned()
789 .contained()
790 .with_style(style.container)
791 .constrained()
792 .with_width(style.button_width)
793 .with_height(style.button_width)
794}
795
796pub enum Event {}
797
798impl Entity for ContactsPanel {
799 type Event = Event;
800}
801
802impl View for ContactsPanel {
803 fn ui_name() -> &'static str {
804 "ContactsPanel"
805 }
806
807 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
808 enum AddContact {}
809
810 let theme = cx.global::<Settings>().theme.clone();
811 let theme = &theme.contacts_panel;
812 Container::new(
813 Flex::column()
814 .with_child(
815 Flex::row()
816 .with_child(
817 ChildView::new(self.filter_editor.clone())
818 .contained()
819 .with_style(theme.user_query_editor.container)
820 .flex(1., true)
821 .boxed(),
822 )
823 .with_child(
824 MouseEventHandler::new::<AddContact, _, _>(0, cx, |_, _| {
825 Svg::new("icons/add-contact.svg")
826 .with_color(theme.add_contact_button.color)
827 .constrained()
828 .with_height(12.)
829 .contained()
830 .with_style(theme.add_contact_button.container)
831 .aligned()
832 .boxed()
833 })
834 .with_cursor_style(CursorStyle::PointingHand)
835 .on_click(|_, cx| cx.dispatch_action(contact_finder::Toggle))
836 .boxed(),
837 )
838 .constrained()
839 .with_height(theme.user_query_editor_height)
840 .boxed(),
841 )
842 .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
843 .boxed(),
844 )
845 .with_style(theme.container)
846 .boxed()
847 }
848
849 fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
850 cx.focus(&self.filter_editor);
851 }
852
853 fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
854 let mut cx = Self::default_keymap_context();
855 cx.set.insert("menu".into());
856 cx
857 }
858}
859
860impl PartialEq for ContactEntry {
861 fn eq(&self, other: &Self) -> bool {
862 match self {
863 ContactEntry::Header(section_1) => {
864 if let ContactEntry::Header(section_2) = other {
865 return section_1 == section_2;
866 }
867 }
868 ContactEntry::IncomingRequest(user_1) => {
869 if let ContactEntry::IncomingRequest(user_2) = other {
870 return user_1.id == user_2.id;
871 }
872 }
873 ContactEntry::OutgoingRequest(user_1) => {
874 if let ContactEntry::OutgoingRequest(user_2) = other {
875 return user_1.id == user_2.id;
876 }
877 }
878 ContactEntry::Contact(contact_1) => {
879 if let ContactEntry::Contact(contact_2) = other {
880 return contact_1.user.id == contact_2.user.id;
881 }
882 }
883 ContactEntry::ContactProject(contact_1, ix_1) => {
884 if let ContactEntry::ContactProject(contact_2, ix_2) = other {
885 return contact_1.user.id == contact_2.user.id && ix_1 == ix_2;
886 }
887 }
888 }
889 false
890 }
891}
892
893#[cfg(test)]
894mod tests {
895 use super::*;
896 use client::{proto, test::FakeServer, ChannelList, Client};
897 use gpui::TestAppContext;
898 use language::LanguageRegistry;
899 use theme::ThemeRegistry;
900 use workspace::WorkspaceParams;
901
902 #[gpui::test]
903 async fn test_contact_panel(cx: &mut TestAppContext) {
904 let (app_state, server) = init(cx).await;
905 let workspace_params = cx.update(WorkspaceParams::test);
906 let workspace = cx.add_view(0, |cx| Workspace::new(&workspace_params, cx));
907 let panel = cx.add_view(0, |cx| {
908 ContactsPanel::new(app_state.clone(), workspace.downgrade(), cx)
909 });
910
911 let get_users_request = server.receive::<proto::GetUsers>().await.unwrap();
912 server
913 .respond(
914 get_users_request.receipt(),
915 proto::UsersResponse {
916 users: [
917 "user_zero",
918 "user_one",
919 "user_two",
920 "user_three",
921 "user_four",
922 "user_five",
923 ]
924 .into_iter()
925 .enumerate()
926 .map(|(id, name)| proto::User {
927 id: id as u64,
928 github_login: name.to_string(),
929 ..Default::default()
930 })
931 .collect(),
932 },
933 )
934 .await;
935
936 server.send(proto::UpdateContacts {
937 incoming_requests: vec![proto::IncomingContactRequest {
938 requester_id: 1,
939 should_notify: false,
940 }],
941 outgoing_requests: vec![2],
942 contacts: vec![
943 proto::Contact {
944 user_id: 3,
945 online: true,
946 should_notify: false,
947 projects: vec![proto::ProjectMetadata {
948 id: 101,
949 worktree_root_names: vec!["dir1".to_string()],
950 is_shared: true,
951 guests: vec![2],
952 }],
953 },
954 proto::Contact {
955 user_id: 4,
956 online: true,
957 should_notify: false,
958 projects: vec![proto::ProjectMetadata {
959 id: 102,
960 worktree_root_names: vec!["dir2".to_string()],
961 is_shared: true,
962 guests: vec![2],
963 }],
964 },
965 proto::Contact {
966 user_id: 5,
967 online: false,
968 should_notify: false,
969 projects: vec![],
970 },
971 ],
972 ..Default::default()
973 });
974
975 cx.foreground().run_until_parked();
976 assert_eq!(
977 render_to_strings(&panel, cx),
978 &[
979 "+",
980 "v Requests",
981 " incoming user_one",
982 " outgoing user_two",
983 "v Online",
984 " user_four",
985 " dir2",
986 " user_three",
987 " dir1",
988 "v Offline",
989 " user_five",
990 ]
991 );
992
993 panel.update(cx, |panel, cx| {
994 panel
995 .filter_editor
996 .update(cx, |editor, cx| editor.set_text("f", cx))
997 });
998 cx.foreground().run_until_parked();
999 assert_eq!(
1000 render_to_strings(&panel, cx),
1001 &[
1002 "+",
1003 "v Online",
1004 " user_four <=== selected",
1005 " dir2",
1006 "v Offline",
1007 " user_five",
1008 ]
1009 );
1010
1011 panel.update(cx, |panel, cx| {
1012 panel.select_next(&Default::default(), cx);
1013 });
1014 assert_eq!(
1015 render_to_strings(&panel, cx),
1016 &[
1017 "+",
1018 "v Online",
1019 " user_four",
1020 " dir2 <=== selected",
1021 "v Offline",
1022 " user_five",
1023 ]
1024 );
1025
1026 panel.update(cx, |panel, cx| {
1027 panel.select_next(&Default::default(), cx);
1028 });
1029 assert_eq!(
1030 render_to_strings(&panel, cx),
1031 &[
1032 "+",
1033 "v Online",
1034 " user_four",
1035 " dir2",
1036 "v Offline <=== selected",
1037 " user_five",
1038 ]
1039 );
1040 }
1041
1042 fn render_to_strings(panel: &ViewHandle<ContactsPanel>, cx: &TestAppContext) -> Vec<String> {
1043 panel.read_with(cx, |panel, _| {
1044 let mut entries = Vec::new();
1045 entries.push("+".to_string());
1046 entries.extend(panel.entries.iter().enumerate().map(|(ix, entry)| {
1047 let mut string = match entry {
1048 ContactEntry::Header(name) => {
1049 let icon = if panel.collapsed_sections.contains(name) {
1050 ">"
1051 } else {
1052 "v"
1053 };
1054 format!("{} {:?}", icon, name)
1055 }
1056 ContactEntry::IncomingRequest(user) => {
1057 format!(" incoming {}", user.github_login)
1058 }
1059 ContactEntry::OutgoingRequest(user) => {
1060 format!(" outgoing {}", user.github_login)
1061 }
1062 ContactEntry::Contact(contact) => {
1063 format!(" {}", contact.user.github_login)
1064 }
1065 ContactEntry::ContactProject(contact, project_ix) => {
1066 format!(
1067 " {}",
1068 contact.projects[*project_ix].worktree_root_names.join(", ")
1069 )
1070 }
1071 };
1072
1073 if panel.selection == Some(ix) {
1074 string.push_str(" <=== selected");
1075 }
1076
1077 string
1078 }));
1079 entries
1080 })
1081 }
1082
1083 async fn init(cx: &mut TestAppContext) -> (Arc<AppState>, FakeServer) {
1084 cx.update(|cx| cx.set_global(Settings::test(cx)));
1085 let themes = ThemeRegistry::new((), cx.font_cache());
1086 let fs = project::FakeFs::new(cx.background().clone());
1087 let languages = Arc::new(LanguageRegistry::test());
1088 let http_client = client::test::FakeHttpClient::with_404_response();
1089 let mut client = Client::new(http_client.clone());
1090 let server = FakeServer::for_client(100, &mut client, &cx).await;
1091 let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
1092 let channel_list =
1093 cx.add_model(|cx| ChannelList::new(user_store.clone(), client.clone(), cx));
1094
1095 let get_channels = server.receive::<proto::GetChannels>().await.unwrap();
1096 server
1097 .respond(get_channels.receipt(), Default::default())
1098 .await;
1099
1100 (
1101 Arc::new(AppState {
1102 languages,
1103 themes,
1104 client,
1105 user_store: user_store.clone(),
1106 fs,
1107 channel_list,
1108 build_window_options: || unimplemented!(),
1109 build_workspace: |_, _, _| unimplemented!(),
1110 }),
1111 server,
1112 )
1113 }
1114}