collab_panel.rs

   1mod channel_modal;
   2mod contact_finder;
   3
   4use self::channel_modal::ChannelModal;
   5use crate::{CollaborationPanelSettings, channel_view::ChannelView};
   6use anyhow::Context as _;
   7use call::ActiveCall;
   8use channel::{Channel, ChannelEvent, ChannelStore};
   9use client::{ChannelId, Client, Contact, User, UserStore};
  10use collections::{HashMap, HashSet};
  11use contact_finder::ContactFinder;
  12use db::kvp::{GlobalKeyValueStore, KeyValueStore};
  13use editor::{Editor, EditorElement, EditorStyle};
  14use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
  15use gpui::{
  16    AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, Context, DismissEvent,
  17    Div, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, InteractiveElement, IntoElement,
  18    KeyContext, ListOffset, ListState, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel,
  19    Render, SharedString, Styled, Subscription, Task, TextStyle, WeakEntity, Window, actions,
  20    anchored, canvas, deferred, div, fill, list, point, prelude::*, px,
  21};
  22use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious};
  23use project::{Fs, Project};
  24use rpc::{
  25    ErrorCode, ErrorExt,
  26    proto::{self, ChannelVisibility, PeerId, reorder_channel::Direction},
  27};
  28use serde::{Deserialize, Serialize};
  29use settings::Settings;
  30use smallvec::SmallVec;
  31use std::{mem, sync::Arc};
  32use theme::ActiveTheme;
  33use theme_settings::ThemeSettings;
  34use ui::{
  35    Avatar, AvatarAvailabilityIndicator, ContextMenu, CopyButton, Facepile, HighlightedLabel,
  36    IconButtonShape, Indicator, ListHeader, ListItem, Tab, Tooltip, prelude::*, tooltip_container,
  37};
  38use util::{ResultExt, TryFutureExt, maybe};
  39use workspace::{
  40    CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, OpenChannelNotesById,
  41    ScreenShare, ShareProject, Workspace,
  42    dock::{DockPosition, Panel, PanelEvent},
  43    notifications::{DetachAndPromptErr, NotifyResultExt},
  44};
  45
  46actions!(
  47    collab_panel,
  48    [
  49        /// Toggles the collab panel.
  50        Toggle,
  51        /// Toggles focus on the collaboration panel.
  52        ToggleFocus,
  53        /// Removes the selected channel or contact.
  54        Remove,
  55        /// Opens the context menu for the selected item.
  56        Secondary,
  57        /// Collapses the selected channel in the tree view.
  58        CollapseSelectedChannel,
  59        /// Expands the selected channel in the tree view.
  60        ExpandSelectedChannel,
  61        /// Opens the meeting notes for the selected channel in the panel.
  62        ///
  63        /// Use `collab::OpenChannelNotes` to open the channel notes for the current call.
  64        OpenSelectedChannelNotes,
  65        /// Toggles whether the selected channel is in the Favorites section.
  66        ToggleSelectedChannelFavorite,
  67        /// Starts moving a channel to a new location.
  68        StartMoveChannel,
  69        /// Moves the selected item to the current location.
  70        MoveSelected,
  71        /// Inserts a space character in the filter input.
  72        InsertSpace,
  73        /// Moves the selected channel up in the list.
  74        MoveChannelUp,
  75        /// Moves the selected channel down in the list.
  76        MoveChannelDown,
  77    ]
  78);
  79
  80#[derive(Debug, Copy, Clone, PartialEq, Eq)]
  81struct ChannelMoveClipboard {
  82    channel_id: ChannelId,
  83}
  84
  85const COLLABORATION_PANEL_KEY: &str = "CollaborationPanel";
  86
  87pub fn init(cx: &mut App) {
  88    cx.observe_new(|workspace: &mut Workspace, _, _| {
  89        workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
  90            workspace.toggle_panel_focus::<CollabPanel>(window, cx);
  91            if let Some(collab_panel) = workspace.panel::<CollabPanel>(cx) {
  92                collab_panel.update(cx, |panel, cx| {
  93                    panel.filter_editor.update(cx, |editor, cx| {
  94                        if editor.snapshot(window, cx).is_focused() {
  95                            editor.select_all(&Default::default(), window, cx);
  96                        }
  97                    });
  98                })
  99            }
 100        });
 101        workspace.register_action(|workspace, _: &Toggle, window, cx| {
 102            if !workspace.toggle_panel_focus::<CollabPanel>(window, cx) {
 103                workspace.close_panel::<CollabPanel>(window, cx);
 104            }
 105        });
 106        workspace.register_action(|_, _: &OpenChannelNotes, window, cx| {
 107            let channel_id = ActiveCall::global(cx)
 108                .read(cx)
 109                .room()
 110                .and_then(|room| room.read(cx).channel_id());
 111
 112            if let Some(channel_id) = channel_id {
 113                let workspace = cx.entity();
 114                window.defer(cx, move |window, cx| {
 115                    ChannelView::open(channel_id, None, workspace, window, cx)
 116                        .detach_and_log_err(cx)
 117                });
 118            }
 119        });
 120        workspace.register_action(|_, action: &OpenChannelNotesById, window, cx| {
 121            let channel_id = client::ChannelId(action.channel_id);
 122            let workspace = cx.entity();
 123            window.defer(cx, move |window, cx| {
 124                ChannelView::open(channel_id, None, workspace, window, cx).detach_and_log_err(cx)
 125            });
 126        });
 127        // TODO: make it possible to bind this one to a held key for push to talk?
 128        // how to make "toggle_on_modifiers_press" contextual?
 129        workspace.register_action(|_, _: &Mute, _, cx| title_bar::collab::toggle_mute(cx));
 130        workspace.register_action(|_, _: &Deafen, _, cx| title_bar::collab::toggle_deafen(cx));
 131        workspace.register_action(|_, _: &LeaveCall, window, cx| {
 132            CollabPanel::leave_call(window, cx);
 133        });
 134        workspace.register_action(|workspace, _: &CopyRoomId, window, cx| {
 135            use workspace::notifications::{NotificationId, NotifyTaskExt as _};
 136
 137            struct RoomIdCopiedToast;
 138
 139            if let Some(room) = ActiveCall::global(cx).read(cx).room() {
 140                let romo_id_fut = room.read(cx).room_id();
 141                let workspace_handle = cx.weak_entity();
 142                cx.spawn(async move |workspace, cx| {
 143                    let room_id = romo_id_fut.await.context("Failed to get livekit room")?;
 144                    workspace.update(cx, |workspace, cx| {
 145                        cx.write_to_clipboard(ClipboardItem::new_string(room_id));
 146                        workspace.show_toast(
 147                            workspace::Toast::new(
 148                                NotificationId::unique::<RoomIdCopiedToast>(),
 149                                "Room ID copied to clipboard",
 150                            )
 151                            .autohide(),
 152                            cx,
 153                        );
 154                    })
 155                })
 156                .detach_and_notify_err(workspace_handle, window, cx);
 157            } else {
 158                workspace.show_error(&"There’s no active call; join one first.", cx);
 159            }
 160        });
 161        workspace.register_action(|workspace, _: &ShareProject, window, cx| {
 162            let project = workspace.project().clone();
 163            println!("{project:?}");
 164            window.defer(cx, move |_window, cx| {
 165                ActiveCall::global(cx).update(cx, move |call, cx| {
 166                    if let Some(room) = call.room() {
 167                        println!("{room:?}");
 168                        if room.read(cx).is_sharing_project() {
 169                            call.unshare_project(project, cx).ok();
 170                        } else {
 171                            call.share_project(project, cx).detach_and_log_err(cx);
 172                        }
 173                    }
 174                });
 175            });
 176        });
 177        // TODO(jk): Is this action ever triggered?
 178        workspace.register_action(|_, _: &ScreenShare, window, cx| {
 179            let room = ActiveCall::global(cx).read(cx).room().cloned();
 180            if let Some(room) = room {
 181                window.defer(cx, move |_window, cx| {
 182                    room.update(cx, |room, cx| {
 183                        if room.is_sharing_screen() {
 184                            room.unshare_screen(true, cx).ok();
 185                        } else {
 186                            #[cfg(target_os = "linux")]
 187                            let is_wayland = gpui::guess_compositor() == "Wayland";
 188                            #[cfg(not(target_os = "linux"))]
 189                            let is_wayland = false;
 190
 191                            #[cfg(target_os = "linux")]
 192                            {
 193                                if is_wayland {
 194                                    room.share_screen_wayland(cx).detach_and_log_err(cx);
 195                                }
 196                            }
 197                            if !is_wayland {
 198                                let sources = cx.screen_capture_sources();
 199
 200                                cx.spawn(async move |room, cx| {
 201                                    let sources = sources.await??;
 202                                    let first = sources.into_iter().next();
 203                                    if let Some(first) = first {
 204                                        room.update(cx, |room, cx| room.share_screen(first, cx))?
 205                                            .await
 206                                    } else {
 207                                        Ok(())
 208                                    }
 209                                })
 210                                .detach_and_log_err(cx);
 211                            }
 212                        };
 213                    });
 214                });
 215            }
 216        });
 217    })
 218    .detach();
 219}
 220
 221#[derive(Debug)]
 222pub enum ChannelEditingState {
 223    Create {
 224        location: Option<ChannelId>,
 225        pending_name: Option<String>,
 226    },
 227    Rename {
 228        location: ChannelId,
 229        pending_name: Option<String>,
 230    },
 231}
 232
 233impl ChannelEditingState {
 234    fn pending_name(&self) -> Option<String> {
 235        match self {
 236            ChannelEditingState::Create { pending_name, .. } => pending_name.clone(),
 237            ChannelEditingState::Rename { pending_name, .. } => pending_name.clone(),
 238        }
 239    }
 240}
 241
 242pub struct CollabPanel {
 243    fs: Arc<dyn Fs>,
 244    focus_handle: FocusHandle,
 245    channel_clipboard: Option<ChannelMoveClipboard>,
 246    pending_serialization: Task<Option<()>>,
 247    context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
 248    list_state: ListState,
 249    filter_editor: Entity<Editor>,
 250    channel_name_editor: Entity<Editor>,
 251    channel_editing_state: Option<ChannelEditingState>,
 252    entries: Vec<ListEntry>,
 253    selection: Option<usize>,
 254    channel_store: Entity<ChannelStore>,
 255    user_store: Entity<UserStore>,
 256    client: Arc<Client>,
 257    project: Entity<Project>,
 258    match_candidates: Vec<StringMatchCandidate>,
 259    subscriptions: Vec<Subscription>,
 260    collapsed_sections: Vec<Section>,
 261    collapsed_channels: Vec<ChannelId>,
 262    filter_active_channels: bool,
 263    workspace: WeakEntity<Workspace>,
 264}
 265
 266#[derive(Serialize, Deserialize)]
 267struct SerializedCollabPanel {
 268    collapsed_channels: Option<Vec<u64>>,
 269}
 270
 271#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
 272enum Section {
 273    ActiveCall,
 274    FavoriteChannels,
 275    Channels,
 276    ChannelInvites,
 277    ContactRequests,
 278    Contacts,
 279    Online,
 280    Offline,
 281}
 282
 283#[derive(Clone, Debug)]
 284enum ListEntry {
 285    Header(Section),
 286    CallParticipant {
 287        user: Arc<User>,
 288        peer_id: Option<PeerId>,
 289        is_pending: bool,
 290        role: proto::ChannelRole,
 291    },
 292    ParticipantProject {
 293        project_id: u64,
 294        worktree_root_names: Vec<String>,
 295        host_user_id: u64,
 296        is_last: bool,
 297    },
 298    ParticipantScreen {
 299        peer_id: Option<PeerId>,
 300        is_last: bool,
 301    },
 302    IncomingRequest(Arc<User>),
 303    OutgoingRequest(Arc<User>),
 304    ChannelInvite(Arc<Channel>),
 305    Channel {
 306        channel: Arc<Channel>,
 307        depth: usize,
 308        has_children: bool,
 309        is_favorite: bool,
 310        // `None` when the channel is a parent of a matched channel.
 311        string_match: Option<StringMatch>,
 312    },
 313    ChannelNotes {
 314        channel_id: ChannelId,
 315    },
 316    ChannelEditor {
 317        depth: usize,
 318    },
 319    Contact {
 320        contact: Arc<Contact>,
 321        calling: bool,
 322    },
 323    ContactPlaceholder,
 324}
 325
 326impl CollabPanel {
 327    pub fn new(
 328        workspace: &mut Workspace,
 329        window: &mut Window,
 330        cx: &mut Context<Workspace>,
 331    ) -> Entity<Self> {
 332        cx.new(|cx| {
 333            let filter_editor = cx.new(|cx| {
 334                let mut editor = Editor::single_line(window, cx);
 335                editor.set_placeholder_text("Search channels…", window, cx);
 336                editor
 337            });
 338
 339            cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
 340                if let editor::EditorEvent::BufferEdited = event {
 341                    let query = this.filter_editor.read(cx).text(cx);
 342                    if !query.is_empty() {
 343                        this.selection.take();
 344                    }
 345                    this.update_entries(true, cx);
 346                    if !query.is_empty() {
 347                        this.selection = this
 348                            .entries
 349                            .iter()
 350                            .position(|entry| !matches!(entry, ListEntry::Header(_)));
 351                    }
 352                }
 353            })
 354            .detach();
 355
 356            let channel_name_editor = cx.new(|cx| Editor::single_line(window, cx));
 357
 358            cx.subscribe_in(
 359                &channel_name_editor,
 360                window,
 361                |this: &mut Self, _, event, window, cx| {
 362                    if let editor::EditorEvent::Blurred = event {
 363                        if let Some(state) = &this.channel_editing_state
 364                            && state.pending_name().is_some()
 365                        {
 366                            return;
 367                        }
 368                        this.take_editing_state(window, cx);
 369                        this.update_entries(false, cx);
 370                        cx.notify();
 371                    }
 372                },
 373            )
 374            .detach();
 375
 376            let mut this = Self {
 377                focus_handle: cx.focus_handle(),
 378                channel_clipboard: None,
 379                fs: workspace.app_state().fs.clone(),
 380                pending_serialization: Task::ready(None),
 381                context_menu: None,
 382                list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
 383                channel_name_editor,
 384                filter_editor,
 385                entries: Vec::default(),
 386                channel_editing_state: None,
 387                selection: None,
 388                channel_store: ChannelStore::global(cx),
 389                user_store: workspace.user_store().clone(),
 390                project: workspace.project().clone(),
 391                subscriptions: Vec::default(),
 392                match_candidates: Vec::default(),
 393                collapsed_sections: vec![Section::Offline],
 394                collapsed_channels: Vec::default(),
 395                filter_active_channels: false,
 396                workspace: workspace.weak_handle(),
 397                client: workspace.app_state().client.clone(),
 398            };
 399
 400            this.update_entries(false, cx);
 401
 402            let active_call = ActiveCall::global(cx);
 403            this.subscriptions
 404                .push(cx.observe(&this.user_store, |this, _, cx| {
 405                    this.update_entries(true, cx)
 406                }));
 407            this.subscriptions
 408                .push(cx.observe(&this.channel_store, move |this, _, cx| {
 409                    this.update_entries(true, cx)
 410                }));
 411            this.subscriptions
 412                .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
 413            this.subscriptions.push(cx.subscribe_in(
 414                &this.channel_store,
 415                window,
 416                |this, _channel_store, e, window, cx| match e {
 417                    ChannelEvent::ChannelCreated(channel_id)
 418                    | ChannelEvent::ChannelRenamed(channel_id) => {
 419                        if this.take_editing_state(window, cx) {
 420                            this.update_entries(false, cx);
 421                            this.selection = this.entries.iter().position(|entry| {
 422                                if let ListEntry::Channel { channel, .. } = entry {
 423                                    channel.id == *channel_id
 424                                } else {
 425                                    false
 426                                }
 427                            });
 428                        }
 429                    }
 430                },
 431            ));
 432
 433            this
 434        })
 435    }
 436
 437    pub async fn load(
 438        workspace: WeakEntity<Workspace>,
 439        mut cx: AsyncWindowContext,
 440    ) -> anyhow::Result<Entity<Self>> {
 441        let serialized_panel = match workspace
 442            .read_with(&cx, |workspace, _| {
 443                CollabPanel::serialization_key(workspace)
 444            })
 445            .ok()
 446            .flatten()
 447        {
 448            Some(serialization_key) => {
 449                let kvp = cx.update(|_, cx| KeyValueStore::global(cx))?;
 450                kvp.read_kvp(&serialization_key)
 451                    .context("reading collaboration panel from key value store")
 452                    .log_err()
 453                    .flatten()
 454                    .map(|panel| serde_json::from_str::<SerializedCollabPanel>(&panel))
 455                    .transpose()
 456                    .log_err()
 457                    .flatten()
 458            }
 459            None => None,
 460        };
 461
 462        workspace.update_in(&mut cx, |workspace, window, cx| {
 463            let panel = CollabPanel::new(workspace, window, cx);
 464            if let Some(serialized_panel) = serialized_panel {
 465                panel.update(cx, |panel, cx| {
 466                    panel.collapsed_channels = serialized_panel
 467                        .collapsed_channels
 468                        .unwrap_or_default()
 469                        .iter()
 470                        .map(|cid| ChannelId(*cid))
 471                        .collect();
 472                    cx.notify();
 473                });
 474            }
 475
 476            let favorites: Vec<ChannelId> = GlobalKeyValueStore::global()
 477                .read_kvp("favorite_channels")
 478                .ok()
 479                .flatten()
 480                .and_then(|json| serde_json::from_str::<Vec<u64>>(&json).ok())
 481                .unwrap_or_default()
 482                .into_iter()
 483                .map(ChannelId)
 484                .collect();
 485
 486            if !favorites.is_empty() {
 487                panel.update(cx, |panel, cx| {
 488                    panel.channel_store.update(cx, |store, cx| {
 489                        store.set_favorite_channel_ids(favorites, cx);
 490                    });
 491                });
 492            }
 493
 494            panel
 495        })
 496    }
 497
 498    fn serialization_key(workspace: &Workspace) -> Option<String> {
 499        workspace
 500            .database_id()
 501            .map(|id| i64::from(id).to_string())
 502            .or(workspace.session_id())
 503            .map(|id| format!("{}-{:?}", COLLABORATION_PANEL_KEY, id))
 504    }
 505
 506    fn serialize(&mut self, cx: &mut Context<Self>) {
 507        let Some(serialization_key) = self
 508            .workspace
 509            .read_with(cx, |workspace, _| CollabPanel::serialization_key(workspace))
 510            .ok()
 511            .flatten()
 512        else {
 513            return;
 514        };
 515        let collapsed_channels = if self.collapsed_channels.is_empty() {
 516            None
 517        } else {
 518            Some(self.collapsed_channels.iter().map(|id| id.0).collect())
 519        };
 520
 521        let kvp = KeyValueStore::global(cx);
 522        self.pending_serialization = cx.background_spawn(
 523            async move {
 524                kvp.write_kvp(
 525                    serialization_key,
 526                    serde_json::to_string(&SerializedCollabPanel { collapsed_channels })?,
 527                )
 528                .await?;
 529                anyhow::Ok(())
 530            }
 531            .log_err(),
 532        );
 533    }
 534
 535    fn scroll_to_item(&mut self, ix: usize) {
 536        self.list_state.scroll_to_reveal_item(ix)
 537    }
 538
 539    fn update_entries(&mut self, select_same_item: bool, cx: &mut Context<Self>) {
 540        let query = self.filter_editor.read(cx).text(cx);
 541        let fg_executor = cx.foreground_executor().clone();
 542        let executor = cx.background_executor().clone();
 543
 544        let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
 545        let old_entries = mem::take(&mut self.entries);
 546        let mut scroll_to_top = false;
 547
 548        if let Some(room) = ActiveCall::global(cx).read(cx).room() {
 549            self.entries.push(ListEntry::Header(Section::ActiveCall));
 550            if !old_entries
 551                .iter()
 552                .any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall)))
 553            {
 554                scroll_to_top = true;
 555            }
 556
 557            if !self.collapsed_sections.contains(&Section::ActiveCall) {
 558                let room = room.read(cx);
 559
 560                if query.is_empty()
 561                    && let Some(channel_id) = room.channel_id()
 562                {
 563                    self.entries.push(ListEntry::ChannelNotes { channel_id });
 564                }
 565
 566                // Populate the active user.
 567                if let Some(user) = self.user_store.read(cx).current_user() {
 568                    self.match_candidates.clear();
 569                    self.match_candidates
 570                        .push(StringMatchCandidate::new(0, &user.github_login));
 571                    let matches = fg_executor.block_on(match_strings(
 572                        &self.match_candidates,
 573                        &query,
 574                        true,
 575                        true,
 576                        usize::MAX,
 577                        &Default::default(),
 578                        executor.clone(),
 579                    ));
 580                    if !matches.is_empty() {
 581                        let user_id = user.id;
 582                        self.entries.push(ListEntry::CallParticipant {
 583                            user,
 584                            peer_id: None,
 585                            is_pending: false,
 586                            role: room.local_participant().role,
 587                        });
 588                        let mut projects = room.local_participant().projects.iter().peekable();
 589                        while let Some(project) = projects.next() {
 590                            self.entries.push(ListEntry::ParticipantProject {
 591                                project_id: project.id,
 592                                worktree_root_names: project.worktree_root_names.clone(),
 593                                host_user_id: user_id,
 594                                is_last: projects.peek().is_none() && !room.is_sharing_screen(),
 595                            });
 596                        }
 597                        if room.is_sharing_screen() {
 598                            self.entries.push(ListEntry::ParticipantScreen {
 599                                peer_id: None,
 600                                is_last: true,
 601                            });
 602                        }
 603                    }
 604                }
 605
 606                // Populate remote participants.
 607                self.match_candidates.clear();
 608                self.match_candidates
 609                    .extend(room.remote_participants().values().map(|participant| {
 610                        StringMatchCandidate::new(
 611                            participant.user.id as usize,
 612                            &participant.user.github_login,
 613                        )
 614                    }));
 615                let mut matches = fg_executor.block_on(match_strings(
 616                    &self.match_candidates,
 617                    &query,
 618                    true,
 619                    true,
 620                    usize::MAX,
 621                    &Default::default(),
 622                    executor.clone(),
 623                ));
 624                matches.sort_by(|a, b| {
 625                    let a_is_guest = room.role_for_user(a.candidate_id as u64)
 626                        == Some(proto::ChannelRole::Guest);
 627                    let b_is_guest = room.role_for_user(b.candidate_id as u64)
 628                        == Some(proto::ChannelRole::Guest);
 629                    a_is_guest
 630                        .cmp(&b_is_guest)
 631                        .then_with(|| a.string.cmp(&b.string))
 632                });
 633                for mat in matches {
 634                    let user_id = mat.candidate_id as u64;
 635                    let participant = &room.remote_participants()[&user_id];
 636                    self.entries.push(ListEntry::CallParticipant {
 637                        user: participant.user.clone(),
 638                        peer_id: Some(participant.peer_id),
 639                        is_pending: false,
 640                        role: participant.role,
 641                    });
 642                    let mut projects = participant.projects.iter().peekable();
 643                    while let Some(project) = projects.next() {
 644                        self.entries.push(ListEntry::ParticipantProject {
 645                            project_id: project.id,
 646                            worktree_root_names: project.worktree_root_names.clone(),
 647                            host_user_id: participant.user.id,
 648                            is_last: projects.peek().is_none() && !participant.has_video_tracks(),
 649                        });
 650                    }
 651                    if participant.has_video_tracks() {
 652                        self.entries.push(ListEntry::ParticipantScreen {
 653                            peer_id: Some(participant.peer_id),
 654                            is_last: true,
 655                        });
 656                    }
 657                }
 658
 659                // Populate pending participants.
 660                self.match_candidates.clear();
 661                self.match_candidates
 662                    .extend(room.pending_participants().iter().enumerate().map(
 663                        |(id, participant)| {
 664                            StringMatchCandidate::new(id, &participant.github_login)
 665                        },
 666                    ));
 667                let matches = fg_executor.block_on(match_strings(
 668                    &self.match_candidates,
 669                    &query,
 670                    true,
 671                    true,
 672                    usize::MAX,
 673                    &Default::default(),
 674                    executor.clone(),
 675                ));
 676                self.entries
 677                    .extend(matches.iter().map(|mat| ListEntry::CallParticipant {
 678                        user: room.pending_participants()[mat.candidate_id].clone(),
 679                        peer_id: None,
 680                        is_pending: true,
 681                        role: proto::ChannelRole::Member,
 682                    }));
 683            }
 684        }
 685
 686        let mut request_entries = Vec::new();
 687
 688        let channel_store = self.channel_store.read(cx);
 689        let user_store = self.user_store.read(cx);
 690
 691        let favorite_ids = channel_store.favorite_channel_ids();
 692        if !favorite_ids.is_empty() {
 693            let favorite_channels: Vec<_> = favorite_ids
 694                .iter()
 695                .filter_map(|id| channel_store.channel_for_id(*id))
 696                .collect();
 697
 698            self.match_candidates.clear();
 699            self.match_candidates.extend(
 700                favorite_channels
 701                    .iter()
 702                    .enumerate()
 703                    .map(|(ix, channel)| StringMatchCandidate::new(ix, &channel.name)),
 704            );
 705
 706            let matches = fg_executor.block_on(match_strings(
 707                &self.match_candidates,
 708                &query,
 709                true,
 710                true,
 711                usize::MAX,
 712                &Default::default(),
 713                executor.clone(),
 714            ));
 715
 716            if !matches.is_empty() || query.is_empty() {
 717                self.entries
 718                    .push(ListEntry::Header(Section::FavoriteChannels));
 719
 720                let matches_by_candidate: HashMap<usize, &StringMatch> =
 721                    matches.iter().map(|mat| (mat.candidate_id, mat)).collect();
 722
 723                for (ix, channel) in favorite_channels.iter().enumerate() {
 724                    if !query.is_empty() && !matches_by_candidate.contains_key(&ix) {
 725                        continue;
 726                    }
 727                    self.entries.push(ListEntry::Channel {
 728                        channel: (*channel).clone(),
 729                        depth: 0,
 730                        has_children: false,
 731                        is_favorite: true,
 732                        string_match: matches_by_candidate.get(&ix).cloned().cloned(),
 733                    });
 734                }
 735            }
 736        }
 737
 738        self.entries.push(ListEntry::Header(Section::Channels));
 739
 740        if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
 741            self.match_candidates.clear();
 742            self.match_candidates.extend(
 743                channel_store
 744                    .ordered_channels()
 745                    .enumerate()
 746                    .map(|(ix, (_, channel))| StringMatchCandidate::new(ix, &channel.name)),
 747            );
 748            let mut channels = channel_store
 749                .ordered_channels()
 750                .map(|(_, chan)| chan)
 751                .collect::<Vec<_>>();
 752            let matches = fg_executor.block_on(match_strings(
 753                &self.match_candidates,
 754                &query,
 755                true,
 756                true,
 757                usize::MAX,
 758                &Default::default(),
 759                executor.clone(),
 760            ));
 761
 762            let matches_by_id: HashMap<_, _> = matches
 763                .iter()
 764                .map(|mat| (channels[mat.candidate_id].id, mat.clone()))
 765                .collect();
 766
 767            let channel_ids_of_matches_or_parents: HashSet<_> = matches
 768                .iter()
 769                .flat_map(|mat| {
 770                    let match_channel = channels[mat.candidate_id];
 771
 772                    match_channel
 773                        .parent_path
 774                        .iter()
 775                        .copied()
 776                        .chain(Some(match_channel.id))
 777                })
 778                .collect();
 779
 780            channels.retain(|chan| channel_ids_of_matches_or_parents.contains(&chan.id));
 781
 782            if self.filter_active_channels {
 783                let active_channel_ids_or_ancestors: HashSet<_> = channel_store
 784                    .ordered_channels()
 785                    .map(|(_, channel)| channel)
 786                    .filter(|channel| !channel_store.channel_participants(channel.id).is_empty())
 787                    .flat_map(|channel| channel.parent_path.iter().copied().chain(Some(channel.id)))
 788                    .collect();
 789                channels.retain(|channel| active_channel_ids_or_ancestors.contains(&channel.id));
 790            }
 791
 792            if let Some(state) = &self.channel_editing_state
 793                && matches!(state, ChannelEditingState::Create { location: None, .. })
 794            {
 795                self.entries.push(ListEntry::ChannelEditor { depth: 0 });
 796            }
 797
 798            let should_respect_collapse = query.is_empty() && !self.filter_active_channels;
 799            let mut collapse_depth = None;
 800
 801            for (idx, channel) in channels.into_iter().enumerate() {
 802                let depth = channel.parent_path.len();
 803
 804                if should_respect_collapse {
 805                    if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) {
 806                        collapse_depth = Some(depth);
 807                    } else if let Some(collapsed_depth) = collapse_depth {
 808                        if depth > collapsed_depth {
 809                            continue;
 810                        }
 811                        if self.is_channel_collapsed(channel.id) {
 812                            collapse_depth = Some(depth);
 813                        } else {
 814                            collapse_depth = None;
 815                        }
 816                    }
 817                }
 818
 819                let has_children = channel_store
 820                    .channel_at_index(idx + 1)
 821                    .is_some_and(|next_channel| next_channel.parent_path.ends_with(&[channel.id]));
 822
 823                match &self.channel_editing_state {
 824                    Some(ChannelEditingState::Create {
 825                        location: parent_id,
 826                        ..
 827                    }) if *parent_id == Some(channel.id) => {
 828                        self.entries.push(ListEntry::Channel {
 829                            channel: channel.clone(),
 830                            depth,
 831                            has_children: false,
 832                            is_favorite: false,
 833                            string_match: matches_by_id.get(&channel.id).map(|mat| (*mat).clone()),
 834                        });
 835                        self.entries
 836                            .push(ListEntry::ChannelEditor { depth: depth + 1 });
 837                    }
 838                    Some(ChannelEditingState::Rename {
 839                        location: parent_id,
 840                        ..
 841                    }) if parent_id == &channel.id => {
 842                        self.entries.push(ListEntry::ChannelEditor { depth });
 843                    }
 844                    _ => {
 845                        self.entries.push(ListEntry::Channel {
 846                            channel: channel.clone(),
 847                            depth,
 848                            has_children,
 849                            is_favorite: false,
 850                            string_match: matches_by_id.get(&channel.id).map(|mat| (*mat).clone()),
 851                        });
 852                    }
 853                }
 854            }
 855        }
 856
 857        let channel_invites = channel_store.channel_invitations();
 858        if !channel_invites.is_empty() {
 859            self.match_candidates.clear();
 860            self.match_candidates.extend(
 861                channel_invites
 862                    .iter()
 863                    .enumerate()
 864                    .map(|(ix, channel)| StringMatchCandidate::new(ix, &channel.name)),
 865            );
 866            let matches = fg_executor.block_on(match_strings(
 867                &self.match_candidates,
 868                &query,
 869                true,
 870                true,
 871                usize::MAX,
 872                &Default::default(),
 873                executor.clone(),
 874            ));
 875            request_entries.extend(
 876                matches
 877                    .iter()
 878                    .map(|mat| ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())),
 879            );
 880
 881            if !request_entries.is_empty() {
 882                self.entries
 883                    .push(ListEntry::Header(Section::ChannelInvites));
 884                if !self.collapsed_sections.contains(&Section::ChannelInvites) {
 885                    self.entries.append(&mut request_entries);
 886                }
 887            }
 888        }
 889
 890        self.entries.push(ListEntry::Header(Section::Contacts));
 891
 892        request_entries.clear();
 893        let incoming = user_store.incoming_contact_requests();
 894        if !incoming.is_empty() {
 895            self.match_candidates.clear();
 896            self.match_candidates.extend(
 897                incoming
 898                    .iter()
 899                    .enumerate()
 900                    .map(|(ix, user)| StringMatchCandidate::new(ix, &user.github_login)),
 901            );
 902            let matches = fg_executor.block_on(match_strings(
 903                &self.match_candidates,
 904                &query,
 905                true,
 906                true,
 907                usize::MAX,
 908                &Default::default(),
 909                executor.clone(),
 910            ));
 911            request_entries.extend(
 912                matches
 913                    .iter()
 914                    .map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
 915            );
 916        }
 917
 918        let outgoing = user_store.outgoing_contact_requests();
 919        if !outgoing.is_empty() {
 920            self.match_candidates.clear();
 921            self.match_candidates.extend(
 922                outgoing
 923                    .iter()
 924                    .enumerate()
 925                    .map(|(ix, user)| StringMatchCandidate::new(ix, &user.github_login)),
 926            );
 927            let matches = fg_executor.block_on(match_strings(
 928                &self.match_candidates,
 929                &query,
 930                true,
 931                true,
 932                usize::MAX,
 933                &Default::default(),
 934                executor.clone(),
 935            ));
 936            request_entries.extend(
 937                matches
 938                    .iter()
 939                    .map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
 940            );
 941        }
 942
 943        if !request_entries.is_empty() {
 944            self.entries
 945                .push(ListEntry::Header(Section::ContactRequests));
 946            if !self.collapsed_sections.contains(&Section::ContactRequests) {
 947                self.entries.append(&mut request_entries);
 948            }
 949        }
 950
 951        let contacts = user_store.contacts();
 952        if !contacts.is_empty() {
 953            self.match_candidates.clear();
 954            self.match_candidates.extend(
 955                contacts
 956                    .iter()
 957                    .enumerate()
 958                    .map(|(ix, contact)| StringMatchCandidate::new(ix, &contact.user.github_login)),
 959            );
 960
 961            let matches = fg_executor.block_on(match_strings(
 962                &self.match_candidates,
 963                &query,
 964                true,
 965                true,
 966                usize::MAX,
 967                &Default::default(),
 968                executor,
 969            ));
 970
 971            let (online_contacts, offline_contacts) = matches
 972                .iter()
 973                .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
 974
 975            for (matches, section) in [
 976                (online_contacts, Section::Online),
 977                (offline_contacts, Section::Offline),
 978            ] {
 979                if !matches.is_empty() {
 980                    self.entries.push(ListEntry::Header(section));
 981                    if !self.collapsed_sections.contains(&section) {
 982                        let active_call = &ActiveCall::global(cx).read(cx);
 983                        for mat in matches {
 984                            let contact = &contacts[mat.candidate_id];
 985                            self.entries.push(ListEntry::Contact {
 986                                contact: contact.clone(),
 987                                calling: active_call.pending_invites().contains(&contact.user.id),
 988                            });
 989                        }
 990                    }
 991                }
 992            }
 993        }
 994
 995        if incoming.is_empty() && outgoing.is_empty() && contacts.is_empty() {
 996            self.entries.push(ListEntry::ContactPlaceholder);
 997        }
 998
 999        if select_same_item {
1000            if let Some(prev_selected_entry) = prev_selected_entry {
1001                let prev_selection = self.selection.take();
1002                for (ix, entry) in self.entries.iter().enumerate() {
1003                    if *entry == prev_selected_entry {
1004                        self.selection = Some(ix);
1005                        break;
1006                    }
1007                }
1008                if self.selection.is_none() {
1009                    self.selection = prev_selection.and_then(|prev_ix| {
1010                        if self.entries.is_empty() {
1011                            None
1012                        } else {
1013                            Some(prev_ix.min(self.entries.len() - 1))
1014                        }
1015                    });
1016                }
1017            }
1018        } else {
1019            self.selection = self.selection.and_then(|prev_selection| {
1020                if self.entries.is_empty() {
1021                    None
1022                } else {
1023                    Some(prev_selection.min(self.entries.len() - 1))
1024                }
1025            });
1026        }
1027
1028        let old_scroll_top = self.list_state.logical_scroll_top();
1029        self.list_state.reset(self.entries.len());
1030
1031        if scroll_to_top {
1032            self.list_state.scroll_to(ListOffset::default());
1033        } else {
1034            // Attempt to maintain the same scroll position.
1035            if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
1036                let new_scroll_top = self
1037                    .entries
1038                    .iter()
1039                    .position(|entry| entry == old_top_entry)
1040                    .map(|item_ix| ListOffset {
1041                        item_ix,
1042                        offset_in_item: old_scroll_top.offset_in_item,
1043                    })
1044                    .or_else(|| {
1045                        let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
1046                        let item_ix = self
1047                            .entries
1048                            .iter()
1049                            .position(|entry| entry == entry_after_old_top)?;
1050                        Some(ListOffset {
1051                            item_ix,
1052                            offset_in_item: Pixels::ZERO,
1053                        })
1054                    })
1055                    .or_else(|| {
1056                        let entry_before_old_top =
1057                            old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
1058                        let item_ix = self
1059                            .entries
1060                            .iter()
1061                            .position(|entry| entry == entry_before_old_top)?;
1062                        Some(ListOffset {
1063                            item_ix,
1064                            offset_in_item: Pixels::ZERO,
1065                        })
1066                    });
1067
1068                self.list_state
1069                    .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
1070            }
1071        }
1072
1073        cx.notify();
1074    }
1075
1076    fn render_call_participant(
1077        &self,
1078        user: &Arc<User>,
1079        peer_id: Option<PeerId>,
1080        is_pending: bool,
1081        role: proto::ChannelRole,
1082        is_selected: bool,
1083        cx: &mut Context<Self>,
1084    ) -> ListItem {
1085        let user_id = user.id;
1086        let is_current_user =
1087            self.user_store.read(cx).current_user().map(|user| user.id) == Some(user_id);
1088        let tooltip = format!("Follow {}", user.github_login);
1089
1090        let is_call_admin = ActiveCall::global(cx).read(cx).room().is_some_and(|room| {
1091            room.read(cx).local_participant().role == proto::ChannelRole::Admin
1092        });
1093
1094        ListItem::new(user.github_login.clone())
1095            .start_slot(Avatar::new(user.avatar_uri.clone()))
1096            .child(render_participant_name_and_handle(user))
1097            .toggle_state(is_selected)
1098            .end_slot(if is_pending {
1099                Label::new("Calling").color(Color::Muted).into_any_element()
1100            } else if is_current_user {
1101                IconButton::new("leave-call", IconName::Exit)
1102                    .style(ButtonStyle::Subtle)
1103                    .on_click(move |_, window, cx| Self::leave_call(window, cx))
1104                    .tooltip(Tooltip::text("Leave Call"))
1105                    .into_any_element()
1106            } else if role == proto::ChannelRole::Guest {
1107                Label::new("Guest").color(Color::Muted).into_any_element()
1108            } else if role == proto::ChannelRole::Talker {
1109                Label::new("Mic only")
1110                    .color(Color::Muted)
1111                    .into_any_element()
1112            } else {
1113                div().into_any_element()
1114            })
1115            .when_some(peer_id, |el, peer_id| {
1116                if role == proto::ChannelRole::Guest {
1117                    return el;
1118                }
1119                el.tooltip(Tooltip::text(tooltip.clone()))
1120                    .on_click(cx.listener(move |this, _, window, cx| {
1121                        this.workspace
1122                            .update(cx, |workspace, cx| workspace.follow(peer_id, window, cx))
1123                            .ok();
1124                    }))
1125            })
1126            .when(is_call_admin, |el| {
1127                el.on_secondary_mouse_down(cx.listener(
1128                    move |this, event: &MouseDownEvent, window, cx| {
1129                        this.deploy_participant_context_menu(
1130                            event.position,
1131                            user_id,
1132                            role,
1133                            window,
1134                            cx,
1135                        )
1136                    },
1137                ))
1138            })
1139    }
1140
1141    fn render_participant_project(
1142        &self,
1143        project_id: u64,
1144        worktree_root_names: &[String],
1145        host_user_id: u64,
1146        is_last: bool,
1147        is_selected: bool,
1148        window: &mut Window,
1149        cx: &mut Context<Self>,
1150    ) -> impl IntoElement {
1151        let project_name: SharedString = if worktree_root_names.is_empty() {
1152            "untitled".to_string()
1153        } else {
1154            worktree_root_names.join(", ")
1155        }
1156        .into();
1157
1158        ListItem::new(project_id as usize)
1159            .toggle_state(is_selected)
1160            .on_click(cx.listener(move |this, _, window, cx| {
1161                this.workspace
1162                    .update(cx, |workspace, cx| {
1163                        let app_state = workspace.app_state().clone();
1164                        workspace::join_in_room_project(project_id, host_user_id, app_state, cx)
1165                            .detach_and_prompt_err(
1166                                "Failed to join project",
1167                                window,
1168                                cx,
1169                                |_, _, _| None,
1170                            );
1171                    })
1172                    .ok();
1173            }))
1174            .start_slot(
1175                h_flex()
1176                    .gap_1()
1177                    .child(render_tree_branch(is_last, false, window, cx))
1178                    .child(IconButton::new(0, IconName::Folder)),
1179            )
1180            .child(Label::new(project_name.clone()))
1181            .tooltip(Tooltip::text(format!("Open {}", project_name)))
1182    }
1183
1184    fn render_participant_screen(
1185        &self,
1186        peer_id: Option<PeerId>,
1187        is_last: bool,
1188        is_selected: bool,
1189        window: &mut Window,
1190        cx: &mut Context<Self>,
1191    ) -> impl IntoElement {
1192        let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize);
1193
1194        ListItem::new(("screen", id))
1195            .toggle_state(is_selected)
1196            .start_slot(
1197                h_flex()
1198                    .gap_1()
1199                    .child(render_tree_branch(is_last, false, window, cx))
1200                    .child(IconButton::new(0, IconName::Screen)),
1201            )
1202            .child(Label::new("Screen"))
1203            .when_some(peer_id, |this, _| {
1204                this.on_click(cx.listener(move |this, _, window, cx| {
1205                    this.workspace
1206                        .update(cx, |workspace, cx| {
1207                            workspace.open_shared_screen(peer_id.unwrap(), window, cx)
1208                        })
1209                        .ok();
1210                }))
1211                .tooltip(Tooltip::text("Open shared screen"))
1212            })
1213    }
1214
1215    fn take_editing_state(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1216        if self.channel_editing_state.take().is_some() {
1217            self.channel_name_editor.update(cx, |editor, cx| {
1218                editor.set_text("", window, cx);
1219            });
1220            true
1221        } else {
1222            false
1223        }
1224    }
1225
1226    fn render_channel_notes(
1227        &self,
1228        channel_id: ChannelId,
1229        is_selected: bool,
1230        window: &mut Window,
1231        cx: &mut Context<Self>,
1232    ) -> impl IntoElement {
1233        let channel_store = self.channel_store.read(cx);
1234        let has_channel_buffer_changed = channel_store.has_channel_buffer_changed(channel_id);
1235        ListItem::new("channel-notes")
1236            .toggle_state(is_selected)
1237            .on_click(cx.listener(move |this, _, window, cx| {
1238                this.open_channel_notes(channel_id, window, cx);
1239            }))
1240            .start_slot(
1241                h_flex()
1242                    .relative()
1243                    .gap_1()
1244                    .child(render_tree_branch(false, true, window, cx))
1245                    .child(IconButton::new(0, IconName::File))
1246                    .children(has_channel_buffer_changed.then(|| {
1247                        div()
1248                            .w_1p5()
1249                            .absolute()
1250                            .right(px(2.))
1251                            .top(px(2.))
1252                            .child(Indicator::dot().color(Color::Info))
1253                    })),
1254            )
1255            .child(Label::new("notes"))
1256            .tooltip(Tooltip::text("Open Channel Notes"))
1257    }
1258
1259    fn has_subchannels(&self, ix: usize) -> bool {
1260        self.entries.get(ix).is_some_and(|entry| {
1261            if let ListEntry::Channel { has_children, .. } = entry {
1262                *has_children
1263            } else {
1264                false
1265            }
1266        })
1267    }
1268
1269    fn deploy_participant_context_menu(
1270        &mut self,
1271        position: Point<Pixels>,
1272        user_id: u64,
1273        role: proto::ChannelRole,
1274        window: &mut Window,
1275        cx: &mut Context<Self>,
1276    ) {
1277        let this = cx.entity();
1278        if !(role == proto::ChannelRole::Guest
1279            || role == proto::ChannelRole::Talker
1280            || role == proto::ChannelRole::Member)
1281        {
1282            return;
1283        }
1284
1285        let context_menu = ContextMenu::build(window, cx, |mut context_menu, window, _| {
1286            if role == proto::ChannelRole::Guest {
1287                context_menu = context_menu.entry(
1288                    "Grant Mic Access",
1289                    None,
1290                    window.handler_for(&this, move |_, window, cx| {
1291                        ActiveCall::global(cx)
1292                            .update(cx, |call, cx| {
1293                                let Some(room) = call.room() else {
1294                                    return Task::ready(Ok(()));
1295                                };
1296                                room.update(cx, |room, cx| {
1297                                    room.set_participant_role(
1298                                        user_id,
1299                                        proto::ChannelRole::Talker,
1300                                        cx,
1301                                    )
1302                                })
1303                            })
1304                            .detach_and_prompt_err(
1305                                "Failed to grant mic access",
1306                                window,
1307                                cx,
1308                                |_, _, _| None,
1309                            )
1310                    }),
1311                );
1312            }
1313            if role == proto::ChannelRole::Guest || role == proto::ChannelRole::Talker {
1314                context_menu = context_menu.entry(
1315                    "Grant Write Access",
1316                    None,
1317                    window.handler_for(&this, move |_, window, cx| {
1318                        ActiveCall::global(cx)
1319                            .update(cx, |call, cx| {
1320                                let Some(room) = call.room() else {
1321                                    return Task::ready(Ok(()));
1322                                };
1323                                room.update(cx, |room, cx| {
1324                                    room.set_participant_role(
1325                                        user_id,
1326                                        proto::ChannelRole::Member,
1327                                        cx,
1328                                    )
1329                                })
1330                            })
1331                            .detach_and_prompt_err("Failed to grant write access", window, cx, |e, _, _| {
1332                                match e.error_code() {
1333                                    ErrorCode::NeedsCla => Some("This user has not yet signed the CLA at https://zed.dev/cla.".into()),
1334                                    _ => None,
1335                                }
1336                            })
1337                    }),
1338                );
1339            }
1340            if role == proto::ChannelRole::Member || role == proto::ChannelRole::Talker {
1341                let label = if role == proto::ChannelRole::Talker {
1342                    "Mute"
1343                } else {
1344                    "Revoke Access"
1345                };
1346                context_menu = context_menu.entry(
1347                    label,
1348                    None,
1349                    window.handler_for(&this, move |_, window, cx| {
1350                        ActiveCall::global(cx)
1351                            .update(cx, |call, cx| {
1352                                let Some(room) = call.room() else {
1353                                    return Task::ready(Ok(()));
1354                                };
1355                                room.update(cx, |room, cx| {
1356                                    room.set_participant_role(
1357                                        user_id,
1358                                        proto::ChannelRole::Guest,
1359                                        cx,
1360                                    )
1361                                })
1362                            })
1363                            .detach_and_prompt_err(
1364                                "Failed to revoke access",
1365                                window,
1366                                cx,
1367                                |_, _, _| None,
1368                            )
1369                    }),
1370                );
1371            }
1372
1373            context_menu
1374        });
1375
1376        window.focus(&context_menu.focus_handle(cx), cx);
1377        let subscription = cx.subscribe_in(
1378            &context_menu,
1379            window,
1380            |this, _, _: &DismissEvent, window, cx| {
1381                if this.context_menu.as_ref().is_some_and(|context_menu| {
1382                    context_menu.0.focus_handle(cx).contains_focused(window, cx)
1383                }) {
1384                    cx.focus_self(window);
1385                }
1386                this.context_menu.take();
1387                cx.notify();
1388            },
1389        );
1390        self.context_menu = Some((context_menu, position, subscription));
1391    }
1392
1393    fn deploy_channel_context_menu(
1394        &mut self,
1395        position: Point<Pixels>,
1396        channel_id: ChannelId,
1397        ix: usize,
1398        window: &mut Window,
1399        cx: &mut Context<Self>,
1400    ) {
1401        let clipboard_channel_name = self.channel_clipboard.as_ref().and_then(|clipboard| {
1402            self.channel_store
1403                .read(cx)
1404                .channel_for_id(clipboard.channel_id)
1405                .map(|channel| channel.name.clone())
1406        });
1407        let this = cx.entity();
1408
1409        let context_menu = ContextMenu::build(window, cx, |mut context_menu, window, cx| {
1410            if self.has_subchannels(ix) {
1411                let expand_action_name = if self.is_channel_collapsed(channel_id) {
1412                    "Expand Subchannels"
1413                } else {
1414                    "Collapse Subchannels"
1415                };
1416                context_menu = context_menu.entry(
1417                    expand_action_name,
1418                    None,
1419                    window.handler_for(&this, move |this, window, cx| {
1420                        this.toggle_channel_collapsed(channel_id, window, cx)
1421                    }),
1422                );
1423            }
1424
1425            context_menu = context_menu
1426                .entry(
1427                    "Open Notes",
1428                    None,
1429                    window.handler_for(&this, move |this, window, cx| {
1430                        this.open_channel_notes(channel_id, window, cx)
1431                    }),
1432                )
1433                .entry(
1434                    "Copy Channel Link",
1435                    None,
1436                    window.handler_for(&this, move |this, _, cx| {
1437                        this.copy_channel_link(channel_id, cx)
1438                    }),
1439                )
1440                .entry(
1441                    "Copy Channel Notes Link",
1442                    None,
1443                    window.handler_for(&this, move |this, _, cx| {
1444                        this.copy_channel_notes_link(channel_id, cx)
1445                    }),
1446                )
1447                .separator()
1448                .entry(
1449                    if self.is_channel_favorited(channel_id, cx) {
1450                        "Remove from Favorites"
1451                    } else {
1452                        "Add to Favorites"
1453                    },
1454                    None,
1455                    window.handler_for(&this, move |this, _window, cx| {
1456                        this.toggle_favorite_channel(channel_id, cx)
1457                    }),
1458                );
1459
1460            let mut has_destructive_actions = false;
1461            if self.channel_store.read(cx).is_channel_admin(channel_id) {
1462                has_destructive_actions = true;
1463                context_menu = context_menu
1464                    .separator()
1465                    .entry(
1466                        "New Subchannel",
1467                        None,
1468                        window.handler_for(&this, move |this, window, cx| {
1469                            this.new_subchannel(channel_id, window, cx)
1470                        }),
1471                    )
1472                    .entry(
1473                        "Rename",
1474                        Some(Box::new(SecondaryConfirm)),
1475                        window.handler_for(&this, move |this, window, cx| {
1476                            this.rename_channel(channel_id, window, cx)
1477                        }),
1478                    );
1479
1480                if let Some(channel_name) = clipboard_channel_name {
1481                    context_menu = context_menu.separator().entry(
1482                        format!("Move '#{}' here", channel_name),
1483                        None,
1484                        window.handler_for(&this, move |this, window, cx| {
1485                            this.move_channel_on_clipboard(channel_id, window, cx)
1486                        }),
1487                    );
1488                }
1489
1490                if self.channel_store.read(cx).is_root_channel(channel_id) {
1491                    context_menu = context_menu.separator().entry(
1492                        "Manage Members",
1493                        None,
1494                        window.handler_for(&this, move |this, window, cx| {
1495                            this.manage_members(channel_id, window, cx)
1496                        }),
1497                    )
1498                } else {
1499                    context_menu = context_menu.entry(
1500                        "Move this channel",
1501                        None,
1502                        window.handler_for(&this, move |this, window, cx| {
1503                            this.start_move_channel(channel_id, window, cx)
1504                        }),
1505                    );
1506                    if self.channel_store.read(cx).is_public_channel(channel_id) {
1507                        context_menu = context_menu.separator().entry(
1508                            "Make Channel Private",
1509                            None,
1510                            window.handler_for(&this, move |this, window, cx| {
1511                                this.set_channel_visibility(
1512                                    channel_id,
1513                                    ChannelVisibility::Members,
1514                                    window,
1515                                    cx,
1516                                )
1517                            }),
1518                        )
1519                    } else {
1520                        context_menu = context_menu.separator().entry(
1521                            "Make Channel Public",
1522                            None,
1523                            window.handler_for(&this, move |this, window, cx| {
1524                                this.set_channel_visibility(
1525                                    channel_id,
1526                                    ChannelVisibility::Public,
1527                                    window,
1528                                    cx,
1529                                )
1530                            }),
1531                        )
1532                    }
1533                }
1534
1535                context_menu = context_menu.entry(
1536                    "Delete",
1537                    None,
1538                    window.handler_for(&this, move |this, window, cx| {
1539                        this.remove_channel(channel_id, window, cx)
1540                    }),
1541                );
1542            }
1543
1544            if self.channel_store.read(cx).is_root_channel(channel_id) {
1545                if !has_destructive_actions {
1546                    context_menu = context_menu.separator()
1547                }
1548                context_menu = context_menu.entry(
1549                    "Leave Channel",
1550                    None,
1551                    window.handler_for(&this, move |this, window, cx| {
1552                        this.leave_channel(channel_id, window, cx)
1553                    }),
1554                );
1555            }
1556
1557            context_menu
1558        });
1559
1560        window.focus(&context_menu.focus_handle(cx), cx);
1561        let subscription = cx.subscribe_in(
1562            &context_menu,
1563            window,
1564            |this, _, _: &DismissEvent, window, cx| {
1565                if this.context_menu.as_ref().is_some_and(|context_menu| {
1566                    context_menu.0.focus_handle(cx).contains_focused(window, cx)
1567                }) {
1568                    cx.focus_self(window);
1569                }
1570                this.context_menu.take();
1571                cx.notify();
1572            },
1573        );
1574        self.context_menu = Some((context_menu, position, subscription));
1575
1576        cx.notify();
1577    }
1578
1579    fn deploy_contact_context_menu(
1580        &mut self,
1581        position: Point<Pixels>,
1582        contact: Arc<Contact>,
1583        window: &mut Window,
1584        cx: &mut Context<Self>,
1585    ) {
1586        let this = cx.entity();
1587        let in_room = ActiveCall::global(cx).read(cx).room().is_some();
1588
1589        let context_menu = ContextMenu::build(window, cx, |mut context_menu, _, _| {
1590            let user_id = contact.user.id;
1591
1592            if contact.online && !contact.busy {
1593                let label = if in_room {
1594                    format!("Invite {} to join", contact.user.github_login)
1595                } else {
1596                    format!("Call {}", contact.user.github_login)
1597                };
1598                context_menu = context_menu.entry(label, None, {
1599                    let this = this.clone();
1600                    move |window, cx| {
1601                        this.update(cx, |this, cx| {
1602                            this.call(user_id, window, cx);
1603                        });
1604                    }
1605                });
1606            }
1607
1608            context_menu.entry("Remove Contact", None, {
1609                let this = this.clone();
1610                move |window, cx| {
1611                    this.update(cx, |this, cx| {
1612                        this.remove_contact(
1613                            contact.user.id,
1614                            &contact.user.github_login,
1615                            window,
1616                            cx,
1617                        );
1618                    });
1619                }
1620            })
1621        });
1622
1623        window.focus(&context_menu.focus_handle(cx), cx);
1624        let subscription = cx.subscribe_in(
1625            &context_menu,
1626            window,
1627            |this, _, _: &DismissEvent, window, cx| {
1628                if this.context_menu.as_ref().is_some_and(|context_menu| {
1629                    context_menu.0.focus_handle(cx).contains_focused(window, cx)
1630                }) {
1631                    cx.focus_self(window);
1632                }
1633                this.context_menu.take();
1634                cx.notify();
1635            },
1636        );
1637        self.context_menu = Some((context_menu, position, subscription));
1638
1639        cx.notify();
1640    }
1641
1642    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1643        self.filter_editor.update(cx, |editor, cx| {
1644            if editor.buffer().read(cx).len(cx).0 > 0 {
1645                editor.set_text("", window, cx);
1646                true
1647            } else {
1648                false
1649            }
1650        })
1651    }
1652
1653    fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1654        if cx.stop_active_drag(window) {
1655            return;
1656        } else if self.take_editing_state(window, cx) {
1657            window.focus(&self.filter_editor.focus_handle(cx), cx);
1658        } else if !self.reset_filter_editor_text(window, cx) {
1659            self.focus_handle.focus(window, cx);
1660        }
1661
1662        if self.context_menu.is_some() {
1663            self.context_menu.take();
1664            cx.notify();
1665        }
1666
1667        self.update_entries(false, cx);
1668    }
1669
1670    pub fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
1671        let ix = self.selection.map_or(0, |ix| ix + 1);
1672        if ix < self.entries.len() {
1673            self.selection = Some(ix);
1674        }
1675
1676        if let Some(ix) = self.selection {
1677            self.scroll_to_item(ix)
1678        }
1679        cx.notify();
1680    }
1681
1682    pub fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context<Self>) {
1683        let ix = self.selection.take().unwrap_or(0);
1684        if ix > 0 {
1685            self.selection = Some(ix - 1);
1686        }
1687
1688        if let Some(ix) = self.selection {
1689            self.scroll_to_item(ix)
1690        }
1691        cx.notify();
1692    }
1693
1694    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1695        if self.confirm_channel_edit(window, cx) {
1696            return;
1697        }
1698
1699        if let Some(selection) = self.selection
1700            && let Some(entry) = self.entries.get(selection)
1701        {
1702            match entry {
1703                ListEntry::Header(section) => match section {
1704                    Section::ActiveCall => Self::leave_call(window, cx),
1705                    Section::Channels => self.new_root_channel(window, cx),
1706                    Section::Contacts => self.toggle_contact_finder(window, cx),
1707                    Section::FavoriteChannels
1708                    | Section::ContactRequests
1709                    | Section::Online
1710                    | Section::Offline
1711                    | Section::ChannelInvites => {
1712                        self.toggle_section_expanded(*section, cx);
1713                    }
1714                },
1715                ListEntry::Contact { contact, calling } => {
1716                    if contact.online && !contact.busy && !calling {
1717                        self.call(contact.user.id, window, cx);
1718                    }
1719                }
1720                ListEntry::ParticipantProject {
1721                    project_id,
1722                    host_user_id,
1723                    ..
1724                } => {
1725                    if let Some(workspace) = self.workspace.upgrade() {
1726                        let app_state = workspace.read(cx).app_state().clone();
1727                        workspace::join_in_room_project(*project_id, *host_user_id, app_state, cx)
1728                            .detach_and_prompt_err(
1729                                "Failed to join project",
1730                                window,
1731                                cx,
1732                                |_, _, _| None,
1733                            );
1734                    }
1735                }
1736                ListEntry::ParticipantScreen { peer_id, .. } => {
1737                    let Some(peer_id) = peer_id else {
1738                        return;
1739                    };
1740                    if let Some(workspace) = self.workspace.upgrade() {
1741                        workspace.update(cx, |workspace, cx| {
1742                            workspace.open_shared_screen(*peer_id, window, cx)
1743                        });
1744                    }
1745                }
1746                ListEntry::Channel { channel, .. } => {
1747                    let is_active = maybe!({
1748                        let call_channel = ActiveCall::global(cx)
1749                            .read(cx)
1750                            .room()?
1751                            .read(cx)
1752                            .channel_id()?;
1753
1754                        Some(call_channel == channel.id)
1755                    })
1756                    .unwrap_or(false);
1757                    if is_active {
1758                        self.open_channel_notes(channel.id, window, cx)
1759                    } else {
1760                        self.join_channel(channel.id, window, cx)
1761                    }
1762                }
1763                ListEntry::ContactPlaceholder => self.toggle_contact_finder(window, cx),
1764                ListEntry::CallParticipant { user, peer_id, .. } => {
1765                    if Some(user) == self.user_store.read(cx).current_user().as_ref() {
1766                        Self::leave_call(window, cx);
1767                    } else if let Some(peer_id) = peer_id {
1768                        self.workspace
1769                            .update(cx, |workspace, cx| workspace.follow(*peer_id, window, cx))
1770                            .ok();
1771                    }
1772                }
1773                ListEntry::IncomingRequest(user) => {
1774                    self.respond_to_contact_request(user.id, true, window, cx)
1775                }
1776                ListEntry::ChannelInvite(channel) => {
1777                    self.respond_to_channel_invite(channel.id, true, cx)
1778                }
1779                ListEntry::ChannelNotes { channel_id } => {
1780                    self.open_channel_notes(*channel_id, window, cx)
1781                }
1782                ListEntry::OutgoingRequest(_) => {}
1783                ListEntry::ChannelEditor { .. } => {}
1784            }
1785        }
1786    }
1787
1788    fn insert_space(&mut self, _: &InsertSpace, window: &mut Window, cx: &mut Context<Self>) {
1789        if self.channel_editing_state.is_some() {
1790            self.channel_name_editor.update(cx, |editor, cx| {
1791                editor.insert(" ", window, cx);
1792            });
1793        } else if self.filter_editor.focus_handle(cx).is_focused(window) {
1794            self.filter_editor.update(cx, |editor, cx| {
1795                editor.insert(" ", window, cx);
1796            });
1797        }
1798    }
1799
1800    fn confirm_channel_edit(&mut self, window: &mut Window, cx: &mut Context<CollabPanel>) -> bool {
1801        if let Some(editing_state) = &mut self.channel_editing_state {
1802            match editing_state {
1803                ChannelEditingState::Create {
1804                    location,
1805                    pending_name,
1806                    ..
1807                } => {
1808                    if pending_name.is_some() {
1809                        return false;
1810                    }
1811                    let channel_name = self.channel_name_editor.read(cx).text(cx);
1812
1813                    *pending_name = Some(channel_name.clone());
1814
1815                    let create = self.channel_store.update(cx, |channel_store, cx| {
1816                        channel_store.create_channel(&channel_name, *location, cx)
1817                    });
1818                    if location.is_none() {
1819                        cx.spawn_in(window, async move |this, cx| {
1820                            let channel_id = create.await?;
1821                            this.update_in(cx, |this, window, cx| {
1822                                this.show_channel_modal(
1823                                    channel_id,
1824                                    channel_modal::Mode::InviteMembers,
1825                                    window,
1826                                    cx,
1827                                )
1828                            })
1829                        })
1830                        .detach_and_prompt_err(
1831                            "Failed to create channel",
1832                            window,
1833                            cx,
1834                            |_, _, _| None,
1835                        );
1836                    } else {
1837                        create.detach_and_prompt_err(
1838                            "Failed to create channel",
1839                            window,
1840                            cx,
1841                            |_, _, _| None,
1842                        );
1843                    }
1844                    cx.notify();
1845                }
1846                ChannelEditingState::Rename {
1847                    location,
1848                    pending_name,
1849                } => {
1850                    if pending_name.is_some() {
1851                        return false;
1852                    }
1853                    let channel_name = self.channel_name_editor.read(cx).text(cx);
1854                    *pending_name = Some(channel_name.clone());
1855
1856                    self.channel_store
1857                        .update(cx, |channel_store, cx| {
1858                            channel_store.rename(*location, &channel_name, cx)
1859                        })
1860                        .detach();
1861                    cx.notify();
1862                }
1863            }
1864            cx.focus_self(window);
1865            true
1866        } else {
1867            false
1868        }
1869    }
1870
1871    fn toggle_section_expanded(&mut self, section: Section, cx: &mut Context<Self>) {
1872        if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
1873            self.collapsed_sections.remove(ix);
1874        } else {
1875            self.collapsed_sections.push(section);
1876        }
1877        self.update_entries(false, cx);
1878    }
1879
1880    fn collapse_selected_channel(
1881        &mut self,
1882        _: &CollapseSelectedChannel,
1883        window: &mut Window,
1884        cx: &mut Context<Self>,
1885    ) {
1886        let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
1887            return;
1888        };
1889
1890        if self.is_channel_collapsed(channel_id) {
1891            return;
1892        }
1893
1894        self.toggle_channel_collapsed(channel_id, window, cx);
1895    }
1896
1897    fn expand_selected_channel(
1898        &mut self,
1899        _: &ExpandSelectedChannel,
1900        window: &mut Window,
1901        cx: &mut Context<Self>,
1902    ) {
1903        let Some(id) = self.selected_channel().map(|channel| channel.id) else {
1904            return;
1905        };
1906
1907        if !self.is_channel_collapsed(id) {
1908            return;
1909        }
1910
1911        self.toggle_channel_collapsed(id, window, cx)
1912    }
1913
1914    fn toggle_channel_collapsed(
1915        &mut self,
1916        channel_id: ChannelId,
1917        window: &mut Window,
1918        cx: &mut Context<Self>,
1919    ) {
1920        match self.collapsed_channels.binary_search(&channel_id) {
1921            Ok(ix) => {
1922                self.collapsed_channels.remove(ix);
1923            }
1924            Err(ix) => {
1925                self.collapsed_channels.insert(ix, channel_id);
1926            }
1927        };
1928        self.serialize(cx);
1929        self.update_entries(true, cx);
1930        cx.notify();
1931        cx.focus_self(window);
1932    }
1933
1934    fn is_channel_collapsed(&self, channel_id: ChannelId) -> bool {
1935        self.collapsed_channels.binary_search(&channel_id).is_ok()
1936    }
1937
1938    pub fn toggle_favorite_channel(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
1939        self.channel_store.update(cx, |store, cx| {
1940            store.toggle_favorite_channel(channel_id, cx);
1941        });
1942        self.persist_favorites(cx);
1943    }
1944
1945    fn is_channel_favorited(&self, channel_id: ChannelId, cx: &App) -> bool {
1946        self.channel_store.read(cx).is_channel_favorited(channel_id)
1947    }
1948
1949    fn persist_favorites(&mut self, cx: &mut Context<Self>) {
1950        // GlobalKeyValueStore uses a sqlez worker thread that the test
1951        // scheduler can't control, causing non-determinism failures.
1952        if cfg!(any(test, feature = "test-support")) {
1953            return;
1954        }
1955
1956        let favorite_ids: Vec<u64> = self
1957            .channel_store
1958            .read(cx)
1959            .favorite_channel_ids()
1960            .iter()
1961            .map(|id| id.0)
1962            .collect();
1963        self.pending_serialization = cx.background_spawn(
1964            async move {
1965                let json = serde_json::to_string(&favorite_ids)?;
1966                GlobalKeyValueStore::global()
1967                    .write_kvp("favorite_channels".to_string(), json)
1968                    .await?;
1969                anyhow::Ok(())
1970            }
1971            .log_err(),
1972        );
1973    }
1974
1975    fn leave_call(window: &mut Window, cx: &mut App) {
1976        ActiveCall::global(cx)
1977            .update(cx, |call, cx| call.hang_up(cx))
1978            .detach_and_prompt_err("Failed to hang up", window, cx, |_, _, _| None);
1979    }
1980
1981    fn toggle_contact_finder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1982        if let Some(workspace) = self.workspace.upgrade() {
1983            workspace.update(cx, |workspace, cx| {
1984                workspace.toggle_modal(window, cx, |window, cx| {
1985                    let mut finder = ContactFinder::new(self.user_store.clone(), window, cx);
1986                    finder.set_query(self.filter_editor.read(cx).text(cx), window, cx);
1987                    finder
1988                });
1989            });
1990        }
1991    }
1992
1993    fn new_root_channel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1994        self.channel_editing_state = Some(ChannelEditingState::Create {
1995            location: None,
1996            pending_name: None,
1997        });
1998        self.update_entries(false, cx);
1999        self.select_channel_editor();
2000        window.focus(&self.channel_name_editor.focus_handle(cx), cx);
2001        cx.notify();
2002    }
2003
2004    fn select_channel_editor(&mut self) {
2005        self.selection = self
2006            .entries
2007            .iter()
2008            .position(|entry| matches!(entry, ListEntry::ChannelEditor { .. }));
2009    }
2010
2011    fn new_subchannel(
2012        &mut self,
2013        channel_id: ChannelId,
2014        window: &mut Window,
2015        cx: &mut Context<Self>,
2016    ) {
2017        self.collapsed_channels
2018            .retain(|channel| *channel != channel_id);
2019        self.channel_editing_state = Some(ChannelEditingState::Create {
2020            location: Some(channel_id),
2021            pending_name: None,
2022        });
2023        self.update_entries(false, cx);
2024        self.select_channel_editor();
2025        window.focus(&self.channel_name_editor.focus_handle(cx), cx);
2026        cx.notify();
2027    }
2028
2029    fn manage_members(
2030        &mut self,
2031        channel_id: ChannelId,
2032        window: &mut Window,
2033        cx: &mut Context<Self>,
2034    ) {
2035        self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, window, cx);
2036    }
2037
2038    fn remove_selected_channel(&mut self, _: &Remove, window: &mut Window, cx: &mut Context<Self>) {
2039        if let Some(channel) = self.selected_channel() {
2040            self.remove_channel(channel.id, window, cx)
2041        }
2042    }
2043
2044    fn rename_selected_channel(
2045        &mut self,
2046        _: &SecondaryConfirm,
2047        window: &mut Window,
2048        cx: &mut Context<Self>,
2049    ) {
2050        if let Some(channel) = self.selected_channel() {
2051            self.rename_channel(channel.id, window, cx);
2052        }
2053    }
2054
2055    fn rename_channel(
2056        &mut self,
2057        channel_id: ChannelId,
2058        window: &mut Window,
2059        cx: &mut Context<Self>,
2060    ) {
2061        let channel_store = self.channel_store.read(cx);
2062        if !channel_store.is_channel_admin(channel_id) {
2063            return;
2064        }
2065        if let Some(channel) = channel_store.channel_for_id(channel_id).cloned() {
2066            self.channel_editing_state = Some(ChannelEditingState::Rename {
2067                location: channel_id,
2068                pending_name: None,
2069            });
2070            self.channel_name_editor.update(cx, |editor, cx| {
2071                editor.set_text(channel.name.clone(), window, cx);
2072                editor.select_all(&Default::default(), window, cx);
2073            });
2074            window.focus(&self.channel_name_editor.focus_handle(cx), cx);
2075            self.update_entries(false, cx);
2076            self.select_channel_editor();
2077        }
2078    }
2079
2080    fn open_selected_channel_notes(
2081        &mut self,
2082        _: &OpenSelectedChannelNotes,
2083        window: &mut Window,
2084        cx: &mut Context<Self>,
2085    ) {
2086        if let Some(channel) = self.selected_channel() {
2087            self.open_channel_notes(channel.id, window, cx);
2088        }
2089    }
2090
2091    pub fn toggle_selected_channel_favorite(
2092        &mut self,
2093        _: &ToggleSelectedChannelFavorite,
2094        _window: &mut Window,
2095        cx: &mut Context<Self>,
2096    ) {
2097        if let Some(channel) = self.selected_channel() {
2098            self.toggle_favorite_channel(channel.id, cx);
2099        }
2100    }
2101
2102    fn set_channel_visibility(
2103        &mut self,
2104        channel_id: ChannelId,
2105        visibility: ChannelVisibility,
2106        window: &mut Window,
2107        cx: &mut Context<Self>,
2108    ) {
2109        self.channel_store
2110            .update(cx, |channel_store, cx| {
2111                channel_store.set_channel_visibility(channel_id, visibility, cx)
2112            })
2113            .detach_and_prompt_err("Failed to set channel visibility", window, cx, |e, _, _| match e.error_code() {
2114                ErrorCode::BadPublicNesting =>
2115                    if e.error_tag("direction") == Some("parent") {
2116                        Some("To make a channel public, its parent channel must be public.".to_string())
2117                    } else {
2118                        Some("To make a channel private, all of its subchannels must be private.".to_string())
2119                    },
2120                _ => None
2121            });
2122    }
2123
2124    fn start_move_channel(
2125        &mut self,
2126        channel_id: ChannelId,
2127        _window: &mut Window,
2128        _cx: &mut Context<Self>,
2129    ) {
2130        self.channel_clipboard = Some(ChannelMoveClipboard { channel_id });
2131    }
2132
2133    fn start_move_selected_channel(
2134        &mut self,
2135        _: &StartMoveChannel,
2136        window: &mut Window,
2137        cx: &mut Context<Self>,
2138    ) {
2139        if let Some(channel) = self.selected_channel() {
2140            self.start_move_channel(channel.id, window, cx);
2141        }
2142    }
2143
2144    fn move_channel_on_clipboard(
2145        &mut self,
2146        to_channel_id: ChannelId,
2147        window: &mut Window,
2148        cx: &mut Context<CollabPanel>,
2149    ) {
2150        if let Some(clipboard) = self.channel_clipboard.take() {
2151            self.move_channel(clipboard.channel_id, to_channel_id, window, cx)
2152        }
2153    }
2154
2155    fn move_channel(
2156        &self,
2157        channel_id: ChannelId,
2158        to: ChannelId,
2159        window: &mut Window,
2160        cx: &mut Context<Self>,
2161    ) {
2162        self.channel_store
2163            .update(cx, |channel_store, cx| {
2164                channel_store.move_channel(channel_id, to, cx)
2165            })
2166            .detach_and_prompt_err("Failed to move channel", window, cx, |e, _, _| {
2167                match e.error_code() {
2168                    ErrorCode::BadPublicNesting => {
2169                        Some("Public channels must have public parents".into())
2170                    }
2171                    ErrorCode::CircularNesting => {
2172                        Some("You cannot move a channel into itself".into())
2173                    }
2174                    ErrorCode::WrongMoveTarget => {
2175                        Some("You cannot move a channel into a different root channel".into())
2176                    }
2177                    _ => None,
2178                }
2179            })
2180    }
2181
2182    pub fn move_channel_up(
2183        &mut self,
2184        _: &MoveChannelUp,
2185        window: &mut Window,
2186        cx: &mut Context<Self>,
2187    ) {
2188        self.reorder_selected_channel(Direction::Up, window, cx);
2189    }
2190
2191    pub fn move_channel_down(
2192        &mut self,
2193        _: &MoveChannelDown,
2194        window: &mut Window,
2195        cx: &mut Context<Self>,
2196    ) {
2197        self.reorder_selected_channel(Direction::Down, window, cx);
2198    }
2199
2200    fn reorder_selected_channel(
2201        &mut self,
2202        direction: Direction,
2203        window: &mut Window,
2204        cx: &mut Context<Self>,
2205    ) {
2206        if let Some(channel) = self.selected_channel().cloned() {
2207            if self.selected_entry_is_favorite() {
2208                self.reorder_favorite(channel.id, direction, cx);
2209                return;
2210            }
2211
2212            self.channel_store.update(cx, |store, cx| {
2213                store
2214                    .reorder_channel(channel.id, direction, cx)
2215                    .detach_and_prompt_err(
2216                        match direction {
2217                            Direction::Up => "Failed to move channel up",
2218                            Direction::Down => "Failed to move channel down",
2219                        },
2220                        window,
2221                        cx,
2222                        |_, _, _| None,
2223                    )
2224            });
2225        }
2226    }
2227
2228    pub fn reorder_favorite(
2229        &mut self,
2230        channel_id: ChannelId,
2231        direction: Direction,
2232        cx: &mut Context<Self>,
2233    ) {
2234        self.channel_store.update(cx, |store, cx| {
2235            let favorite_ids = store.favorite_channel_ids();
2236            let Some(channel_index) = favorite_ids.iter().position(|id| *id == channel_id) else {
2237                return;
2238            };
2239            let target_channel_index = match direction {
2240                Direction::Up => channel_index.checked_sub(1),
2241                Direction::Down => {
2242                    let next = channel_index + 1;
2243                    (next < favorite_ids.len()).then_some(next)
2244                }
2245            };
2246            if let Some(target_channel_index) = target_channel_index {
2247                let mut new_ids = favorite_ids.to_vec();
2248                new_ids.swap(channel_index, target_channel_index);
2249                store.set_favorite_channel_ids(new_ids, cx);
2250            }
2251        });
2252        self.persist_favorites(cx);
2253    }
2254
2255    fn open_channel_notes(
2256        &mut self,
2257        channel_id: ChannelId,
2258        window: &mut Window,
2259        cx: &mut Context<Self>,
2260    ) {
2261        if let Some(workspace) = self.workspace.upgrade() {
2262            ChannelView::open(channel_id, None, workspace, window, cx).detach();
2263        }
2264    }
2265
2266    fn show_inline_context_menu(
2267        &mut self,
2268        _: &Secondary,
2269        window: &mut Window,
2270        cx: &mut Context<Self>,
2271    ) {
2272        let Some(bounds) = self
2273            .selection
2274            .and_then(|ix| self.list_state.bounds_for_item(ix))
2275        else {
2276            return;
2277        };
2278
2279        if let Some(channel) = self.selected_channel() {
2280            self.deploy_channel_context_menu(
2281                bounds.center(),
2282                channel.id,
2283                self.selection.unwrap(),
2284                window,
2285                cx,
2286            );
2287            cx.stop_propagation();
2288            return;
2289        };
2290
2291        if let Some(contact) = self.selected_contact() {
2292            self.deploy_contact_context_menu(bounds.center(), contact, window, cx);
2293            cx.stop_propagation();
2294        }
2295    }
2296
2297    fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
2298        let mut dispatch_context = KeyContext::new_with_defaults();
2299        dispatch_context.add("CollabPanel");
2300        dispatch_context.add("menu");
2301
2302        let identifier = if self.channel_name_editor.focus_handle(cx).is_focused(window)
2303            || self.filter_editor.focus_handle(cx).is_focused(window)
2304        {
2305            "editing"
2306        } else {
2307            "not_editing"
2308        };
2309
2310        dispatch_context.add(identifier);
2311        dispatch_context
2312    }
2313
2314    fn selected_channel(&self) -> Option<&Arc<Channel>> {
2315        self.selection
2316            .and_then(|ix| self.entries.get(ix))
2317            .and_then(|entry| match entry {
2318                ListEntry::Channel { channel, .. } => Some(channel),
2319                _ => None,
2320            })
2321    }
2322
2323    fn selected_entry_is_favorite(&self) -> bool {
2324        self.selection
2325            .and_then(|ix| self.entries.get(ix))
2326            .is_some_and(|entry| {
2327                matches!(
2328                    entry,
2329                    ListEntry::Channel {
2330                        is_favorite: true,
2331                        ..
2332                    }
2333                )
2334            })
2335    }
2336
2337    fn selected_contact(&self) -> Option<Arc<Contact>> {
2338        self.selection
2339            .and_then(|ix| self.entries.get(ix))
2340            .and_then(|entry| match entry {
2341                ListEntry::Contact { contact, .. } => Some(contact.clone()),
2342                _ => None,
2343            })
2344    }
2345
2346    fn show_channel_modal(
2347        &mut self,
2348        channel_id: ChannelId,
2349        mode: channel_modal::Mode,
2350        window: &mut Window,
2351        cx: &mut Context<Self>,
2352    ) {
2353        let workspace = self.workspace.clone();
2354        let user_store = self.user_store.clone();
2355        let channel_store = self.channel_store.clone();
2356
2357        cx.spawn_in(window, async move |_, cx| {
2358            workspace.update_in(cx, |workspace, window, cx| {
2359                workspace.toggle_modal(window, cx, |window, cx| {
2360                    ChannelModal::new(
2361                        user_store.clone(),
2362                        channel_store.clone(),
2363                        channel_id,
2364                        mode,
2365                        window,
2366                        cx,
2367                    )
2368                });
2369            })
2370        })
2371        .detach();
2372    }
2373
2374    fn leave_channel(&self, channel_id: ChannelId, window: &mut Window, cx: &mut Context<Self>) {
2375        let Some(user_id) = self.user_store.read(cx).current_user().map(|u| u.id) else {
2376            return;
2377        };
2378        let Some(channel) = self.channel_store.read(cx).channel_for_id(channel_id) else {
2379            return;
2380        };
2381        let prompt_message = format!("Are you sure you want to leave \"#{}\"?", channel.name);
2382        let answer = window.prompt(
2383            PromptLevel::Warning,
2384            &prompt_message,
2385            None,
2386            &["Leave", "Cancel"],
2387            cx,
2388        );
2389        cx.spawn_in(window, async move |this, cx| {
2390            if answer.await? != 0 {
2391                return Ok(());
2392            }
2393            this.update(cx, |this, cx| {
2394                this.channel_store.update(cx, |channel_store, cx| {
2395                    channel_store.remove_member(channel_id, user_id, cx)
2396                })
2397            })?
2398            .await
2399        })
2400        .detach_and_prompt_err("Failed to leave channel", window, cx, |_, _, _| None)
2401    }
2402
2403    fn remove_channel(
2404        &mut self,
2405        channel_id: ChannelId,
2406        window: &mut Window,
2407        cx: &mut Context<Self>,
2408    ) {
2409        let channel_store = self.channel_store.clone();
2410        if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
2411            let prompt_message = format!(
2412                "Are you sure you want to remove the channel \"{}\"?",
2413                channel.name
2414            );
2415            let answer = window.prompt(
2416                PromptLevel::Warning,
2417                &prompt_message,
2418                None,
2419                &["Remove", "Cancel"],
2420                cx,
2421            );
2422            let workspace = self.workspace.clone();
2423            cx.spawn_in(window, async move |this, mut cx| {
2424                if answer.await? == 0 {
2425                    channel_store
2426                        .update(cx, |channels, _| channels.remove_channel(channel_id))
2427                        .await
2428                        .notify_workspace_async_err(workspace, &mut cx);
2429                    this.update_in(cx, |_, window, cx| cx.focus_self(window))
2430                        .ok();
2431                }
2432                anyhow::Ok(())
2433            })
2434            .detach();
2435        }
2436    }
2437
2438    fn remove_contact(
2439        &mut self,
2440        user_id: u64,
2441        github_login: &str,
2442        window: &mut Window,
2443        cx: &mut Context<Self>,
2444    ) {
2445        let user_store = self.user_store.clone();
2446        let prompt_message = format!(
2447            "Are you sure you want to remove \"{}\" from your contacts?",
2448            github_login
2449        );
2450        let answer = window.prompt(
2451            PromptLevel::Warning,
2452            &prompt_message,
2453            None,
2454            &["Remove", "Cancel"],
2455            cx,
2456        );
2457        let workspace = self.workspace.clone();
2458        cx.spawn_in(window, async move |_, mut cx| {
2459            if answer.await? == 0 {
2460                user_store
2461                    .update(cx, |store, cx| store.remove_contact(user_id, cx))
2462                    .await
2463                    .notify_workspace_async_err(workspace, &mut cx);
2464            }
2465            anyhow::Ok(())
2466        })
2467        .detach_and_prompt_err("Failed to remove contact", window, cx, |_, _, _| None);
2468    }
2469
2470    fn respond_to_contact_request(
2471        &mut self,
2472        user_id: u64,
2473        accept: bool,
2474        window: &mut Window,
2475        cx: &mut Context<Self>,
2476    ) {
2477        self.user_store
2478            .update(cx, |store, cx| {
2479                store.respond_to_contact_request(user_id, accept, cx)
2480            })
2481            .detach_and_prompt_err(
2482                "Failed to respond to contact request",
2483                window,
2484                cx,
2485                |_, _, _| None,
2486            );
2487    }
2488
2489    fn respond_to_channel_invite(
2490        &mut self,
2491        channel_id: ChannelId,
2492        accept: bool,
2493        cx: &mut Context<Self>,
2494    ) {
2495        self.channel_store
2496            .update(cx, |store, cx| {
2497                store.respond_to_channel_invite(channel_id, accept, cx)
2498            })
2499            .detach();
2500    }
2501
2502    fn call(&mut self, recipient_user_id: u64, window: &mut Window, cx: &mut Context<Self>) {
2503        ActiveCall::global(cx)
2504            .update(cx, |call, cx| {
2505                call.invite(recipient_user_id, Some(self.project.clone()), cx)
2506            })
2507            .detach_and_prompt_err("Call failed", window, cx, |_, _, _| None);
2508    }
2509
2510    fn join_channel(&self, channel_id: ChannelId, window: &mut Window, cx: &mut Context<Self>) {
2511        let Some(workspace) = self.workspace.upgrade() else {
2512            return;
2513        };
2514
2515        let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() else {
2516            return;
2517        };
2518        workspace::join_channel(
2519            channel_id,
2520            workspace.read(cx).app_state().clone(),
2521            Some(handle),
2522            Some(self.workspace.clone()),
2523            cx,
2524        )
2525        .detach_and_prompt_err("Failed to join channel", window, cx, |_, _, _| None)
2526    }
2527
2528    fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
2529        let channel_store = self.channel_store.read(cx);
2530        let Some(channel) = channel_store.channel_for_id(channel_id) else {
2531            return;
2532        };
2533        let item = ClipboardItem::new_string(channel.link(cx));
2534        cx.write_to_clipboard(item)
2535    }
2536
2537    fn copy_channel_notes_link(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
2538        let channel_store = self.channel_store.read(cx);
2539        let Some(channel) = channel_store.channel_for_id(channel_id) else {
2540            return;
2541        };
2542        let item = ClipboardItem::new_string(channel.notes_link(None, cx));
2543        cx.write_to_clipboard(item)
2544    }
2545
2546    fn render_signed_out(&mut self, cx: &mut Context<Self>) -> Div {
2547        let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more.";
2548
2549        // Two distinct "not connected" states:
2550        //   - Authenticated (has credentials): user just needs to connect.
2551        //   - Unauthenticated (no credentials): user needs to sign in via GitHub.
2552        let is_authenticated = self.client.user_id().is_some();
2553        let status = *self.client.status().borrow();
2554        let is_busy = status.is_signing_in();
2555
2556        let (button_id, button_label, button_icon) = if is_authenticated {
2557            (
2558                "connect",
2559                if is_busy { "Connecting…" } else { "Connect" },
2560                IconName::Public,
2561            )
2562        } else {
2563            (
2564                "sign_in",
2565                if is_busy {
2566                    "Signing in…"
2567                } else {
2568                    "Sign In with GitHub"
2569                },
2570                IconName::Github,
2571            )
2572        };
2573
2574        v_flex()
2575            .p_4()
2576            .gap_4()
2577            .size_full()
2578            .text_center()
2579            .justify_center()
2580            .child(Label::new(collab_blurb))
2581            .child(
2582                Button::new(button_id, button_label)
2583                    .full_width()
2584                    .start_icon(Icon::new(button_icon).color(Color::Muted))
2585                    .style(ButtonStyle::Outlined)
2586                    .disabled(is_busy)
2587                    .on_click(cx.listener(|this, _, window, cx| {
2588                        let client = this.client.clone();
2589                        let workspace = this.workspace.clone();
2590                        cx.spawn_in(window, async move |_, mut cx| {
2591                            client
2592                                .connect(true, &mut cx)
2593                                .await
2594                                .into_response()
2595                                .notify_workspace_async_err(workspace, &mut cx);
2596                        })
2597                        .detach()
2598                    })),
2599            )
2600    }
2601
2602    fn render_list_entry(
2603        &mut self,
2604        ix: usize,
2605        window: &mut Window,
2606        cx: &mut Context<Self>,
2607    ) -> AnyElement {
2608        let entry = &self.entries[ix];
2609
2610        let is_selected = self.selection == Some(ix);
2611        match entry {
2612            ListEntry::Header(section) => {
2613                let is_collapsed = self.collapsed_sections.contains(section);
2614                self.render_header(*section, is_selected, is_collapsed, cx)
2615                    .into_any_element()
2616            }
2617            ListEntry::Contact { contact, calling } => self
2618                .render_contact(contact, *calling, is_selected, cx)
2619                .into_any_element(),
2620            ListEntry::ContactPlaceholder => self
2621                .render_contact_placeholder(is_selected, cx)
2622                .into_any_element(),
2623            ListEntry::IncomingRequest(user) => self
2624                .render_contact_request(user, true, is_selected, cx)
2625                .into_any_element(),
2626            ListEntry::OutgoingRequest(user) => self
2627                .render_contact_request(user, false, is_selected, cx)
2628                .into_any_element(),
2629            ListEntry::Channel {
2630                channel,
2631                depth,
2632                has_children,
2633                string_match,
2634                ..
2635            } => self
2636                .render_channel(
2637                    channel,
2638                    *depth,
2639                    *has_children,
2640                    is_selected,
2641                    ix,
2642                    string_match.as_ref(),
2643                    cx,
2644                )
2645                .into_any_element(),
2646            ListEntry::ChannelEditor { depth } => self
2647                .render_channel_editor(*depth, window, cx)
2648                .into_any_element(),
2649            ListEntry::ChannelInvite(channel) => self
2650                .render_channel_invite(channel, is_selected, cx)
2651                .into_any_element(),
2652            ListEntry::CallParticipant {
2653                user,
2654                peer_id,
2655                is_pending,
2656                role,
2657            } => self
2658                .render_call_participant(user, *peer_id, *is_pending, *role, is_selected, cx)
2659                .into_any_element(),
2660            ListEntry::ParticipantProject {
2661                project_id,
2662                worktree_root_names,
2663                host_user_id,
2664                is_last,
2665            } => self
2666                .render_participant_project(
2667                    *project_id,
2668                    worktree_root_names,
2669                    *host_user_id,
2670                    *is_last,
2671                    is_selected,
2672                    window,
2673                    cx,
2674                )
2675                .into_any_element(),
2676            ListEntry::ParticipantScreen { peer_id, is_last } => self
2677                .render_participant_screen(*peer_id, *is_last, is_selected, window, cx)
2678                .into_any_element(),
2679            ListEntry::ChannelNotes { channel_id } => self
2680                .render_channel_notes(*channel_id, is_selected, window, cx)
2681                .into_any_element(),
2682        }
2683    }
2684
2685    fn render_signed_in(&mut self, _: &mut Window, cx: &mut Context<Self>) -> Div {
2686        self.channel_store.update(cx, |channel_store, _| {
2687            channel_store.initialize();
2688        });
2689
2690        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
2691
2692        v_flex()
2693            .size_full()
2694            .gap_1()
2695            .child(
2696                h_flex()
2697                    .p_2()
2698                    .h(Tab::container_height(cx))
2699                    .gap_1p5()
2700                    .border_b_1()
2701                    .border_color(cx.theme().colors().border)
2702                    .child(
2703                        Icon::new(IconName::MagnifyingGlass)
2704                            .size(IconSize::Small)
2705                            .color(Color::Muted),
2706                    )
2707                    .child(self.render_filter_input(&self.filter_editor, cx))
2708                    .when(has_query, |this| {
2709                        this.pr_2p5().child(
2710                            IconButton::new("clear_filter", IconName::Close)
2711                                .shape(IconButtonShape::Square)
2712                                .tooltip(Tooltip::text("Clear Filter"))
2713                                .on_click(cx.listener(|this, _, window, cx| {
2714                                    this.reset_filter_editor_text(window, cx);
2715                                    cx.notify();
2716                                })),
2717                        )
2718                    }),
2719            )
2720            .child(
2721                list(
2722                    self.list_state.clone(),
2723                    cx.processor(Self::render_list_entry),
2724                )
2725                .size_full(),
2726            )
2727    }
2728
2729    fn render_filter_input(
2730        &self,
2731        editor: &Entity<Editor>,
2732        cx: &mut Context<Self>,
2733    ) -> impl IntoElement {
2734        let settings = ThemeSettings::get_global(cx);
2735        let text_style = TextStyle {
2736            color: if editor.read(cx).read_only(cx) {
2737                cx.theme().colors().text_disabled
2738            } else {
2739                cx.theme().colors().text
2740            },
2741            font_family: settings.ui_font.family.clone(),
2742            font_features: settings.ui_font.features.clone(),
2743            font_fallbacks: settings.ui_font.fallbacks.clone(),
2744            font_size: rems(0.875).into(),
2745            font_weight: settings.ui_font.weight,
2746            font_style: FontStyle::Normal,
2747            line_height: relative(1.3),
2748            ..Default::default()
2749        };
2750
2751        EditorElement::new(
2752            editor,
2753            EditorStyle {
2754                local_player: cx.theme().players().local(),
2755                text: text_style,
2756                ..Default::default()
2757            },
2758        )
2759    }
2760
2761    fn render_header(
2762        &self,
2763        section: Section,
2764        is_selected: bool,
2765        is_collapsed: bool,
2766        cx: &mut Context<Self>,
2767    ) -> impl IntoElement {
2768        let mut channel_link = None;
2769        let mut channel_tooltip_text = None;
2770        let mut channel_icon = None;
2771
2772        let text = match section {
2773            Section::ActiveCall => {
2774                let channel_name = maybe!({
2775                    let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
2776
2777                    let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
2778
2779                    channel_link = Some(channel.link(cx));
2780                    (channel_icon, channel_tooltip_text) = match channel.visibility {
2781                        proto::ChannelVisibility::Public => {
2782                            (Some("icons/public.svg"), Some("Copy public channel link."))
2783                        }
2784                        proto::ChannelVisibility::Members => {
2785                            (Some("icons/hash.svg"), Some("Copy private channel link."))
2786                        }
2787                    };
2788
2789                    Some(channel.name.as_ref())
2790                });
2791
2792                if let Some(name) = channel_name {
2793                    SharedString::from(name.to_string())
2794                } else {
2795                    SharedString::from("Current Call")
2796                }
2797            }
2798            Section::FavoriteChannels => SharedString::from("Favorites"),
2799            Section::ContactRequests => SharedString::from("Requests"),
2800            Section::Contacts => SharedString::from("Contacts"),
2801            Section::Channels => SharedString::from("Channels"),
2802            Section::ChannelInvites => SharedString::from("Invites"),
2803            Section::Online => SharedString::from("Online"),
2804            Section::Offline => SharedString::from("Offline"),
2805        };
2806
2807        let button = match section {
2808            Section::ActiveCall => channel_link.map(|channel_link| {
2809                CopyButton::new("copy-channel-link", channel_link)
2810                    .visible_on_hover("section-header")
2811                    .tooltip_label("Copy Channel Link")
2812                    .into_any_element()
2813            }),
2814            Section::Contacts => Some(
2815                IconButton::new("add-contact", IconName::Plus)
2816                    .icon_size(IconSize::Small)
2817                    .on_click(
2818                        cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)),
2819                    )
2820                    .tooltip(Tooltip::text("Search for new contact"))
2821                    .into_any_element(),
2822            ),
2823            Section::Channels => {
2824                Some(
2825                    h_flex()
2826                        .child(
2827                            IconButton::new("filter-active-channels", IconName::ListFilter)
2828                                .icon_size(IconSize::Small)
2829                                .toggle_state(self.filter_active_channels)
2830                                .on_click(cx.listener(|this, _, _window, cx| {
2831                                    this.filter_active_channels = !this.filter_active_channels;
2832                                    this.update_entries(true, cx);
2833                                }))
2834                                .tooltip(Tooltip::text(if self.filter_active_channels {
2835                                    "Show All Channels"
2836                                } else {
2837                                    "Show Occupied Channels"
2838                                })),
2839                        )
2840                        .child(
2841                            IconButton::new("add-channel", IconName::Plus)
2842                                .icon_size(IconSize::Small)
2843                                .on_click(cx.listener(|this, _, window, cx| {
2844                                    this.new_root_channel(window, cx)
2845                                }))
2846                                .tooltip(Tooltip::text("Create Channel")),
2847                        )
2848                        .into_any_element(),
2849                )
2850            }
2851            _ => None,
2852        };
2853
2854        let can_collapse = match section {
2855            Section::ActiveCall
2856            | Section::Channels
2857            | Section::Contacts
2858            | Section::FavoriteChannels => false,
2859
2860            Section::ChannelInvites
2861            | Section::ContactRequests
2862            | Section::Online
2863            | Section::Offline => true,
2864        };
2865
2866        h_flex().w_full().group("section-header").child(
2867            ListHeader::new(text)
2868                .when(can_collapse, |header| {
2869                    header.toggle(Some(!is_collapsed)).on_toggle(cx.listener(
2870                        move |this, _, _, cx| {
2871                            this.toggle_section_expanded(section, cx);
2872                        },
2873                    ))
2874                })
2875                .inset(true)
2876                .end_slot::<AnyElement>(button)
2877                .toggle_state(is_selected),
2878        )
2879    }
2880
2881    fn render_contact(
2882        &self,
2883        contact: &Arc<Contact>,
2884        calling: bool,
2885        is_selected: bool,
2886        cx: &mut Context<Self>,
2887    ) -> impl IntoElement {
2888        let online = contact.online;
2889        let busy = contact.busy || calling;
2890        let github_login = contact.user.github_login.clone();
2891        let item = ListItem::new(github_login.clone())
2892            .indent_level(1)
2893            .indent_step_size(px(20.))
2894            .toggle_state(is_selected)
2895            .child(
2896                h_flex()
2897                    .w_full()
2898                    .justify_between()
2899                    .child(render_participant_name_and_handle(&contact.user))
2900                    .when(calling, |el| {
2901                        el.child(Label::new("Calling").color(Color::Muted))
2902                    })
2903                    .when(!calling, |el| {
2904                        el.child(
2905                            IconButton::new("contact context menu", IconName::Ellipsis)
2906                                .icon_color(Color::Muted)
2907                                .visible_on_hover("")
2908                                .on_click(cx.listener({
2909                                    let contact = contact.clone();
2910                                    move |this, event: &ClickEvent, window, cx| {
2911                                        this.deploy_contact_context_menu(
2912                                            event.position(),
2913                                            contact.clone(),
2914                                            window,
2915                                            cx,
2916                                        );
2917                                    }
2918                                })),
2919                        )
2920                    }),
2921            )
2922            .on_secondary_mouse_down(cx.listener({
2923                let contact = contact.clone();
2924                move |this, event: &MouseDownEvent, window, cx| {
2925                    this.deploy_contact_context_menu(event.position, contact.clone(), window, cx);
2926                }
2927            }))
2928            .start_slot(
2929                // todo handle contacts with no avatar
2930                Avatar::new(contact.user.avatar_uri.clone())
2931                    .indicator::<AvatarAvailabilityIndicator>(if online {
2932                        Some(AvatarAvailabilityIndicator::new(match busy {
2933                            true => ui::CollaboratorAvailability::Busy,
2934                            false => ui::CollaboratorAvailability::Free,
2935                        }))
2936                    } else {
2937                        None
2938                    }),
2939            );
2940
2941        div()
2942            .id(github_login.clone())
2943            .group("")
2944            .child(item)
2945            .tooltip(move |_, cx| {
2946                let text = if !online {
2947                    format!(" {} is offline", &github_login)
2948                } else if busy {
2949                    format!(" {} is on a call", &github_login)
2950                } else {
2951                    let room = ActiveCall::global(cx).read(cx).room();
2952                    if room.is_some() {
2953                        format!("Invite {} to join call", &github_login)
2954                    } else {
2955                        format!("Call {}", &github_login)
2956                    }
2957                };
2958                Tooltip::simple(text, cx)
2959            })
2960    }
2961
2962    fn render_contact_request(
2963        &self,
2964        user: &Arc<User>,
2965        is_incoming: bool,
2966        is_selected: bool,
2967        cx: &mut Context<Self>,
2968    ) -> impl IntoElement {
2969        let github_login = user.github_login.clone();
2970        let user_id = user.id;
2971        let is_response_pending = self.user_store.read(cx).is_contact_request_pending(user);
2972        let color = if is_response_pending {
2973            Color::Muted
2974        } else {
2975            Color::Default
2976        };
2977
2978        let controls = if is_incoming {
2979            vec![
2980                IconButton::new("decline-contact", IconName::Close)
2981                    .on_click(cx.listener(move |this, _, window, cx| {
2982                        this.respond_to_contact_request(user_id, false, window, cx);
2983                    }))
2984                    .icon_color(color)
2985                    .tooltip(Tooltip::text("Decline invite")),
2986                IconButton::new("accept-contact", IconName::Check)
2987                    .on_click(cx.listener(move |this, _, window, cx| {
2988                        this.respond_to_contact_request(user_id, true, window, cx);
2989                    }))
2990                    .icon_color(color)
2991                    .tooltip(Tooltip::text("Accept invite")),
2992            ]
2993        } else {
2994            let github_login = github_login.clone();
2995            vec![
2996                IconButton::new("remove_contact", IconName::Close)
2997                    .on_click(cx.listener(move |this, _, window, cx| {
2998                        this.remove_contact(user_id, &github_login, window, cx);
2999                    }))
3000                    .icon_color(color)
3001                    .tooltip(Tooltip::text("Cancel invite")),
3002            ]
3003        };
3004
3005        ListItem::new(github_login.clone())
3006            .indent_level(1)
3007            .indent_step_size(px(20.))
3008            .toggle_state(is_selected)
3009            .child(
3010                h_flex()
3011                    .w_full()
3012                    .justify_between()
3013                    .child(Label::new(github_login))
3014                    .child(h_flex().children(controls)),
3015            )
3016            .start_slot(Avatar::new(user.avatar_uri.clone()))
3017    }
3018
3019    fn render_channel_invite(
3020        &self,
3021        channel: &Arc<Channel>,
3022        is_selected: bool,
3023        cx: &mut Context<Self>,
3024    ) -> ListItem {
3025        let channel_id = channel.id;
3026        let response_is_pending = self
3027            .channel_store
3028            .read(cx)
3029            .has_pending_channel_invite_response(channel);
3030        let color = if response_is_pending {
3031            Color::Muted
3032        } else {
3033            Color::Default
3034        };
3035
3036        let controls = [
3037            IconButton::new("reject-invite", IconName::Close)
3038                .on_click(cx.listener(move |this, _, _, cx| {
3039                    this.respond_to_channel_invite(channel_id, false, cx);
3040                }))
3041                .icon_color(color)
3042                .tooltip(Tooltip::text("Decline invite")),
3043            IconButton::new("accept-invite", IconName::Check)
3044                .on_click(cx.listener(move |this, _, _, cx| {
3045                    this.respond_to_channel_invite(channel_id, true, cx);
3046                }))
3047                .icon_color(color)
3048                .tooltip(Tooltip::text("Accept invite")),
3049        ];
3050
3051        ListItem::new(("channel-invite", channel.id.0 as usize))
3052            .toggle_state(is_selected)
3053            .child(
3054                h_flex()
3055                    .w_full()
3056                    .justify_between()
3057                    .child(Label::new(channel.name.clone()))
3058                    .child(h_flex().children(controls)),
3059            )
3060            .start_slot(
3061                Icon::new(IconName::Hash)
3062                    .size(IconSize::Small)
3063                    .color(Color::Muted),
3064            )
3065    }
3066
3067    fn render_contact_placeholder(&self, is_selected: bool, cx: &mut Context<Self>) -> ListItem {
3068        ListItem::new("contact-placeholder")
3069            .child(Icon::new(IconName::Plus))
3070            .child(Label::new("Add a Contact"))
3071            .toggle_state(is_selected)
3072            .on_click(cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)))
3073    }
3074
3075    fn render_channel(
3076        &self,
3077        channel: &Channel,
3078        depth: usize,
3079        has_children: bool,
3080        is_selected: bool,
3081        ix: usize,
3082        string_match: Option<&StringMatch>,
3083        cx: &mut Context<Self>,
3084    ) -> impl IntoElement {
3085        let channel_id = channel.id;
3086
3087        let is_active = maybe!({
3088            let call_channel = ActiveCall::global(cx)
3089                .read(cx)
3090                .room()?
3091                .read(cx)
3092                .channel_id()?;
3093            Some(call_channel == channel_id)
3094        })
3095        .unwrap_or(false);
3096        let channel_store = self.channel_store.read(cx);
3097        let is_public = channel_store
3098            .channel_for_id(channel_id)
3099            .map(|channel| channel.visibility)
3100            == Some(proto::ChannelVisibility::Public);
3101        let disclosed =
3102            has_children.then(|| self.collapsed_channels.binary_search(&channel.id).is_err());
3103
3104        let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id);
3105
3106        const FACEPILE_LIMIT: usize = 3;
3107        let participants = self.channel_store.read(cx).channel_participants(channel_id);
3108
3109        let face_pile = if participants.is_empty() {
3110            None
3111        } else {
3112            let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
3113            let result = Facepile::new(
3114                participants
3115                    .iter()
3116                    .map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
3117                    .take(FACEPILE_LIMIT)
3118                    .chain(if extra_count > 0 {
3119                        Some(
3120                            Label::new(format!("+{extra_count}"))
3121                                .ml_2()
3122                                .into_any_element(),
3123                        )
3124                    } else {
3125                        None
3126                    })
3127                    .collect::<SmallVec<_>>(),
3128            );
3129
3130            Some(result)
3131        };
3132
3133        let width = self
3134            .workspace
3135            .read_with(cx, |workspace, cx| {
3136                workspace
3137                    .panel_size_state::<Self>(cx)
3138                    .and_then(|size_state| size_state.size)
3139            })
3140            .ok()
3141            .flatten()
3142            .unwrap_or(px(240.));
3143        let root_id = channel.root_id();
3144
3145        let is_favorited = self.is_channel_favorited(channel_id, cx);
3146        let (favorite_icon, favorite_color, favorite_tooltip) = if is_favorited {
3147            (IconName::StarFilled, Color::Accent, "Remove from Favorites")
3148        } else {
3149            (IconName::Star, Color::Muted, "Add to Favorites")
3150        };
3151
3152        h_flex()
3153            .id(ix)
3154            .group("")
3155            .h_6()
3156            .w_full()
3157            .when(!channel.is_root_channel(), |el| {
3158                el.on_drag(channel.clone(), move |channel, _, _, cx| {
3159                    cx.new(|_| DraggedChannelView {
3160                        channel: channel.clone(),
3161                        width,
3162                    })
3163                })
3164            })
3165            .drag_over::<Channel>({
3166                move |style, dragged_channel: &Channel, _window, cx| {
3167                    if dragged_channel.root_id() == root_id {
3168                        style.bg(cx.theme().colors().ghost_element_hover)
3169                    } else {
3170                        style
3171                    }
3172                }
3173            })
3174            .on_drop(
3175                cx.listener(move |this, dragged_channel: &Channel, window, cx| {
3176                    if dragged_channel.root_id() != root_id {
3177                        return;
3178                    }
3179                    this.move_channel(dragged_channel.id, channel_id, window, cx);
3180                }),
3181            )
3182            .child(
3183                ListItem::new(ix)
3184                    // Add one level of depth for the disclosure arrow.
3185                    .height(px(26.))
3186                    .indent_level(depth + 1)
3187                    .indent_step_size(px(20.))
3188                    .toggle_state(is_selected || is_active)
3189                    .toggle(disclosed)
3190                    .on_toggle(cx.listener(move |this, _, window, cx| {
3191                        this.toggle_channel_collapsed(channel_id, window, cx)
3192                    }))
3193                    .on_click(cx.listener(move |this, _, window, cx| {
3194                        if is_active {
3195                            this.open_channel_notes(channel_id, window, cx)
3196                        } else {
3197                            this.join_channel(channel_id, window, cx)
3198                        }
3199                    }))
3200                    .on_secondary_mouse_down(cx.listener(
3201                        move |this, event: &MouseDownEvent, window, cx| {
3202                            this.deploy_channel_context_menu(
3203                                event.position,
3204                                channel_id,
3205                                ix,
3206                                window,
3207                                cx,
3208                            )
3209                        },
3210                    ))
3211                    .child(
3212                        h_flex()
3213                            .id(format!("inside-{}", channel_id.0))
3214                            .w_full()
3215                            .gap_1()
3216                            .child(
3217                                div()
3218                                    .relative()
3219                                    .child(
3220                                        Icon::new(if is_public {
3221                                            IconName::Public
3222                                        } else {
3223                                            IconName::Hash
3224                                        })
3225                                        .size(IconSize::Small)
3226                                        .color(Color::Muted),
3227                                    )
3228                                    .children(has_notes_notification.then(|| {
3229                                        div()
3230                                            .w_1p5()
3231                                            .absolute()
3232                                            .right(px(-1.))
3233                                            .top(px(-1.))
3234                                            .child(Indicator::dot().color(Color::Info))
3235                                    })),
3236                            )
3237                            .child(
3238                                h_flex()
3239                                    .id(channel_id.0 as usize)
3240                                    .child(match string_match {
3241                                        None => Label::new(channel.name.clone()).into_any_element(),
3242                                        Some(string_match) => HighlightedLabel::new(
3243                                            channel.name.clone(),
3244                                            string_match.positions.clone(),
3245                                        )
3246                                        .into_any_element(),
3247                                    })
3248                                    .children(face_pile.map(|face_pile| face_pile.p_1())),
3249                            )
3250                            .tooltip({
3251                                let channel_store = self.channel_store.clone();
3252                                move |_window, cx| {
3253                                    cx.new(|_| JoinChannelTooltip {
3254                                        channel_store: channel_store.clone(),
3255                                        channel_id,
3256                                        has_notes_notification,
3257                                    })
3258                                    .into()
3259                                }
3260                            }),
3261                    ),
3262            )
3263            .child(
3264                h_flex()
3265                    .absolute()
3266                    .right_0()
3267                    .visible_on_hover("")
3268                    .h_full()
3269                    .pl_1()
3270                    .pr_1p5()
3271                    .gap_0p5()
3272                    .bg(cx.theme().colors().background.opacity(0.5))
3273                    .child({
3274                        let focus_handle = self.focus_handle.clone();
3275                        IconButton::new("channel_favorite", favorite_icon)
3276                            .icon_size(IconSize::Small)
3277                            .icon_color(favorite_color)
3278                            .on_click(cx.listener(move |this, _, _window, cx| {
3279                                this.toggle_favorite_channel(channel_id, cx)
3280                            }))
3281                            .tooltip(move |_window, cx| {
3282                                Tooltip::for_action_in(
3283                                    favorite_tooltip,
3284                                    &ToggleSelectedChannelFavorite,
3285                                    &focus_handle,
3286                                    cx,
3287                                )
3288                            })
3289                    })
3290                    .child({
3291                        let focus_handle = self.focus_handle.clone();
3292                        IconButton::new("channel_notes", IconName::Reader)
3293                            .icon_size(IconSize::Small)
3294                            .when(!has_notes_notification, |this| {
3295                                this.icon_color(Color::Muted)
3296                            })
3297                            .on_click(cx.listener(move |this, _, window, cx| {
3298                                this.open_channel_notes(channel_id, window, cx)
3299                            }))
3300                            .tooltip(move |_window, cx| {
3301                                Tooltip::for_action_in(
3302                                    "Open Channel Notes",
3303                                    &OpenSelectedChannelNotes,
3304                                    &focus_handle,
3305                                    cx,
3306                                )
3307                            })
3308                    }),
3309            )
3310    }
3311
3312    fn render_channel_editor(
3313        &self,
3314        depth: usize,
3315        _window: &mut Window,
3316        _cx: &mut Context<Self>,
3317    ) -> impl IntoElement {
3318        let item = ListItem::new("channel-editor")
3319            .inset(false)
3320            // Add one level of depth for the disclosure arrow.
3321            .indent_level(depth + 1)
3322            .indent_step_size(px(20.))
3323            .start_slot(
3324                Icon::new(IconName::Hash)
3325                    .size(IconSize::Small)
3326                    .color(Color::Muted),
3327            );
3328
3329        if let Some(pending_name) = self
3330            .channel_editing_state
3331            .as_ref()
3332            .and_then(|state| state.pending_name())
3333        {
3334            item.child(Label::new(pending_name))
3335        } else {
3336            item.child(self.channel_name_editor.clone())
3337        }
3338    }
3339}
3340
3341fn render_tree_branch(
3342    is_last: bool,
3343    overdraw: bool,
3344    window: &mut Window,
3345    cx: &mut App,
3346) -> impl IntoElement {
3347    let rem_size = window.rem_size();
3348    let line_height = window.text_style().line_height_in_pixels(rem_size);
3349    let width = rem_size * 1.5;
3350    let thickness = px(1.);
3351    let color = cx.theme().colors().text;
3352
3353    canvas(
3354        |_, _, _| {},
3355        move |bounds, _, window, _| {
3356            let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
3357            let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
3358            let right = bounds.right();
3359            let top = bounds.top();
3360
3361            window.paint_quad(fill(
3362                Bounds::from_corners(
3363                    point(start_x, top),
3364                    point(
3365                        start_x + thickness,
3366                        if is_last {
3367                            start_y
3368                        } else {
3369                            bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
3370                        },
3371                    ),
3372                ),
3373                color,
3374            ));
3375            window.paint_quad(fill(
3376                Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
3377                color,
3378            ));
3379        },
3380    )
3381    .w(width)
3382    .h(line_height)
3383}
3384
3385fn render_participant_name_and_handle(user: &User) -> impl IntoElement {
3386    Label::new(if let Some(ref display_name) = user.name {
3387        format!("{display_name} ({})", user.github_login)
3388    } else {
3389        user.github_login.to_string()
3390    })
3391}
3392
3393impl Render for CollabPanel {
3394    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3395        let status = *self.client.status().borrow();
3396
3397        v_flex()
3398            .key_context(self.dispatch_context(window, cx))
3399            .on_action(cx.listener(CollabPanel::cancel))
3400            .on_action(cx.listener(CollabPanel::select_next))
3401            .on_action(cx.listener(CollabPanel::select_previous))
3402            .on_action(cx.listener(CollabPanel::confirm))
3403            .on_action(cx.listener(CollabPanel::insert_space))
3404            .on_action(cx.listener(CollabPanel::remove_selected_channel))
3405            .on_action(cx.listener(CollabPanel::show_inline_context_menu))
3406            .on_action(cx.listener(CollabPanel::rename_selected_channel))
3407            .on_action(cx.listener(CollabPanel::open_selected_channel_notes))
3408            .on_action(cx.listener(CollabPanel::toggle_selected_channel_favorite))
3409            .on_action(cx.listener(CollabPanel::collapse_selected_channel))
3410            .on_action(cx.listener(CollabPanel::expand_selected_channel))
3411            .on_action(cx.listener(CollabPanel::start_move_selected_channel))
3412            .on_action(cx.listener(CollabPanel::move_channel_up))
3413            .on_action(cx.listener(CollabPanel::move_channel_down))
3414            .track_focus(&self.focus_handle)
3415            .size_full()
3416            .child(if !status.is_or_was_connected() || status.is_signing_in() {
3417                self.render_signed_out(cx)
3418            } else {
3419                self.render_signed_in(window, cx)
3420            })
3421            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
3422                deferred(
3423                    anchored()
3424                        .position(*position)
3425                        .anchor(gpui::Corner::TopLeft)
3426                        .child(menu.clone()),
3427                )
3428                .with_priority(1)
3429            }))
3430    }
3431}
3432
3433impl EventEmitter<PanelEvent> for CollabPanel {}
3434
3435impl Panel for CollabPanel {
3436    fn position(&self, _window: &Window, cx: &App) -> DockPosition {
3437        CollaborationPanelSettings::get_global(cx).dock
3438    }
3439
3440    fn position_is_valid(&self, position: DockPosition) -> bool {
3441        matches!(position, DockPosition::Left | DockPosition::Right)
3442    }
3443
3444    fn set_position(
3445        &mut self,
3446        position: DockPosition,
3447        _window: &mut Window,
3448        cx: &mut Context<Self>,
3449    ) {
3450        settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3451            settings.collaboration_panel.get_or_insert_default().dock = Some(position.into())
3452        });
3453    }
3454
3455    fn default_size(&self, _window: &Window, cx: &App) -> Pixels {
3456        CollaborationPanelSettings::get_global(cx).default_width
3457    }
3458
3459    fn icon(&self, _window: &Window, cx: &App) -> Option<ui::IconName> {
3460        CollaborationPanelSettings::get_global(cx)
3461            .button
3462            .then_some(ui::IconName::UserGroup)
3463    }
3464
3465    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
3466        Some("Collab Panel")
3467    }
3468
3469    fn toggle_action(&self) -> Box<dyn gpui::Action> {
3470        Box::new(ToggleFocus)
3471    }
3472
3473    fn persistent_name() -> &'static str {
3474        "CollabPanel"
3475    }
3476
3477    fn panel_key() -> &'static str {
3478        COLLABORATION_PANEL_KEY
3479    }
3480
3481    fn activation_priority(&self) -> u32 {
3482        5
3483    }
3484}
3485
3486impl Focusable for CollabPanel {
3487    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
3488        self.filter_editor.focus_handle(cx)
3489    }
3490}
3491
3492impl PartialEq for ListEntry {
3493    fn eq(&self, other: &Self) -> bool {
3494        match self {
3495            ListEntry::Header(section_1) => {
3496                if let ListEntry::Header(section_2) = other {
3497                    return section_1 == section_2;
3498                }
3499            }
3500            ListEntry::CallParticipant { user: user_1, .. } => {
3501                if let ListEntry::CallParticipant { user: user_2, .. } = other {
3502                    return user_1.id == user_2.id;
3503                }
3504            }
3505            ListEntry::ParticipantProject {
3506                project_id: project_id_1,
3507                ..
3508            } => {
3509                if let ListEntry::ParticipantProject {
3510                    project_id: project_id_2,
3511                    ..
3512                } = other
3513                {
3514                    return project_id_1 == project_id_2;
3515                }
3516            }
3517            ListEntry::ParticipantScreen {
3518                peer_id: peer_id_1, ..
3519            } => {
3520                if let ListEntry::ParticipantScreen {
3521                    peer_id: peer_id_2, ..
3522                } = other
3523                {
3524                    return peer_id_1 == peer_id_2;
3525                }
3526            }
3527            ListEntry::Channel {
3528                channel: channel_1,
3529                is_favorite: is_favorite_1,
3530                ..
3531            } => {
3532                if let ListEntry::Channel {
3533                    channel: channel_2,
3534                    is_favorite: is_favorite_2,
3535                    ..
3536                } = other
3537                {
3538                    return channel_1.id == channel_2.id && is_favorite_1 == is_favorite_2;
3539                }
3540            }
3541            ListEntry::ChannelNotes { channel_id } => {
3542                if let ListEntry::ChannelNotes {
3543                    channel_id: other_id,
3544                } = other
3545                {
3546                    return channel_id == other_id;
3547                }
3548            }
3549            ListEntry::ChannelInvite(channel_1) => {
3550                if let ListEntry::ChannelInvite(channel_2) = other {
3551                    return channel_1.id == channel_2.id;
3552                }
3553            }
3554            ListEntry::IncomingRequest(user_1) => {
3555                if let ListEntry::IncomingRequest(user_2) = other {
3556                    return user_1.id == user_2.id;
3557                }
3558            }
3559            ListEntry::OutgoingRequest(user_1) => {
3560                if let ListEntry::OutgoingRequest(user_2) = other {
3561                    return user_1.id == user_2.id;
3562                }
3563            }
3564            ListEntry::Contact {
3565                contact: contact_1, ..
3566            } => {
3567                if let ListEntry::Contact {
3568                    contact: contact_2, ..
3569                } = other
3570                {
3571                    return contact_1.user.id == contact_2.user.id;
3572                }
3573            }
3574            ListEntry::ChannelEditor { depth } => {
3575                if let ListEntry::ChannelEditor { depth: other_depth } = other {
3576                    return depth == other_depth;
3577                }
3578            }
3579            ListEntry::ContactPlaceholder => {
3580                if let ListEntry::ContactPlaceholder = other {
3581                    return true;
3582                }
3583            }
3584        }
3585        false
3586    }
3587}
3588
3589struct DraggedChannelView {
3590    channel: Channel,
3591    width: Pixels,
3592}
3593
3594impl Render for DraggedChannelView {
3595    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3596        let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
3597        h_flex()
3598            .font_family(ui_font)
3599            .bg(cx.theme().colors().background)
3600            .w(self.width)
3601            .p_1()
3602            .gap_1()
3603            .child(
3604                Icon::new(
3605                    if self.channel.visibility == proto::ChannelVisibility::Public {
3606                        IconName::Public
3607                    } else {
3608                        IconName::Hash
3609                    },
3610                )
3611                .size(IconSize::Small)
3612                .color(Color::Muted),
3613            )
3614            .child(Label::new(self.channel.name.clone()))
3615    }
3616}
3617
3618struct JoinChannelTooltip {
3619    channel_store: Entity<ChannelStore>,
3620    channel_id: ChannelId,
3621    #[allow(unused)]
3622    has_notes_notification: bool,
3623}
3624
3625impl Render for JoinChannelTooltip {
3626    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3627        tooltip_container(cx, |container, cx| {
3628            let participants = self
3629                .channel_store
3630                .read(cx)
3631                .channel_participants(self.channel_id);
3632
3633            container
3634                .child(Label::new("Join Channel"))
3635                .children(participants.iter().map(|participant| {
3636                    h_flex()
3637                        .gap_2()
3638                        .child(Avatar::new(participant.avatar_uri.clone()))
3639                        .child(render_participant_name_and_handle(participant))
3640                }))
3641        })
3642    }
3643}
3644
3645#[cfg(any(test, feature = "test-support"))]
3646impl CollabPanel {
3647    pub fn entries_as_strings(&self) -> Vec<String> {
3648        let mut string_entries = Vec::new();
3649        for (index, entry) in self.entries.iter().enumerate() {
3650            let selected_marker = if self.selection == Some(index) {
3651                "  <== selected"
3652            } else {
3653                ""
3654            };
3655            match entry {
3656                ListEntry::Header(section) => {
3657                    let name = match section {
3658                        Section::ActiveCall => "Active Call",
3659                        Section::FavoriteChannels => "Favorites",
3660                        Section::Channels => "Channels",
3661                        Section::ChannelInvites => "Channel Invites",
3662                        Section::ContactRequests => "Contact Requests",
3663                        Section::Contacts => "Contacts",
3664                        Section::Online => "Online",
3665                        Section::Offline => "Offline",
3666                    };
3667                    string_entries.push(format!("[{name}]"));
3668                }
3669                ListEntry::Channel {
3670                    channel,
3671                    depth,
3672                    has_children,
3673                    ..
3674                } => {
3675                    let indent = "  ".repeat(*depth + 1);
3676                    let icon = if *has_children {
3677                        "v "
3678                    } else if channel.visibility == proto::ChannelVisibility::Public {
3679                        "🛜 "
3680                    } else {
3681                        "#️⃣ "
3682                    };
3683                    string_entries.push(format!("{indent}{icon}{}{selected_marker}", channel.name));
3684                }
3685                ListEntry::ChannelNotes { .. } => {
3686                    string_entries.push(format!("  (notes){selected_marker}"));
3687                }
3688                ListEntry::ChannelEditor { depth } => {
3689                    let indent = "  ".repeat(*depth + 1);
3690                    string_entries.push(format!("{indent}[editor]{selected_marker}"));
3691                }
3692                ListEntry::ChannelInvite(channel) => {
3693                    string_entries.push(format!("  (invite) #{}{selected_marker}", channel.name));
3694                }
3695                ListEntry::CallParticipant { user, .. } => {
3696                    string_entries.push(format!("  {}{selected_marker}", user.github_login));
3697                }
3698                ListEntry::ParticipantProject {
3699                    worktree_root_names,
3700                    ..
3701                } => {
3702                    string_entries.push(format!(
3703                        "    {}{selected_marker}",
3704                        worktree_root_names.join(", ")
3705                    ));
3706                }
3707                ListEntry::ParticipantScreen { .. } => {
3708                    string_entries.push(format!("    (screen){selected_marker}"));
3709                }
3710                ListEntry::IncomingRequest(user) => {
3711                    string_entries.push(format!(
3712                        "  (incoming) {}{selected_marker}",
3713                        user.github_login
3714                    ));
3715                }
3716                ListEntry::OutgoingRequest(user) => {
3717                    string_entries.push(format!(
3718                        "  (outgoing) {}{selected_marker}",
3719                        user.github_login
3720                    ));
3721                }
3722                ListEntry::Contact { contact, .. } => {
3723                    string_entries
3724                        .push(format!("  {}{selected_marker}", contact.user.github_login));
3725                }
3726                ListEntry::ContactPlaceholder => {}
3727            }
3728        }
3729        string_entries
3730    }
3731}