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