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