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