collab_panel.rs

   1mod channel_modal;
   2mod contact_finder;
   3
   4use self::channel_modal::ChannelModal;
   5use crate::{
   6    channel_view::ChannelView, chat_panel::ChatPanel, face_pile::FacePile,
   7    CollaborationPanelSettings,
   8};
   9use call::ActiveCall;
  10use channel::{Channel, ChannelEvent, ChannelId, ChannelStore};
  11use client::{Client, Contact, User, UserStore};
  12use contact_finder::ContactFinder;
  13use db::kvp::KEY_VALUE_STORE;
  14use editor::{Editor, EditorElement, EditorStyle};
  15use fuzzy::{match_strings, StringMatchCandidate};
  16use gpui::{
  17    actions, canvas, div, fill, list, overlay, point, prelude::*, px, AnyElement, AppContext,
  18    AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, Div, EventEmitter, FocusHandle,
  19    FocusableView, FontStyle, FontWeight, InteractiveElement, IntoElement, ListOffset, ListState,
  20    Model, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, RenderOnce,
  21    SharedString, Styled, Subscription, Task, TextStyle, View, ViewContext, VisualContext,
  22    WeakView, WhiteSpace,
  23};
  24use menu::{Cancel, Confirm, SelectNext, SelectPrev};
  25use project::{Fs, Project};
  26use rpc::proto::{self, PeerId};
  27use serde_derive::{Deserialize, Serialize};
  28use settings::Settings;
  29use smallvec::SmallVec;
  30use std::{mem, sync::Arc};
  31use theme::{ActiveTheme, ThemeSettings};
  32use ui::{
  33    prelude::*, Avatar, AvatarAvailabilityIndicator, Button, Color, ContextMenu, Icon, IconButton,
  34    IconName, IconSize, Label, ListHeader, ListItem, Tooltip,
  35};
  36use util::{maybe, ResultExt, TryFutureExt};
  37use workspace::{
  38    dock::{DockPosition, Panel, PanelEvent},
  39    notifications::{NotifyResultExt, NotifyTaskExt},
  40    Workspace,
  41};
  42
  43actions!(
  44    collab_panel,
  45    [
  46        ToggleFocus,
  47        Remove,
  48        Secondary,
  49        CollapseSelectedChannel,
  50        ExpandSelectedChannel,
  51        StartMoveChannel,
  52        MoveSelected,
  53        InsertSpace,
  54    ]
  55);
  56
  57#[derive(Debug, Copy, Clone, PartialEq, Eq)]
  58struct ChannelMoveClipboard {
  59    channel_id: ChannelId,
  60}
  61
  62const COLLABORATION_PANEL_KEY: &'static str = "CollaborationPanel";
  63
  64pub fn init(cx: &mut AppContext) {
  65    cx.observe_new_views(|workspace: &mut Workspace, _| {
  66        workspace.register_action(|workspace, _: &ToggleFocus, cx| {
  67            workspace.toggle_panel_focus::<CollabPanel>(cx);
  68        });
  69    })
  70    .detach();
  71}
  72
  73#[derive(Debug)]
  74pub enum ChannelEditingState {
  75    Create {
  76        location: Option<ChannelId>,
  77        pending_name: Option<String>,
  78    },
  79    Rename {
  80        location: ChannelId,
  81        pending_name: Option<String>,
  82    },
  83}
  84
  85impl ChannelEditingState {
  86    fn pending_name(&self) -> Option<String> {
  87        match self {
  88            ChannelEditingState::Create { pending_name, .. } => pending_name.clone(),
  89            ChannelEditingState::Rename { pending_name, .. } => pending_name.clone(),
  90        }
  91    }
  92}
  93
  94pub struct CollabPanel {
  95    width: Option<Pixels>,
  96    fs: Arc<dyn Fs>,
  97    focus_handle: FocusHandle,
  98    channel_clipboard: Option<ChannelMoveClipboard>,
  99    pending_serialization: Task<Option<()>>,
 100    context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
 101    list_state: ListState,
 102    filter_editor: View<Editor>,
 103    channel_name_editor: View<Editor>,
 104    channel_editing_state: Option<ChannelEditingState>,
 105    entries: Vec<ListEntry>,
 106    selection: Option<usize>,
 107    channel_store: Model<ChannelStore>,
 108    user_store: Model<UserStore>,
 109    client: Arc<Client>,
 110    project: Model<Project>,
 111    match_candidates: Vec<StringMatchCandidate>,
 112    subscriptions: Vec<Subscription>,
 113    collapsed_sections: Vec<Section>,
 114    collapsed_channels: Vec<ChannelId>,
 115    workspace: WeakView<Workspace>,
 116}
 117
 118#[derive(Serialize, Deserialize)]
 119struct SerializedCollabPanel {
 120    width: Option<Pixels>,
 121    collapsed_channels: Option<Vec<u64>>,
 122}
 123
 124#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
 125enum Section {
 126    ActiveCall,
 127    Channels,
 128    ChannelInvites,
 129    ContactRequests,
 130    Contacts,
 131    Online,
 132    Offline,
 133}
 134
 135#[derive(Clone, Debug)]
 136enum ListEntry {
 137    Header(Section),
 138    CallParticipant {
 139        user: Arc<User>,
 140        peer_id: Option<PeerId>,
 141        is_pending: bool,
 142        role: proto::ChannelRole,
 143    },
 144    ParticipantProject {
 145        project_id: u64,
 146        worktree_root_names: Vec<String>,
 147        host_user_id: u64,
 148        is_last: bool,
 149    },
 150    ParticipantScreen {
 151        peer_id: Option<PeerId>,
 152        is_last: bool,
 153    },
 154    IncomingRequest(Arc<User>),
 155    OutgoingRequest(Arc<User>),
 156    ChannelInvite(Arc<Channel>),
 157    Channel {
 158        channel: Arc<Channel>,
 159        depth: usize,
 160        has_children: bool,
 161    },
 162    ChannelNotes {
 163        channel_id: ChannelId,
 164    },
 165    ChannelChat {
 166        channel_id: ChannelId,
 167    },
 168    ChannelEditor {
 169        depth: usize,
 170    },
 171    Contact {
 172        contact: Arc<Contact>,
 173        calling: bool,
 174    },
 175    ContactPlaceholder,
 176}
 177
 178impl CollabPanel {
 179    pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
 180        cx.new_view(|cx| {
 181            let filter_editor = cx.new_view(|cx| {
 182                let mut editor = Editor::single_line(cx);
 183                editor.set_placeholder_text("Filter...", cx);
 184                editor
 185            });
 186
 187            cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
 188                if let editor::EditorEvent::BufferEdited = event {
 189                    let query = this.filter_editor.read(cx).text(cx);
 190                    if !query.is_empty() {
 191                        this.selection.take();
 192                    }
 193                    this.update_entries(true, cx);
 194                    if !query.is_empty() {
 195                        this.selection = this
 196                            .entries
 197                            .iter()
 198                            .position(|entry| !matches!(entry, ListEntry::Header(_)));
 199                    }
 200                }
 201            })
 202            .detach();
 203
 204            let channel_name_editor = cx.new_view(|cx| Editor::single_line(cx));
 205
 206            cx.subscribe(&channel_name_editor, |this: &mut Self, _, event, cx| {
 207                if let editor::EditorEvent::Blurred = event {
 208                    if let Some(state) = &this.channel_editing_state {
 209                        if state.pending_name().is_some() {
 210                            return;
 211                        }
 212                    }
 213                    this.take_editing_state(cx);
 214                    this.update_entries(false, cx);
 215                    cx.notify();
 216                }
 217            })
 218            .detach();
 219
 220            let view = cx.view().downgrade();
 221            let list_state =
 222                ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| {
 223                    if let Some(view) = view.upgrade() {
 224                        view.update(cx, |view, cx| view.render_list_entry(ix, cx))
 225                    } else {
 226                        div().into_any()
 227                    }
 228                });
 229
 230            let mut this = Self {
 231                width: None,
 232                focus_handle: cx.focus_handle(),
 233                channel_clipboard: None,
 234                fs: workspace.app_state().fs.clone(),
 235                pending_serialization: Task::ready(None),
 236                context_menu: None,
 237                list_state,
 238                channel_name_editor,
 239                filter_editor,
 240                entries: Vec::default(),
 241                channel_editing_state: None,
 242                selection: None,
 243                channel_store: ChannelStore::global(cx),
 244                user_store: workspace.user_store().clone(),
 245                project: workspace.project().clone(),
 246                subscriptions: Vec::default(),
 247                match_candidates: Vec::default(),
 248                collapsed_sections: vec![Section::Offline],
 249                collapsed_channels: Vec::default(),
 250                workspace: workspace.weak_handle(),
 251                client: workspace.app_state().client.clone(),
 252            };
 253
 254            this.update_entries(false, cx);
 255
 256            let active_call = ActiveCall::global(cx);
 257            this.subscriptions
 258                .push(cx.observe(&this.user_store, |this, _, cx| {
 259                    this.update_entries(true, cx)
 260                }));
 261            this.subscriptions
 262                .push(cx.observe(&this.channel_store, |this, _, cx| {
 263                    this.update_entries(true, cx)
 264                }));
 265            this.subscriptions
 266                .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
 267            this.subscriptions.push(cx.subscribe(
 268                &this.channel_store,
 269                |this, _channel_store, e, cx| match e {
 270                    ChannelEvent::ChannelCreated(channel_id)
 271                    | ChannelEvent::ChannelRenamed(channel_id) => {
 272                        if this.take_editing_state(cx) {
 273                            this.update_entries(false, cx);
 274                            this.selection = this.entries.iter().position(|entry| {
 275                                if let ListEntry::Channel { channel, .. } = entry {
 276                                    channel.id == *channel_id
 277                                } else {
 278                                    false
 279                                }
 280                            });
 281                        }
 282                    }
 283                },
 284            ));
 285
 286            this
 287        })
 288    }
 289
 290    pub async fn load(
 291        workspace: WeakView<Workspace>,
 292        mut cx: AsyncWindowContext,
 293    ) -> anyhow::Result<View<Self>> {
 294        let serialized_panel = cx
 295            .background_executor()
 296            .spawn(async move { KEY_VALUE_STORE.read_kvp(COLLABORATION_PANEL_KEY) })
 297            .await
 298            .map_err(|_| anyhow::anyhow!("Failed to read collaboration panel from key value store"))
 299            .log_err()
 300            .flatten()
 301            .map(|panel| serde_json::from_str::<SerializedCollabPanel>(&panel))
 302            .transpose()
 303            .log_err()
 304            .flatten();
 305
 306        workspace.update(&mut cx, |workspace, cx| {
 307            let panel = CollabPanel::new(workspace, cx);
 308            if let Some(serialized_panel) = serialized_panel {
 309                panel.update(cx, |panel, cx| {
 310                    panel.width = serialized_panel.width;
 311                    panel.collapsed_channels = serialized_panel
 312                        .collapsed_channels
 313                        .unwrap_or_else(|| Vec::new());
 314                    cx.notify();
 315                });
 316            }
 317            panel
 318        })
 319    }
 320
 321    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
 322        let width = self.width;
 323        let collapsed_channels = self.collapsed_channels.clone();
 324        self.pending_serialization = cx.background_executor().spawn(
 325            async move {
 326                KEY_VALUE_STORE
 327                    .write_kvp(
 328                        COLLABORATION_PANEL_KEY.into(),
 329                        serde_json::to_string(&SerializedCollabPanel {
 330                            width,
 331                            collapsed_channels: Some(collapsed_channels),
 332                        })?,
 333                    )
 334                    .await?;
 335                anyhow::Ok(())
 336            }
 337            .log_err(),
 338        );
 339    }
 340
 341    fn scroll_to_item(&mut self, ix: usize) {
 342        self.list_state.scroll_to_reveal_item(ix)
 343    }
 344
 345    fn update_entries(&mut self, select_same_item: bool, cx: &mut ViewContext<Self>) {
 346        let channel_store = self.channel_store.read(cx);
 347        let user_store = self.user_store.read(cx);
 348        let query = self.filter_editor.read(cx).text(cx);
 349        let executor = cx.background_executor().clone();
 350
 351        let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
 352        let old_entries = mem::take(&mut self.entries);
 353        let mut scroll_to_top = false;
 354
 355        if let Some(room) = ActiveCall::global(cx).read(cx).room() {
 356            self.entries.push(ListEntry::Header(Section::ActiveCall));
 357            if !old_entries
 358                .iter()
 359                .any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall)))
 360            {
 361                scroll_to_top = true;
 362            }
 363
 364            if !self.collapsed_sections.contains(&Section::ActiveCall) {
 365                let room = room.read(cx);
 366
 367                if let Some(channel_id) = room.channel_id() {
 368                    self.entries.push(ListEntry::ChannelNotes { channel_id });
 369                    self.entries.push(ListEntry::ChannelChat { channel_id });
 370                }
 371
 372                // Populate the active user.
 373                if let Some(user) = user_store.current_user() {
 374                    self.match_candidates.clear();
 375                    self.match_candidates.push(StringMatchCandidate {
 376                        id: 0,
 377                        string: user.github_login.clone(),
 378                        char_bag: user.github_login.chars().collect(),
 379                    });
 380                    let matches = executor.block(match_strings(
 381                        &self.match_candidates,
 382                        &query,
 383                        true,
 384                        usize::MAX,
 385                        &Default::default(),
 386                        executor.clone(),
 387                    ));
 388                    if !matches.is_empty() {
 389                        let user_id = user.id;
 390                        self.entries.push(ListEntry::CallParticipant {
 391                            user,
 392                            peer_id: None,
 393                            is_pending: false,
 394                            role: room.local_participant().role,
 395                        });
 396                        let mut projects = room.local_participant().projects.iter().peekable();
 397                        while let Some(project) = projects.next() {
 398                            self.entries.push(ListEntry::ParticipantProject {
 399                                project_id: project.id,
 400                                worktree_root_names: project.worktree_root_names.clone(),
 401                                host_user_id: user_id,
 402                                is_last: projects.peek().is_none() && !room.is_screen_sharing(),
 403                            });
 404                        }
 405                        if room.is_screen_sharing() {
 406                            self.entries.push(ListEntry::ParticipantScreen {
 407                                peer_id: None,
 408                                is_last: true,
 409                            });
 410                        }
 411                    }
 412                }
 413
 414                // Populate remote participants.
 415                self.match_candidates.clear();
 416                self.match_candidates
 417                    .extend(
 418                        room.remote_participants()
 419                            .iter()
 420                            .filter_map(|(_, participant)| {
 421                                Some(StringMatchCandidate {
 422                                    id: participant.user.id as usize,
 423                                    string: participant.user.github_login.clone(),
 424                                    char_bag: participant.user.github_login.chars().collect(),
 425                                })
 426                            }),
 427                    );
 428                let mut matches = executor.block(match_strings(
 429                    &self.match_candidates,
 430                    &query,
 431                    true,
 432                    usize::MAX,
 433                    &Default::default(),
 434                    executor.clone(),
 435                ));
 436                matches.sort_by(|a, b| {
 437                    let a_is_guest = room.role_for_user(a.candidate_id as u64)
 438                        == Some(proto::ChannelRole::Guest);
 439                    let b_is_guest = room.role_for_user(b.candidate_id as u64)
 440                        == Some(proto::ChannelRole::Guest);
 441                    a_is_guest
 442                        .cmp(&b_is_guest)
 443                        .then_with(|| a.string.cmp(&b.string))
 444                });
 445                for mat in matches {
 446                    let user_id = mat.candidate_id as u64;
 447                    let participant = &room.remote_participants()[&user_id];
 448                    self.entries.push(ListEntry::CallParticipant {
 449                        user: participant.user.clone(),
 450                        peer_id: Some(participant.peer_id),
 451                        is_pending: false,
 452                        role: participant.role,
 453                    });
 454                    let mut projects = participant.projects.iter().peekable();
 455                    while let Some(project) = projects.next() {
 456                        self.entries.push(ListEntry::ParticipantProject {
 457                            project_id: project.id,
 458                            worktree_root_names: project.worktree_root_names.clone(),
 459                            host_user_id: participant.user.id,
 460                            is_last: projects.peek().is_none()
 461                                && participant.video_tracks.is_empty(),
 462                        });
 463                    }
 464                    if !participant.video_tracks.is_empty() {
 465                        self.entries.push(ListEntry::ParticipantScreen {
 466                            peer_id: Some(participant.peer_id),
 467                            is_last: true,
 468                        });
 469                    }
 470                }
 471
 472                // Populate pending participants.
 473                self.match_candidates.clear();
 474                self.match_candidates
 475                    .extend(room.pending_participants().iter().enumerate().map(
 476                        |(id, participant)| StringMatchCandidate {
 477                            id,
 478                            string: participant.github_login.clone(),
 479                            char_bag: participant.github_login.chars().collect(),
 480                        },
 481                    ));
 482                let matches = executor.block(match_strings(
 483                    &self.match_candidates,
 484                    &query,
 485                    true,
 486                    usize::MAX,
 487                    &Default::default(),
 488                    executor.clone(),
 489                ));
 490                self.entries
 491                    .extend(matches.iter().map(|mat| ListEntry::CallParticipant {
 492                        user: room.pending_participants()[mat.candidate_id].clone(),
 493                        peer_id: None,
 494                        is_pending: true,
 495                        role: proto::ChannelRole::Member,
 496                    }));
 497            }
 498        }
 499
 500        let mut request_entries = Vec::new();
 501
 502        self.entries.push(ListEntry::Header(Section::Channels));
 503
 504        if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
 505            self.match_candidates.clear();
 506            self.match_candidates
 507                .extend(
 508                    channel_store
 509                        .ordered_channels()
 510                        .enumerate()
 511                        .map(|(ix, (_, channel))| StringMatchCandidate {
 512                            id: ix,
 513                            string: channel.name.clone().into(),
 514                            char_bag: channel.name.chars().collect(),
 515                        }),
 516                );
 517            let matches = executor.block(match_strings(
 518                &self.match_candidates,
 519                &query,
 520                true,
 521                usize::MAX,
 522                &Default::default(),
 523                executor.clone(),
 524            ));
 525            if let Some(state) = &self.channel_editing_state {
 526                if matches!(state, ChannelEditingState::Create { location: None, .. }) {
 527                    self.entries.push(ListEntry::ChannelEditor { depth: 0 });
 528                }
 529            }
 530            let mut collapse_depth = None;
 531            for mat in matches {
 532                let channel = channel_store.channel_at_index(mat.candidate_id).unwrap();
 533                let depth = channel.parent_path.len();
 534
 535                if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) {
 536                    collapse_depth = Some(depth);
 537                } else if let Some(collapsed_depth) = collapse_depth {
 538                    if depth > collapsed_depth {
 539                        continue;
 540                    }
 541                    if self.is_channel_collapsed(channel.id) {
 542                        collapse_depth = Some(depth);
 543                    } else {
 544                        collapse_depth = None;
 545                    }
 546                }
 547
 548                let has_children = channel_store
 549                    .channel_at_index(mat.candidate_id + 1)
 550                    .map_or(false, |next_channel| {
 551                        next_channel.parent_path.ends_with(&[channel.id])
 552                    });
 553
 554                match &self.channel_editing_state {
 555                    Some(ChannelEditingState::Create {
 556                        location: parent_id,
 557                        ..
 558                    }) if *parent_id == Some(channel.id) => {
 559                        self.entries.push(ListEntry::Channel {
 560                            channel: channel.clone(),
 561                            depth,
 562                            has_children: false,
 563                        });
 564                        self.entries
 565                            .push(ListEntry::ChannelEditor { depth: depth + 1 });
 566                    }
 567                    Some(ChannelEditingState::Rename {
 568                        location: parent_id,
 569                        ..
 570                    }) if parent_id == &channel.id => {
 571                        self.entries.push(ListEntry::ChannelEditor { depth });
 572                    }
 573                    _ => {
 574                        self.entries.push(ListEntry::Channel {
 575                            channel: channel.clone(),
 576                            depth,
 577                            has_children,
 578                        });
 579                    }
 580                }
 581            }
 582        }
 583
 584        let channel_invites = channel_store.channel_invitations();
 585        if !channel_invites.is_empty() {
 586            self.match_candidates.clear();
 587            self.match_candidates
 588                .extend(channel_invites.iter().enumerate().map(|(ix, channel)| {
 589                    StringMatchCandidate {
 590                        id: ix,
 591                        string: channel.name.clone().into(),
 592                        char_bag: channel.name.chars().collect(),
 593                    }
 594                }));
 595            let matches = executor.block(match_strings(
 596                &self.match_candidates,
 597                &query,
 598                true,
 599                usize::MAX,
 600                &Default::default(),
 601                executor.clone(),
 602            ));
 603            request_entries.extend(
 604                matches
 605                    .iter()
 606                    .map(|mat| ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())),
 607            );
 608
 609            if !request_entries.is_empty() {
 610                self.entries
 611                    .push(ListEntry::Header(Section::ChannelInvites));
 612                if !self.collapsed_sections.contains(&Section::ChannelInvites) {
 613                    self.entries.append(&mut request_entries);
 614                }
 615            }
 616        }
 617
 618        self.entries.push(ListEntry::Header(Section::Contacts));
 619
 620        request_entries.clear();
 621        let incoming = user_store.incoming_contact_requests();
 622        if !incoming.is_empty() {
 623            self.match_candidates.clear();
 624            self.match_candidates
 625                .extend(
 626                    incoming
 627                        .iter()
 628                        .enumerate()
 629                        .map(|(ix, user)| StringMatchCandidate {
 630                            id: ix,
 631                            string: user.github_login.clone(),
 632                            char_bag: user.github_login.chars().collect(),
 633                        }),
 634                );
 635            let matches = executor.block(match_strings(
 636                &self.match_candidates,
 637                &query,
 638                true,
 639                usize::MAX,
 640                &Default::default(),
 641                executor.clone(),
 642            ));
 643            request_entries.extend(
 644                matches
 645                    .iter()
 646                    .map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
 647            );
 648        }
 649
 650        let outgoing = user_store.outgoing_contact_requests();
 651        if !outgoing.is_empty() {
 652            self.match_candidates.clear();
 653            self.match_candidates
 654                .extend(
 655                    outgoing
 656                        .iter()
 657                        .enumerate()
 658                        .map(|(ix, user)| StringMatchCandidate {
 659                            id: ix,
 660                            string: user.github_login.clone(),
 661                            char_bag: user.github_login.chars().collect(),
 662                        }),
 663                );
 664            let matches = executor.block(match_strings(
 665                &self.match_candidates,
 666                &query,
 667                true,
 668                usize::MAX,
 669                &Default::default(),
 670                executor.clone(),
 671            ));
 672            request_entries.extend(
 673                matches
 674                    .iter()
 675                    .map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
 676            );
 677        }
 678
 679        if !request_entries.is_empty() {
 680            self.entries
 681                .push(ListEntry::Header(Section::ContactRequests));
 682            if !self.collapsed_sections.contains(&Section::ContactRequests) {
 683                self.entries.append(&mut request_entries);
 684            }
 685        }
 686
 687        let contacts = user_store.contacts();
 688        if !contacts.is_empty() {
 689            self.match_candidates.clear();
 690            self.match_candidates
 691                .extend(
 692                    contacts
 693                        .iter()
 694                        .enumerate()
 695                        .map(|(ix, contact)| StringMatchCandidate {
 696                            id: ix,
 697                            string: contact.user.github_login.clone(),
 698                            char_bag: contact.user.github_login.chars().collect(),
 699                        }),
 700                );
 701
 702            let matches = executor.block(match_strings(
 703                &self.match_candidates,
 704                &query,
 705                true,
 706                usize::MAX,
 707                &Default::default(),
 708                executor.clone(),
 709            ));
 710
 711            let (online_contacts, offline_contacts) = matches
 712                .iter()
 713                .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
 714
 715            for (matches, section) in [
 716                (online_contacts, Section::Online),
 717                (offline_contacts, Section::Offline),
 718            ] {
 719                if !matches.is_empty() {
 720                    self.entries.push(ListEntry::Header(section));
 721                    if !self.collapsed_sections.contains(&section) {
 722                        let active_call = &ActiveCall::global(cx).read(cx);
 723                        for mat in matches {
 724                            let contact = &contacts[mat.candidate_id];
 725                            self.entries.push(ListEntry::Contact {
 726                                contact: contact.clone(),
 727                                calling: active_call.pending_invites().contains(&contact.user.id),
 728                            });
 729                        }
 730                    }
 731                }
 732            }
 733        }
 734
 735        if incoming.is_empty() && outgoing.is_empty() && contacts.is_empty() {
 736            self.entries.push(ListEntry::ContactPlaceholder);
 737        }
 738
 739        if select_same_item {
 740            if let Some(prev_selected_entry) = prev_selected_entry {
 741                self.selection.take();
 742                for (ix, entry) in self.entries.iter().enumerate() {
 743                    if *entry == prev_selected_entry {
 744                        self.selection = Some(ix);
 745                        break;
 746                    }
 747                }
 748            }
 749        } else {
 750            self.selection = self.selection.and_then(|prev_selection| {
 751                if self.entries.is_empty() {
 752                    None
 753                } else {
 754                    Some(prev_selection.min(self.entries.len() - 1))
 755                }
 756            });
 757        }
 758
 759        let old_scroll_top = self.list_state.logical_scroll_top();
 760        self.list_state.reset(self.entries.len());
 761
 762        if scroll_to_top {
 763            self.list_state.scroll_to(ListOffset::default());
 764        } else {
 765            // Attempt to maintain the same scroll position.
 766            if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
 767                let new_scroll_top = self
 768                    .entries
 769                    .iter()
 770                    .position(|entry| entry == old_top_entry)
 771                    .map(|item_ix| ListOffset {
 772                        item_ix,
 773                        offset_in_item: old_scroll_top.offset_in_item,
 774                    })
 775                    .or_else(|| {
 776                        let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
 777                        let item_ix = self
 778                            .entries
 779                            .iter()
 780                            .position(|entry| entry == entry_after_old_top)?;
 781                        Some(ListOffset {
 782                            item_ix,
 783                            offset_in_item: Pixels::ZERO,
 784                        })
 785                    })
 786                    .or_else(|| {
 787                        let entry_before_old_top =
 788                            old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
 789                        let item_ix = self
 790                            .entries
 791                            .iter()
 792                            .position(|entry| entry == entry_before_old_top)?;
 793                        Some(ListOffset {
 794                            item_ix,
 795                            offset_in_item: Pixels::ZERO,
 796                        })
 797                    });
 798
 799                self.list_state
 800                    .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
 801            }
 802        }
 803
 804        cx.notify();
 805    }
 806
 807    fn render_call_participant(
 808        &self,
 809        user: &Arc<User>,
 810        peer_id: Option<PeerId>,
 811        is_pending: bool,
 812        role: proto::ChannelRole,
 813        is_selected: bool,
 814        cx: &mut ViewContext<Self>,
 815    ) -> ListItem {
 816        let user_id = user.id;
 817        let is_current_user =
 818            self.user_store.read(cx).current_user().map(|user| user.id) == Some(user_id);
 819        let tooltip = format!("Follow {}", user.github_login);
 820
 821        let is_call_admin = ActiveCall::global(cx).read(cx).room().is_some_and(|room| {
 822            room.read(cx).local_participant().role == proto::ChannelRole::Admin
 823        });
 824
 825        ListItem::new(SharedString::from(user.github_login.clone()))
 826            .start_slot(Avatar::new(user.avatar_uri.clone()))
 827            .child(Label::new(user.github_login.clone()))
 828            .selected(is_selected)
 829            .end_slot(if is_pending {
 830                Label::new("Calling").color(Color::Muted).into_any_element()
 831            } else if is_current_user {
 832                IconButton::new("leave-call", IconName::Exit)
 833                    .style(ButtonStyle::Subtle)
 834                    .on_click(move |_, cx| Self::leave_call(cx))
 835                    .tooltip(|cx| Tooltip::text("Leave Call", cx))
 836                    .into_any_element()
 837            } else if role == proto::ChannelRole::Guest {
 838                Label::new("Guest").color(Color::Muted).into_any_element()
 839            } else {
 840                div().into_any_element()
 841            })
 842            .when_some(peer_id, |el, peer_id| {
 843                if role == proto::ChannelRole::Guest {
 844                    return el;
 845                }
 846                el.tooltip(move |cx| Tooltip::text(tooltip.clone(), cx))
 847                    .on_click(cx.listener(move |this, _, cx| {
 848                        this.workspace
 849                            .update(cx, |workspace, cx| workspace.follow(peer_id, cx))
 850                            .ok();
 851                    }))
 852            })
 853            .when(is_call_admin, |el| {
 854                el.on_secondary_mouse_down(cx.listener(move |this, event: &MouseDownEvent, cx| {
 855                    this.deploy_participant_context_menu(event.position, user_id, role, cx)
 856                }))
 857            })
 858    }
 859
 860    fn render_participant_project(
 861        &self,
 862        project_id: u64,
 863        worktree_root_names: &[String],
 864        host_user_id: u64,
 865        is_last: bool,
 866        is_selected: bool,
 867        cx: &mut ViewContext<Self>,
 868    ) -> impl IntoElement {
 869        let project_name: SharedString = if worktree_root_names.is_empty() {
 870            "untitled".to_string()
 871        } else {
 872            worktree_root_names.join(", ")
 873        }
 874        .into();
 875
 876        ListItem::new(project_id as usize)
 877            .selected(is_selected)
 878            .on_click(cx.listener(move |this, _, cx| {
 879                this.workspace
 880                    .update(cx, |workspace, cx| {
 881                        let app_state = workspace.app_state().clone();
 882                        workspace::join_remote_project(project_id, host_user_id, app_state, cx)
 883                            .detach_and_log_err(cx);
 884                    })
 885                    .ok();
 886            }))
 887            .start_slot(
 888                h_flex()
 889                    .gap_1()
 890                    .child(render_tree_branch(is_last, false, cx))
 891                    .child(IconButton::new(0, IconName::Folder)),
 892            )
 893            .child(Label::new(project_name.clone()))
 894            .tooltip(move |cx| Tooltip::text(format!("Open {}", project_name), cx))
 895    }
 896
 897    fn render_participant_screen(
 898        &self,
 899        peer_id: Option<PeerId>,
 900        is_last: bool,
 901        is_selected: bool,
 902        cx: &mut ViewContext<Self>,
 903    ) -> impl IntoElement {
 904        let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize);
 905
 906        ListItem::new(("screen", id))
 907            .selected(is_selected)
 908            .start_slot(
 909                h_flex()
 910                    .gap_1()
 911                    .child(render_tree_branch(is_last, false, cx))
 912                    .child(IconButton::new(0, IconName::Screen)),
 913            )
 914            .child(Label::new("Screen"))
 915            .when_some(peer_id, |this, _| {
 916                this.on_click(cx.listener(move |this, _, cx| {
 917                    this.workspace
 918                        .update(cx, |workspace, cx| {
 919                            workspace.open_shared_screen(peer_id.unwrap(), cx)
 920                        })
 921                        .ok();
 922                }))
 923                .tooltip(move |cx| Tooltip::text(format!("Open shared screen"), cx))
 924            })
 925    }
 926
 927    fn take_editing_state(&mut self, cx: &mut ViewContext<Self>) -> bool {
 928        if let Some(_) = self.channel_editing_state.take() {
 929            self.channel_name_editor.update(cx, |editor, cx| {
 930                editor.set_text("", cx);
 931            });
 932            true
 933        } else {
 934            false
 935        }
 936    }
 937
 938    fn render_channel_notes(
 939        &self,
 940        channel_id: ChannelId,
 941        is_selected: bool,
 942        cx: &mut ViewContext<Self>,
 943    ) -> impl IntoElement {
 944        ListItem::new("channel-notes")
 945            .selected(is_selected)
 946            .on_click(cx.listener(move |this, _, cx| {
 947                this.open_channel_notes(channel_id, cx);
 948            }))
 949            .start_slot(
 950                h_flex()
 951                    .gap_1()
 952                    .child(render_tree_branch(false, true, cx))
 953                    .child(IconButton::new(0, IconName::File)),
 954            )
 955            .child(div().h_7().w_full().child(Label::new("notes")))
 956            .tooltip(move |cx| Tooltip::text("Open Channel Notes", cx))
 957    }
 958
 959    fn render_channel_chat(
 960        &self,
 961        channel_id: ChannelId,
 962        is_selected: bool,
 963        cx: &mut ViewContext<Self>,
 964    ) -> impl IntoElement {
 965        ListItem::new("channel-chat")
 966            .selected(is_selected)
 967            .on_click(cx.listener(move |this, _, cx| {
 968                this.join_channel_chat(channel_id, cx);
 969            }))
 970            .start_slot(
 971                h_flex()
 972                    .gap_1()
 973                    .child(render_tree_branch(false, false, cx))
 974                    .child(IconButton::new(0, IconName::MessageBubbles)),
 975            )
 976            .child(Label::new("chat"))
 977            .tooltip(move |cx| Tooltip::text("Open Chat", cx))
 978    }
 979
 980    fn has_subchannels(&self, ix: usize) -> bool {
 981        self.entries.get(ix).map_or(false, |entry| {
 982            if let ListEntry::Channel { has_children, .. } = entry {
 983                *has_children
 984            } else {
 985                false
 986            }
 987        })
 988    }
 989
 990    fn deploy_participant_context_menu(
 991        &mut self,
 992        position: Point<Pixels>,
 993        user_id: u64,
 994        role: proto::ChannelRole,
 995        cx: &mut ViewContext<Self>,
 996    ) {
 997        let this = cx.view().clone();
 998        if !(role == proto::ChannelRole::Guest || role == proto::ChannelRole::Member) {
 999            return;
1000        }
1001
1002        let context_menu = ContextMenu::build(cx, |context_menu, cx| {
1003            if role == proto::ChannelRole::Guest {
1004                context_menu.entry(
1005                    "Grant Write Access",
1006                    None,
1007                    cx.handler_for(&this, move |_, cx| {
1008                        ActiveCall::global(cx)
1009                            .update(cx, |call, cx| {
1010                                let Some(room) = call.room() else {
1011                                    return Task::ready(Ok(()));
1012                                };
1013                                room.update(cx, |room, cx| {
1014                                    room.set_participant_role(
1015                                        user_id,
1016                                        proto::ChannelRole::Member,
1017                                        cx,
1018                                    )
1019                                })
1020                            })
1021                            .detach_and_notify_err(cx)
1022                    }),
1023                )
1024            } else if role == proto::ChannelRole::Member {
1025                context_menu.entry(
1026                    "Revoke Write Access",
1027                    None,
1028                    cx.handler_for(&this, move |_, cx| {
1029                        ActiveCall::global(cx)
1030                            .update(cx, |call, cx| {
1031                                let Some(room) = call.room() else {
1032                                    return Task::ready(Ok(()));
1033                                };
1034                                room.update(cx, |room, cx| {
1035                                    room.set_participant_role(
1036                                        user_id,
1037                                        proto::ChannelRole::Guest,
1038                                        cx,
1039                                    )
1040                                })
1041                            })
1042                            .detach_and_notify_err(cx)
1043                    }),
1044                )
1045            } else {
1046                unreachable!()
1047            }
1048        });
1049
1050        cx.focus_view(&context_menu);
1051        let subscription =
1052            cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
1053                if this.context_menu.as_ref().is_some_and(|context_menu| {
1054                    context_menu.0.focus_handle(cx).contains_focused(cx)
1055                }) {
1056                    cx.focus_self();
1057                }
1058                this.context_menu.take();
1059                cx.notify();
1060            });
1061        self.context_menu = Some((context_menu, position, subscription));
1062    }
1063
1064    fn deploy_channel_context_menu(
1065        &mut self,
1066        position: Point<Pixels>,
1067        channel_id: ChannelId,
1068        ix: usize,
1069        cx: &mut ViewContext<Self>,
1070    ) {
1071        let clipboard_channel_name = self.channel_clipboard.as_ref().and_then(|clipboard| {
1072            self.channel_store
1073                .read(cx)
1074                .channel_for_id(clipboard.channel_id)
1075                .map(|channel| channel.name.clone())
1076        });
1077        let this = cx.view().clone();
1078
1079        let context_menu = ContextMenu::build(cx, |mut context_menu, cx| {
1080            if self.has_subchannels(ix) {
1081                let expand_action_name = if self.is_channel_collapsed(channel_id) {
1082                    "Expand Subchannels"
1083                } else {
1084                    "Collapse Subchannels"
1085                };
1086                context_menu = context_menu.entry(
1087                    expand_action_name,
1088                    None,
1089                    cx.handler_for(&this, move |this, cx| {
1090                        this.toggle_channel_collapsed(channel_id, cx)
1091                    }),
1092                );
1093            }
1094
1095            context_menu = context_menu
1096                .entry(
1097                    "Open Notes",
1098                    None,
1099                    cx.handler_for(&this, move |this, cx| {
1100                        this.open_channel_notes(channel_id, cx)
1101                    }),
1102                )
1103                .entry(
1104                    "Open Chat",
1105                    None,
1106                    cx.handler_for(&this, move |this, cx| {
1107                        this.join_channel_chat(channel_id, cx)
1108                    }),
1109                )
1110                .entry(
1111                    "Copy Channel Link",
1112                    None,
1113                    cx.handler_for(&this, move |this, cx| {
1114                        this.copy_channel_link(channel_id, cx)
1115                    }),
1116                );
1117
1118            if self.channel_store.read(cx).is_channel_admin(channel_id) {
1119                context_menu = context_menu
1120                    .separator()
1121                    .entry(
1122                        "New Subchannel",
1123                        None,
1124                        cx.handler_for(&this, move |this, cx| this.new_subchannel(channel_id, cx)),
1125                    )
1126                    .entry(
1127                        "Rename",
1128                        None,
1129                        cx.handler_for(&this, move |this, cx| this.rename_channel(channel_id, cx)),
1130                    )
1131                    .entry(
1132                        "Move this channel",
1133                        None,
1134                        cx.handler_for(&this, move |this, cx| {
1135                            this.start_move_channel(channel_id, cx)
1136                        }),
1137                    );
1138
1139                if let Some(channel_name) = clipboard_channel_name {
1140                    context_menu = context_menu.separator().entry(
1141                        format!("Move '#{}' here", channel_name),
1142                        None,
1143                        cx.handler_for(&this, move |this, cx| {
1144                            this.move_channel_on_clipboard(channel_id, cx)
1145                        }),
1146                    );
1147                }
1148
1149                context_menu = context_menu
1150                    .separator()
1151                    .entry(
1152                        "Invite Members",
1153                        None,
1154                        cx.handler_for(&this, move |this, cx| this.invite_members(channel_id, cx)),
1155                    )
1156                    .entry(
1157                        "Manage Members",
1158                        None,
1159                        cx.handler_for(&this, move |this, cx| this.manage_members(channel_id, cx)),
1160                    )
1161                    .entry(
1162                        "Delete",
1163                        None,
1164                        cx.handler_for(&this, move |this, cx| this.remove_channel(channel_id, cx)),
1165                    );
1166            }
1167
1168            context_menu
1169        });
1170
1171        cx.focus_view(&context_menu);
1172        let subscription =
1173            cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
1174                if this.context_menu.as_ref().is_some_and(|context_menu| {
1175                    context_menu.0.focus_handle(cx).contains_focused(cx)
1176                }) {
1177                    cx.focus_self();
1178                }
1179                this.context_menu.take();
1180                cx.notify();
1181            });
1182        self.context_menu = Some((context_menu, position, subscription));
1183
1184        cx.notify();
1185    }
1186
1187    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
1188        if self.take_editing_state(cx) {
1189            cx.focus_view(&self.filter_editor);
1190        } else {
1191            self.filter_editor.update(cx, |editor, cx| {
1192                if editor.buffer().read(cx).len(cx) > 0 {
1193                    editor.set_text("", cx);
1194                }
1195            });
1196        }
1197
1198        self.update_entries(false, cx);
1199    }
1200
1201    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1202        let ix = self.selection.map_or(0, |ix| ix + 1);
1203        if ix < self.entries.len() {
1204            self.selection = Some(ix);
1205        }
1206
1207        if let Some(ix) = self.selection {
1208            self.scroll_to_item(ix)
1209        }
1210        cx.notify();
1211    }
1212
1213    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
1214        let ix = self.selection.take().unwrap_or(0);
1215        if ix > 0 {
1216            self.selection = Some(ix - 1);
1217        }
1218
1219        if let Some(ix) = self.selection {
1220            self.scroll_to_item(ix)
1221        }
1222        cx.notify();
1223    }
1224
1225    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1226        if self.confirm_channel_edit(cx) {
1227            return;
1228        }
1229
1230        if let Some(selection) = self.selection {
1231            if let Some(entry) = self.entries.get(selection) {
1232                match entry {
1233                    ListEntry::Header(section) => match section {
1234                        Section::ActiveCall => Self::leave_call(cx),
1235                        Section::Channels => self.new_root_channel(cx),
1236                        Section::Contacts => self.toggle_contact_finder(cx),
1237                        Section::ContactRequests
1238                        | Section::Online
1239                        | Section::Offline
1240                        | Section::ChannelInvites => {
1241                            self.toggle_section_expanded(*section, cx);
1242                        }
1243                    },
1244                    ListEntry::Contact { contact, calling } => {
1245                        if contact.online && !contact.busy && !calling {
1246                            self.call(contact.user.id, cx);
1247                        }
1248                    }
1249                    ListEntry::ParticipantProject {
1250                        project_id,
1251                        host_user_id,
1252                        ..
1253                    } => {
1254                        if let Some(workspace) = self.workspace.upgrade() {
1255                            let app_state = workspace.read(cx).app_state().clone();
1256                            workspace::join_remote_project(
1257                                *project_id,
1258                                *host_user_id,
1259                                app_state,
1260                                cx,
1261                            )
1262                            .detach_and_log_err(cx);
1263                        }
1264                    }
1265                    ListEntry::ParticipantScreen { peer_id, .. } => {
1266                        let Some(peer_id) = peer_id else {
1267                            return;
1268                        };
1269                        if let Some(workspace) = self.workspace.upgrade() {
1270                            workspace.update(cx, |workspace, cx| {
1271                                workspace.open_shared_screen(*peer_id, cx)
1272                            });
1273                        }
1274                    }
1275                    ListEntry::Channel { channel, .. } => {
1276                        let is_active = maybe!({
1277                            let call_channel = ActiveCall::global(cx)
1278                                .read(cx)
1279                                .room()?
1280                                .read(cx)
1281                                .channel_id()?;
1282
1283                            Some(call_channel == channel.id)
1284                        })
1285                        .unwrap_or(false);
1286                        if is_active {
1287                            self.open_channel_notes(channel.id, cx)
1288                        } else {
1289                            self.join_channel(channel.id, cx)
1290                        }
1291                    }
1292                    ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
1293                    ListEntry::CallParticipant { user, peer_id, .. } => {
1294                        if Some(user) == self.user_store.read(cx).current_user().as_ref() {
1295                            Self::leave_call(cx);
1296                        } else if let Some(peer_id) = peer_id {
1297                            self.workspace
1298                                .update(cx, |workspace, cx| workspace.follow(*peer_id, cx))
1299                                .ok();
1300                        }
1301                    }
1302                    ListEntry::IncomingRequest(user) => {
1303                        self.respond_to_contact_request(user.id, true, cx)
1304                    }
1305                    ListEntry::ChannelInvite(channel) => {
1306                        self.respond_to_channel_invite(channel.id, true, cx)
1307                    }
1308                    ListEntry::ChannelNotes { channel_id } => {
1309                        self.open_channel_notes(*channel_id, cx)
1310                    }
1311                    ListEntry::ChannelChat { channel_id } => {
1312                        self.join_channel_chat(*channel_id, cx)
1313                    }
1314
1315                    ListEntry::OutgoingRequest(_) => {}
1316                    ListEntry::ChannelEditor { .. } => {}
1317                }
1318            }
1319        }
1320    }
1321
1322    fn insert_space(&mut self, _: &InsertSpace, cx: &mut ViewContext<Self>) {
1323        if self.channel_editing_state.is_some() {
1324            self.channel_name_editor.update(cx, |editor, cx| {
1325                editor.insert(" ", cx);
1326            });
1327        }
1328    }
1329
1330    fn confirm_channel_edit(&mut self, cx: &mut ViewContext<CollabPanel>) -> bool {
1331        if let Some(editing_state) = &mut self.channel_editing_state {
1332            match editing_state {
1333                ChannelEditingState::Create {
1334                    location,
1335                    pending_name,
1336                    ..
1337                } => {
1338                    if pending_name.is_some() {
1339                        return false;
1340                    }
1341                    let channel_name = self.channel_name_editor.read(cx).text(cx);
1342
1343                    *pending_name = Some(channel_name.clone());
1344
1345                    self.channel_store
1346                        .update(cx, |channel_store, cx| {
1347                            channel_store.create_channel(&channel_name, *location, cx)
1348                        })
1349                        .detach();
1350                    cx.notify();
1351                }
1352                ChannelEditingState::Rename {
1353                    location,
1354                    pending_name,
1355                } => {
1356                    if pending_name.is_some() {
1357                        return false;
1358                    }
1359                    let channel_name = self.channel_name_editor.read(cx).text(cx);
1360                    *pending_name = Some(channel_name.clone());
1361
1362                    self.channel_store
1363                        .update(cx, |channel_store, cx| {
1364                            channel_store.rename(*location, &channel_name, cx)
1365                        })
1366                        .detach();
1367                    cx.notify();
1368                }
1369            }
1370            cx.focus_self();
1371            true
1372        } else {
1373            false
1374        }
1375    }
1376
1377    fn toggle_section_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
1378        if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
1379            self.collapsed_sections.remove(ix);
1380        } else {
1381            self.collapsed_sections.push(section);
1382        }
1383        self.update_entries(false, cx);
1384    }
1385
1386    fn collapse_selected_channel(
1387        &mut self,
1388        _: &CollapseSelectedChannel,
1389        cx: &mut ViewContext<Self>,
1390    ) {
1391        let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
1392            return;
1393        };
1394
1395        if self.is_channel_collapsed(channel_id) {
1396            return;
1397        }
1398
1399        self.toggle_channel_collapsed(channel_id, cx);
1400    }
1401
1402    fn expand_selected_channel(&mut self, _: &ExpandSelectedChannel, cx: &mut ViewContext<Self>) {
1403        let Some(id) = self.selected_channel().map(|channel| channel.id) else {
1404            return;
1405        };
1406
1407        if !self.is_channel_collapsed(id) {
1408            return;
1409        }
1410
1411        self.toggle_channel_collapsed(id, cx)
1412    }
1413
1414    fn toggle_channel_collapsed<'a>(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1415        match self.collapsed_channels.binary_search(&channel_id) {
1416            Ok(ix) => {
1417                self.collapsed_channels.remove(ix);
1418            }
1419            Err(ix) => {
1420                self.collapsed_channels.insert(ix, channel_id);
1421            }
1422        };
1423        self.serialize(cx);
1424        self.update_entries(true, cx);
1425        cx.notify();
1426        cx.focus_self();
1427    }
1428
1429    fn is_channel_collapsed(&self, channel_id: ChannelId) -> bool {
1430        self.collapsed_channels.binary_search(&channel_id).is_ok()
1431    }
1432
1433    fn leave_call(cx: &mut WindowContext) {
1434        ActiveCall::global(cx)
1435            .update(cx, |call, cx| call.hang_up(cx))
1436            .detach_and_log_err(cx);
1437    }
1438
1439    fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
1440        if let Some(workspace) = self.workspace.upgrade() {
1441            workspace.update(cx, |workspace, cx| {
1442                workspace.toggle_modal(cx, |cx| {
1443                    let mut finder = ContactFinder::new(self.user_store.clone(), cx);
1444                    finder.set_query(self.filter_editor.read(cx).text(cx), cx);
1445                    finder
1446                });
1447            });
1448        }
1449    }
1450
1451    fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
1452        self.channel_editing_state = Some(ChannelEditingState::Create {
1453            location: None,
1454            pending_name: None,
1455        });
1456        self.update_entries(false, cx);
1457        self.select_channel_editor();
1458        cx.focus_view(&self.channel_name_editor);
1459        cx.notify();
1460    }
1461
1462    fn select_channel_editor(&mut self) {
1463        self.selection = self.entries.iter().position(|entry| match entry {
1464            ListEntry::ChannelEditor { .. } => true,
1465            _ => false,
1466        });
1467    }
1468
1469    fn new_subchannel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1470        self.collapsed_channels
1471            .retain(|channel| *channel != channel_id);
1472        self.channel_editing_state = Some(ChannelEditingState::Create {
1473            location: Some(channel_id),
1474            pending_name: None,
1475        });
1476        self.update_entries(false, cx);
1477        self.select_channel_editor();
1478        cx.focus_view(&self.channel_name_editor);
1479        cx.notify();
1480    }
1481
1482    fn invite_members(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1483        self.show_channel_modal(channel_id, channel_modal::Mode::InviteMembers, cx);
1484    }
1485
1486    fn manage_members(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1487        self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, cx);
1488    }
1489
1490    fn remove_selected_channel(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
1491        if let Some(channel) = self.selected_channel() {
1492            self.remove_channel(channel.id, cx)
1493        }
1494    }
1495
1496    fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
1497        if let Some(channel) = self.selected_channel() {
1498            self.rename_channel(channel.id, cx);
1499        }
1500    }
1501
1502    fn rename_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1503        let channel_store = self.channel_store.read(cx);
1504        if !channel_store.is_channel_admin(channel_id) {
1505            return;
1506        }
1507        if let Some(channel) = channel_store.channel_for_id(channel_id).cloned() {
1508            self.channel_editing_state = Some(ChannelEditingState::Rename {
1509                location: channel_id,
1510                pending_name: None,
1511            });
1512            self.channel_name_editor.update(cx, |editor, cx| {
1513                editor.set_text(channel.name.clone(), cx);
1514                editor.select_all(&Default::default(), cx);
1515            });
1516            cx.focus_view(&self.channel_name_editor);
1517            self.update_entries(false, cx);
1518            self.select_channel_editor();
1519        }
1520    }
1521
1522    fn start_move_channel(&mut self, channel_id: ChannelId, _cx: &mut ViewContext<Self>) {
1523        self.channel_clipboard = Some(ChannelMoveClipboard { channel_id });
1524    }
1525
1526    fn start_move_selected_channel(&mut self, _: &StartMoveChannel, cx: &mut ViewContext<Self>) {
1527        if let Some(channel) = self.selected_channel() {
1528            self.start_move_channel(channel.id, cx);
1529        }
1530    }
1531
1532    fn move_channel_on_clipboard(
1533        &mut self,
1534        to_channel_id: ChannelId,
1535        cx: &mut ViewContext<CollabPanel>,
1536    ) {
1537        if let Some(clipboard) = self.channel_clipboard.take() {
1538            self.channel_store.update(cx, |channel_store, cx| {
1539                channel_store
1540                    .move_channel(clipboard.channel_id, Some(to_channel_id), cx)
1541                    .detach_and_log_err(cx)
1542            })
1543        }
1544    }
1545
1546    fn open_channel_notes(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1547        if let Some(workspace) = self.workspace.upgrade() {
1548            ChannelView::open(channel_id, workspace, cx).detach();
1549        }
1550    }
1551
1552    fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
1553        let Some(channel) = self.selected_channel() else {
1554            return;
1555        };
1556        let Some(bounds) = self
1557            .selection
1558            .and_then(|ix| self.list_state.bounds_for_item(ix))
1559        else {
1560            return;
1561        };
1562
1563        self.deploy_channel_context_menu(bounds.center(), channel.id, self.selection.unwrap(), cx);
1564        cx.stop_propagation();
1565    }
1566
1567    fn selected_channel(&self) -> Option<&Arc<Channel>> {
1568        self.selection
1569            .and_then(|ix| self.entries.get(ix))
1570            .and_then(|entry| match entry {
1571                ListEntry::Channel { channel, .. } => Some(channel),
1572                _ => None,
1573            })
1574    }
1575
1576    fn show_channel_modal(
1577        &mut self,
1578        channel_id: ChannelId,
1579        mode: channel_modal::Mode,
1580        cx: &mut ViewContext<Self>,
1581    ) {
1582        let workspace = self.workspace.clone();
1583        let user_store = self.user_store.clone();
1584        let channel_store = self.channel_store.clone();
1585        let members = self.channel_store.update(cx, |channel_store, cx| {
1586            channel_store.get_channel_member_details(channel_id, cx)
1587        });
1588
1589        cx.spawn(|_, mut cx| async move {
1590            let members = members.await?;
1591            workspace.update(&mut cx, |workspace, cx| {
1592                workspace.toggle_modal(cx, |cx| {
1593                    ChannelModal::new(
1594                        user_store.clone(),
1595                        channel_store.clone(),
1596                        channel_id,
1597                        mode,
1598                        members,
1599                        cx,
1600                    )
1601                });
1602            })
1603        })
1604        .detach();
1605    }
1606
1607    fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1608        let channel_store = self.channel_store.clone();
1609        if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
1610            let prompt_message = format!(
1611                "Are you sure you want to remove the channel \"{}\"?",
1612                channel.name
1613            );
1614            let answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
1615            cx.spawn(|this, mut cx| async move {
1616                if answer.await? == 0 {
1617                    channel_store
1618                        .update(&mut cx, |channels, _| channels.remove_channel(channel_id))?
1619                        .await
1620                        .notify_async_err(&mut cx);
1621                    this.update(&mut cx, |_, cx| cx.focus_self()).ok();
1622                }
1623                anyhow::Ok(())
1624            })
1625            .detach();
1626        }
1627    }
1628
1629    fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
1630        let user_store = self.user_store.clone();
1631        let prompt_message = format!(
1632            "Are you sure you want to remove \"{}\" from your contacts?",
1633            github_login
1634        );
1635        let answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
1636        cx.spawn(|_, mut cx| async move {
1637            if answer.await? == 0 {
1638                user_store
1639                    .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))?
1640                    .await
1641                    .notify_async_err(&mut cx);
1642            }
1643            anyhow::Ok(())
1644        })
1645        .detach_and_log_err(cx);
1646    }
1647
1648    fn respond_to_contact_request(
1649        &mut self,
1650        user_id: u64,
1651        accept: bool,
1652        cx: &mut ViewContext<Self>,
1653    ) {
1654        self.user_store
1655            .update(cx, |store, cx| {
1656                store.respond_to_contact_request(user_id, accept, cx)
1657            })
1658            .detach_and_log_err(cx);
1659    }
1660
1661    fn respond_to_channel_invite(
1662        &mut self,
1663        channel_id: u64,
1664        accept: bool,
1665        cx: &mut ViewContext<Self>,
1666    ) {
1667        self.channel_store
1668            .update(cx, |store, cx| {
1669                store.respond_to_channel_invite(channel_id, accept, cx)
1670            })
1671            .detach();
1672    }
1673
1674    fn call(&mut self, recipient_user_id: u64, cx: &mut ViewContext<Self>) {
1675        ActiveCall::global(cx)
1676            .update(cx, |call, cx| {
1677                call.invite(recipient_user_id, Some(self.project.clone()), cx)
1678            })
1679            .detach_and_log_err(cx);
1680    }
1681
1682    fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
1683        let Some(workspace) = self.workspace.upgrade() else {
1684            return;
1685        };
1686        let Some(handle) = cx.window_handle().downcast::<Workspace>() else {
1687            return;
1688        };
1689        workspace::join_channel(
1690            channel_id,
1691            workspace.read(cx).app_state().clone(),
1692            Some(handle),
1693            cx,
1694        )
1695        .detach_and_log_err(cx)
1696    }
1697
1698    fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1699        let Some(workspace) = self.workspace.upgrade() else {
1700            return;
1701        };
1702        cx.window_context().defer(move |cx| {
1703            workspace.update(cx, |workspace, cx| {
1704                if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
1705                    panel.update(cx, |panel, cx| {
1706                        panel
1707                            .select_channel(channel_id, None, cx)
1708                            .detach_and_log_err(cx);
1709                    });
1710                }
1711            });
1712        });
1713    }
1714
1715    fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
1716        let channel_store = self.channel_store.read(cx);
1717        let Some(channel) = channel_store.channel_for_id(channel_id) else {
1718            return;
1719        };
1720        let item = ClipboardItem::new(channel.link());
1721        cx.write_to_clipboard(item)
1722    }
1723
1724    fn render_signed_out(&mut self, cx: &mut ViewContext<Self>) -> Div {
1725        let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more.";
1726
1727        v_flex()
1728            .gap_6()
1729            .p_4()
1730            .child(Label::new(collab_blurb))
1731            .child(
1732                v_flex()
1733                    .gap_2()
1734                    .child(
1735                        Button::new("sign_in", "Sign in")
1736                            .icon_color(Color::Muted)
1737                            .icon(IconName::Github)
1738                            .icon_position(IconPosition::Start)
1739                            .style(ButtonStyle::Filled)
1740                            .full_width()
1741                            .on_click(cx.listener(|this, _, cx| {
1742                                let client = this.client.clone();
1743                                cx.spawn(|_, mut cx| async move {
1744                                    client
1745                                        .authenticate_and_connect(true, &cx)
1746                                        .await
1747                                        .notify_async_err(&mut cx);
1748                                })
1749                                .detach()
1750                            })),
1751                    )
1752                    .child(
1753                        div().flex().w_full().items_center().child(
1754                            Label::new("Sign in to enable collaboration.")
1755                                .color(Color::Muted)
1756                                .size(LabelSize::Small),
1757                        ),
1758                    ),
1759            )
1760    }
1761
1762    fn render_list_entry(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement {
1763        let entry = &self.entries[ix];
1764
1765        let is_selected = self.selection == Some(ix);
1766        match entry {
1767            ListEntry::Header(section) => {
1768                let is_collapsed = self.collapsed_sections.contains(section);
1769                self.render_header(*section, is_selected, is_collapsed, cx)
1770                    .into_any_element()
1771            }
1772            ListEntry::Contact { contact, calling } => self
1773                .render_contact(contact, *calling, is_selected, cx)
1774                .into_any_element(),
1775            ListEntry::ContactPlaceholder => self
1776                .render_contact_placeholder(is_selected, cx)
1777                .into_any_element(),
1778            ListEntry::IncomingRequest(user) => self
1779                .render_contact_request(user, true, is_selected, cx)
1780                .into_any_element(),
1781            ListEntry::OutgoingRequest(user) => self
1782                .render_contact_request(user, false, is_selected, cx)
1783                .into_any_element(),
1784            ListEntry::Channel {
1785                channel,
1786                depth,
1787                has_children,
1788            } => self
1789                .render_channel(channel, *depth, *has_children, is_selected, ix, cx)
1790                .into_any_element(),
1791            ListEntry::ChannelEditor { depth } => {
1792                self.render_channel_editor(*depth, cx).into_any_element()
1793            }
1794            ListEntry::ChannelInvite(channel) => self
1795                .render_channel_invite(channel, is_selected, cx)
1796                .into_any_element(),
1797            ListEntry::CallParticipant {
1798                user,
1799                peer_id,
1800                is_pending,
1801                role,
1802            } => self
1803                .render_call_participant(user, *peer_id, *is_pending, *role, is_selected, cx)
1804                .into_any_element(),
1805            ListEntry::ParticipantProject {
1806                project_id,
1807                worktree_root_names,
1808                host_user_id,
1809                is_last,
1810            } => self
1811                .render_participant_project(
1812                    *project_id,
1813                    &worktree_root_names,
1814                    *host_user_id,
1815                    *is_last,
1816                    is_selected,
1817                    cx,
1818                )
1819                .into_any_element(),
1820            ListEntry::ParticipantScreen { peer_id, is_last } => self
1821                .render_participant_screen(*peer_id, *is_last, is_selected, cx)
1822                .into_any_element(),
1823            ListEntry::ChannelNotes { channel_id } => self
1824                .render_channel_notes(*channel_id, is_selected, cx)
1825                .into_any_element(),
1826            ListEntry::ChannelChat { channel_id } => self
1827                .render_channel_chat(*channel_id, is_selected, cx)
1828                .into_any_element(),
1829        }
1830    }
1831
1832    fn render_signed_in(&mut self, cx: &mut ViewContext<Self>) -> Div {
1833        v_flex()
1834            .size_full()
1835            .child(list(self.list_state.clone()).full())
1836            .child(
1837                v_flex()
1838                    .child(div().mx_2().border_primary(cx).border_t())
1839                    .child(
1840                        v_flex()
1841                            .p_2()
1842                            .child(self.render_filter_input(&self.filter_editor, cx)),
1843                    ),
1844            )
1845    }
1846
1847    fn render_filter_input(
1848        &self,
1849        editor: &View<Editor>,
1850        cx: &mut ViewContext<Self>,
1851    ) -> impl IntoElement {
1852        let settings = ThemeSettings::get_global(cx);
1853        let text_style = TextStyle {
1854            color: if editor.read(cx).read_only(cx) {
1855                cx.theme().colors().text_disabled
1856            } else {
1857                cx.theme().colors().text
1858            },
1859            font_family: settings.ui_font.family.clone(),
1860            font_features: settings.ui_font.features,
1861            font_size: rems(0.875).into(),
1862            font_weight: FontWeight::NORMAL,
1863            font_style: FontStyle::Normal,
1864            line_height: relative(1.3).into(),
1865            background_color: None,
1866            underline: None,
1867            white_space: WhiteSpace::Normal,
1868        };
1869
1870        EditorElement::new(
1871            editor,
1872            EditorStyle {
1873                local_player: cx.theme().players().local(),
1874                text: text_style,
1875                ..Default::default()
1876            },
1877        )
1878    }
1879
1880    fn render_header(
1881        &self,
1882        section: Section,
1883        is_selected: bool,
1884        is_collapsed: bool,
1885        cx: &ViewContext<Self>,
1886    ) -> impl IntoElement {
1887        let mut channel_link = None;
1888        let mut channel_tooltip_text = None;
1889        let mut channel_icon = None;
1890
1891        let text = match section {
1892            Section::ActiveCall => {
1893                let channel_name = maybe!({
1894                    let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
1895
1896                    let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
1897
1898                    channel_link = Some(channel.link());
1899                    (channel_icon, channel_tooltip_text) = match channel.visibility {
1900                        proto::ChannelVisibility::Public => {
1901                            (Some("icons/public.svg"), Some("Copy public channel link."))
1902                        }
1903                        proto::ChannelVisibility::Members => {
1904                            (Some("icons/hash.svg"), Some("Copy private channel link."))
1905                        }
1906                    };
1907
1908                    Some(channel.name.as_ref())
1909                });
1910
1911                if let Some(name) = channel_name {
1912                    SharedString::from(format!("{}", name))
1913                } else {
1914                    SharedString::from("Current Call")
1915                }
1916            }
1917            Section::ContactRequests => SharedString::from("Requests"),
1918            Section::Contacts => SharedString::from("Contacts"),
1919            Section::Channels => SharedString::from("Channels"),
1920            Section::ChannelInvites => SharedString::from("Invites"),
1921            Section::Online => SharedString::from("Online"),
1922            Section::Offline => SharedString::from("Offline"),
1923        };
1924
1925        let button = match section {
1926            Section::ActiveCall => channel_link.map(|channel_link| {
1927                let channel_link_copy = channel_link.clone();
1928                IconButton::new("channel-link", IconName::Copy)
1929                    .icon_size(IconSize::Small)
1930                    .size(ButtonSize::None)
1931                    .visible_on_hover("section-header")
1932                    .on_click(move |_, cx| {
1933                        let item = ClipboardItem::new(channel_link_copy.clone());
1934                        cx.write_to_clipboard(item)
1935                    })
1936                    .tooltip(|cx| Tooltip::text("Copy channel link", cx))
1937                    .into_any_element()
1938            }),
1939            Section::Contacts => Some(
1940                IconButton::new("add-contact", IconName::Plus)
1941                    .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
1942                    .tooltip(|cx| Tooltip::text("Search for new contact", cx))
1943                    .into_any_element(),
1944            ),
1945            Section::Channels => Some(
1946                IconButton::new("add-channel", IconName::Plus)
1947                    .on_click(cx.listener(|this, _, cx| this.new_root_channel(cx)))
1948                    .tooltip(|cx| Tooltip::text("Create a channel", cx))
1949                    .into_any_element(),
1950            ),
1951            _ => None,
1952        };
1953
1954        let can_collapse = match section {
1955            Section::ActiveCall | Section::Channels | Section::Contacts => false,
1956            Section::ChannelInvites
1957            | Section::ContactRequests
1958            | Section::Online
1959            | Section::Offline => true,
1960        };
1961
1962        h_flex()
1963            .w_full()
1964            .group("section-header")
1965            .child(
1966                ListHeader::new(text)
1967                    .when(can_collapse, |header| {
1968                        header.toggle(Some(!is_collapsed)).on_toggle(cx.listener(
1969                            move |this, _, cx| {
1970                                this.toggle_section_expanded(section, cx);
1971                            },
1972                        ))
1973                    })
1974                    .inset(true)
1975                    .end_slot::<AnyElement>(button)
1976                    .selected(is_selected),
1977            )
1978            .when(section == Section::Channels, |el| {
1979                el.drag_over::<Channel>(|style| style.bg(cx.theme().colors().ghost_element_hover))
1980                    .on_drop(cx.listener(move |this, dragged_channel: &Channel, cx| {
1981                        this.channel_store
1982                            .update(cx, |channel_store, cx| {
1983                                channel_store.move_channel(dragged_channel.id, None, cx)
1984                            })
1985                            .detach_and_log_err(cx)
1986                    }))
1987            })
1988    }
1989
1990    fn render_contact(
1991        &self,
1992        contact: &Contact,
1993        calling: bool,
1994        is_selected: bool,
1995        cx: &mut ViewContext<Self>,
1996    ) -> impl IntoElement {
1997        let online = contact.online;
1998        let busy = contact.busy || calling;
1999        let user_id = contact.user.id;
2000        let github_login = SharedString::from(contact.user.github_login.clone());
2001        let item = ListItem::new(github_login.clone())
2002            .indent_level(1)
2003            .indent_step_size(px(20.))
2004            .selected(is_selected)
2005            .on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
2006            .child(
2007                h_flex()
2008                    .w_full()
2009                    .justify_between()
2010                    .child(Label::new(github_login.clone()))
2011                    .when(calling, |el| {
2012                        el.child(Label::new("Calling").color(Color::Muted))
2013                    })
2014                    .when(!calling, |el| {
2015                        el.child(
2016                            IconButton::new("remove_contact", IconName::Close)
2017                                .icon_color(Color::Muted)
2018                                .visible_on_hover("")
2019                                .tooltip(|cx| Tooltip::text("Remove Contact", cx))
2020                                .on_click(cx.listener({
2021                                    let github_login = github_login.clone();
2022                                    move |this, _, cx| {
2023                                        this.remove_contact(user_id, &github_login, cx);
2024                                    }
2025                                })),
2026                        )
2027                    }),
2028            )
2029            .start_slot(
2030                // todo handle contacts with no avatar
2031                Avatar::new(contact.user.avatar_uri.clone())
2032                    .indicator::<AvatarAvailabilityIndicator>(if online {
2033                        Some(AvatarAvailabilityIndicator::new(match busy {
2034                            true => ui::Availability::Busy,
2035                            false => ui::Availability::Free,
2036                        }))
2037                    } else {
2038                        None
2039                    }),
2040            )
2041            .when(online && !busy, |el| {
2042                el.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
2043            });
2044
2045        div()
2046            .id(github_login.clone())
2047            .group("")
2048            .child(item)
2049            .tooltip(move |cx| {
2050                let text = if !online {
2051                    format!(" {} is offline", &github_login)
2052                } else if busy {
2053                    format!(" {} is on a call", &github_login)
2054                } else {
2055                    let room = ActiveCall::global(cx).read(cx).room();
2056                    if room.is_some() {
2057                        format!("Invite {} to join call", &github_login)
2058                    } else {
2059                        format!("Call {}", &github_login)
2060                    }
2061                };
2062                Tooltip::text(text, cx)
2063            })
2064    }
2065
2066    fn render_contact_request(
2067        &self,
2068        user: &Arc<User>,
2069        is_incoming: bool,
2070        is_selected: bool,
2071        cx: &mut ViewContext<Self>,
2072    ) -> impl IntoElement {
2073        let github_login = SharedString::from(user.github_login.clone());
2074        let user_id = user.id;
2075        let is_response_pending = self.user_store.read(cx).is_contact_request_pending(&user);
2076        let color = if is_response_pending {
2077            Color::Muted
2078        } else {
2079            Color::Default
2080        };
2081
2082        let controls = if is_incoming {
2083            vec![
2084                IconButton::new("decline-contact", IconName::Close)
2085                    .on_click(cx.listener(move |this, _, cx| {
2086                        this.respond_to_contact_request(user_id, false, cx);
2087                    }))
2088                    .icon_color(color)
2089                    .tooltip(|cx| Tooltip::text("Decline invite", cx)),
2090                IconButton::new("accept-contact", IconName::Check)
2091                    .on_click(cx.listener(move |this, _, cx| {
2092                        this.respond_to_contact_request(user_id, true, cx);
2093                    }))
2094                    .icon_color(color)
2095                    .tooltip(|cx| Tooltip::text("Accept invite", cx)),
2096            ]
2097        } else {
2098            let github_login = github_login.clone();
2099            vec![IconButton::new("remove_contact", IconName::Close)
2100                .on_click(cx.listener(move |this, _, cx| {
2101                    this.remove_contact(user_id, &github_login, cx);
2102                }))
2103                .icon_color(color)
2104                .tooltip(|cx| Tooltip::text("Cancel invite", cx))]
2105        };
2106
2107        ListItem::new(github_login.clone())
2108            .indent_level(1)
2109            .indent_step_size(px(20.))
2110            .selected(is_selected)
2111            .child(
2112                h_flex()
2113                    .w_full()
2114                    .justify_between()
2115                    .child(Label::new(github_login.clone()))
2116                    .child(h_flex().children(controls)),
2117            )
2118            .start_slot(Avatar::new(user.avatar_uri.clone()))
2119    }
2120
2121    fn render_channel_invite(
2122        &self,
2123        channel: &Arc<Channel>,
2124        is_selected: bool,
2125        cx: &mut ViewContext<Self>,
2126    ) -> ListItem {
2127        let channel_id = channel.id;
2128        let response_is_pending = self
2129            .channel_store
2130            .read(cx)
2131            .has_pending_channel_invite_response(&channel);
2132        let color = if response_is_pending {
2133            Color::Muted
2134        } else {
2135            Color::Default
2136        };
2137
2138        let controls = [
2139            IconButton::new("reject-invite", IconName::Close)
2140                .on_click(cx.listener(move |this, _, cx| {
2141                    this.respond_to_channel_invite(channel_id, false, cx);
2142                }))
2143                .icon_color(color)
2144                .tooltip(|cx| Tooltip::text("Decline invite", cx)),
2145            IconButton::new("accept-invite", IconName::Check)
2146                .on_click(cx.listener(move |this, _, cx| {
2147                    this.respond_to_channel_invite(channel_id, true, cx);
2148                }))
2149                .icon_color(color)
2150                .tooltip(|cx| Tooltip::text("Accept invite", cx)),
2151        ];
2152
2153        ListItem::new(("channel-invite", channel.id as usize))
2154            .selected(is_selected)
2155            .child(
2156                h_flex()
2157                    .w_full()
2158                    .justify_between()
2159                    .child(Label::new(channel.name.clone()))
2160                    .child(h_flex().children(controls)),
2161            )
2162            .start_slot(
2163                Icon::new(IconName::Hash)
2164                    .size(IconSize::Small)
2165                    .color(Color::Muted),
2166            )
2167    }
2168
2169    fn render_contact_placeholder(
2170        &self,
2171        is_selected: bool,
2172        cx: &mut ViewContext<Self>,
2173    ) -> ListItem {
2174        ListItem::new("contact-placeholder")
2175            .child(Icon::new(IconName::Plus))
2176            .child(Label::new("Add a Contact"))
2177            .selected(is_selected)
2178            .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
2179    }
2180
2181    fn render_channel(
2182        &self,
2183        channel: &Channel,
2184        depth: usize,
2185        has_children: bool,
2186        is_selected: bool,
2187        ix: usize,
2188        cx: &mut ViewContext<Self>,
2189    ) -> impl IntoElement {
2190        let channel_id = channel.id;
2191
2192        let is_active = maybe!({
2193            let call_channel = ActiveCall::global(cx)
2194                .read(cx)
2195                .room()?
2196                .read(cx)
2197                .channel_id()?;
2198            Some(call_channel == channel_id)
2199        })
2200        .unwrap_or(false);
2201        let is_public = self
2202            .channel_store
2203            .read(cx)
2204            .channel_for_id(channel_id)
2205            .map(|channel| channel.visibility)
2206            == Some(proto::ChannelVisibility::Public);
2207        let disclosed =
2208            has_children.then(|| !self.collapsed_channels.binary_search(&channel.id).is_ok());
2209
2210        let has_messages_notification = channel.unseen_message_id.is_some();
2211        let has_notes_notification = channel.unseen_note_version.is_some();
2212
2213        const FACEPILE_LIMIT: usize = 3;
2214        let participants = self.channel_store.read(cx).channel_participants(channel_id);
2215
2216        let face_pile = if !participants.is_empty() {
2217            let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
2218            let result = FacePile {
2219                faces: participants
2220                    .iter()
2221                    .map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
2222                    .take(FACEPILE_LIMIT)
2223                    .chain(if extra_count > 0 {
2224                        Some(
2225                            div()
2226                                .ml_1()
2227                                .child(Label::new(format!("+{extra_count}")))
2228                                .into_any_element(),
2229                        )
2230                    } else {
2231                        None
2232                    })
2233                    .collect::<SmallVec<_>>(),
2234            };
2235
2236            Some(result)
2237        } else {
2238            None
2239        };
2240
2241        let width = self.width.unwrap_or(px(240.));
2242
2243        div()
2244            .id(channel_id as usize)
2245            .group("")
2246            .flex()
2247            .w_full()
2248            .on_drag(channel.clone(), move |channel, cx| {
2249                cx.new_view(|_| DraggedChannelView {
2250                    channel: channel.clone(),
2251                    width,
2252                })
2253            })
2254            .drag_over::<Channel>(|style| style.bg(cx.theme().colors().ghost_element_hover))
2255            .on_drop(cx.listener(move |this, dragged_channel: &Channel, cx| {
2256                this.channel_store
2257                    .update(cx, |channel_store, cx| {
2258                        channel_store.move_channel(dragged_channel.id, Some(channel_id), cx)
2259                    })
2260                    .detach_and_log_err(cx)
2261            }))
2262            .child(
2263                ListItem::new(channel_id as usize)
2264                    // Add one level of depth for the disclosure arrow.
2265                    .indent_level(depth + 1)
2266                    .indent_step_size(px(20.))
2267                    .selected(is_selected || is_active)
2268                    .toggle(disclosed)
2269                    .on_toggle(
2270                        cx.listener(move |this, _, cx| {
2271                            this.toggle_channel_collapsed(channel_id, cx)
2272                        }),
2273                    )
2274                    .on_click(cx.listener(move |this, _, cx| {
2275                        if is_active {
2276                            this.open_channel_notes(channel_id, cx)
2277                        } else {
2278                            this.join_channel(channel_id, cx)
2279                        }
2280                    }))
2281                    .on_secondary_mouse_down(cx.listener(
2282                        move |this, event: &MouseDownEvent, cx| {
2283                            this.deploy_channel_context_menu(event.position, channel_id, ix, cx)
2284                        },
2285                    ))
2286                    .start_slot(
2287                        Icon::new(if is_public {
2288                            IconName::Public
2289                        } else {
2290                            IconName::Hash
2291                        })
2292                        .size(IconSize::Small)
2293                        .color(Color::Muted),
2294                    )
2295                    .child(
2296                        h_flex()
2297                            .id(channel_id as usize)
2298                            .child(Label::new(channel.name.clone()))
2299                            .children(face_pile.map(|face_pile| face_pile.render(cx))),
2300                    ),
2301            )
2302            .child(
2303                h_flex()
2304                    .absolute()
2305                    .right(rems(0.))
2306                    .h_full()
2307                    // HACK: Without this the channel name clips on top of the icons, but I'm not sure why.
2308                    .z_index(10)
2309                    .child(
2310                        h_flex()
2311                            .h_full()
2312                            .gap_1()
2313                            .px_1()
2314                            .child(
2315                                IconButton::new("channel_chat", IconName::MessageBubbles)
2316                                    .style(ButtonStyle::Filled)
2317                                    .shape(ui::IconButtonShape::Square)
2318                                    .icon_size(IconSize::Small)
2319                                    .icon_color(if has_messages_notification {
2320                                        Color::Default
2321                                    } else {
2322                                        Color::Muted
2323                                    })
2324                                    .on_click(cx.listener(move |this, _, cx| {
2325                                        this.join_channel_chat(channel_id, cx)
2326                                    }))
2327                                    .tooltip(|cx| Tooltip::text("Open channel chat", cx))
2328                                    .when(!has_messages_notification, |this| {
2329                                        this.visible_on_hover("")
2330                                    }),
2331                            )
2332                            .child(
2333                                IconButton::new("channel_notes", IconName::File)
2334                                    .style(ButtonStyle::Filled)
2335                                    .shape(ui::IconButtonShape::Square)
2336                                    .icon_size(IconSize::Small)
2337                                    .icon_color(if has_notes_notification {
2338                                        Color::Default
2339                                    } else {
2340                                        Color::Muted
2341                                    })
2342                                    .on_click(cx.listener(move |this, _, cx| {
2343                                        this.open_channel_notes(channel_id, cx)
2344                                    }))
2345                                    .tooltip(|cx| Tooltip::text("Open channel notes", cx))
2346                                    .when(!has_notes_notification, |this| {
2347                                        this.visible_on_hover("")
2348                                    }),
2349                            ),
2350                    ),
2351            )
2352            .tooltip(|cx| Tooltip::text("Join channel", cx))
2353    }
2354
2355    fn render_channel_editor(&self, depth: usize, _cx: &mut ViewContext<Self>) -> impl IntoElement {
2356        let item = ListItem::new("channel-editor")
2357            .inset(false)
2358            // Add one level of depth for the disclosure arrow.
2359            .indent_level(depth + 1)
2360            .indent_step_size(px(20.))
2361            .start_slot(
2362                Icon::new(IconName::Hash)
2363                    .size(IconSize::Small)
2364                    .color(Color::Muted),
2365            );
2366
2367        if let Some(pending_name) = self
2368            .channel_editing_state
2369            .as_ref()
2370            .and_then(|state| state.pending_name())
2371        {
2372            item.child(Label::new(pending_name))
2373        } else {
2374            item.child(self.channel_name_editor.clone())
2375        }
2376    }
2377}
2378
2379fn render_tree_branch(is_last: bool, overdraw: bool, cx: &mut WindowContext) -> impl IntoElement {
2380    let rem_size = cx.rem_size();
2381    let line_height = cx.text_style().line_height_in_pixels(rem_size);
2382    let width = rem_size * 1.5;
2383    let thickness = px(1.);
2384    let color = cx.theme().colors().text;
2385
2386    canvas(move |bounds, cx| {
2387        let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
2388        let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
2389        let right = bounds.right();
2390        let top = bounds.top();
2391
2392        cx.paint_quad(fill(
2393            Bounds::from_corners(
2394                point(start_x, top),
2395                point(
2396                    start_x + thickness,
2397                    if is_last {
2398                        start_y
2399                    } else {
2400                        bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
2401                    },
2402                ),
2403            ),
2404            color,
2405        ));
2406        cx.paint_quad(fill(
2407            Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
2408            color,
2409        ));
2410    })
2411    .w(width)
2412    .h(line_height)
2413}
2414
2415impl Render for CollabPanel {
2416    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2417        v_flex()
2418            .key_context("CollabPanel")
2419            .on_action(cx.listener(CollabPanel::cancel))
2420            .on_action(cx.listener(CollabPanel::select_next))
2421            .on_action(cx.listener(CollabPanel::select_prev))
2422            .on_action(cx.listener(CollabPanel::confirm))
2423            .on_action(cx.listener(CollabPanel::insert_space))
2424            .on_action(cx.listener(CollabPanel::remove_selected_channel))
2425            .on_action(cx.listener(CollabPanel::show_inline_context_menu))
2426            .on_action(cx.listener(CollabPanel::rename_selected_channel))
2427            .on_action(cx.listener(CollabPanel::collapse_selected_channel))
2428            .on_action(cx.listener(CollabPanel::expand_selected_channel))
2429            .on_action(cx.listener(CollabPanel::start_move_selected_channel))
2430            .track_focus(&self.focus_handle)
2431            .size_full()
2432            .child(if self.user_store.read(cx).current_user().is_none() {
2433                self.render_signed_out(cx)
2434            } else {
2435                self.render_signed_in(cx)
2436            })
2437            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2438                overlay()
2439                    .position(*position)
2440                    .anchor(gpui::AnchorCorner::TopLeft)
2441                    .child(menu.clone())
2442            }))
2443    }
2444}
2445
2446impl EventEmitter<PanelEvent> for CollabPanel {}
2447
2448impl Panel for CollabPanel {
2449    fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
2450        CollaborationPanelSettings::get_global(cx).dock
2451    }
2452
2453    fn position_is_valid(&self, position: DockPosition) -> bool {
2454        matches!(position, DockPosition::Left | DockPosition::Right)
2455    }
2456
2457    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2458        settings::update_settings_file::<CollaborationPanelSettings>(
2459            self.fs.clone(),
2460            cx,
2461            move |settings| settings.dock = Some(position),
2462        );
2463    }
2464
2465    fn size(&self, cx: &gpui::WindowContext) -> Pixels {
2466        self.width
2467            .unwrap_or_else(|| CollaborationPanelSettings::get_global(cx).default_width)
2468    }
2469
2470    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
2471        self.width = size;
2472        self.serialize(cx);
2473        cx.notify();
2474    }
2475
2476    fn icon(&self, cx: &gpui::WindowContext) -> Option<ui::IconName> {
2477        CollaborationPanelSettings::get_global(cx)
2478            .button
2479            .then(|| ui::IconName::Collab)
2480    }
2481
2482    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
2483        Some("Collab Panel")
2484    }
2485
2486    fn toggle_action(&self) -> Box<dyn gpui::Action> {
2487        Box::new(ToggleFocus)
2488    }
2489
2490    fn persistent_name() -> &'static str {
2491        "CollabPanel"
2492    }
2493}
2494
2495impl FocusableView for CollabPanel {
2496    fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
2497        self.filter_editor.focus_handle(cx).clone()
2498    }
2499}
2500
2501impl PartialEq for ListEntry {
2502    fn eq(&self, other: &Self) -> bool {
2503        match self {
2504            ListEntry::Header(section_1) => {
2505                if let ListEntry::Header(section_2) = other {
2506                    return section_1 == section_2;
2507                }
2508            }
2509            ListEntry::CallParticipant { user: user_1, .. } => {
2510                if let ListEntry::CallParticipant { user: user_2, .. } = other {
2511                    return user_1.id == user_2.id;
2512                }
2513            }
2514            ListEntry::ParticipantProject {
2515                project_id: project_id_1,
2516                ..
2517            } => {
2518                if let ListEntry::ParticipantProject {
2519                    project_id: project_id_2,
2520                    ..
2521                } = other
2522                {
2523                    return project_id_1 == project_id_2;
2524                }
2525            }
2526            ListEntry::ParticipantScreen {
2527                peer_id: peer_id_1, ..
2528            } => {
2529                if let ListEntry::ParticipantScreen {
2530                    peer_id: peer_id_2, ..
2531                } = other
2532                {
2533                    return peer_id_1 == peer_id_2;
2534                }
2535            }
2536            ListEntry::Channel {
2537                channel: channel_1, ..
2538            } => {
2539                if let ListEntry::Channel {
2540                    channel: channel_2, ..
2541                } = other
2542                {
2543                    return channel_1.id == channel_2.id;
2544                }
2545            }
2546            ListEntry::ChannelNotes { channel_id } => {
2547                if let ListEntry::ChannelNotes {
2548                    channel_id: other_id,
2549                } = other
2550                {
2551                    return channel_id == other_id;
2552                }
2553            }
2554            ListEntry::ChannelChat { channel_id } => {
2555                if let ListEntry::ChannelChat {
2556                    channel_id: other_id,
2557                } = other
2558                {
2559                    return channel_id == other_id;
2560                }
2561            }
2562            ListEntry::ChannelInvite(channel_1) => {
2563                if let ListEntry::ChannelInvite(channel_2) = other {
2564                    return channel_1.id == channel_2.id;
2565                }
2566            }
2567            ListEntry::IncomingRequest(user_1) => {
2568                if let ListEntry::IncomingRequest(user_2) = other {
2569                    return user_1.id == user_2.id;
2570                }
2571            }
2572            ListEntry::OutgoingRequest(user_1) => {
2573                if let ListEntry::OutgoingRequest(user_2) = other {
2574                    return user_1.id == user_2.id;
2575                }
2576            }
2577            ListEntry::Contact {
2578                contact: contact_1, ..
2579            } => {
2580                if let ListEntry::Contact {
2581                    contact: contact_2, ..
2582                } = other
2583                {
2584                    return contact_1.user.id == contact_2.user.id;
2585                }
2586            }
2587            ListEntry::ChannelEditor { depth } => {
2588                if let ListEntry::ChannelEditor { depth: other_depth } = other {
2589                    return depth == other_depth;
2590                }
2591            }
2592            ListEntry::ContactPlaceholder => {
2593                if let ListEntry::ContactPlaceholder = other {
2594                    return true;
2595                }
2596            }
2597        }
2598        false
2599    }
2600}
2601
2602struct DraggedChannelView {
2603    channel: Channel,
2604    width: Pixels,
2605}
2606
2607impl Render for DraggedChannelView {
2608    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
2609        let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
2610        h_flex()
2611            .font(ui_font)
2612            .bg(cx.theme().colors().background)
2613            .w(self.width)
2614            .p_1()
2615            .gap_1()
2616            .child(
2617                Icon::new(
2618                    if self.channel.visibility == proto::ChannelVisibility::Public {
2619                        IconName::Public
2620                    } else {
2621                        IconName::Hash
2622                    },
2623                )
2624                .size(IconSize::Small)
2625                .color(Color::Muted),
2626            )
2627            .child(Label::new(self.channel.name.clone()))
2628    }
2629}