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