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::new::<Header, _, _>(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::new::<JoinProject, _, _>(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::new::<ToggleProjectOnline, _, _>(
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::new::<LocalProject, _, _>(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::new::<ToggleOnline, _, _>(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.deref_mut());
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::new::<Decline, _, _>(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::new::<Accept, _, _>(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::new::<Cancel, _, _>(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::new::<AddContact, _, _>(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::new::<InviteLink, _, _>(
1106 0,
1107 cx,
1108 |state, cx| {
1109 let style =
1110 theme.invite_row.style_for(state, false).clone();
1111
1112 let copied =
1113 cx.read_from_clipboard().map_or(false, |item| {
1114 item.text().as_str() == info.url.as_ref()
1115 });
1116
1117 Label::new(
1118 format!(
1119 "{} invite link ({} left)",
1120 if copied { "Copied" } else { "Copy" },
1121 info.count
1122 ),
1123 style.label.clone(),
1124 )
1125 .aligned()
1126 .left()
1127 .constrained()
1128 .with_height(theme.row_height)
1129 .contained()
1130 .with_style(style.container)
1131 .boxed()
1132 },
1133 )
1134 .with_cursor_style(CursorStyle::PointingHand)
1135 .on_click(MouseButton::Left, move |_, cx| {
1136 cx.write_to_clipboard(ClipboardItem::new(
1137 info.url.to_string(),
1138 ));
1139 cx.notify();
1140 })
1141 .boxed(),
1142 )
1143 } else {
1144 None
1145 }
1146 }),
1147 )
1148 .boxed(),
1149 )
1150 .with_style(theme.container)
1151 .boxed()
1152 }
1153
1154 fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
1155 cx.focus(&self.filter_editor);
1156 }
1157
1158 fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
1159 let mut cx = Self::default_keymap_context();
1160 cx.set.insert("menu".into());
1161 cx
1162 }
1163}
1164
1165impl PartialEq for ContactEntry {
1166 fn eq(&self, other: &Self) -> bool {
1167 match self {
1168 ContactEntry::Header(section_1) => {
1169 if let ContactEntry::Header(section_2) = other {
1170 return section_1 == section_2;
1171 }
1172 }
1173 ContactEntry::IncomingRequest(user_1) => {
1174 if let ContactEntry::IncomingRequest(user_2) = other {
1175 return user_1.id == user_2.id;
1176 }
1177 }
1178 ContactEntry::OutgoingRequest(user_1) => {
1179 if let ContactEntry::OutgoingRequest(user_2) = other {
1180 return user_1.id == user_2.id;
1181 }
1182 }
1183 ContactEntry::Contact(contact_1) => {
1184 if let ContactEntry::Contact(contact_2) = other {
1185 return contact_1.user.id == contact_2.user.id;
1186 }
1187 }
1188 ContactEntry::ContactProject(contact_1, ix_1, _) => {
1189 if let ContactEntry::ContactProject(contact_2, ix_2, _) = other {
1190 return contact_1.user.id == contact_2.user.id && ix_1 == ix_2;
1191 }
1192 }
1193 ContactEntry::OfflineProject(project_1) => {
1194 if let ContactEntry::OfflineProject(project_2) = other {
1195 return project_1.id() == project_2.id();
1196 }
1197 }
1198 }
1199 false
1200 }
1201}
1202
1203#[cfg(test)]
1204mod tests {
1205 use super::*;
1206 use client::{
1207 proto,
1208 test::{FakeHttpClient, FakeServer},
1209 Client,
1210 };
1211 use collections::HashSet;
1212 use gpui::{serde_json::json, TestAppContext};
1213 use language::LanguageRegistry;
1214 use project::{FakeFs, Project};
1215
1216 #[gpui::test]
1217 async fn test_contact_panel(cx: &mut TestAppContext) {
1218 Settings::test_async(cx);
1219 let current_user_id = 100;
1220
1221 let languages = Arc::new(LanguageRegistry::test());
1222 let http_client = FakeHttpClient::with_404_response();
1223 let client = Client::new(http_client.clone());
1224 let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
1225 let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
1226 let server = FakeServer::for_client(current_user_id, &client, cx).await;
1227 let fs = FakeFs::new(cx.background());
1228 fs.insert_tree("/private_dir", json!({ "one.rs": "" }))
1229 .await;
1230 let project = cx.update(|cx| {
1231 Project::local(
1232 false,
1233 client.clone(),
1234 user_store.clone(),
1235 project_store.clone(),
1236 languages,
1237 fs,
1238 cx,
1239 )
1240 });
1241 let worktree_id = project
1242 .update(cx, |project, cx| {
1243 project.find_or_create_local_worktree("/private_dir", true, cx)
1244 })
1245 .await
1246 .unwrap()
1247 .0
1248 .read_with(cx, |worktree, _| worktree.id().to_proto());
1249
1250 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
1251 let panel = cx.add_view(&workspace, |cx| {
1252 ContactsPanel::new(
1253 user_store.clone(),
1254 project_store.clone(),
1255 workspace.downgrade(),
1256 cx,
1257 )
1258 });
1259
1260 workspace.update(cx, |_, cx| {
1261 cx.observe(&panel, |_, panel, cx| {
1262 let entries = render_to_strings(&panel, cx);
1263 assert!(
1264 entries.iter().collect::<HashSet<_>>().len() == entries.len(),
1265 "Duplicate contact panel entries {:?}",
1266 entries
1267 )
1268 })
1269 .detach();
1270 });
1271
1272 let get_users_request = server.receive::<proto::GetUsers>().await.unwrap();
1273 server
1274 .respond(
1275 get_users_request.receipt(),
1276 proto::UsersResponse {
1277 users: [
1278 "user_zero",
1279 "user_one",
1280 "user_two",
1281 "user_three",
1282 "user_four",
1283 "user_five",
1284 ]
1285 .into_iter()
1286 .enumerate()
1287 .map(|(id, name)| proto::User {
1288 id: id as u64,
1289 github_login: name.to_string(),
1290 ..Default::default()
1291 })
1292 .chain([proto::User {
1293 id: current_user_id,
1294 github_login: "the_current_user".to_string(),
1295 ..Default::default()
1296 }])
1297 .collect(),
1298 },
1299 )
1300 .await;
1301
1302 let request = server.receive::<proto::RegisterProject>().await.unwrap();
1303 server
1304 .respond(
1305 request.receipt(),
1306 proto::RegisterProjectResponse { project_id: 200 },
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}