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