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