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