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 actions,
12 elements::*,
13 geometry::{rect::RectF, vector::vec2f},
14 impl_actions, impl_internal_actions,
15 platform::CursorStyle,
16 AppContext, ClipboardItem, Element, ElementBox, Entity, ModelHandle, MutableAppContext,
17 RenderContext, Subscription, View, ViewContext, ViewHandle, WeakModelHandle, WeakViewHandle,
18};
19use join_project_notification::JoinProjectNotification;
20use menu::{Confirm, SelectNext, SelectPrev};
21use project::{Project, ProjectStore};
22use serde::Deserialize;
23use settings::Settings;
24use std::{ops::DerefMut, sync::Arc};
25use theme::IconButton;
26use workspace::{sidebar::SidebarItem, JoinProject, ToggleProjectOnline, Workspace};
27
28actions!(contacts_panel, [Toggle]);
29
30impl_actions!(
31 contacts_panel,
32 [RequestContact, RemoveContact, RespondToContactRequest]
33);
34
35impl_internal_actions!(contacts_panel, [ToggleExpanded]);
36
37#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
38enum Section {
39 Requests,
40 Online,
41 Offline,
42}
43
44#[derive(Clone)]
45enum ContactEntry {
46 Header(Section),
47 IncomingRequest(Arc<User>),
48 OutgoingRequest(Arc<User>),
49 Contact(Arc<Contact>),
50 ContactProject(Arc<Contact>, usize, Option<WeakModelHandle<Project>>),
51 OfflineProject(WeakModelHandle<Project>),
52}
53
54#[derive(Clone, PartialEq)]
55struct ToggleExpanded(Section);
56
57pub struct ContactsPanel {
58 entries: Vec<ContactEntry>,
59 match_candidates: Vec<StringMatchCandidate>,
60 list_state: ListState,
61 user_store: ModelHandle<UserStore>,
62 project_store: ModelHandle<ProjectStore>,
63 filter_editor: ViewHandle<Editor>,
64 collapsed_sections: Vec<Section>,
65 selection: Option<usize>,
66 _maintain_contacts: Subscription,
67}
68
69#[derive(Clone, Deserialize, PartialEq)]
70pub struct RequestContact(pub u64);
71
72#[derive(Clone, Deserialize, PartialEq)]
73pub struct RemoveContact(pub u64);
74
75#[derive(Clone, Deserialize, PartialEq)]
76pub struct RespondToContactRequest {
77 pub user_id: u64,
78 pub accept: bool,
79}
80
81pub fn init(cx: &mut MutableAppContext) {
82 contact_finder::init(cx);
83 contact_notification::init(cx);
84 join_project_notification::init(cx);
85 cx.add_action(ContactsPanel::request_contact);
86 cx.add_action(ContactsPanel::remove_contact);
87 cx.add_action(ContactsPanel::respond_to_contact_request);
88 cx.add_action(ContactsPanel::clear_filter);
89 cx.add_action(ContactsPanel::select_next);
90 cx.add_action(ContactsPanel::select_prev);
91 cx.add_action(ContactsPanel::confirm);
92 cx.add_action(ContactsPanel::toggle_expanded);
93}
94
95impl ContactsPanel {
96 pub fn new(
97 user_store: ModelHandle<UserStore>,
98 project_store: ModelHandle<ProjectStore>,
99 workspace: WeakViewHandle<Workspace>,
100 cx: &mut ViewContext<Self>,
101 ) -> Self {
102 let filter_editor = cx.add_view(|cx| {
103 let mut editor = Editor::single_line(
104 Some(|theme| theme.contacts_panel.user_query_editor.clone()),
105 cx,
106 );
107 editor.set_placeholder_text("Filter contacts", cx);
108 editor
109 });
110
111 cx.subscribe(&filter_editor, |this, _, event, cx| {
112 if let editor::Event::BufferEdited = event {
113 let query = this.filter_editor.read(cx).text(cx);
114 if !query.is_empty() {
115 this.selection.take();
116 }
117 this.update_entries(cx);
118 if !query.is_empty() {
119 this.selection = this
120 .entries
121 .iter()
122 .position(|entry| !matches!(entry, ContactEntry::Header(_)));
123 }
124 }
125 })
126 .detach();
127
128 cx.defer({
129 let workspace = workspace.clone();
130 move |_, cx| {
131 if let Some(workspace_handle) = workspace.upgrade(cx) {
132 cx.subscribe(&workspace_handle.read(cx).project().clone(), {
133 let workspace = workspace.clone();
134 move |_, project, event, cx| match event {
135 project::Event::ContactRequestedJoin(user) => {
136 if let Some(workspace) = workspace.upgrade(cx) {
137 workspace.update(cx, |workspace, cx| {
138 workspace.show_notification(user.id as usize, cx, |cx| {
139 cx.add_view(|cx| {
140 JoinProjectNotification::new(
141 project,
142 user.clone(),
143 cx,
144 )
145 })
146 })
147 });
148 }
149 }
150 _ => {}
151 }
152 })
153 .detach();
154 }
155 }
156 });
157
158 cx.observe(&project_store, |this, _, cx| this.update_entries(cx))
159 .detach();
160
161 cx.subscribe(&user_store, move |_, user_store, event, cx| {
162 if let Some(workspace) = workspace.upgrade(cx) {
163 workspace.update(cx, |workspace, cx| match event {
164 client::Event::Contact { user, kind } => match kind {
165 ContactEventKind::Requested | ContactEventKind::Accepted => workspace
166 .show_notification(user.id as usize, cx, |cx| {
167 cx.add_view(|cx| {
168 ContactNotification::new(user.clone(), *kind, user_store, cx)
169 })
170 }),
171 _ => {}
172 },
173 _ => {}
174 });
175 }
176
177 if let client::Event::ShowContacts = event {
178 cx.emit(Event::Activate);
179 }
180 })
181 .detach();
182
183 let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
184 let theme = cx.global::<Settings>().theme.clone();
185 let current_user_id = this.user_store.read(cx).current_user().map(|user| user.id);
186 let is_selected = this.selection == Some(ix);
187
188 match &this.entries[ix] {
189 ContactEntry::Header(section) => {
190 let is_collapsed = this.collapsed_sections.contains(§ion);
191 Self::render_header(
192 *section,
193 &theme.contacts_panel,
194 is_selected,
195 is_collapsed,
196 cx,
197 )
198 }
199 ContactEntry::IncomingRequest(user) => Self::render_contact_request(
200 user.clone(),
201 this.user_store.clone(),
202 &theme.contacts_panel,
203 true,
204 is_selected,
205 cx,
206 ),
207 ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
208 user.clone(),
209 this.user_store.clone(),
210 &theme.contacts_panel,
211 false,
212 is_selected,
213 cx,
214 ),
215 ContactEntry::Contact(contact) => {
216 Self::render_contact(&contact.user, &theme.contacts_panel, is_selected)
217 }
218 ContactEntry::ContactProject(contact, project_ix, open_project) => {
219 let is_last_project_for_contact =
220 this.entries.get(ix + 1).map_or(true, |next| {
221 if let ContactEntry::ContactProject(next_contact, _, _) = next {
222 next_contact.user.id != contact.user.id
223 } else {
224 true
225 }
226 });
227 Self::render_project(
228 contact.clone(),
229 current_user_id,
230 *project_ix,
231 open_project.clone(),
232 &theme.contacts_panel,
233 &theme.tooltip,
234 is_last_project_for_contact,
235 is_selected,
236 cx,
237 )
238 }
239 ContactEntry::OfflineProject(project) => Self::render_offline_project(
240 project.clone(),
241 &theme.contacts_panel,
242 &theme.tooltip,
243 is_selected,
244 cx,
245 ),
246 }
247 });
248
249 let mut this = Self {
250 list_state,
251 selection: None,
252 collapsed_sections: Default::default(),
253 entries: Default::default(),
254 match_candidates: Default::default(),
255 filter_editor,
256 _maintain_contacts: cx.observe(&user_store, |this, _, cx| this.update_entries(cx)),
257 user_store,
258 project_store,
259 };
260 this.update_entries(cx);
261 this
262 }
263
264 fn render_header(
265 section: Section,
266 theme: &theme::ContactsPanel,
267 is_selected: bool,
268 is_collapsed: bool,
269 cx: &mut RenderContext<Self>,
270 ) -> ElementBox {
271 enum Header {}
272
273 let header_style = theme.header_row.style_for(Default::default(), is_selected);
274 let text = match section {
275 Section::Requests => "Requests",
276 Section::Online => "Online",
277 Section::Offline => "Offline",
278 };
279 let icon_size = theme.section_icon_size;
280 MouseEventHandler::new::<Header, _, _>(section as usize, cx, |_, _| {
281 Flex::row()
282 .with_child(
283 Svg::new(if is_collapsed {
284 "icons/disclosure-closed.svg"
285 } else {
286 "icons/disclosure-open.svg"
287 })
288 .with_color(header_style.text.color)
289 .constrained()
290 .with_max_width(icon_size)
291 .with_max_height(icon_size)
292 .aligned()
293 .constrained()
294 .with_width(icon_size)
295 .boxed(),
296 )
297 .with_child(
298 Label::new(text.to_string(), header_style.text.clone())
299 .aligned()
300 .left()
301 .contained()
302 .with_margin_left(theme.contact_username.container.margin.left)
303 .flex(1., true)
304 .boxed(),
305 )
306 .constrained()
307 .with_height(theme.row_height)
308 .contained()
309 .with_style(header_style.container)
310 .boxed()
311 })
312 .with_cursor_style(CursorStyle::PointingHand)
313 .on_click(move |_, _, cx| cx.dispatch_action(ToggleExpanded(section)))
314 .boxed()
315 }
316
317 fn render_contact(user: &User, theme: &theme::ContactsPanel, is_selected: bool) -> ElementBox {
318 Flex::row()
319 .with_children(user.avatar.clone().map(|avatar| {
320 Image::new(avatar)
321 .with_style(theme.contact_avatar)
322 .aligned()
323 .left()
324 .boxed()
325 }))
326 .with_child(
327 Label::new(
328 user.github_login.clone(),
329 theme.contact_username.text.clone(),
330 )
331 .contained()
332 .with_style(theme.contact_username.container)
333 .aligned()
334 .left()
335 .flex(1., true)
336 .boxed(),
337 )
338 .constrained()
339 .with_height(theme.row_height)
340 .contained()
341 .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
342 .boxed()
343 }
344
345 fn render_project(
346 contact: Arc<Contact>,
347 current_user_id: Option<u64>,
348 project_index: usize,
349 open_project: Option<WeakModelHandle<Project>>,
350 theme: &theme::ContactsPanel,
351 tooltip_style: &TooltipStyle,
352 is_last_project: bool,
353 is_selected: bool,
354 cx: &mut RenderContext<Self>,
355 ) -> ElementBox {
356 enum ToggleOnline {}
357
358 let project = &contact.projects[project_index];
359 let project_id = project.id;
360 let is_host = Some(contact.user.id) == current_user_id;
361 let open_project = open_project.and_then(|p| p.upgrade(cx.deref_mut()));
362
363 let font_cache = cx.font_cache();
364 let host_avatar_height = theme
365 .contact_avatar
366 .width
367 .or(theme.contact_avatar.height)
368 .unwrap_or(0.);
369 let row = &theme.project_row.default;
370 let tree_branch = theme.tree_branch.clone();
371 let line_height = row.name.text.line_height(font_cache);
372 let cap_height = row.name.text.cap_height(font_cache);
373 let baseline_offset =
374 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
375
376 MouseEventHandler::new::<JoinProject, _, _>(project_id as usize, cx, |mouse_state, cx| {
377 let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
378 let row = theme.project_row.style_for(mouse_state, is_selected);
379
380 Flex::row()
381 .with_child(
382 Stack::new()
383 .with_child(
384 Canvas::new(move |bounds, _, cx| {
385 let start_x = bounds.min_x() + (bounds.width() / 2.)
386 - (tree_branch.width / 2.);
387 let end_x = bounds.max_x();
388 let start_y = bounds.min_y();
389 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
390
391 cx.scene.push_quad(gpui::Quad {
392 bounds: RectF::from_points(
393 vec2f(start_x, start_y),
394 vec2f(
395 start_x + tree_branch.width,
396 if is_last_project {
397 end_y
398 } else {
399 bounds.max_y()
400 },
401 ),
402 ),
403 background: Some(tree_branch.color),
404 border: gpui::Border::default(),
405 corner_radius: 0.,
406 });
407 cx.scene.push_quad(gpui::Quad {
408 bounds: RectF::from_points(
409 vec2f(start_x, end_y),
410 vec2f(end_x, end_y + tree_branch.width),
411 ),
412 background: Some(tree_branch.color),
413 border: gpui::Border::default(),
414 corner_radius: 0.,
415 });
416 })
417 .boxed(),
418 )
419 .with_children(open_project.and_then(|open_project| {
420 let is_going_offline = !open_project.read(cx).is_online();
421 if !mouse_state.hovered && !is_going_offline {
422 return None;
423 }
424
425 let button = MouseEventHandler::new::<ToggleProjectOnline, _, _>(
426 project_id as usize,
427 cx,
428 |state, _| {
429 let mut icon_style =
430 *theme.private_button.style_for(state, false);
431 icon_style.container.background_color =
432 row.container.background_color;
433 if is_going_offline {
434 icon_style.color = theme.disabled_button.color;
435 }
436 render_icon_button(&icon_style, "icons/lock-8.svg")
437 .aligned()
438 .boxed()
439 },
440 );
441
442 if is_going_offline {
443 Some(button.boxed())
444 } else {
445 Some(
446 button
447 .with_cursor_style(CursorStyle::PointingHand)
448 .on_click(move |_, _, cx| {
449 cx.dispatch_action(ToggleProjectOnline {
450 project: Some(open_project.clone()),
451 })
452 })
453 .with_tooltip::<ToggleOnline, _>(
454 project_id as usize,
455 "Take project offline".to_string(),
456 None,
457 tooltip_style.clone(),
458 cx,
459 )
460 .boxed(),
461 )
462 }
463 }))
464 .constrained()
465 .with_width(host_avatar_height)
466 .boxed(),
467 )
468 .with_child(
469 Label::new(
470 project.visible_worktree_root_names.join(", "),
471 row.name.text.clone(),
472 )
473 .aligned()
474 .left()
475 .contained()
476 .with_style(row.name.container)
477 .flex(1., false)
478 .boxed(),
479 )
480 .with_children(project.guests.iter().filter_map(|participant| {
481 participant.avatar.clone().map(|avatar| {
482 Image::new(avatar)
483 .with_style(row.guest_avatar)
484 .aligned()
485 .left()
486 .contained()
487 .with_margin_right(row.guest_avatar_spacing)
488 .boxed()
489 })
490 }))
491 .constrained()
492 .with_height(theme.row_height)
493 .contained()
494 .with_style(row.container)
495 .boxed()
496 })
497 .with_cursor_style(if !is_host {
498 CursorStyle::PointingHand
499 } else {
500 CursorStyle::Arrow
501 })
502 .on_click(move |_, _, cx| {
503 if !is_host {
504 cx.dispatch_global_action(JoinProject {
505 contact: contact.clone(),
506 project_index,
507 });
508 }
509 })
510 .boxed()
511 }
512
513 fn render_offline_project(
514 project_handle: WeakModelHandle<Project>,
515 theme: &theme::ContactsPanel,
516 tooltip_style: &TooltipStyle,
517 is_selected: bool,
518 cx: &mut RenderContext<Self>,
519 ) -> ElementBox {
520 let host_avatar_height = theme
521 .contact_avatar
522 .width
523 .or(theme.contact_avatar.height)
524 .unwrap_or(0.);
525
526 enum LocalProject {}
527 enum ToggleOnline {}
528
529 let project_id = project_handle.id();
530 MouseEventHandler::new::<LocalProject, _, _>(project_id, cx, |state, cx| {
531 let row = theme.project_row.style_for(state, is_selected);
532 let mut worktree_root_names = String::new();
533 let project = if let Some(project) = project_handle.upgrade(cx.deref_mut()) {
534 project.read(cx)
535 } else {
536 return Empty::new().boxed();
537 };
538 let is_going_online = project.is_online();
539 for tree in project.visible_worktrees(cx) {
540 if !worktree_root_names.is_empty() {
541 worktree_root_names.push_str(", ");
542 }
543 worktree_root_names.push_str(tree.read(cx).root_name());
544 }
545
546 Flex::row()
547 .with_child({
548 let button =
549 MouseEventHandler::new::<ToggleOnline, _, _>(project_id, cx, |state, _| {
550 let mut style = *theme.private_button.style_for(state, false);
551 if is_going_online {
552 style.color = theme.disabled_button.color;
553 }
554 render_icon_button(&style, "icons/lock-8.svg")
555 .aligned()
556 .constrained()
557 .with_width(host_avatar_height)
558 .boxed()
559 });
560
561 if is_going_online {
562 button.boxed()
563 } else {
564 button
565 .with_cursor_style(CursorStyle::PointingHand)
566 .on_click(move |_, _, cx| {
567 let project = project_handle.upgrade(cx.deref_mut());
568 cx.dispatch_action(ToggleProjectOnline { project })
569 })
570 .with_tooltip::<ToggleOnline, _>(
571 project_id,
572 "Take project online".to_string(),
573 None,
574 tooltip_style.clone(),
575 cx,
576 )
577 .boxed()
578 }
579 })
580 .with_child(
581 Label::new(worktree_root_names, row.name.text.clone())
582 .aligned()
583 .left()
584 .contained()
585 .with_style(row.name.container)
586 .flex(1., false)
587 .boxed(),
588 )
589 .constrained()
590 .with_height(theme.row_height)
591 .contained()
592 .with_style(row.container)
593 .boxed()
594 })
595 .boxed()
596 }
597
598 fn render_contact_request(
599 user: Arc<User>,
600 user_store: ModelHandle<UserStore>,
601 theme: &theme::ContactsPanel,
602 is_incoming: bool,
603 is_selected: bool,
604 cx: &mut RenderContext<ContactsPanel>,
605 ) -> ElementBox {
606 enum Decline {}
607 enum Accept {}
608 enum Cancel {}
609
610 let mut row = Flex::row()
611 .with_children(user.avatar.clone().map(|avatar| {
612 Image::new(avatar)
613 .with_style(theme.contact_avatar)
614 .aligned()
615 .left()
616 .boxed()
617 }))
618 .with_child(
619 Label::new(
620 user.github_login.clone(),
621 theme.contact_username.text.clone(),
622 )
623 .contained()
624 .with_style(theme.contact_username.container)
625 .aligned()
626 .left()
627 .flex(1., true)
628 .boxed(),
629 );
630
631 let user_id = user.id;
632 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
633 let button_spacing = theme.contact_button_spacing;
634
635 if is_incoming {
636 row.add_children([
637 MouseEventHandler::new::<Decline, _, _>(user.id as usize, cx, |mouse_state, _| {
638 let button_style = if is_contact_request_pending {
639 &theme.disabled_button
640 } else {
641 &theme.contact_button.style_for(mouse_state, false)
642 };
643 render_icon_button(button_style, "icons/decline.svg")
644 .aligned()
645 // .flex_float()
646 .boxed()
647 })
648 .with_cursor_style(CursorStyle::PointingHand)
649 .on_click(move |_, _, cx| {
650 cx.dispatch_action(RespondToContactRequest {
651 user_id,
652 accept: false,
653 })
654 })
655 // .flex_float()
656 .contained()
657 .with_margin_right(button_spacing)
658 .boxed(),
659 MouseEventHandler::new::<Accept, _, _>(user.id as usize, cx, |mouse_state, _| {
660 let button_style = if is_contact_request_pending {
661 &theme.disabled_button
662 } else {
663 &theme.contact_button.style_for(mouse_state, false)
664 };
665 render_icon_button(button_style, "icons/accept.svg")
666 .aligned()
667 .flex_float()
668 .boxed()
669 })
670 .with_cursor_style(CursorStyle::PointingHand)
671 .on_click(move |_, _, cx| {
672 cx.dispatch_action(RespondToContactRequest {
673 user_id,
674 accept: true,
675 })
676 })
677 .boxed(),
678 ]);
679 } else {
680 row.add_child(
681 MouseEventHandler::new::<Cancel, _, _>(user.id as usize, cx, |mouse_state, _| {
682 let button_style = if is_contact_request_pending {
683 &theme.disabled_button
684 } else {
685 &theme.contact_button.style_for(mouse_state, false)
686 };
687 render_icon_button(button_style, "icons/decline.svg")
688 .aligned()
689 .flex_float()
690 .boxed()
691 })
692 .with_padding(Padding::uniform(2.))
693 .with_cursor_style(CursorStyle::PointingHand)
694 .on_click(move |_, _, cx| cx.dispatch_action(RemoveContact(user_id)))
695 .flex_float()
696 .boxed(),
697 );
698 }
699
700 row.constrained()
701 .with_height(theme.row_height)
702 .contained()
703 .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
704 .boxed()
705 }
706
707 fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
708 let user_store = self.user_store.read(cx);
709 let project_store = self.project_store.read(cx);
710 let query = self.filter_editor.read(cx).text(cx);
711 let executor = cx.background().clone();
712
713 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
714 self.entries.clear();
715
716 let mut request_entries = Vec::new();
717 let incoming = user_store.incoming_contact_requests();
718 if !incoming.is_empty() {
719 self.match_candidates.clear();
720 self.match_candidates
721 .extend(
722 incoming
723 .iter()
724 .enumerate()
725 .map(|(ix, user)| StringMatchCandidate {
726 id: ix,
727 string: user.github_login.clone(),
728 char_bag: user.github_login.chars().collect(),
729 }),
730 );
731 let matches = executor.block(match_strings(
732 &self.match_candidates,
733 &query,
734 true,
735 usize::MAX,
736 &Default::default(),
737 executor.clone(),
738 ));
739 request_entries.extend(
740 matches
741 .iter()
742 .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
743 );
744 }
745
746 let outgoing = user_store.outgoing_contact_requests();
747 if !outgoing.is_empty() {
748 self.match_candidates.clear();
749 self.match_candidates
750 .extend(
751 outgoing
752 .iter()
753 .enumerate()
754 .map(|(ix, user)| StringMatchCandidate {
755 id: ix,
756 string: user.github_login.clone(),
757 char_bag: user.github_login.chars().collect(),
758 }),
759 );
760 let matches = executor.block(match_strings(
761 &self.match_candidates,
762 &query,
763 true,
764 usize::MAX,
765 &Default::default(),
766 executor.clone(),
767 ));
768 request_entries.extend(
769 matches
770 .iter()
771 .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
772 );
773 }
774
775 if !request_entries.is_empty() {
776 self.entries.push(ContactEntry::Header(Section::Requests));
777 if !self.collapsed_sections.contains(&Section::Requests) {
778 self.entries.append(&mut request_entries);
779 }
780 }
781
782 let current_user = user_store.current_user();
783
784 let contacts = user_store.contacts();
785 if !contacts.is_empty() {
786 // Always put the current user first.
787 self.match_candidates.clear();
788 self.match_candidates.reserve(contacts.len());
789 self.match_candidates.push(StringMatchCandidate {
790 id: 0,
791 string: Default::default(),
792 char_bag: Default::default(),
793 });
794 for (ix, contact) in contacts.iter().enumerate() {
795 let candidate = StringMatchCandidate {
796 id: ix,
797 string: contact.user.github_login.clone(),
798 char_bag: contact.user.github_login.chars().collect(),
799 };
800 if current_user
801 .as_ref()
802 .map_or(false, |current_user| current_user.id == contact.user.id)
803 {
804 self.match_candidates[0] = candidate;
805 } else {
806 self.match_candidates.push(candidate);
807 }
808 }
809 if self.match_candidates[0].string.is_empty() {
810 self.match_candidates.remove(0);
811 }
812
813 let matches = executor.block(match_strings(
814 &self.match_candidates,
815 &query,
816 true,
817 usize::MAX,
818 &Default::default(),
819 executor.clone(),
820 ));
821
822 let (online_contacts, offline_contacts) = matches
823 .iter()
824 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
825
826 for (matches, section) in [
827 (online_contacts, Section::Online),
828 (offline_contacts, Section::Offline),
829 ] {
830 if !matches.is_empty() {
831 self.entries.push(ContactEntry::Header(section));
832 if !self.collapsed_sections.contains(§ion) {
833 for mat in matches {
834 let contact = &contacts[mat.candidate_id];
835 self.entries.push(ContactEntry::Contact(contact.clone()));
836
837 let is_current_user = current_user
838 .as_ref()
839 .map_or(false, |user| user.id == contact.user.id);
840 if is_current_user {
841 let mut open_projects =
842 project_store.projects(cx).collect::<Vec<_>>();
843 self.entries.extend(
844 contact.projects.iter().enumerate().filter_map(
845 |(ix, project)| {
846 let open_project = open_projects
847 .iter()
848 .position(|p| {
849 p.read(cx).remote_id() == Some(project.id)
850 })
851 .map(|ix| open_projects.remove(ix).downgrade());
852 if project.visible_worktree_root_names.is_empty() {
853 None
854 } else {
855 Some(ContactEntry::ContactProject(
856 contact.clone(),
857 ix,
858 open_project,
859 ))
860 }
861 },
862 ),
863 );
864 self.entries.extend(open_projects.into_iter().filter_map(
865 |project| {
866 if project.read(cx).visible_worktrees(cx).next().is_none() {
867 None
868 } else {
869 Some(ContactEntry::OfflineProject(project.downgrade()))
870 }
871 },
872 ));
873 } else {
874 self.entries.extend(
875 contact.projects.iter().enumerate().filter_map(
876 |(ix, project)| {
877 if project.visible_worktree_root_names.is_empty() {
878 None
879 } else {
880 Some(ContactEntry::ContactProject(
881 contact.clone(),
882 ix,
883 None,
884 ))
885 }
886 },
887 ),
888 );
889 }
890 }
891 }
892 }
893 }
894 }
895
896 if let Some(prev_selected_entry) = prev_selected_entry {
897 self.selection.take();
898 for (ix, entry) in self.entries.iter().enumerate() {
899 if *entry == prev_selected_entry {
900 self.selection = Some(ix);
901 break;
902 }
903 }
904 }
905
906 self.list_state.reset(self.entries.len());
907 cx.notify();
908 }
909
910 fn request_contact(&mut self, request: &RequestContact, cx: &mut ViewContext<Self>) {
911 self.user_store
912 .update(cx, |store, cx| store.request_contact(request.0, cx))
913 .detach();
914 }
915
916 fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
917 self.user_store
918 .update(cx, |store, cx| store.remove_contact(request.0, cx))
919 .detach();
920 }
921
922 fn respond_to_contact_request(
923 &mut self,
924 action: &RespondToContactRequest,
925 cx: &mut ViewContext<Self>,
926 ) {
927 self.user_store
928 .update(cx, |store, cx| {
929 store.respond_to_contact_request(action.user_id, action.accept, cx)
930 })
931 .detach();
932 }
933
934 fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
935 let did_clear = self.filter_editor.update(cx, |editor, cx| {
936 if editor.buffer().read(cx).len(cx) > 0 {
937 editor.set_text("", cx);
938 true
939 } else {
940 false
941 }
942 });
943 if !did_clear {
944 cx.propagate_action();
945 }
946 }
947
948 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
949 if let Some(ix) = self.selection {
950 if self.entries.len() > ix + 1 {
951 self.selection = Some(ix + 1);
952 }
953 } else if !self.entries.is_empty() {
954 self.selection = Some(0);
955 }
956 cx.notify();
957 self.list_state.reset(self.entries.len());
958 }
959
960 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
961 if let Some(ix) = self.selection {
962 if ix > 0 {
963 self.selection = Some(ix - 1);
964 } else {
965 self.selection = None;
966 }
967 }
968 cx.notify();
969 self.list_state.reset(self.entries.len());
970 }
971
972 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
973 if let Some(selection) = self.selection {
974 if let Some(entry) = self.entries.get(selection) {
975 match entry {
976 ContactEntry::Header(section) => {
977 let section = *section;
978 self.toggle_expanded(&ToggleExpanded(section), cx);
979 }
980 ContactEntry::ContactProject(contact, project_index, open_project) => {
981 if let Some(open_project) = open_project {
982 workspace::activate_workspace_for_project(cx, |_, cx| {
983 cx.model_id() == open_project.id()
984 });
985 } else {
986 cx.dispatch_global_action(JoinProject {
987 contact: contact.clone(),
988 project_index: *project_index,
989 })
990 }
991 }
992 _ => {}
993 }
994 }
995 }
996 }
997
998 fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
999 let section = action.0;
1000 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
1001 self.collapsed_sections.remove(ix);
1002 } else {
1003 self.collapsed_sections.push(section);
1004 }
1005 self.update_entries(cx);
1006 }
1007}
1008
1009impl SidebarItem for ContactsPanel {
1010 fn should_show_badge(&self, cx: &AppContext) -> bool {
1011 !self
1012 .user_store
1013 .read(cx)
1014 .incoming_contact_requests()
1015 .is_empty()
1016 }
1017
1018 fn contains_focused_view(&self, cx: &AppContext) -> bool {
1019 self.filter_editor.is_focused(cx)
1020 }
1021
1022 fn should_activate_item_on_event(&self, event: &Event, _: &AppContext) -> bool {
1023 matches!(event, Event::Activate)
1024 }
1025}
1026
1027fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
1028 Svg::new(svg_path)
1029 .with_color(style.color)
1030 .constrained()
1031 .with_width(style.icon_width)
1032 .aligned()
1033 .contained()
1034 .with_style(style.container)
1035 .constrained()
1036 .with_width(style.button_width)
1037 .with_height(style.button_width)
1038}
1039
1040pub enum Event {
1041 Activate,
1042}
1043
1044impl Entity for ContactsPanel {
1045 type Event = Event;
1046}
1047
1048impl View for ContactsPanel {
1049 fn ui_name() -> &'static str {
1050 "ContactsPanel"
1051 }
1052
1053 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
1054 enum AddContact {}
1055
1056 let theme = cx.global::<Settings>().theme.clone();
1057 let theme = &theme.contacts_panel;
1058 Container::new(
1059 Flex::column()
1060 .with_child(
1061 Flex::row()
1062 .with_child(
1063 ChildView::new(self.filter_editor.clone())
1064 .contained()
1065 .with_style(theme.user_query_editor.container)
1066 .flex(1., true)
1067 .boxed(),
1068 )
1069 .with_child(
1070 MouseEventHandler::new::<AddContact, _, _>(0, cx, |_, _| {
1071 Svg::new("icons/add-contact.svg")
1072 .with_color(theme.add_contact_button.color)
1073 .constrained()
1074 .with_height(12.)
1075 .contained()
1076 .with_style(theme.add_contact_button.container)
1077 .aligned()
1078 .boxed()
1079 })
1080 .with_cursor_style(CursorStyle::PointingHand)
1081 .on_click(|_, _, cx| cx.dispatch_action(contact_finder::Toggle))
1082 .boxed(),
1083 )
1084 .constrained()
1085 .with_height(theme.user_query_editor_height)
1086 .boxed(),
1087 )
1088 .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
1089 .with_children(
1090 self.user_store
1091 .read(cx)
1092 .invite_info()
1093 .cloned()
1094 .and_then(|info| {
1095 enum InviteLink {}
1096
1097 if info.count > 0 {
1098 Some(
1099 MouseEventHandler::new::<InviteLink, _, _>(
1100 0,
1101 cx,
1102 |state, cx| {
1103 let style =
1104 theme.invite_row.style_for(state, false).clone();
1105
1106 let copied =
1107 cx.read_from_clipboard().map_or(false, |item| {
1108 item.text().as_str() == info.url.as_ref()
1109 });
1110
1111 Label::new(
1112 format!(
1113 "{} invite link ({} left)",
1114 if copied { "Copied" } else { "Copy" },
1115 info.count
1116 ),
1117 style.label.clone(),
1118 )
1119 .aligned()
1120 .left()
1121 .constrained()
1122 .with_height(theme.row_height)
1123 .contained()
1124 .with_style(style.container)
1125 .boxed()
1126 },
1127 )
1128 .with_cursor_style(CursorStyle::PointingHand)
1129 .on_click(move |_, _, cx| {
1130 cx.write_to_clipboard(ClipboardItem::new(
1131 info.url.to_string(),
1132 ));
1133 cx.notify();
1134 })
1135 .boxed(),
1136 )
1137 } else {
1138 None
1139 }
1140 }),
1141 )
1142 .boxed(),
1143 )
1144 .with_style(theme.container)
1145 .boxed()
1146 }
1147
1148 fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
1149 cx.focus(&self.filter_editor);
1150 }
1151
1152 fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
1153 let mut cx = Self::default_keymap_context();
1154 cx.set.insert("menu".into());
1155 cx
1156 }
1157}
1158
1159impl PartialEq for ContactEntry {
1160 fn eq(&self, other: &Self) -> bool {
1161 match self {
1162 ContactEntry::Header(section_1) => {
1163 if let ContactEntry::Header(section_2) = other {
1164 return section_1 == section_2;
1165 }
1166 }
1167 ContactEntry::IncomingRequest(user_1) => {
1168 if let ContactEntry::IncomingRequest(user_2) = other {
1169 return user_1.id == user_2.id;
1170 }
1171 }
1172 ContactEntry::OutgoingRequest(user_1) => {
1173 if let ContactEntry::OutgoingRequest(user_2) = other {
1174 return user_1.id == user_2.id;
1175 }
1176 }
1177 ContactEntry::Contact(contact_1) => {
1178 if let ContactEntry::Contact(contact_2) = other {
1179 return contact_1.user.id == contact_2.user.id;
1180 }
1181 }
1182 ContactEntry::ContactProject(contact_1, ix_1, _) => {
1183 if let ContactEntry::ContactProject(contact_2, ix_2, _) = other {
1184 return contact_1.user.id == contact_2.user.id && ix_1 == ix_2;
1185 }
1186 }
1187 ContactEntry::OfflineProject(project_1) => {
1188 if let ContactEntry::OfflineProject(project_2) = other {
1189 return project_1.id() == project_2.id();
1190 }
1191 }
1192 }
1193 false
1194 }
1195}
1196
1197#[cfg(test)]
1198mod tests {
1199 use super::*;
1200 use client::{
1201 proto,
1202 test::{FakeHttpClient, FakeServer},
1203 Client,
1204 };
1205 use collections::HashSet;
1206 use gpui::{serde_json::json, TestAppContext};
1207 use language::LanguageRegistry;
1208 use project::{FakeFs, Project};
1209
1210 #[gpui::test]
1211 async fn test_contact_panel(cx: &mut TestAppContext) {
1212 Settings::test_async(cx);
1213 let current_user_id = 100;
1214
1215 let languages = Arc::new(LanguageRegistry::test());
1216 let http_client = FakeHttpClient::with_404_response();
1217 let client = Client::new(http_client.clone());
1218 let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
1219 let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
1220 let server = FakeServer::for_client(current_user_id, &client, &cx).await;
1221 let fs = FakeFs::new(cx.background());
1222 fs.insert_tree("/private_dir", json!({ "one.rs": "" }))
1223 .await;
1224 let project = cx.update(|cx| {
1225 Project::local(
1226 false,
1227 client.clone(),
1228 user_store.clone(),
1229 project_store.clone(),
1230 languages,
1231 fs,
1232 cx,
1233 )
1234 });
1235 let worktree_id = project
1236 .update(cx, |project, cx| {
1237 project.find_or_create_local_worktree("/private_dir", true, cx)
1238 })
1239 .await
1240 .unwrap()
1241 .0
1242 .read_with(cx, |worktree, _| worktree.id().to_proto());
1243
1244 let workspace = cx.add_view(0, |cx| Workspace::new(project.clone(), cx));
1245 let panel = cx.add_view(0, |cx| {
1246 ContactsPanel::new(
1247 user_store.clone(),
1248 project_store.clone(),
1249 workspace.downgrade(),
1250 cx,
1251 )
1252 });
1253
1254 workspace.update(cx, |_, cx| {
1255 cx.observe(&panel, |_, panel, cx| {
1256 let entries = render_to_strings(&panel, cx);
1257 assert!(
1258 entries.iter().collect::<HashSet<_>>().len() == entries.len(),
1259 "Duplicate contact panel entries {:?}",
1260 entries
1261 )
1262 })
1263 .detach();
1264 });
1265
1266 let request = server.receive::<proto::RegisterProject>().await.unwrap();
1267 server
1268 .respond(
1269 request.receipt(),
1270 proto::RegisterProjectResponse { project_id: 200 },
1271 )
1272 .await;
1273 let get_users_request = server.receive::<proto::GetUsers>().await.unwrap();
1274 server
1275 .respond(
1276 get_users_request.receipt(),
1277 proto::UsersResponse {
1278 users: [
1279 "user_zero",
1280 "user_one",
1281 "user_two",
1282 "user_three",
1283 "user_four",
1284 "user_five",
1285 ]
1286 .into_iter()
1287 .enumerate()
1288 .map(|(id, name)| proto::User {
1289 id: id as u64,
1290 github_login: name.to_string(),
1291 ..Default::default()
1292 })
1293 .chain([proto::User {
1294 id: current_user_id,
1295 github_login: "the_current_user".to_string(),
1296 ..Default::default()
1297 }])
1298 .collect(),
1299 },
1300 )
1301 .await;
1302
1303 server.send(proto::UpdateContacts {
1304 incoming_requests: vec![proto::IncomingContactRequest {
1305 requester_id: 1,
1306 should_notify: false,
1307 }],
1308 outgoing_requests: vec![2],
1309 contacts: vec![
1310 proto::Contact {
1311 user_id: 3,
1312 online: true,
1313 should_notify: false,
1314 projects: vec![proto::ProjectMetadata {
1315 id: 101,
1316 visible_worktree_root_names: vec!["dir1".to_string()],
1317 guests: vec![2],
1318 }],
1319 },
1320 proto::Contact {
1321 user_id: 4,
1322 online: true,
1323 should_notify: false,
1324 projects: vec![proto::ProjectMetadata {
1325 id: 102,
1326 visible_worktree_root_names: vec!["dir2".to_string()],
1327 guests: vec![2],
1328 }],
1329 },
1330 proto::Contact {
1331 user_id: 5,
1332 online: false,
1333 should_notify: false,
1334 projects: vec![],
1335 },
1336 proto::Contact {
1337 user_id: current_user_id,
1338 online: true,
1339 should_notify: false,
1340 projects: vec![proto::ProjectMetadata {
1341 id: 103,
1342 visible_worktree_root_names: vec!["dir3".to_string()],
1343 guests: vec![3],
1344 }],
1345 },
1346 ],
1347 ..Default::default()
1348 });
1349
1350 assert_eq!(
1351 server
1352 .receive::<proto::UpdateProject>()
1353 .await
1354 .unwrap()
1355 .payload,
1356 proto::UpdateProject {
1357 project_id: 200,
1358 online: false,
1359 worktrees: vec![]
1360 },
1361 );
1362
1363 cx.foreground().run_until_parked();
1364 assert_eq!(
1365 cx.read(|cx| render_to_strings(&panel, cx)),
1366 &[
1367 "v Requests",
1368 " incoming user_one",
1369 " outgoing user_two",
1370 "v Online",
1371 " the_current_user",
1372 " dir3",
1373 " 🔒 private_dir",
1374 " user_four",
1375 " dir2",
1376 " user_three",
1377 " dir1",
1378 "v Offline",
1379 " user_five",
1380 ]
1381 );
1382
1383 // Take a project online. It appears as loading, since the project
1384 // isn't yet visible to other contacts.
1385 project.update(cx, |project, cx| project.set_online(true, cx));
1386 cx.foreground().run_until_parked();
1387 assert_eq!(
1388 cx.read(|cx| render_to_strings(&panel, cx)),
1389 &[
1390 "v Requests",
1391 " incoming user_one",
1392 " outgoing user_two",
1393 "v Online",
1394 " the_current_user",
1395 " dir3",
1396 " 🔒 private_dir (going online...)",
1397 " user_four",
1398 " dir2",
1399 " user_three",
1400 " dir1",
1401 "v Offline",
1402 " user_five",
1403 ]
1404 );
1405
1406 // The server receives the project's metadata and updates the contact metadata
1407 // for the current user. Now the project appears as online.
1408 assert_eq!(
1409 server
1410 .receive::<proto::UpdateProject>()
1411 .await
1412 .unwrap()
1413 .payload,
1414 proto::UpdateProject {
1415 project_id: 200,
1416 online: true,
1417 worktrees: vec![proto::WorktreeMetadata {
1418 id: worktree_id,
1419 root_name: "private_dir".to_string(),
1420 visible: true,
1421 }]
1422 },
1423 );
1424 server
1425 .receive::<proto::UpdateWorktreeExtensions>()
1426 .await
1427 .unwrap();
1428
1429 server.send(proto::UpdateContacts {
1430 contacts: vec![proto::Contact {
1431 user_id: current_user_id,
1432 online: true,
1433 should_notify: false,
1434 projects: vec![
1435 proto::ProjectMetadata {
1436 id: 103,
1437 visible_worktree_root_names: vec!["dir3".to_string()],
1438 guests: vec![3],
1439 },
1440 proto::ProjectMetadata {
1441 id: 200,
1442 visible_worktree_root_names: vec!["private_dir".to_string()],
1443 guests: vec![3],
1444 },
1445 ],
1446 }],
1447 ..Default::default()
1448 });
1449 cx.foreground().run_until_parked();
1450 assert_eq!(
1451 cx.read(|cx| render_to_strings(&panel, cx)),
1452 &[
1453 "v Requests",
1454 " incoming user_one",
1455 " outgoing user_two",
1456 "v Online",
1457 " the_current_user",
1458 " dir3",
1459 " private_dir",
1460 " user_four",
1461 " dir2",
1462 " user_three",
1463 " dir1",
1464 "v Offline",
1465 " user_five",
1466 ]
1467 );
1468
1469 // Take the project offline. It appears as loading.
1470 project.update(cx, |project, cx| project.set_online(false, cx));
1471 cx.foreground().run_until_parked();
1472 assert_eq!(
1473 cx.read(|cx| render_to_strings(&panel, cx)),
1474 &[
1475 "v Requests",
1476 " incoming user_one",
1477 " outgoing user_two",
1478 "v Online",
1479 " the_current_user",
1480 " dir3",
1481 " private_dir (going offline...)",
1482 " user_four",
1483 " dir2",
1484 " user_three",
1485 " dir1",
1486 "v Offline",
1487 " user_five",
1488 ]
1489 );
1490
1491 // The server receives the unregister request and updates the contact
1492 // metadata for the current user. The project is now offline.
1493 assert_eq!(
1494 server
1495 .receive::<proto::UpdateProject>()
1496 .await
1497 .unwrap()
1498 .payload,
1499 proto::UpdateProject {
1500 project_id: 200,
1501 online: false,
1502 worktrees: vec![]
1503 },
1504 );
1505
1506 server.send(proto::UpdateContacts {
1507 contacts: vec![proto::Contact {
1508 user_id: current_user_id,
1509 online: true,
1510 should_notify: false,
1511 projects: vec![proto::ProjectMetadata {
1512 id: 103,
1513 visible_worktree_root_names: vec!["dir3".to_string()],
1514 guests: vec![3],
1515 }],
1516 }],
1517 ..Default::default()
1518 });
1519 cx.foreground().run_until_parked();
1520 assert_eq!(
1521 cx.read(|cx| render_to_strings(&panel, cx)),
1522 &[
1523 "v Requests",
1524 " incoming user_one",
1525 " outgoing user_two",
1526 "v Online",
1527 " the_current_user",
1528 " dir3",
1529 " 🔒 private_dir",
1530 " user_four",
1531 " dir2",
1532 " user_three",
1533 " dir1",
1534 "v Offline",
1535 " user_five",
1536 ]
1537 );
1538
1539 panel.update(cx, |panel, cx| {
1540 panel
1541 .filter_editor
1542 .update(cx, |editor, cx| editor.set_text("f", cx))
1543 });
1544 cx.foreground().run_until_parked();
1545 assert_eq!(
1546 cx.read(|cx| render_to_strings(&panel, cx)),
1547 &[
1548 "v Online",
1549 " user_four <=== selected",
1550 " dir2",
1551 "v Offline",
1552 " user_five",
1553 ]
1554 );
1555
1556 panel.update(cx, |panel, cx| {
1557 panel.select_next(&Default::default(), cx);
1558 });
1559 assert_eq!(
1560 cx.read(|cx| render_to_strings(&panel, cx)),
1561 &[
1562 "v Online",
1563 " user_four",
1564 " dir2 <=== selected",
1565 "v Offline",
1566 " user_five",
1567 ]
1568 );
1569
1570 panel.update(cx, |panel, cx| {
1571 panel.select_next(&Default::default(), cx);
1572 });
1573 assert_eq!(
1574 cx.read(|cx| render_to_strings(&panel, cx)),
1575 &[
1576 "v Online",
1577 " user_four",
1578 " dir2",
1579 "v Offline <=== selected",
1580 " user_five",
1581 ]
1582 );
1583 }
1584
1585 fn render_to_strings(panel: &ViewHandle<ContactsPanel>, cx: &AppContext) -> Vec<String> {
1586 let panel = panel.read(cx);
1587 let mut entries = Vec::new();
1588 entries.extend(panel.entries.iter().enumerate().map(|(ix, entry)| {
1589 let mut string = match entry {
1590 ContactEntry::Header(name) => {
1591 let icon = if panel.collapsed_sections.contains(name) {
1592 ">"
1593 } else {
1594 "v"
1595 };
1596 format!("{} {:?}", icon, name)
1597 }
1598 ContactEntry::IncomingRequest(user) => {
1599 format!(" incoming {}", user.github_login)
1600 }
1601 ContactEntry::OutgoingRequest(user) => {
1602 format!(" outgoing {}", user.github_login)
1603 }
1604 ContactEntry::Contact(contact) => {
1605 format!(" {}", contact.user.github_login)
1606 }
1607 ContactEntry::ContactProject(contact, project_ix, project) => {
1608 let project = project
1609 .and_then(|p| p.upgrade(cx))
1610 .map(|project| project.read(cx));
1611 format!(
1612 " {}{}",
1613 contact.projects[*project_ix]
1614 .visible_worktree_root_names
1615 .join(", "),
1616 if project.map_or(true, |project| project.is_online()) {
1617 ""
1618 } else {
1619 " (going offline...)"
1620 },
1621 )
1622 }
1623 ContactEntry::OfflineProject(project) => {
1624 let project = project.upgrade(cx).unwrap().read(cx);
1625 format!(
1626 " 🔒 {}{}",
1627 project
1628 .worktree_root_names(cx)
1629 .collect::<Vec<_>>()
1630 .join(", "),
1631 if project.is_online() {
1632 " (going online...)"
1633 } else {
1634 ""
1635 },
1636 )
1637 }
1638 };
1639
1640 if panel.selection == Some(ix) {
1641 string.push_str(" <=== selected");
1642 }
1643
1644 string
1645 }));
1646 entries
1647 }
1648}