contacts_panel.rs

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