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