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