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