contacts_panel.rs

   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(&section) {
 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}