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::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> = KeyValueStore::global(cx)
 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        let favorite_ids: Vec<u64> = self
1951            .channel_store
1952            .read(cx)
1953            .favorite_channel_ids()
1954            .iter()
1955            .map(|id| id.0)
1956            .collect();
1957        let kvp_store = KeyValueStore::global(cx);
1958        self.pending_serialization = cx.background_spawn(
1959            async move {
1960                let json = serde_json::to_string(&favorite_ids)?;
1961                kvp_store
1962                    .write_kvp("favorite_channels".to_string(), json)
1963                    .await?;
1964                anyhow::Ok(())
1965            }
1966            .log_err(),
1967        );
1968    }
1969
1970    fn leave_call(window: &mut Window, cx: &mut App) {
1971        ActiveCall::global(cx)
1972            .update(cx, |call, cx| call.hang_up(cx))
1973            .detach_and_prompt_err("Failed to hang up", window, cx, |_, _, _| None);
1974    }
1975
1976    fn toggle_contact_finder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1977        if let Some(workspace) = self.workspace.upgrade() {
1978            workspace.update(cx, |workspace, cx| {
1979                workspace.toggle_modal(window, cx, |window, cx| {
1980                    let mut finder = ContactFinder::new(self.user_store.clone(), window, cx);
1981                    finder.set_query(self.filter_editor.read(cx).text(cx), window, cx);
1982                    finder
1983                });
1984            });
1985        }
1986    }
1987
1988    fn new_root_channel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1989        self.channel_editing_state = Some(ChannelEditingState::Create {
1990            location: None,
1991            pending_name: None,
1992        });
1993        self.update_entries(false, cx);
1994        self.select_channel_editor();
1995        window.focus(&self.channel_name_editor.focus_handle(cx), cx);
1996        cx.notify();
1997    }
1998
1999    fn select_channel_editor(&mut self) {
2000        self.selection = self
2001            .entries
2002            .iter()
2003            .position(|entry| matches!(entry, ListEntry::ChannelEditor { .. }));
2004    }
2005
2006    fn new_subchannel(
2007        &mut self,
2008        channel_id: ChannelId,
2009        window: &mut Window,
2010        cx: &mut Context<Self>,
2011    ) {
2012        self.collapsed_channels
2013            .retain(|channel| *channel != channel_id);
2014        self.channel_editing_state = Some(ChannelEditingState::Create {
2015            location: Some(channel_id),
2016            pending_name: None,
2017        });
2018        self.update_entries(false, cx);
2019        self.select_channel_editor();
2020        window.focus(&self.channel_name_editor.focus_handle(cx), cx);
2021        cx.notify();
2022    }
2023
2024    fn manage_members(
2025        &mut self,
2026        channel_id: ChannelId,
2027        window: &mut Window,
2028        cx: &mut Context<Self>,
2029    ) {
2030        self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, window, cx);
2031    }
2032
2033    fn remove_selected_channel(&mut self, _: &Remove, window: &mut Window, cx: &mut Context<Self>) {
2034        if let Some(channel) = self.selected_channel() {
2035            self.remove_channel(channel.id, window, cx)
2036        }
2037    }
2038
2039    fn rename_selected_channel(
2040        &mut self,
2041        _: &SecondaryConfirm,
2042        window: &mut Window,
2043        cx: &mut Context<Self>,
2044    ) {
2045        if let Some(channel) = self.selected_channel() {
2046            self.rename_channel(channel.id, window, cx);
2047        }
2048    }
2049
2050    fn rename_channel(
2051        &mut self,
2052        channel_id: ChannelId,
2053        window: &mut Window,
2054        cx: &mut Context<Self>,
2055    ) {
2056        let channel_store = self.channel_store.read(cx);
2057        if !channel_store.is_channel_admin(channel_id) {
2058            return;
2059        }
2060        if let Some(channel) = channel_store.channel_for_id(channel_id).cloned() {
2061            self.channel_editing_state = Some(ChannelEditingState::Rename {
2062                location: channel_id,
2063                pending_name: None,
2064            });
2065            self.channel_name_editor.update(cx, |editor, cx| {
2066                editor.set_text(channel.name.clone(), window, cx);
2067                editor.select_all(&Default::default(), window, cx);
2068            });
2069            window.focus(&self.channel_name_editor.focus_handle(cx), cx);
2070            self.update_entries(false, cx);
2071            self.select_channel_editor();
2072        }
2073    }
2074
2075    fn open_selected_channel_notes(
2076        &mut self,
2077        _: &OpenSelectedChannelNotes,
2078        window: &mut Window,
2079        cx: &mut Context<Self>,
2080    ) {
2081        if let Some(channel) = self.selected_channel() {
2082            self.open_channel_notes(channel.id, window, cx);
2083        }
2084    }
2085
2086    pub fn toggle_selected_channel_favorite(
2087        &mut self,
2088        _: &ToggleSelectedChannelFavorite,
2089        _window: &mut Window,
2090        cx: &mut Context<Self>,
2091    ) {
2092        if let Some(channel) = self.selected_channel() {
2093            self.toggle_favorite_channel(channel.id, cx);
2094        }
2095    }
2096
2097    fn set_channel_visibility(
2098        &mut self,
2099        channel_id: ChannelId,
2100        visibility: ChannelVisibility,
2101        window: &mut Window,
2102        cx: &mut Context<Self>,
2103    ) {
2104        self.channel_store
2105            .update(cx, |channel_store, cx| {
2106                channel_store.set_channel_visibility(channel_id, visibility, cx)
2107            })
2108            .detach_and_prompt_err("Failed to set channel visibility", window, cx, |e, _, _| match e.error_code() {
2109                ErrorCode::BadPublicNesting =>
2110                    if e.error_tag("direction") == Some("parent") {
2111                        Some("To make a channel public, its parent channel must be public.".to_string())
2112                    } else {
2113                        Some("To make a channel private, all of its subchannels must be private.".to_string())
2114                    },
2115                _ => None
2116            });
2117    }
2118
2119    fn start_move_channel(
2120        &mut self,
2121        channel_id: ChannelId,
2122        _window: &mut Window,
2123        _cx: &mut Context<Self>,
2124    ) {
2125        self.channel_clipboard = Some(ChannelMoveClipboard { channel_id });
2126    }
2127
2128    fn start_move_selected_channel(
2129        &mut self,
2130        _: &StartMoveChannel,
2131        window: &mut Window,
2132        cx: &mut Context<Self>,
2133    ) {
2134        if let Some(channel) = self.selected_channel() {
2135            self.start_move_channel(channel.id, window, cx);
2136        }
2137    }
2138
2139    fn move_channel_on_clipboard(
2140        &mut self,
2141        to_channel_id: ChannelId,
2142        window: &mut Window,
2143        cx: &mut Context<CollabPanel>,
2144    ) {
2145        if let Some(clipboard) = self.channel_clipboard.take() {
2146            self.move_channel(clipboard.channel_id, to_channel_id, window, cx)
2147        }
2148    }
2149
2150    fn move_channel(
2151        &self,
2152        channel_id: ChannelId,
2153        to: ChannelId,
2154        window: &mut Window,
2155        cx: &mut Context<Self>,
2156    ) {
2157        self.channel_store
2158            .update(cx, |channel_store, cx| {
2159                channel_store.move_channel(channel_id, to, cx)
2160            })
2161            .detach_and_prompt_err("Failed to move channel", window, cx, |e, _, _| {
2162                match e.error_code() {
2163                    ErrorCode::BadPublicNesting => {
2164                        Some("Public channels must have public parents".into())
2165                    }
2166                    ErrorCode::CircularNesting => {
2167                        Some("You cannot move a channel into itself".into())
2168                    }
2169                    ErrorCode::WrongMoveTarget => {
2170                        Some("You cannot move a channel into a different root channel".into())
2171                    }
2172                    _ => None,
2173                }
2174            })
2175    }
2176
2177    pub fn move_channel_up(
2178        &mut self,
2179        _: &MoveChannelUp,
2180        window: &mut Window,
2181        cx: &mut Context<Self>,
2182    ) {
2183        self.reorder_selected_channel(Direction::Up, window, cx);
2184    }
2185
2186    pub fn move_channel_down(
2187        &mut self,
2188        _: &MoveChannelDown,
2189        window: &mut Window,
2190        cx: &mut Context<Self>,
2191    ) {
2192        self.reorder_selected_channel(Direction::Down, window, cx);
2193    }
2194
2195    fn reorder_selected_channel(
2196        &mut self,
2197        direction: Direction,
2198        window: &mut Window,
2199        cx: &mut Context<Self>,
2200    ) {
2201        if let Some(channel) = self.selected_channel().cloned() {
2202            if self.selected_entry_is_favorite() {
2203                self.reorder_favorite(channel.id, direction, cx);
2204                return;
2205            }
2206
2207            self.channel_store.update(cx, |store, cx| {
2208                store
2209                    .reorder_channel(channel.id, direction, cx)
2210                    .detach_and_prompt_err(
2211                        match direction {
2212                            Direction::Up => "Failed to move channel up",
2213                            Direction::Down => "Failed to move channel down",
2214                        },
2215                        window,
2216                        cx,
2217                        |_, _, _| None,
2218                    )
2219            });
2220        }
2221    }
2222
2223    pub fn reorder_favorite(
2224        &mut self,
2225        channel_id: ChannelId,
2226        direction: Direction,
2227        cx: &mut Context<Self>,
2228    ) {
2229        self.channel_store.update(cx, |store, cx| {
2230            let favorite_ids = store.favorite_channel_ids();
2231            let Some(channel_index) = favorite_ids.iter().position(|id| *id == channel_id) else {
2232                return;
2233            };
2234            let target_channel_index = match direction {
2235                Direction::Up => channel_index.checked_sub(1),
2236                Direction::Down => {
2237                    let next = channel_index + 1;
2238                    (next < favorite_ids.len()).then_some(next)
2239                }
2240            };
2241            if let Some(target_channel_index) = target_channel_index {
2242                let mut new_ids = favorite_ids.to_vec();
2243                new_ids.swap(channel_index, target_channel_index);
2244                store.set_favorite_channel_ids(new_ids, cx);
2245            }
2246        });
2247        self.persist_favorites(cx);
2248    }
2249
2250    fn open_channel_notes(
2251        &mut self,
2252        channel_id: ChannelId,
2253        window: &mut Window,
2254        cx: &mut Context<Self>,
2255    ) {
2256        if let Some(workspace) = self.workspace.upgrade() {
2257            ChannelView::open(channel_id, None, workspace, window, cx).detach();
2258        }
2259    }
2260
2261    fn show_inline_context_menu(
2262        &mut self,
2263        _: &Secondary,
2264        window: &mut Window,
2265        cx: &mut Context<Self>,
2266    ) {
2267        let Some(bounds) = self
2268            .selection
2269            .and_then(|ix| self.list_state.bounds_for_item(ix))
2270        else {
2271            return;
2272        };
2273
2274        if let Some(channel) = self.selected_channel() {
2275            self.deploy_channel_context_menu(
2276                bounds.center(),
2277                channel.id,
2278                self.selection.unwrap(),
2279                window,
2280                cx,
2281            );
2282            cx.stop_propagation();
2283            return;
2284        };
2285
2286        if let Some(contact) = self.selected_contact() {
2287            self.deploy_contact_context_menu(bounds.center(), contact, window, cx);
2288            cx.stop_propagation();
2289        }
2290    }
2291
2292    fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
2293        let mut dispatch_context = KeyContext::new_with_defaults();
2294        dispatch_context.add("CollabPanel");
2295        dispatch_context.add("menu");
2296
2297        let identifier = if self.channel_name_editor.focus_handle(cx).is_focused(window)
2298            || self.filter_editor.focus_handle(cx).is_focused(window)
2299        {
2300            "editing"
2301        } else {
2302            "not_editing"
2303        };
2304
2305        dispatch_context.add(identifier);
2306        dispatch_context
2307    }
2308
2309    fn selected_channel(&self) -> Option<&Arc<Channel>> {
2310        self.selection
2311            .and_then(|ix| self.entries.get(ix))
2312            .and_then(|entry| match entry {
2313                ListEntry::Channel { channel, .. } => Some(channel),
2314                _ => None,
2315            })
2316    }
2317
2318    fn selected_entry_is_favorite(&self) -> bool {
2319        self.selection
2320            .and_then(|ix| self.entries.get(ix))
2321            .is_some_and(|entry| {
2322                matches!(
2323                    entry,
2324                    ListEntry::Channel {
2325                        is_favorite: true,
2326                        ..
2327                    }
2328                )
2329            })
2330    }
2331
2332    fn selected_contact(&self) -> Option<Arc<Contact>> {
2333        self.selection
2334            .and_then(|ix| self.entries.get(ix))
2335            .and_then(|entry| match entry {
2336                ListEntry::Contact { contact, .. } => Some(contact.clone()),
2337                _ => None,
2338            })
2339    }
2340
2341    fn show_channel_modal(
2342        &mut self,
2343        channel_id: ChannelId,
2344        mode: channel_modal::Mode,
2345        window: &mut Window,
2346        cx: &mut Context<Self>,
2347    ) {
2348        let workspace = self.workspace.clone();
2349        let user_store = self.user_store.clone();
2350        let channel_store = self.channel_store.clone();
2351
2352        cx.spawn_in(window, async move |_, cx| {
2353            workspace.update_in(cx, |workspace, window, cx| {
2354                workspace.toggle_modal(window, cx, |window, cx| {
2355                    ChannelModal::new(
2356                        user_store.clone(),
2357                        channel_store.clone(),
2358                        channel_id,
2359                        mode,
2360                        window,
2361                        cx,
2362                    )
2363                });
2364            })
2365        })
2366        .detach();
2367    }
2368
2369    fn leave_channel(&self, channel_id: ChannelId, window: &mut Window, cx: &mut Context<Self>) {
2370        let Some(user_id) = self.user_store.read(cx).current_user().map(|u| u.id) else {
2371            return;
2372        };
2373        let Some(channel) = self.channel_store.read(cx).channel_for_id(channel_id) else {
2374            return;
2375        };
2376        let prompt_message = format!("Are you sure you want to leave \"#{}\"?", channel.name);
2377        let answer = window.prompt(
2378            PromptLevel::Warning,
2379            &prompt_message,
2380            None,
2381            &["Leave", "Cancel"],
2382            cx,
2383        );
2384        cx.spawn_in(window, async move |this, cx| {
2385            if answer.await? != 0 {
2386                return Ok(());
2387            }
2388            this.update(cx, |this, cx| {
2389                this.channel_store.update(cx, |channel_store, cx| {
2390                    channel_store.remove_member(channel_id, user_id, cx)
2391                })
2392            })?
2393            .await
2394        })
2395        .detach_and_prompt_err("Failed to leave channel", window, cx, |_, _, _| None)
2396    }
2397
2398    fn remove_channel(
2399        &mut self,
2400        channel_id: ChannelId,
2401        window: &mut Window,
2402        cx: &mut Context<Self>,
2403    ) {
2404        let channel_store = self.channel_store.clone();
2405        if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
2406            let prompt_message = format!(
2407                "Are you sure you want to remove the channel \"{}\"?",
2408                channel.name
2409            );
2410            let answer = window.prompt(
2411                PromptLevel::Warning,
2412                &prompt_message,
2413                None,
2414                &["Remove", "Cancel"],
2415                cx,
2416            );
2417            let workspace = self.workspace.clone();
2418            cx.spawn_in(window, async move |this, mut cx| {
2419                if answer.await? == 0 {
2420                    channel_store
2421                        .update(cx, |channels, _| channels.remove_channel(channel_id))
2422                        .await
2423                        .notify_workspace_async_err(workspace, &mut cx);
2424                    this.update_in(cx, |_, window, cx| cx.focus_self(window))
2425                        .ok();
2426                }
2427                anyhow::Ok(())
2428            })
2429            .detach();
2430        }
2431    }
2432
2433    fn remove_contact(
2434        &mut self,
2435        user_id: u64,
2436        github_login: &str,
2437        window: &mut Window,
2438        cx: &mut Context<Self>,
2439    ) {
2440        let user_store = self.user_store.clone();
2441        let prompt_message = format!(
2442            "Are you sure you want to remove \"{}\" from your contacts?",
2443            github_login
2444        );
2445        let answer = window.prompt(
2446            PromptLevel::Warning,
2447            &prompt_message,
2448            None,
2449            &["Remove", "Cancel"],
2450            cx,
2451        );
2452        let workspace = self.workspace.clone();
2453        cx.spawn_in(window, async move |_, mut cx| {
2454            if answer.await? == 0 {
2455                user_store
2456                    .update(cx, |store, cx| store.remove_contact(user_id, cx))
2457                    .await
2458                    .notify_workspace_async_err(workspace, &mut cx);
2459            }
2460            anyhow::Ok(())
2461        })
2462        .detach_and_prompt_err("Failed to remove contact", window, cx, |_, _, _| None);
2463    }
2464
2465    fn respond_to_contact_request(
2466        &mut self,
2467        user_id: u64,
2468        accept: bool,
2469        window: &mut Window,
2470        cx: &mut Context<Self>,
2471    ) {
2472        self.user_store
2473            .update(cx, |store, cx| {
2474                store.respond_to_contact_request(user_id, accept, cx)
2475            })
2476            .detach_and_prompt_err(
2477                "Failed to respond to contact request",
2478                window,
2479                cx,
2480                |_, _, _| None,
2481            );
2482    }
2483
2484    fn respond_to_channel_invite(
2485        &mut self,
2486        channel_id: ChannelId,
2487        accept: bool,
2488        cx: &mut Context<Self>,
2489    ) {
2490        self.channel_store
2491            .update(cx, |store, cx| {
2492                store.respond_to_channel_invite(channel_id, accept, cx)
2493            })
2494            .detach();
2495    }
2496
2497    fn call(&mut self, recipient_user_id: u64, window: &mut Window, cx: &mut Context<Self>) {
2498        ActiveCall::global(cx)
2499            .update(cx, |call, cx| {
2500                call.invite(recipient_user_id, Some(self.project.clone()), cx)
2501            })
2502            .detach_and_prompt_err("Call failed", window, cx, |_, _, _| None);
2503    }
2504
2505    fn join_channel(&self, channel_id: ChannelId, window: &mut Window, cx: &mut Context<Self>) {
2506        let Some(workspace) = self.workspace.upgrade() else {
2507            return;
2508        };
2509
2510        let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() else {
2511            return;
2512        };
2513        workspace::join_channel(
2514            channel_id,
2515            workspace.read(cx).app_state().clone(),
2516            Some(handle),
2517            Some(self.workspace.clone()),
2518            cx,
2519        )
2520        .detach_and_prompt_err("Failed to join channel", window, cx, |_, _, _| None)
2521    }
2522
2523    fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
2524        let channel_store = self.channel_store.read(cx);
2525        let Some(channel) = channel_store.channel_for_id(channel_id) else {
2526            return;
2527        };
2528        let item = ClipboardItem::new_string(channel.link(cx));
2529        cx.write_to_clipboard(item)
2530    }
2531
2532    fn copy_channel_notes_link(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
2533        let channel_store = self.channel_store.read(cx);
2534        let Some(channel) = channel_store.channel_for_id(channel_id) else {
2535            return;
2536        };
2537        let item = ClipboardItem::new_string(channel.notes_link(None, cx));
2538        cx.write_to_clipboard(item)
2539    }
2540
2541    fn render_signed_out(&mut self, cx: &mut Context<Self>) -> Div {
2542        let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more.";
2543
2544        // Two distinct "not connected" states:
2545        //   - Authenticated (has credentials): user just needs to connect.
2546        //   - Unauthenticated (no credentials): user needs to sign in via GitHub.
2547        let is_authenticated = self.client.user_id().is_some();
2548        let status = *self.client.status().borrow();
2549        let is_busy = status.is_signing_in();
2550
2551        let (button_id, button_label, button_icon) = if is_authenticated {
2552            (
2553                "connect",
2554                if is_busy { "Connecting…" } else { "Connect" },
2555                IconName::Public,
2556            )
2557        } else {
2558            (
2559                "sign_in",
2560                if is_busy {
2561                    "Signing in…"
2562                } else {
2563                    "Sign In with GitHub"
2564                },
2565                IconName::Github,
2566            )
2567        };
2568
2569        v_flex()
2570            .p_4()
2571            .gap_4()
2572            .size_full()
2573            .text_center()
2574            .justify_center()
2575            .child(Label::new(collab_blurb))
2576            .child(
2577                Button::new(button_id, button_label)
2578                    .full_width()
2579                    .start_icon(Icon::new(button_icon).color(Color::Muted))
2580                    .style(ButtonStyle::Outlined)
2581                    .disabled(is_busy)
2582                    .on_click(cx.listener(|this, _, window, cx| {
2583                        let client = this.client.clone();
2584                        let workspace = this.workspace.clone();
2585                        cx.spawn_in(window, async move |_, mut cx| {
2586                            client
2587                                .connect(true, &mut cx)
2588                                .await
2589                                .into_response()
2590                                .notify_workspace_async_err(workspace, &mut cx);
2591                        })
2592                        .detach()
2593                    })),
2594            )
2595    }
2596
2597    fn render_list_entry(
2598        &mut self,
2599        ix: usize,
2600        window: &mut Window,
2601        cx: &mut Context<Self>,
2602    ) -> AnyElement {
2603        let entry = &self.entries[ix];
2604
2605        let is_selected = self.selection == Some(ix);
2606        match entry {
2607            ListEntry::Header(section) => {
2608                let is_collapsed = self.collapsed_sections.contains(section);
2609                self.render_header(*section, is_selected, is_collapsed, cx)
2610                    .into_any_element()
2611            }
2612            ListEntry::Contact { contact, calling } => self
2613                .render_contact(contact, *calling, is_selected, cx)
2614                .into_any_element(),
2615            ListEntry::ContactPlaceholder => self
2616                .render_contact_placeholder(is_selected, cx)
2617                .into_any_element(),
2618            ListEntry::IncomingRequest(user) => self
2619                .render_contact_request(user, true, is_selected, cx)
2620                .into_any_element(),
2621            ListEntry::OutgoingRequest(user) => self
2622                .render_contact_request(user, false, is_selected, cx)
2623                .into_any_element(),
2624            ListEntry::Channel {
2625                channel,
2626                depth,
2627                has_children,
2628                string_match,
2629                ..
2630            } => self
2631                .render_channel(
2632                    channel,
2633                    *depth,
2634                    *has_children,
2635                    is_selected,
2636                    ix,
2637                    string_match.as_ref(),
2638                    cx,
2639                )
2640                .into_any_element(),
2641            ListEntry::ChannelEditor { depth } => self
2642                .render_channel_editor(*depth, window, cx)
2643                .into_any_element(),
2644            ListEntry::ChannelInvite(channel) => self
2645                .render_channel_invite(channel, is_selected, cx)
2646                .into_any_element(),
2647            ListEntry::CallParticipant {
2648                user,
2649                peer_id,
2650                is_pending,
2651                role,
2652            } => self
2653                .render_call_participant(user, *peer_id, *is_pending, *role, is_selected, cx)
2654                .into_any_element(),
2655            ListEntry::ParticipantProject {
2656                project_id,
2657                worktree_root_names,
2658                host_user_id,
2659                is_last,
2660            } => self
2661                .render_participant_project(
2662                    *project_id,
2663                    worktree_root_names,
2664                    *host_user_id,
2665                    *is_last,
2666                    is_selected,
2667                    window,
2668                    cx,
2669                )
2670                .into_any_element(),
2671            ListEntry::ParticipantScreen { peer_id, is_last } => self
2672                .render_participant_screen(*peer_id, *is_last, is_selected, window, cx)
2673                .into_any_element(),
2674            ListEntry::ChannelNotes { channel_id } => self
2675                .render_channel_notes(*channel_id, is_selected, window, cx)
2676                .into_any_element(),
2677        }
2678    }
2679
2680    fn render_signed_in(&mut self, _: &mut Window, cx: &mut Context<Self>) -> Div {
2681        self.channel_store.update(cx, |channel_store, _| {
2682            channel_store.initialize();
2683        });
2684
2685        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
2686
2687        v_flex()
2688            .size_full()
2689            .gap_1()
2690            .child(
2691                h_flex()
2692                    .p_2()
2693                    .h(Tab::container_height(cx))
2694                    .gap_1p5()
2695                    .border_b_1()
2696                    .border_color(cx.theme().colors().border)
2697                    .child(
2698                        Icon::new(IconName::MagnifyingGlass)
2699                            .size(IconSize::Small)
2700                            .color(Color::Muted),
2701                    )
2702                    .child(self.render_filter_input(&self.filter_editor, cx))
2703                    .when(has_query, |this| {
2704                        this.pr_2p5().child(
2705                            IconButton::new("clear_filter", IconName::Close)
2706                                .shape(IconButtonShape::Square)
2707                                .tooltip(Tooltip::text("Clear Filter"))
2708                                .on_click(cx.listener(|this, _, window, cx| {
2709                                    this.reset_filter_editor_text(window, cx);
2710                                    cx.notify();
2711                                })),
2712                        )
2713                    }),
2714            )
2715            .child(
2716                list(
2717                    self.list_state.clone(),
2718                    cx.processor(Self::render_list_entry),
2719                )
2720                .size_full(),
2721            )
2722    }
2723
2724    fn render_filter_input(
2725        &self,
2726        editor: &Entity<Editor>,
2727        cx: &mut Context<Self>,
2728    ) -> impl IntoElement {
2729        let settings = ThemeSettings::get_global(cx);
2730        let text_style = TextStyle {
2731            color: if editor.read(cx).read_only(cx) {
2732                cx.theme().colors().text_disabled
2733            } else {
2734                cx.theme().colors().text
2735            },
2736            font_family: settings.ui_font.family.clone(),
2737            font_features: settings.ui_font.features.clone(),
2738            font_fallbacks: settings.ui_font.fallbacks.clone(),
2739            font_size: rems(0.875).into(),
2740            font_weight: settings.ui_font.weight,
2741            font_style: FontStyle::Normal,
2742            line_height: relative(1.3),
2743            ..Default::default()
2744        };
2745
2746        EditorElement::new(
2747            editor,
2748            EditorStyle {
2749                local_player: cx.theme().players().local(),
2750                text: text_style,
2751                ..Default::default()
2752            },
2753        )
2754    }
2755
2756    fn render_header(
2757        &self,
2758        section: Section,
2759        is_selected: bool,
2760        is_collapsed: bool,
2761        cx: &mut Context<Self>,
2762    ) -> impl IntoElement {
2763        let mut channel_link = None;
2764        let mut channel_tooltip_text = None;
2765        let mut channel_icon = None;
2766
2767        let text = match section {
2768            Section::ActiveCall => {
2769                let channel_name = maybe!({
2770                    let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
2771
2772                    let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
2773
2774                    channel_link = Some(channel.link(cx));
2775                    (channel_icon, channel_tooltip_text) = match channel.visibility {
2776                        proto::ChannelVisibility::Public => {
2777                            (Some("icons/public.svg"), Some("Copy public channel link."))
2778                        }
2779                        proto::ChannelVisibility::Members => {
2780                            (Some("icons/hash.svg"), Some("Copy private channel link."))
2781                        }
2782                    };
2783
2784                    Some(channel.name.as_ref())
2785                });
2786
2787                if let Some(name) = channel_name {
2788                    SharedString::from(name.to_string())
2789                } else {
2790                    SharedString::from("Current Call")
2791                }
2792            }
2793            Section::FavoriteChannels => SharedString::from("Favorites"),
2794            Section::ContactRequests => SharedString::from("Requests"),
2795            Section::Contacts => SharedString::from("Contacts"),
2796            Section::Channels => SharedString::from("Channels"),
2797            Section::ChannelInvites => SharedString::from("Invites"),
2798            Section::Online => SharedString::from("Online"),
2799            Section::Offline => SharedString::from("Offline"),
2800        };
2801
2802        let button = match section {
2803            Section::ActiveCall => channel_link.map(|channel_link| {
2804                CopyButton::new("copy-channel-link", channel_link)
2805                    .visible_on_hover("section-header")
2806                    .tooltip_label("Copy Channel Link")
2807                    .into_any_element()
2808            }),
2809            Section::Contacts => Some(
2810                IconButton::new("add-contact", IconName::Plus)
2811                    .icon_size(IconSize::Small)
2812                    .on_click(
2813                        cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)),
2814                    )
2815                    .tooltip(Tooltip::text("Search for new contact"))
2816                    .into_any_element(),
2817            ),
2818            Section::Channels => {
2819                Some(
2820                    h_flex()
2821                        .child(
2822                            IconButton::new("filter-active-channels", IconName::ListFilter)
2823                                .icon_size(IconSize::Small)
2824                                .toggle_state(self.filter_active_channels)
2825                                .on_click(cx.listener(|this, _, _window, cx| {
2826                                    this.filter_active_channels = !this.filter_active_channels;
2827                                    this.update_entries(true, cx);
2828                                }))
2829                                .tooltip(Tooltip::text(if self.filter_active_channels {
2830                                    "Show All Channels"
2831                                } else {
2832                                    "Show Occupied Channels"
2833                                })),
2834                        )
2835                        .child(
2836                            IconButton::new("add-channel", IconName::Plus)
2837                                .icon_size(IconSize::Small)
2838                                .on_click(cx.listener(|this, _, window, cx| {
2839                                    this.new_root_channel(window, cx)
2840                                }))
2841                                .tooltip(Tooltip::text("Create Channel")),
2842                        )
2843                        .into_any_element(),
2844                )
2845            }
2846            _ => None,
2847        };
2848
2849        let can_collapse = match section {
2850            Section::ActiveCall
2851            | Section::Channels
2852            | Section::Contacts
2853            | Section::FavoriteChannels => false,
2854
2855            Section::ChannelInvites
2856            | Section::ContactRequests
2857            | Section::Online
2858            | Section::Offline => true,
2859        };
2860
2861        h_flex().w_full().group("section-header").child(
2862            ListHeader::new(text)
2863                .when(can_collapse, |header| {
2864                    header.toggle(Some(!is_collapsed)).on_toggle(cx.listener(
2865                        move |this, _, _, cx| {
2866                            this.toggle_section_expanded(section, cx);
2867                        },
2868                    ))
2869                })
2870                .inset(true)
2871                .end_slot::<AnyElement>(button)
2872                .toggle_state(is_selected),
2873        )
2874    }
2875
2876    fn render_contact(
2877        &self,
2878        contact: &Arc<Contact>,
2879        calling: bool,
2880        is_selected: bool,
2881        cx: &mut Context<Self>,
2882    ) -> impl IntoElement {
2883        let online = contact.online;
2884        let busy = contact.busy || calling;
2885        let github_login = contact.user.github_login.clone();
2886        let item = ListItem::new(github_login.clone())
2887            .indent_level(1)
2888            .indent_step_size(px(20.))
2889            .toggle_state(is_selected)
2890            .child(
2891                h_flex()
2892                    .w_full()
2893                    .justify_between()
2894                    .child(render_participant_name_and_handle(&contact.user))
2895                    .when(calling, |el| {
2896                        el.child(Label::new("Calling").color(Color::Muted))
2897                    })
2898                    .when(!calling, |el| {
2899                        el.child(
2900                            IconButton::new("contact context menu", IconName::Ellipsis)
2901                                .icon_color(Color::Muted)
2902                                .visible_on_hover("")
2903                                .on_click(cx.listener({
2904                                    let contact = contact.clone();
2905                                    move |this, event: &ClickEvent, window, cx| {
2906                                        this.deploy_contact_context_menu(
2907                                            event.position(),
2908                                            contact.clone(),
2909                                            window,
2910                                            cx,
2911                                        );
2912                                    }
2913                                })),
2914                        )
2915                    }),
2916            )
2917            .on_secondary_mouse_down(cx.listener({
2918                let contact = contact.clone();
2919                move |this, event: &MouseDownEvent, window, cx| {
2920                    this.deploy_contact_context_menu(event.position, contact.clone(), window, cx);
2921                }
2922            }))
2923            .start_slot(
2924                // todo handle contacts with no avatar
2925                Avatar::new(contact.user.avatar_uri.clone())
2926                    .indicator::<AvatarAvailabilityIndicator>(if online {
2927                        Some(AvatarAvailabilityIndicator::new(match busy {
2928                            true => ui::CollaboratorAvailability::Busy,
2929                            false => ui::CollaboratorAvailability::Free,
2930                        }))
2931                    } else {
2932                        None
2933                    }),
2934            );
2935
2936        div()
2937            .id(github_login.clone())
2938            .group("")
2939            .child(item)
2940            .tooltip(move |_, cx| {
2941                let text = if !online {
2942                    format!(" {} is offline", &github_login)
2943                } else if busy {
2944                    format!(" {} is on a call", &github_login)
2945                } else {
2946                    let room = ActiveCall::global(cx).read(cx).room();
2947                    if room.is_some() {
2948                        format!("Invite {} to join call", &github_login)
2949                    } else {
2950                        format!("Call {}", &github_login)
2951                    }
2952                };
2953                Tooltip::simple(text, cx)
2954            })
2955    }
2956
2957    fn render_contact_request(
2958        &self,
2959        user: &Arc<User>,
2960        is_incoming: bool,
2961        is_selected: bool,
2962        cx: &mut Context<Self>,
2963    ) -> impl IntoElement {
2964        let github_login = user.github_login.clone();
2965        let user_id = user.id;
2966        let is_response_pending = self.user_store.read(cx).is_contact_request_pending(user);
2967        let color = if is_response_pending {
2968            Color::Muted
2969        } else {
2970            Color::Default
2971        };
2972
2973        let controls = if is_incoming {
2974            vec![
2975                IconButton::new("decline-contact", IconName::Close)
2976                    .on_click(cx.listener(move |this, _, window, cx| {
2977                        this.respond_to_contact_request(user_id, false, window, cx);
2978                    }))
2979                    .icon_color(color)
2980                    .tooltip(Tooltip::text("Decline invite")),
2981                IconButton::new("accept-contact", IconName::Check)
2982                    .on_click(cx.listener(move |this, _, window, cx| {
2983                        this.respond_to_contact_request(user_id, true, window, cx);
2984                    }))
2985                    .icon_color(color)
2986                    .tooltip(Tooltip::text("Accept invite")),
2987            ]
2988        } else {
2989            let github_login = github_login.clone();
2990            vec![
2991                IconButton::new("remove_contact", IconName::Close)
2992                    .on_click(cx.listener(move |this, _, window, cx| {
2993                        this.remove_contact(user_id, &github_login, window, cx);
2994                    }))
2995                    .icon_color(color)
2996                    .tooltip(Tooltip::text("Cancel invite")),
2997            ]
2998        };
2999
3000        ListItem::new(github_login.clone())
3001            .indent_level(1)
3002            .indent_step_size(px(20.))
3003            .toggle_state(is_selected)
3004            .child(
3005                h_flex()
3006                    .w_full()
3007                    .justify_between()
3008                    .child(Label::new(github_login))
3009                    .child(h_flex().children(controls)),
3010            )
3011            .start_slot(Avatar::new(user.avatar_uri.clone()))
3012    }
3013
3014    fn render_channel_invite(
3015        &self,
3016        channel: &Arc<Channel>,
3017        is_selected: bool,
3018        cx: &mut Context<Self>,
3019    ) -> ListItem {
3020        let channel_id = channel.id;
3021        let response_is_pending = self
3022            .channel_store
3023            .read(cx)
3024            .has_pending_channel_invite_response(channel);
3025        let color = if response_is_pending {
3026            Color::Muted
3027        } else {
3028            Color::Default
3029        };
3030
3031        let controls = [
3032            IconButton::new("reject-invite", IconName::Close)
3033                .on_click(cx.listener(move |this, _, _, cx| {
3034                    this.respond_to_channel_invite(channel_id, false, cx);
3035                }))
3036                .icon_color(color)
3037                .tooltip(Tooltip::text("Decline invite")),
3038            IconButton::new("accept-invite", IconName::Check)
3039                .on_click(cx.listener(move |this, _, _, cx| {
3040                    this.respond_to_channel_invite(channel_id, true, cx);
3041                }))
3042                .icon_color(color)
3043                .tooltip(Tooltip::text("Accept invite")),
3044        ];
3045
3046        ListItem::new(("channel-invite", channel.id.0 as usize))
3047            .toggle_state(is_selected)
3048            .child(
3049                h_flex()
3050                    .w_full()
3051                    .justify_between()
3052                    .child(Label::new(channel.name.clone()))
3053                    .child(h_flex().children(controls)),
3054            )
3055            .start_slot(
3056                Icon::new(IconName::Hash)
3057                    .size(IconSize::Small)
3058                    .color(Color::Muted),
3059            )
3060    }
3061
3062    fn render_contact_placeholder(&self, is_selected: bool, cx: &mut Context<Self>) -> ListItem {
3063        ListItem::new("contact-placeholder")
3064            .child(Icon::new(IconName::Plus))
3065            .child(Label::new("Add a Contact"))
3066            .toggle_state(is_selected)
3067            .on_click(cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)))
3068    }
3069
3070    fn render_channel(
3071        &self,
3072        channel: &Channel,
3073        depth: usize,
3074        has_children: bool,
3075        is_selected: bool,
3076        ix: usize,
3077        string_match: Option<&StringMatch>,
3078        cx: &mut Context<Self>,
3079    ) -> impl IntoElement {
3080        let channel_id = channel.id;
3081
3082        let is_active = maybe!({
3083            let call_channel = ActiveCall::global(cx)
3084                .read(cx)
3085                .room()?
3086                .read(cx)
3087                .channel_id()?;
3088            Some(call_channel == channel_id)
3089        })
3090        .unwrap_or(false);
3091        let channel_store = self.channel_store.read(cx);
3092        let is_public = channel_store
3093            .channel_for_id(channel_id)
3094            .map(|channel| channel.visibility)
3095            == Some(proto::ChannelVisibility::Public);
3096        let disclosed =
3097            has_children.then(|| self.collapsed_channels.binary_search(&channel.id).is_err());
3098
3099        let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id);
3100
3101        const FACEPILE_LIMIT: usize = 3;
3102        let participants = self.channel_store.read(cx).channel_participants(channel_id);
3103
3104        let face_pile = if participants.is_empty() {
3105            None
3106        } else {
3107            let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
3108            let result = Facepile::new(
3109                participants
3110                    .iter()
3111                    .map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
3112                    .take(FACEPILE_LIMIT)
3113                    .chain(if extra_count > 0 {
3114                        Some(
3115                            Label::new(format!("+{extra_count}"))
3116                                .ml_2()
3117                                .into_any_element(),
3118                        )
3119                    } else {
3120                        None
3121                    })
3122                    .collect::<SmallVec<_>>(),
3123            );
3124
3125            Some(result)
3126        };
3127
3128        let width = self
3129            .workspace
3130            .read_with(cx, |workspace, cx| {
3131                workspace
3132                    .panel_size_state::<Self>(cx)
3133                    .and_then(|size_state| size_state.size)
3134            })
3135            .ok()
3136            .flatten()
3137            .unwrap_or(px(240.));
3138        let root_id = channel.root_id();
3139
3140        let is_favorited = self.is_channel_favorited(channel_id, cx);
3141        let (favorite_icon, favorite_color, favorite_tooltip) = if is_favorited {
3142            (IconName::StarFilled, Color::Accent, "Remove from Favorites")
3143        } else {
3144            (IconName::Star, Color::Default, "Add to Favorites")
3145        };
3146
3147        h_flex()
3148            .id(ix)
3149            .group("")
3150            .w_full()
3151            .when(!channel.is_root_channel(), |el| {
3152                el.on_drag(channel.clone(), move |channel, _, _, cx| {
3153                    cx.new(|_| DraggedChannelView {
3154                        channel: channel.clone(),
3155                        width,
3156                    })
3157                })
3158            })
3159            .drag_over::<Channel>({
3160                move |style, dragged_channel: &Channel, _window, cx| {
3161                    if dragged_channel.root_id() == root_id {
3162                        style.bg(cx.theme().colors().ghost_element_hover)
3163                    } else {
3164                        style
3165                    }
3166                }
3167            })
3168            .on_drop(
3169                cx.listener(move |this, dragged_channel: &Channel, window, cx| {
3170                    if dragged_channel.root_id() != root_id {
3171                        return;
3172                    }
3173                    this.move_channel(dragged_channel.id, channel_id, window, cx);
3174                }),
3175            )
3176            .child(
3177                ListItem::new(ix)
3178                    // Add one level of depth for the disclosure arrow.
3179                    .indent_level(depth + 1)
3180                    .indent_step_size(px(20.))
3181                    .toggle_state(is_selected || is_active)
3182                    .toggle(disclosed)
3183                    .on_toggle(cx.listener(move |this, _, window, cx| {
3184                        this.toggle_channel_collapsed(channel_id, window, cx)
3185                    }))
3186                    .on_click(cx.listener(move |this, _, window, cx| {
3187                        if is_active {
3188                            this.open_channel_notes(channel_id, window, cx)
3189                        } else {
3190                            this.join_channel(channel_id, window, cx)
3191                        }
3192                    }))
3193                    .on_secondary_mouse_down(cx.listener(
3194                        move |this, event: &MouseDownEvent, window, cx| {
3195                            this.deploy_channel_context_menu(
3196                                event.position,
3197                                channel_id,
3198                                ix,
3199                                window,
3200                                cx,
3201                            )
3202                        },
3203                    ))
3204                    .child(
3205                        h_flex()
3206                            .id(format!("inside-{}", channel_id.0))
3207                            .w_full()
3208                            .gap_1()
3209                            .child(
3210                                div()
3211                                    .relative()
3212                                    .child(
3213                                        Icon::new(if is_public {
3214                                            IconName::Public
3215                                        } else {
3216                                            IconName::Hash
3217                                        })
3218                                        .size(IconSize::Small)
3219                                        .color(Color::Muted),
3220                                    )
3221                                    .children(has_notes_notification.then(|| {
3222                                        div()
3223                                            .w_1p5()
3224                                            .absolute()
3225                                            .right(px(-1.))
3226                                            .top(px(-1.))
3227                                            .child(Indicator::dot().color(Color::Info))
3228                                    })),
3229                            )
3230                            .child(
3231                                h_flex()
3232                                    .id(channel_id.0 as usize)
3233                                    .child(match string_match {
3234                                        None => Label::new(channel.name.clone()).into_any_element(),
3235                                        Some(string_match) => HighlightedLabel::new(
3236                                            channel.name.clone(),
3237                                            string_match.positions.clone(),
3238                                        )
3239                                        .into_any_element(),
3240                                    })
3241                                    .children(face_pile.map(|face_pile| face_pile.p_1())),
3242                            )
3243                            .tooltip({
3244                                let channel_store = self.channel_store.clone();
3245                                move |_window, cx| {
3246                                    cx.new(|_| JoinChannelTooltip {
3247                                        channel_store: channel_store.clone(),
3248                                        channel_id,
3249                                        has_notes_notification,
3250                                    })
3251                                    .into()
3252                                }
3253                            }),
3254                    ),
3255            )
3256            .child(
3257                h_flex()
3258                    .visible_on_hover("")
3259                    .absolute()
3260                    .right_0()
3261                    .px_1()
3262                    .gap_px()
3263                    .bg(cx.theme().colors().background)
3264                    .rounded_l_md()
3265                    .child({
3266                        let focus_handle = self.focus_handle.clone();
3267                        IconButton::new("channel_favorite", favorite_icon)
3268                            .icon_size(IconSize::Small)
3269                            .icon_color(favorite_color)
3270                            .on_click(cx.listener(move |this, _, _window, cx| {
3271                                this.toggle_favorite_channel(channel_id, cx)
3272                            }))
3273                            .tooltip(move |_window, cx| {
3274                                Tooltip::for_action_in(
3275                                    favorite_tooltip,
3276                                    &ToggleSelectedChannelFavorite,
3277                                    &focus_handle,
3278                                    cx,
3279                                )
3280                            })
3281                    })
3282                    .child({
3283                        let focus_handle = self.focus_handle.clone();
3284                        IconButton::new("channel_notes", IconName::Reader)
3285                            .icon_size(IconSize::Small)
3286                            .on_click(cx.listener(move |this, _, window, cx| {
3287                                this.open_channel_notes(channel_id, window, cx)
3288                            }))
3289                            .tooltip(move |_window, cx| {
3290                                Tooltip::for_action_in(
3291                                    "Open Channel Notes",
3292                                    &OpenSelectedChannelNotes,
3293                                    &focus_handle,
3294                                    cx,
3295                                )
3296                            })
3297                    }),
3298            )
3299    }
3300
3301    fn render_channel_editor(
3302        &self,
3303        depth: usize,
3304        _window: &mut Window,
3305        _cx: &mut Context<Self>,
3306    ) -> impl IntoElement {
3307        let item = ListItem::new("channel-editor")
3308            .inset(false)
3309            // Add one level of depth for the disclosure arrow.
3310            .indent_level(depth + 1)
3311            .indent_step_size(px(20.))
3312            .start_slot(
3313                Icon::new(IconName::Hash)
3314                    .size(IconSize::Small)
3315                    .color(Color::Muted),
3316            );
3317
3318        if let Some(pending_name) = self
3319            .channel_editing_state
3320            .as_ref()
3321            .and_then(|state| state.pending_name())
3322        {
3323            item.child(Label::new(pending_name))
3324        } else {
3325            item.child(self.channel_name_editor.clone())
3326        }
3327    }
3328}
3329
3330fn render_tree_branch(
3331    is_last: bool,
3332    overdraw: bool,
3333    window: &mut Window,
3334    cx: &mut App,
3335) -> impl IntoElement {
3336    let rem_size = window.rem_size();
3337    let line_height = window.text_style().line_height_in_pixels(rem_size);
3338    let width = rem_size * 1.5;
3339    let thickness = px(1.);
3340    let color = cx.theme().colors().text;
3341
3342    canvas(
3343        |_, _, _| {},
3344        move |bounds, _, window, _| {
3345            let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
3346            let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
3347            let right = bounds.right();
3348            let top = bounds.top();
3349
3350            window.paint_quad(fill(
3351                Bounds::from_corners(
3352                    point(start_x, top),
3353                    point(
3354                        start_x + thickness,
3355                        if is_last {
3356                            start_y
3357                        } else {
3358                            bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
3359                        },
3360                    ),
3361                ),
3362                color,
3363            ));
3364            window.paint_quad(fill(
3365                Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
3366                color,
3367            ));
3368        },
3369    )
3370    .w(width)
3371    .h(line_height)
3372}
3373
3374fn render_participant_name_and_handle(user: &User) -> impl IntoElement {
3375    Label::new(if let Some(ref display_name) = user.name {
3376        format!("{display_name} ({})", user.github_login)
3377    } else {
3378        user.github_login.to_string()
3379    })
3380}
3381
3382impl Render for CollabPanel {
3383    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3384        let status = *self.client.status().borrow();
3385
3386        v_flex()
3387            .key_context(self.dispatch_context(window, cx))
3388            .on_action(cx.listener(CollabPanel::cancel))
3389            .on_action(cx.listener(CollabPanel::select_next))
3390            .on_action(cx.listener(CollabPanel::select_previous))
3391            .on_action(cx.listener(CollabPanel::confirm))
3392            .on_action(cx.listener(CollabPanel::insert_space))
3393            .on_action(cx.listener(CollabPanel::remove_selected_channel))
3394            .on_action(cx.listener(CollabPanel::show_inline_context_menu))
3395            .on_action(cx.listener(CollabPanel::rename_selected_channel))
3396            .on_action(cx.listener(CollabPanel::open_selected_channel_notes))
3397            .on_action(cx.listener(CollabPanel::toggle_selected_channel_favorite))
3398            .on_action(cx.listener(CollabPanel::collapse_selected_channel))
3399            .on_action(cx.listener(CollabPanel::expand_selected_channel))
3400            .on_action(cx.listener(CollabPanel::start_move_selected_channel))
3401            .on_action(cx.listener(CollabPanel::move_channel_up))
3402            .on_action(cx.listener(CollabPanel::move_channel_down))
3403            .track_focus(&self.focus_handle)
3404            .size_full()
3405            .child(if !status.is_or_was_connected() || status.is_signing_in() {
3406                self.render_signed_out(cx)
3407            } else {
3408                self.render_signed_in(window, cx)
3409            })
3410            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
3411                deferred(
3412                    anchored()
3413                        .position(*position)
3414                        .anchor(gpui::Corner::TopLeft)
3415                        .child(menu.clone()),
3416                )
3417                .with_priority(1)
3418            }))
3419    }
3420}
3421
3422impl EventEmitter<PanelEvent> for CollabPanel {}
3423
3424impl Panel for CollabPanel {
3425    fn position(&self, _window: &Window, cx: &App) -> DockPosition {
3426        CollaborationPanelSettings::get_global(cx).dock
3427    }
3428
3429    fn position_is_valid(&self, position: DockPosition) -> bool {
3430        matches!(position, DockPosition::Left | DockPosition::Right)
3431    }
3432
3433    fn set_position(
3434        &mut self,
3435        position: DockPosition,
3436        _window: &mut Window,
3437        cx: &mut Context<Self>,
3438    ) {
3439        settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3440            settings.collaboration_panel.get_or_insert_default().dock = Some(position.into())
3441        });
3442    }
3443
3444    fn default_size(&self, _window: &Window, cx: &App) -> Pixels {
3445        CollaborationPanelSettings::get_global(cx).default_width
3446    }
3447
3448    fn icon(&self, _window: &Window, cx: &App) -> Option<ui::IconName> {
3449        CollaborationPanelSettings::get_global(cx)
3450            .button
3451            .then_some(ui::IconName::UserGroup)
3452    }
3453
3454    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
3455        Some("Collab Panel")
3456    }
3457
3458    fn toggle_action(&self) -> Box<dyn gpui::Action> {
3459        Box::new(ToggleFocus)
3460    }
3461
3462    fn persistent_name() -> &'static str {
3463        "CollabPanel"
3464    }
3465
3466    fn panel_key() -> &'static str {
3467        COLLABORATION_PANEL_KEY
3468    }
3469
3470    fn activation_priority(&self) -> u32 {
3471        5
3472    }
3473}
3474
3475impl Focusable for CollabPanel {
3476    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
3477        self.filter_editor.focus_handle(cx)
3478    }
3479}
3480
3481impl PartialEq for ListEntry {
3482    fn eq(&self, other: &Self) -> bool {
3483        match self {
3484            ListEntry::Header(section_1) => {
3485                if let ListEntry::Header(section_2) = other {
3486                    return section_1 == section_2;
3487                }
3488            }
3489            ListEntry::CallParticipant { user: user_1, .. } => {
3490                if let ListEntry::CallParticipant { user: user_2, .. } = other {
3491                    return user_1.id == user_2.id;
3492                }
3493            }
3494            ListEntry::ParticipantProject {
3495                project_id: project_id_1,
3496                ..
3497            } => {
3498                if let ListEntry::ParticipantProject {
3499                    project_id: project_id_2,
3500                    ..
3501                } = other
3502                {
3503                    return project_id_1 == project_id_2;
3504                }
3505            }
3506            ListEntry::ParticipantScreen {
3507                peer_id: peer_id_1, ..
3508            } => {
3509                if let ListEntry::ParticipantScreen {
3510                    peer_id: peer_id_2, ..
3511                } = other
3512                {
3513                    return peer_id_1 == peer_id_2;
3514                }
3515            }
3516            ListEntry::Channel {
3517                channel: channel_1,
3518                is_favorite: is_favorite_1,
3519                ..
3520            } => {
3521                if let ListEntry::Channel {
3522                    channel: channel_2,
3523                    is_favorite: is_favorite_2,
3524                    ..
3525                } = other
3526                {
3527                    return channel_1.id == channel_2.id && is_favorite_1 == is_favorite_2;
3528                }
3529            }
3530            ListEntry::ChannelNotes { channel_id } => {
3531                if let ListEntry::ChannelNotes {
3532                    channel_id: other_id,
3533                } = other
3534                {
3535                    return channel_id == other_id;
3536                }
3537            }
3538            ListEntry::ChannelInvite(channel_1) => {
3539                if let ListEntry::ChannelInvite(channel_2) = other {
3540                    return channel_1.id == channel_2.id;
3541                }
3542            }
3543            ListEntry::IncomingRequest(user_1) => {
3544                if let ListEntry::IncomingRequest(user_2) = other {
3545                    return user_1.id == user_2.id;
3546                }
3547            }
3548            ListEntry::OutgoingRequest(user_1) => {
3549                if let ListEntry::OutgoingRequest(user_2) = other {
3550                    return user_1.id == user_2.id;
3551                }
3552            }
3553            ListEntry::Contact {
3554                contact: contact_1, ..
3555            } => {
3556                if let ListEntry::Contact {
3557                    contact: contact_2, ..
3558                } = other
3559                {
3560                    return contact_1.user.id == contact_2.user.id;
3561                }
3562            }
3563            ListEntry::ChannelEditor { depth } => {
3564                if let ListEntry::ChannelEditor { depth: other_depth } = other {
3565                    return depth == other_depth;
3566                }
3567            }
3568            ListEntry::ContactPlaceholder => {
3569                if let ListEntry::ContactPlaceholder = other {
3570                    return true;
3571                }
3572            }
3573        }
3574        false
3575    }
3576}
3577
3578struct DraggedChannelView {
3579    channel: Channel,
3580    width: Pixels,
3581}
3582
3583impl Render for DraggedChannelView {
3584    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3585        let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
3586        h_flex()
3587            .font_family(ui_font)
3588            .bg(cx.theme().colors().background)
3589            .w(self.width)
3590            .p_1()
3591            .gap_1()
3592            .child(
3593                Icon::new(
3594                    if self.channel.visibility == proto::ChannelVisibility::Public {
3595                        IconName::Public
3596                    } else {
3597                        IconName::Hash
3598                    },
3599                )
3600                .size(IconSize::Small)
3601                .color(Color::Muted),
3602            )
3603            .child(Label::new(self.channel.name.clone()))
3604    }
3605}
3606
3607struct JoinChannelTooltip {
3608    channel_store: Entity<ChannelStore>,
3609    channel_id: ChannelId,
3610    #[allow(unused)]
3611    has_notes_notification: bool,
3612}
3613
3614impl Render for JoinChannelTooltip {
3615    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3616        tooltip_container(cx, |container, cx| {
3617            let participants = self
3618                .channel_store
3619                .read(cx)
3620                .channel_participants(self.channel_id);
3621
3622            container
3623                .child(Label::new("Join Channel"))
3624                .children(participants.iter().map(|participant| {
3625                    h_flex()
3626                        .gap_2()
3627                        .child(Avatar::new(participant.avatar_uri.clone()))
3628                        .child(render_participant_name_and_handle(participant))
3629                }))
3630        })
3631    }
3632}
3633
3634#[cfg(any(test, feature = "test-support"))]
3635impl CollabPanel {
3636    pub fn entries_as_strings(&self) -> Vec<String> {
3637        let mut string_entries = Vec::new();
3638        for (index, entry) in self.entries.iter().enumerate() {
3639            let selected_marker = if self.selection == Some(index) {
3640                "  <== selected"
3641            } else {
3642                ""
3643            };
3644            match entry {
3645                ListEntry::Header(section) => {
3646                    let name = match section {
3647                        Section::ActiveCall => "Active Call",
3648                        Section::FavoriteChannels => "Favorites",
3649                        Section::Channels => "Channels",
3650                        Section::ChannelInvites => "Channel Invites",
3651                        Section::ContactRequests => "Contact Requests",
3652                        Section::Contacts => "Contacts",
3653                        Section::Online => "Online",
3654                        Section::Offline => "Offline",
3655                    };
3656                    string_entries.push(format!("[{name}]"));
3657                }
3658                ListEntry::Channel {
3659                    channel,
3660                    depth,
3661                    has_children,
3662                    ..
3663                } => {
3664                    let indent = "  ".repeat(*depth + 1);
3665                    let icon = if *has_children {
3666                        "v "
3667                    } else if channel.visibility == proto::ChannelVisibility::Public {
3668                        "🛜 "
3669                    } else {
3670                        "#️⃣ "
3671                    };
3672                    string_entries.push(format!("{indent}{icon}{}{selected_marker}", channel.name));
3673                }
3674                ListEntry::ChannelNotes { .. } => {
3675                    string_entries.push(format!("  (notes){selected_marker}"));
3676                }
3677                ListEntry::ChannelEditor { depth } => {
3678                    let indent = "  ".repeat(*depth + 1);
3679                    string_entries.push(format!("{indent}[editor]{selected_marker}"));
3680                }
3681                ListEntry::ChannelInvite(channel) => {
3682                    string_entries.push(format!("  (invite) #{}{selected_marker}", channel.name));
3683                }
3684                ListEntry::CallParticipant { user, .. } => {
3685                    string_entries.push(format!("  {}{selected_marker}", user.github_login));
3686                }
3687                ListEntry::ParticipantProject {
3688                    worktree_root_names,
3689                    ..
3690                } => {
3691                    string_entries.push(format!(
3692                        "    {}{selected_marker}",
3693                        worktree_root_names.join(", ")
3694                    ));
3695                }
3696                ListEntry::ParticipantScreen { .. } => {
3697                    string_entries.push(format!("    (screen){selected_marker}"));
3698                }
3699                ListEntry::IncomingRequest(user) => {
3700                    string_entries.push(format!(
3701                        "  (incoming) {}{selected_marker}",
3702                        user.github_login
3703                    ));
3704                }
3705                ListEntry::OutgoingRequest(user) => {
3706                    string_entries.push(format!(
3707                        "  (outgoing) {}{selected_marker}",
3708                        user.github_login
3709                    ));
3710                }
3711                ListEntry::Contact { contact, .. } => {
3712                    string_entries
3713                        .push(format!("  {}{selected_marker}", contact.user.github_login));
3714                }
3715                ListEntry::ContactPlaceholder => {}
3716            }
3717        }
3718        string_entries
3719    }
3720}