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