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_disabled_by_organization(&mut self, _cx: &mut Context<Self>) -> Div {
2624        v_flex()
2625            .p_4()
2626            .gap_4()
2627            .size_full()
2628            .text_center()
2629            .justify_center()
2630            .child(Label::new(
2631                "Collaboration is disabled for this organization.",
2632            ))
2633    }
2634
2635    fn render_signed_out(&mut self, cx: &mut Context<Self>) -> Div {
2636        let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more.";
2637
2638        // Two distinct "not connected" states:
2639        //   - Authenticated (has credentials): user just needs to connect.
2640        //   - Unauthenticated (no credentials): user needs to sign in via GitHub.
2641        let is_authenticated = self.client.user_id().is_some();
2642        let status = *self.client.status().borrow();
2643        let is_busy = status.is_signing_in();
2644
2645        let (button_id, button_label, button_icon) = if is_authenticated {
2646            (
2647                "connect",
2648                if is_busy { "Connecting…" } else { "Connect" },
2649                IconName::Public,
2650            )
2651        } else {
2652            (
2653                "sign_in",
2654                if is_busy {
2655                    "Signing in…"
2656                } else {
2657                    "Sign In with GitHub"
2658                },
2659                IconName::Github,
2660            )
2661        };
2662
2663        v_flex()
2664            .p_4()
2665            .gap_4()
2666            .size_full()
2667            .text_center()
2668            .justify_center()
2669            .child(Label::new(collab_blurb))
2670            .child(
2671                Button::new(button_id, button_label)
2672                    .full_width()
2673                    .start_icon(Icon::new(button_icon).color(Color::Muted))
2674                    .style(ButtonStyle::Outlined)
2675                    .disabled(is_busy)
2676                    .on_click(cx.listener(|this, _, window, cx| {
2677                        let client = this.client.clone();
2678                        let workspace = this.workspace.clone();
2679                        cx.spawn_in(window, async move |_, mut cx| {
2680                            client
2681                                .connect(true, &mut cx)
2682                                .await
2683                                .into_response()
2684                                .notify_workspace_async_err(workspace, &mut cx);
2685                        })
2686                        .detach()
2687                    })),
2688            )
2689    }
2690
2691    fn render_list_entry(
2692        &mut self,
2693        ix: usize,
2694        window: &mut Window,
2695        cx: &mut Context<Self>,
2696    ) -> AnyElement {
2697        let entry = self.entries[ix].clone();
2698
2699        let is_selected = self.selection == Some(ix);
2700        match entry {
2701            ListEntry::Header(section) => {
2702                let is_collapsed = self.collapsed_sections.contains(&section);
2703                self.render_header(section, is_selected, is_collapsed, cx)
2704                    .into_any_element()
2705            }
2706            ListEntry::Contact { contact, calling } => {
2707                self.mark_contact_request_accepted_notifications_read(contact.user.id, cx);
2708                self.render_contact(&contact, calling, is_selected, cx)
2709                    .into_any_element()
2710            }
2711            ListEntry::ContactPlaceholder => self
2712                .render_contact_placeholder(is_selected, cx)
2713                .into_any_element(),
2714            ListEntry::IncomingRequest(user) => self
2715                .render_contact_request(&user, true, is_selected, cx)
2716                .into_any_element(),
2717            ListEntry::OutgoingRequest(user) => self
2718                .render_contact_request(&user, false, is_selected, cx)
2719                .into_any_element(),
2720            ListEntry::Channel {
2721                channel,
2722                depth,
2723                has_children,
2724                string_match,
2725                ..
2726            } => self
2727                .render_channel(
2728                    &channel,
2729                    depth,
2730                    has_children,
2731                    is_selected,
2732                    ix,
2733                    string_match.as_ref(),
2734                    cx,
2735                )
2736                .into_any_element(),
2737            ListEntry::ChannelEditor { depth } => self
2738                .render_channel_editor(depth, window, cx)
2739                .into_any_element(),
2740            ListEntry::ChannelInvite(channel) => self
2741                .render_channel_invite(&channel, is_selected, cx)
2742                .into_any_element(),
2743            ListEntry::CallParticipant {
2744                user,
2745                peer_id,
2746                is_pending,
2747                role,
2748            } => self
2749                .render_call_participant(&user, peer_id, is_pending, role, is_selected, cx)
2750                .into_any_element(),
2751            ListEntry::ParticipantProject {
2752                project_id,
2753                worktree_root_names,
2754                host_user_id,
2755                is_last,
2756            } => self
2757                .render_participant_project(
2758                    project_id,
2759                    &worktree_root_names,
2760                    host_user_id,
2761                    is_last,
2762                    is_selected,
2763                    window,
2764                    cx,
2765                )
2766                .into_any_element(),
2767            ListEntry::ParticipantScreen { peer_id, is_last } => self
2768                .render_participant_screen(peer_id, is_last, is_selected, window, cx)
2769                .into_any_element(),
2770            ListEntry::ChannelNotes { channel_id } => self
2771                .render_channel_notes(channel_id, is_selected, window, cx)
2772                .into_any_element(),
2773        }
2774    }
2775
2776    fn render_signed_in(&mut self, _: &mut Window, cx: &mut Context<Self>) -> Div {
2777        self.channel_store.update(cx, |channel_store, _| {
2778            channel_store.initialize();
2779        });
2780
2781        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
2782
2783        v_flex()
2784            .size_full()
2785            .gap_1()
2786            .child(
2787                h_flex()
2788                    .p_2()
2789                    .h(Tab::container_height(cx))
2790                    .gap_1p5()
2791                    .border_b_1()
2792                    .border_color(cx.theme().colors().border)
2793                    .child(
2794                        Icon::new(IconName::MagnifyingGlass)
2795                            .size(IconSize::Small)
2796                            .color(Color::Muted),
2797                    )
2798                    .child(self.render_filter_input(&self.filter_editor, cx))
2799                    .when(has_query, |this| {
2800                        this.pr_2p5().child(
2801                            IconButton::new("clear_filter", IconName::Close)
2802                                .shape(IconButtonShape::Square)
2803                                .tooltip(Tooltip::text("Clear Filter"))
2804                                .on_click(cx.listener(|this, _, window, cx| {
2805                                    this.reset_filter_editor_text(window, cx);
2806                                    cx.notify();
2807                                })),
2808                        )
2809                    }),
2810            )
2811            .child(
2812                list(
2813                    self.list_state.clone(),
2814                    cx.processor(Self::render_list_entry),
2815                )
2816                .size_full(),
2817            )
2818    }
2819
2820    fn render_filter_input(
2821        &self,
2822        editor: &Entity<Editor>,
2823        cx: &mut Context<Self>,
2824    ) -> impl IntoElement {
2825        let settings = ThemeSettings::get_global(cx);
2826        let text_style = TextStyle {
2827            color: if editor.read(cx).read_only(cx) {
2828                cx.theme().colors().text_disabled
2829            } else {
2830                cx.theme().colors().text
2831            },
2832            font_family: settings.ui_font.family.clone(),
2833            font_features: settings.ui_font.features.clone(),
2834            font_fallbacks: settings.ui_font.fallbacks.clone(),
2835            font_size: rems(0.875).into(),
2836            font_weight: settings.ui_font.weight,
2837            font_style: FontStyle::Normal,
2838            line_height: relative(1.3),
2839            ..Default::default()
2840        };
2841
2842        EditorElement::new(
2843            editor,
2844            EditorStyle {
2845                local_player: cx.theme().players().local(),
2846                text: text_style,
2847                ..Default::default()
2848            },
2849        )
2850    }
2851
2852    fn render_header(
2853        &self,
2854        section: Section,
2855        is_selected: bool,
2856        is_collapsed: bool,
2857        cx: &mut Context<Self>,
2858    ) -> impl IntoElement {
2859        let mut channel_link = None;
2860        let mut channel_tooltip_text = None;
2861        let mut channel_icon = None;
2862
2863        let text = match section {
2864            Section::ActiveCall => {
2865                let channel_name = maybe!({
2866                    let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
2867
2868                    let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
2869
2870                    channel_link = Some(channel.link(cx));
2871                    (channel_icon, channel_tooltip_text) = match channel.visibility {
2872                        proto::ChannelVisibility::Public => {
2873                            (Some("icons/public.svg"), Some("Copy public channel link."))
2874                        }
2875                        proto::ChannelVisibility::Members => {
2876                            (Some("icons/hash.svg"), Some("Copy private channel link."))
2877                        }
2878                    };
2879
2880                    Some(channel.name.clone())
2881                });
2882
2883                if let Some(name) = channel_name {
2884                    name
2885                } else {
2886                    SharedString::from("Current Call")
2887                }
2888            }
2889            Section::FavoriteChannels => SharedString::from("Favorites"),
2890            Section::ContactRequests => SharedString::from("Requests"),
2891            Section::Contacts => SharedString::from("Contacts"),
2892            Section::Channels => SharedString::from("Channels"),
2893            Section::ChannelInvites => SharedString::from("Invites"),
2894            Section::Online => SharedString::from("Online"),
2895            Section::Offline => SharedString::from("Offline"),
2896        };
2897
2898        let button = match section {
2899            Section::ActiveCall => channel_link.map(|channel_link| {
2900                CopyButton::new("copy-channel-link", channel_link)
2901                    .visible_on_hover("section-header")
2902                    .tooltip_label("Copy Channel Link")
2903                    .into_any_element()
2904            }),
2905            Section::Contacts => Some(
2906                IconButton::new("add-contact", IconName::Plus)
2907                    .icon_size(IconSize::Small)
2908                    .on_click(
2909                        cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)),
2910                    )
2911                    .tooltip(Tooltip::text("Search for new contact"))
2912                    .into_any_element(),
2913            ),
2914            Section::Channels => {
2915                Some(
2916                    h_flex()
2917                        .child(
2918                            IconButton::new("filter-occupied-channels", IconName::ListFilter)
2919                                .icon_size(IconSize::Small)
2920                                .toggle_state(self.filter_occupied_channels)
2921                                .on_click(cx.listener(|this, _, _window, cx| {
2922                                    this.filter_occupied_channels = !this.filter_occupied_channels;
2923                                    this.update_entries(true, cx);
2924                                    this.persist_filter_occupied_channels(cx);
2925                                }))
2926                                .tooltip(Tooltip::text(if self.filter_occupied_channels {
2927                                    "Show All Channels"
2928                                } else {
2929                                    "Show Occupied Channels"
2930                                })),
2931                        )
2932                        .child(
2933                            IconButton::new("add-channel", IconName::Plus)
2934                                .icon_size(IconSize::Small)
2935                                .on_click(cx.listener(|this, _, window, cx| {
2936                                    this.new_root_channel(window, cx)
2937                                }))
2938                                .tooltip(Tooltip::text("Create Channel")),
2939                        )
2940                        .into_any_element(),
2941                )
2942            }
2943            _ => None,
2944        };
2945
2946        let can_collapse = match section {
2947            Section::ActiveCall
2948            | Section::Channels
2949            | Section::Contacts
2950            | Section::FavoriteChannels => false,
2951
2952            Section::ChannelInvites
2953            | Section::ContactRequests
2954            | Section::Online
2955            | Section::Offline => true,
2956        };
2957
2958        h_flex().w_full().group("section-header").child(
2959            ListHeader::new(text)
2960                .when(can_collapse, |header| {
2961                    header.toggle(Some(!is_collapsed)).on_toggle(cx.listener(
2962                        move |this, _, _, cx| {
2963                            this.toggle_section_expanded(section, cx);
2964                        },
2965                    ))
2966                })
2967                .inset(true)
2968                .end_slot::<AnyElement>(button)
2969                .toggle_state(is_selected),
2970        )
2971    }
2972
2973    fn render_contact(
2974        &self,
2975        contact: &Arc<Contact>,
2976        calling: bool,
2977        is_selected: bool,
2978        cx: &mut Context<Self>,
2979    ) -> impl IntoElement {
2980        let online = contact.online;
2981        let busy = contact.busy || calling;
2982        let github_login = contact.user.github_login.clone();
2983        let item = ListItem::new(github_login.clone())
2984            .indent_level(1)
2985            .indent_step_size(px(20.))
2986            .toggle_state(is_selected)
2987            .child(
2988                h_flex()
2989                    .w_full()
2990                    .justify_between()
2991                    .child(render_participant_name_and_handle(&contact.user))
2992                    .when(calling, |el| {
2993                        el.child(Label::new("Calling").color(Color::Muted))
2994                    })
2995                    .when(!calling, |el| {
2996                        el.child(
2997                            IconButton::new("contact context menu", IconName::Ellipsis)
2998                                .icon_color(Color::Muted)
2999                                .visible_on_hover("")
3000                                .on_click(cx.listener({
3001                                    let contact = contact.clone();
3002                                    move |this, event: &ClickEvent, window, cx| {
3003                                        this.deploy_contact_context_menu(
3004                                            event.position(),
3005                                            contact.clone(),
3006                                            window,
3007                                            cx,
3008                                        );
3009                                    }
3010                                })),
3011                        )
3012                    }),
3013            )
3014            .on_secondary_mouse_down(cx.listener({
3015                let contact = contact.clone();
3016                move |this, event: &MouseDownEvent, window, cx| {
3017                    this.deploy_contact_context_menu(event.position, contact.clone(), window, cx);
3018                }
3019            }))
3020            .start_slot(
3021                // todo handle contacts with no avatar
3022                Avatar::new(contact.user.avatar_uri.clone())
3023                    .indicator::<AvatarAvailabilityIndicator>(if online {
3024                        Some(AvatarAvailabilityIndicator::new(match busy {
3025                            true => ui::CollaboratorAvailability::Busy,
3026                            false => ui::CollaboratorAvailability::Free,
3027                        }))
3028                    } else {
3029                        None
3030                    }),
3031            );
3032
3033        div()
3034            .id(github_login.clone())
3035            .group("")
3036            .child(item)
3037            .tooltip(move |_, cx| {
3038                let text = if !online {
3039                    format!(" {} is offline", &github_login)
3040                } else if busy {
3041                    format!(" {} is on a call", &github_login)
3042                } else {
3043                    let room = ActiveCall::global(cx).read(cx).room();
3044                    if room.is_some() {
3045                        format!("Invite {} to join call", &github_login)
3046                    } else {
3047                        format!("Call {}", &github_login)
3048                    }
3049                };
3050                Tooltip::simple(text, cx)
3051            })
3052    }
3053
3054    fn render_contact_request(
3055        &self,
3056        user: &Arc<User>,
3057        is_incoming: bool,
3058        is_selected: bool,
3059        cx: &mut Context<Self>,
3060    ) -> impl IntoElement {
3061        let github_login = user.github_login.clone();
3062        let user_id = user.id;
3063        let is_response_pending = self.user_store.read(cx).is_contact_request_pending(user);
3064        let color = if is_response_pending {
3065            Color::Muted
3066        } else {
3067            Color::Default
3068        };
3069
3070        let controls = if is_incoming {
3071            vec![
3072                IconButton::new("decline-contact", IconName::Close)
3073                    .on_click(cx.listener(move |this, _, window, cx| {
3074                        this.respond_to_contact_request(user_id, false, window, cx);
3075                    }))
3076                    .icon_color(color)
3077                    .tooltip(Tooltip::text("Decline invite")),
3078                IconButton::new("accept-contact", IconName::Check)
3079                    .on_click(cx.listener(move |this, _, window, cx| {
3080                        this.respond_to_contact_request(user_id, true, window, cx);
3081                    }))
3082                    .icon_color(color)
3083                    .tooltip(Tooltip::text("Accept invite")),
3084            ]
3085        } else {
3086            let github_login = github_login.clone();
3087            vec![
3088                IconButton::new("remove_contact", IconName::Close)
3089                    .on_click(cx.listener(move |this, _, window, cx| {
3090                        this.remove_contact(user_id, &github_login, window, cx);
3091                    }))
3092                    .icon_color(color)
3093                    .tooltip(Tooltip::text("Cancel invite")),
3094            ]
3095        };
3096
3097        ListItem::new(github_login.clone())
3098            .indent_level(1)
3099            .indent_step_size(px(20.))
3100            .toggle_state(is_selected)
3101            .child(
3102                h_flex()
3103                    .w_full()
3104                    .justify_between()
3105                    .child(Label::new(github_login))
3106                    .child(h_flex().children(controls)),
3107            )
3108            .start_slot(Avatar::new(user.avatar_uri.clone()))
3109    }
3110
3111    fn render_channel_invite(
3112        &self,
3113        channel: &Arc<Channel>,
3114        is_selected: bool,
3115        cx: &mut Context<Self>,
3116    ) -> ListItem {
3117        let channel_id = channel.id;
3118        let response_is_pending = self
3119            .channel_store
3120            .read(cx)
3121            .has_pending_channel_invite_response(channel);
3122        let color = if response_is_pending {
3123            Color::Muted
3124        } else {
3125            Color::Default
3126        };
3127
3128        let controls = [
3129            IconButton::new("reject-invite", IconName::Close)
3130                .on_click(cx.listener(move |this, _, _, cx| {
3131                    this.respond_to_channel_invite(channel_id, false, cx);
3132                }))
3133                .icon_color(color)
3134                .tooltip(Tooltip::text("Decline invite")),
3135            IconButton::new("accept-invite", IconName::Check)
3136                .on_click(cx.listener(move |this, _, _, cx| {
3137                    this.respond_to_channel_invite(channel_id, true, cx);
3138                }))
3139                .icon_color(color)
3140                .tooltip(Tooltip::text("Accept invite")),
3141        ];
3142
3143        ListItem::new(("channel-invite", channel.id.0 as usize))
3144            .toggle_state(is_selected)
3145            .child(
3146                h_flex()
3147                    .w_full()
3148                    .justify_between()
3149                    .child(Label::new(channel.name.clone()))
3150                    .child(h_flex().children(controls)),
3151            )
3152            .start_slot(
3153                Icon::new(IconName::Hash)
3154                    .size(IconSize::Small)
3155                    .color(Color::Muted),
3156            )
3157    }
3158
3159    fn render_contact_placeholder(&self, is_selected: bool, cx: &mut Context<Self>) -> ListItem {
3160        ListItem::new("contact-placeholder")
3161            .child(Icon::new(IconName::Plus))
3162            .child(Label::new("Add a Contact"))
3163            .toggle_state(is_selected)
3164            .on_click(cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)))
3165    }
3166
3167    fn render_channel(
3168        &self,
3169        channel: &Channel,
3170        depth: usize,
3171        has_children: bool,
3172        is_selected: bool,
3173        ix: usize,
3174        string_match: Option<&StringMatch>,
3175        cx: &mut Context<Self>,
3176    ) -> impl IntoElement {
3177        let channel_id = channel.id;
3178
3179        let is_active = maybe!({
3180            let call_channel = ActiveCall::global(cx)
3181                .read(cx)
3182                .room()?
3183                .read(cx)
3184                .channel_id()?;
3185            Some(call_channel == channel_id)
3186        })
3187        .unwrap_or(false);
3188        let channel_store = self.channel_store.read(cx);
3189        let is_public = channel_store
3190            .channel_for_id(channel_id)
3191            .map(|channel| channel.visibility)
3192            == Some(proto::ChannelVisibility::Public);
3193        let disclosed =
3194            has_children.then(|| self.collapsed_channels.binary_search(&channel.id).is_err());
3195
3196        let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id);
3197
3198        const FACEPILE_LIMIT: usize = 3;
3199        let participants = self.channel_store.read(cx).channel_participants(channel_id);
3200
3201        let face_pile = if participants.is_empty() {
3202            None
3203        } else {
3204            let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
3205            let result = Facepile::new(
3206                participants
3207                    .iter()
3208                    .map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
3209                    .take(FACEPILE_LIMIT)
3210                    .chain(if extra_count > 0 {
3211                        Some(
3212                            Label::new(format!("+{extra_count}"))
3213                                .ml_2()
3214                                .into_any_element(),
3215                        )
3216                    } else {
3217                        None
3218                    })
3219                    .collect::<SmallVec<_>>(),
3220            );
3221
3222            Some(result)
3223        };
3224
3225        let width = self
3226            .workspace
3227            .read_with(cx, |workspace, cx| {
3228                workspace
3229                    .panel_size_state::<Self>(cx)
3230                    .and_then(|size_state| size_state.size)
3231            })
3232            .ok()
3233            .flatten()
3234            .unwrap_or(px(240.));
3235        let root_id = channel.root_id();
3236
3237        let is_favorited = self.is_channel_favorited(channel_id, cx);
3238        let (favorite_icon, favorite_color, favorite_tooltip) = if is_favorited {
3239            (IconName::StarFilled, Color::Accent, "Remove from Favorites")
3240        } else {
3241            (IconName::Star, Color::Default, "Add to Favorites")
3242        };
3243
3244        let height = rems_from_px(24.);
3245
3246        h_flex()
3247            .id(ix)
3248            .group("")
3249            .h(height)
3250            .w_full()
3251            .overflow_hidden()
3252            .when(!channel.is_root_channel(), |el| {
3253                el.on_drag(channel.clone(), move |channel, _, _, cx| {
3254                    cx.new(|_| DraggedChannelView {
3255                        channel: channel.clone(),
3256                        width,
3257                    })
3258                })
3259            })
3260            .drag_over::<Channel>({
3261                move |style, dragged_channel: &Channel, _window, cx| {
3262                    if dragged_channel.root_id() == root_id {
3263                        style.bg(cx.theme().colors().ghost_element_hover)
3264                    } else {
3265                        style
3266                    }
3267                }
3268            })
3269            .on_drop(
3270                cx.listener(move |this, dragged_channel: &Channel, window, cx| {
3271                    if dragged_channel.root_id() != root_id {
3272                        return;
3273                    }
3274                    this.move_channel(dragged_channel.id, channel_id, window, cx);
3275                }),
3276            )
3277            .child(
3278                ListItem::new(ix)
3279                    .height(height)
3280                    // Add one level of depth for the disclosure arrow.
3281                    .indent_level(depth + 1)
3282                    .indent_step_size(px(20.))
3283                    .toggle_state(is_selected || is_active)
3284                    .toggle(disclosed)
3285                    .on_toggle(cx.listener(move |this, _, window, cx| {
3286                        this.toggle_channel_collapsed(channel_id, window, cx)
3287                    }))
3288                    .on_click(cx.listener(move |this, _, window, cx| {
3289                        if is_active {
3290                            this.open_channel_notes(channel_id, window, cx)
3291                        } else {
3292                            this.join_channel(channel_id, window, cx)
3293                        }
3294                    }))
3295                    .on_secondary_mouse_down(cx.listener(
3296                        move |this, event: &MouseDownEvent, window, cx| {
3297                            this.deploy_channel_context_menu(
3298                                event.position,
3299                                channel_id,
3300                                ix,
3301                                window,
3302                                cx,
3303                            )
3304                        },
3305                    ))
3306                    .child(
3307                        h_flex()
3308                            .id(format!("inside-{}", channel_id.0))
3309                            .w_full()
3310                            .gap_1()
3311                            .child(
3312                                div()
3313                                    .relative()
3314                                    .child(
3315                                        Icon::new(if is_public {
3316                                            IconName::Public
3317                                        } else {
3318                                            IconName::Hash
3319                                        })
3320                                        .size(IconSize::Small)
3321                                        .color(Color::Muted),
3322                                    )
3323                                    .children(has_notes_notification.then(|| {
3324                                        div()
3325                                            .w_1p5()
3326                                            .absolute()
3327                                            .right(px(-1.))
3328                                            .top(px(-1.))
3329                                            .child(Indicator::dot().color(Color::Info))
3330                                    })),
3331                            )
3332                            .child(
3333                                h_flex()
3334                                    .id(channel_id.0 as usize)
3335                                    .child(match string_match {
3336                                        None => Label::new(channel.name.clone()).into_any_element(),
3337                                        Some(string_match) => HighlightedLabel::new(
3338                                            channel.name.clone(),
3339                                            string_match.positions.clone(),
3340                                        )
3341                                        .into_any_element(),
3342                                    })
3343                                    .children(face_pile.map(|face_pile| face_pile.p_1())),
3344                            )
3345                            .tooltip({
3346                                let channel_store = self.channel_store.clone();
3347                                move |_window, cx| {
3348                                    cx.new(|_| JoinChannelTooltip {
3349                                        channel_store: channel_store.clone(),
3350                                        channel_id,
3351                                        has_notes_notification,
3352                                    })
3353                                    .into()
3354                                }
3355                            }),
3356                    ),
3357            )
3358            .child(
3359                h_flex()
3360                    .visible_on_hover("")
3361                    .h_full()
3362                    .absolute()
3363                    .right_0()
3364                    .px_1()
3365                    .gap_px()
3366                    .rounded_l_md()
3367                    .bg(cx.theme().colors().background)
3368                    .child({
3369                        let focus_handle = self.focus_handle.clone();
3370                        IconButton::new("channel_favorite", favorite_icon)
3371                            .icon_size(IconSize::Small)
3372                            .icon_color(favorite_color)
3373                            .on_click(cx.listener(move |this, _, _window, cx| {
3374                                this.toggle_favorite_channel(channel_id, cx)
3375                            }))
3376                            .tooltip(move |_window, cx| {
3377                                Tooltip::for_action_in(
3378                                    favorite_tooltip,
3379                                    &ToggleSelectedChannelFavorite,
3380                                    &focus_handle,
3381                                    cx,
3382                                )
3383                            })
3384                    })
3385                    .child({
3386                        let focus_handle = self.focus_handle.clone();
3387                        IconButton::new("channel_notes", IconName::Reader)
3388                            .icon_size(IconSize::Small)
3389                            .on_click(cx.listener(move |this, _, window, cx| {
3390                                this.open_channel_notes(channel_id, window, cx)
3391                            }))
3392                            .tooltip(move |_window, cx| {
3393                                Tooltip::for_action_in(
3394                                    "Open Channel Notes",
3395                                    &OpenSelectedChannelNotes,
3396                                    &focus_handle,
3397                                    cx,
3398                                )
3399                            })
3400                    }),
3401            )
3402    }
3403
3404    fn render_channel_editor(
3405        &self,
3406        depth: usize,
3407        _window: &mut Window,
3408        _cx: &mut Context<Self>,
3409    ) -> impl IntoElement {
3410        let item = ListItem::new("channel-editor")
3411            .inset(false)
3412            // Add one level of depth for the disclosure arrow.
3413            .indent_level(depth + 1)
3414            .indent_step_size(px(20.))
3415            .start_slot(
3416                Icon::new(IconName::Hash)
3417                    .size(IconSize::Small)
3418                    .color(Color::Muted),
3419            );
3420
3421        if let Some(pending_name) = self
3422            .channel_editing_state
3423            .as_ref()
3424            .and_then(|state| state.pending_name())
3425        {
3426            item.child(Label::new(pending_name))
3427        } else {
3428            item.child(self.channel_name_editor.clone())
3429        }
3430    }
3431
3432    fn on_notification_event(
3433        &mut self,
3434        _: &Entity<NotificationStore>,
3435        event: &NotificationEvent,
3436        _window: &mut Window,
3437        cx: &mut Context<Self>,
3438    ) {
3439        match event {
3440            NotificationEvent::NewNotification { entry } => {
3441                self.add_toast(entry, cx);
3442                cx.notify();
3443            }
3444            NotificationEvent::NotificationRemoved { entry }
3445            | NotificationEvent::NotificationRead { entry } => {
3446                self.remove_toast(entry.id, cx);
3447                cx.notify();
3448            }
3449            NotificationEvent::NotificationsUpdated { .. } => {
3450                cx.notify();
3451            }
3452        }
3453    }
3454
3455    fn present_notification(
3456        &self,
3457        entry: &NotificationEntry,
3458        cx: &App,
3459    ) -> Option<(Option<Arc<User>>, String)> {
3460        let user_store = self.user_store.read(cx);
3461        match &entry.notification {
3462            Notification::ContactRequest { sender_id } => {
3463                let requester = user_store.get_cached_user(*sender_id)?;
3464                Some((
3465                    Some(requester.clone()),
3466                    format!("{} wants to add you as a contact", requester.github_login),
3467                ))
3468            }
3469            Notification::ContactRequestAccepted { responder_id } => {
3470                let responder = user_store.get_cached_user(*responder_id)?;
3471                Some((
3472                    Some(responder.clone()),
3473                    format!("{} accepted your contact request", responder.github_login),
3474                ))
3475            }
3476            Notification::ChannelInvitation {
3477                channel_name,
3478                inviter_id,
3479                ..
3480            } => {
3481                let inviter = user_store.get_cached_user(*inviter_id)?;
3482                Some((
3483                    Some(inviter.clone()),
3484                    format!(
3485                        "{} invited you to join the #{channel_name} channel",
3486                        inviter.github_login
3487                    ),
3488                ))
3489            }
3490        }
3491    }
3492
3493    fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut Context<Self>) {
3494        let Some((actor, text)) = self.present_notification(entry, cx) else {
3495            return;
3496        };
3497
3498        let notification = entry.notification.clone();
3499        let needs_response = matches!(
3500            notification,
3501            Notification::ContactRequest { .. } | Notification::ChannelInvitation { .. }
3502        );
3503
3504        let notification_id = entry.id;
3505
3506        self.current_notification_toast = Some((
3507            notification_id,
3508            cx.spawn(async move |this, cx| {
3509                cx.background_executor().timer(TOAST_DURATION).await;
3510                this.update(cx, |this, cx| this.remove_toast(notification_id, cx))
3511                    .ok();
3512            }),
3513        ));
3514
3515        let collab_panel = cx.entity().downgrade();
3516        self.workspace
3517            .update(cx, |workspace, cx| {
3518                let id = NotificationId::unique::<CollabNotificationToast>();
3519
3520                workspace.dismiss_notification(&id, cx);
3521                workspace.show_notification(id, cx, |cx| {
3522                    let workspace = cx.entity().downgrade();
3523                    cx.new(|cx| CollabNotificationToast {
3524                        actor,
3525                        text,
3526                        notification: needs_response.then(|| notification),
3527                        workspace,
3528                        collab_panel: collab_panel.clone(),
3529                        focus_handle: cx.focus_handle(),
3530                    })
3531                })
3532            })
3533            .ok();
3534    }
3535
3536    fn mark_notification_read(&mut self, notification_id: u64, cx: &mut Context<Self>) {
3537        let client = self.client.clone();
3538        self.mark_as_read_tasks
3539            .entry(notification_id)
3540            .or_insert_with(|| {
3541                cx.spawn(async move |this, cx| {
3542                    let request_result = client
3543                        .request(proto::MarkNotificationRead { notification_id })
3544                        .await;
3545
3546                    this.update(cx, |this, _| {
3547                        this.mark_as_read_tasks.remove(&notification_id);
3548                    })?;
3549
3550                    request_result?;
3551                    Ok(())
3552                })
3553            });
3554    }
3555
3556    fn mark_contact_request_accepted_notifications_read(
3557        &mut self,
3558        contact_user_id: u64,
3559        cx: &mut Context<Self>,
3560    ) {
3561        let notification_ids = self.notification_store.read_with(cx, |store, _| {
3562            (0..store.notification_count())
3563                .filter_map(|index| {
3564                    let entry = store.notification_at(index)?;
3565                    if entry.is_read {
3566                        return None;
3567                    }
3568
3569                    match &entry.notification {
3570                        Notification::ContactRequestAccepted { responder_id }
3571                            if *responder_id == contact_user_id =>
3572                        {
3573                            Some(entry.id)
3574                        }
3575                        _ => None,
3576                    }
3577                })
3578                .collect::<Vec<_>>()
3579        });
3580
3581        for notification_id in notification_ids {
3582            self.mark_notification_read(notification_id, cx);
3583        }
3584    }
3585
3586    fn remove_toast(&mut self, notification_id: u64, cx: &mut Context<Self>) {
3587        if let Some((current_id, _)) = &self.current_notification_toast {
3588            if *current_id == notification_id {
3589                self.dismiss_toast(cx);
3590            }
3591        }
3592    }
3593
3594    fn dismiss_toast(&mut self, cx: &mut Context<Self>) {
3595        self.current_notification_toast.take();
3596        self.workspace
3597            .update(cx, |workspace, cx| {
3598                let id = NotificationId::unique::<CollabNotificationToast>();
3599                workspace.dismiss_notification(&id, cx)
3600            })
3601            .ok();
3602    }
3603}
3604
3605fn render_tree_branch(
3606    is_last: bool,
3607    overdraw: bool,
3608    window: &mut Window,
3609    cx: &mut App,
3610) -> impl IntoElement {
3611    let rem_size = window.rem_size();
3612    let line_height = window.text_style().line_height_in_pixels(rem_size);
3613    let thickness = px(1.);
3614    let color = cx.theme().colors().icon_disabled;
3615
3616    canvas(
3617        |_, _, _| {},
3618        move |bounds, _, window, _| {
3619            let start_x = (bounds.left() + bounds.right() - thickness) / 2.;
3620            let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.;
3621            let right = bounds.right();
3622            let top = bounds.top();
3623
3624            window.paint_quad(fill(
3625                Bounds::from_corners(
3626                    point(start_x, top),
3627                    point(
3628                        start_x + thickness,
3629                        if is_last {
3630                            start_y
3631                        } else {
3632                            bounds.bottom() + if overdraw { px(1.) } else { px(0.) }
3633                        },
3634                    ),
3635                ),
3636                color,
3637            ));
3638            window.paint_quad(fill(
3639                Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)),
3640                color,
3641            ));
3642        },
3643    )
3644    .w(rem_size)
3645    .h(line_height - px(2.))
3646}
3647
3648fn render_participant_name_and_handle(user: &User) -> impl IntoElement {
3649    Label::new(if let Some(ref display_name) = user.name {
3650        format!("{display_name} ({})", user.github_login)
3651    } else {
3652        user.github_login.to_string()
3653    })
3654}
3655
3656impl Render for CollabPanel {
3657    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3658        let status = *self.client.status().borrow();
3659
3660        let is_collaboration_disabled = self
3661            .user_store
3662            .read(cx)
3663            .current_organization_configuration()
3664            .is_some_and(|config| !config.is_collaboration_enabled);
3665
3666        v_flex()
3667            .key_context(self.dispatch_context(window, cx))
3668            .on_action(cx.listener(CollabPanel::cancel))
3669            .on_action(cx.listener(CollabPanel::select_next))
3670            .on_action(cx.listener(CollabPanel::select_previous))
3671            .on_action(cx.listener(CollabPanel::confirm))
3672            .on_action(cx.listener(CollabPanel::insert_space))
3673            .on_action(cx.listener(CollabPanel::remove_selected_channel))
3674            .on_action(cx.listener(CollabPanel::show_inline_context_menu))
3675            .on_action(cx.listener(CollabPanel::rename_selected_channel))
3676            .on_action(cx.listener(CollabPanel::open_selected_channel_notes))
3677            .on_action(cx.listener(CollabPanel::toggle_selected_channel_favorite))
3678            .on_action(cx.listener(CollabPanel::collapse_selected_channel))
3679            .on_action(cx.listener(CollabPanel::expand_selected_channel))
3680            .on_action(cx.listener(CollabPanel::start_move_selected_channel))
3681            .on_action(cx.listener(CollabPanel::move_channel_up))
3682            .on_action(cx.listener(CollabPanel::move_channel_down))
3683            .track_focus(&self.focus_handle)
3684            .size_full()
3685            .child(if is_collaboration_disabled {
3686                self.render_disabled_by_organization(cx)
3687            } else if !status.is_or_was_connected() || status.is_signing_in() {
3688                self.render_signed_out(cx)
3689            } else {
3690                self.render_signed_in(window, cx)
3691            })
3692            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
3693                deferred(
3694                    anchored()
3695                        .position(*position)
3696                        .anchor(gpui::Corner::TopLeft)
3697                        .child(menu.clone()),
3698                )
3699                .with_priority(1)
3700            }))
3701    }
3702}
3703
3704impl EventEmitter<PanelEvent> for CollabPanel {}
3705
3706impl Panel for CollabPanel {
3707    fn position(&self, _window: &Window, cx: &App) -> DockPosition {
3708        CollaborationPanelSettings::get_global(cx).dock
3709    }
3710
3711    fn position_is_valid(&self, position: DockPosition) -> bool {
3712        matches!(position, DockPosition::Left | DockPosition::Right)
3713    }
3714
3715    fn set_position(
3716        &mut self,
3717        position: DockPosition,
3718        _window: &mut Window,
3719        cx: &mut Context<Self>,
3720    ) {
3721        settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3722            settings.collaboration_panel.get_or_insert_default().dock = Some(position.into())
3723        });
3724    }
3725
3726    fn default_size(&self, _window: &Window, cx: &App) -> Pixels {
3727        CollaborationPanelSettings::get_global(cx).default_width
3728    }
3729
3730    fn set_active(&mut self, active: bool, _window: &mut Window, cx: &mut Context<Self>) {
3731        if active && self.current_notification_toast.is_some() {
3732            self.current_notification_toast.take();
3733            let workspace = self.workspace.clone();
3734            cx.defer(move |cx| {
3735                workspace
3736                    .update(cx, |workspace, cx| {
3737                        let id = NotificationId::unique::<CollabNotificationToast>();
3738                        workspace.dismiss_notification(&id, cx)
3739                    })
3740                    .ok();
3741            });
3742        }
3743    }
3744
3745    fn icon(&self, _window: &Window, cx: &App) -> Option<ui::IconName> {
3746        CollaborationPanelSettings::get_global(cx)
3747            .button
3748            .then_some(ui::IconName::UserGroup)
3749    }
3750
3751    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
3752        Some("Collab Panel")
3753    }
3754
3755    fn toggle_action(&self) -> Box<dyn gpui::Action> {
3756        Box::new(ToggleFocus)
3757    }
3758
3759    fn persistent_name() -> &'static str {
3760        "CollabPanel"
3761    }
3762
3763    fn panel_key() -> &'static str {
3764        COLLABORATION_PANEL_KEY
3765    }
3766
3767    fn activation_priority(&self) -> u32 {
3768        5
3769    }
3770}
3771
3772impl Focusable for CollabPanel {
3773    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
3774        self.filter_editor.focus_handle(cx)
3775    }
3776}
3777
3778impl PartialEq for ListEntry {
3779    fn eq(&self, other: &Self) -> bool {
3780        match self {
3781            ListEntry::Header(section_1) => {
3782                if let ListEntry::Header(section_2) = other {
3783                    return section_1 == section_2;
3784                }
3785            }
3786            ListEntry::CallParticipant { user: user_1, .. } => {
3787                if let ListEntry::CallParticipant { user: user_2, .. } = other {
3788                    return user_1.id == user_2.id;
3789                }
3790            }
3791            ListEntry::ParticipantProject {
3792                project_id: project_id_1,
3793                ..
3794            } => {
3795                if let ListEntry::ParticipantProject {
3796                    project_id: project_id_2,
3797                    ..
3798                } = other
3799                {
3800                    return project_id_1 == project_id_2;
3801                }
3802            }
3803            ListEntry::ParticipantScreen {
3804                peer_id: peer_id_1, ..
3805            } => {
3806                if let ListEntry::ParticipantScreen {
3807                    peer_id: peer_id_2, ..
3808                } = other
3809                {
3810                    return peer_id_1 == peer_id_2;
3811                }
3812            }
3813            ListEntry::Channel {
3814                channel: channel_1,
3815                is_favorite: is_favorite_1,
3816                ..
3817            } => {
3818                if let ListEntry::Channel {
3819                    channel: channel_2,
3820                    is_favorite: is_favorite_2,
3821                    ..
3822                } = other
3823                {
3824                    return channel_1.id == channel_2.id && is_favorite_1 == is_favorite_2;
3825                }
3826            }
3827            ListEntry::ChannelNotes { channel_id } => {
3828                if let ListEntry::ChannelNotes {
3829                    channel_id: other_id,
3830                } = other
3831                {
3832                    return channel_id == other_id;
3833                }
3834            }
3835            ListEntry::ChannelInvite(channel_1) => {
3836                if let ListEntry::ChannelInvite(channel_2) = other {
3837                    return channel_1.id == channel_2.id;
3838                }
3839            }
3840            ListEntry::IncomingRequest(user_1) => {
3841                if let ListEntry::IncomingRequest(user_2) = other {
3842                    return user_1.id == user_2.id;
3843                }
3844            }
3845            ListEntry::OutgoingRequest(user_1) => {
3846                if let ListEntry::OutgoingRequest(user_2) = other {
3847                    return user_1.id == user_2.id;
3848                }
3849            }
3850            ListEntry::Contact {
3851                contact: contact_1, ..
3852            } => {
3853                if let ListEntry::Contact {
3854                    contact: contact_2, ..
3855                } = other
3856                {
3857                    return contact_1.user.id == contact_2.user.id;
3858                }
3859            }
3860            ListEntry::ChannelEditor { depth } => {
3861                if let ListEntry::ChannelEditor { depth: other_depth } = other {
3862                    return depth == other_depth;
3863                }
3864            }
3865            ListEntry::ContactPlaceholder => {
3866                if let ListEntry::ContactPlaceholder = other {
3867                    return true;
3868                }
3869            }
3870        }
3871        false
3872    }
3873}
3874
3875struct DraggedChannelView {
3876    channel: Channel,
3877    width: Pixels,
3878}
3879
3880impl Render for DraggedChannelView {
3881    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3882        let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
3883        h_flex()
3884            .font_family(ui_font)
3885            .bg(cx.theme().colors().background)
3886            .w(self.width)
3887            .p_1()
3888            .gap_1()
3889            .child(
3890                Icon::new(
3891                    if self.channel.visibility == proto::ChannelVisibility::Public {
3892                        IconName::Public
3893                    } else {
3894                        IconName::Hash
3895                    },
3896                )
3897                .size(IconSize::Small)
3898                .color(Color::Muted),
3899            )
3900            .child(Label::new(self.channel.name.clone()))
3901    }
3902}
3903
3904struct JoinChannelTooltip {
3905    channel_store: Entity<ChannelStore>,
3906    channel_id: ChannelId,
3907    #[allow(unused)]
3908    has_notes_notification: bool,
3909}
3910
3911impl Render for JoinChannelTooltip {
3912    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3913        tooltip_container(cx, |container, cx| {
3914            let participants = self
3915                .channel_store
3916                .read(cx)
3917                .channel_participants(self.channel_id);
3918
3919            container
3920                .child(Label::new("Join Channel"))
3921                .children(participants.iter().map(|participant| {
3922                    h_flex()
3923                        .gap_2()
3924                        .child(Avatar::new(participant.avatar_uri.clone()))
3925                        .child(render_participant_name_and_handle(participant))
3926                }))
3927        })
3928    }
3929}
3930
3931pub struct CollabNotificationToast {
3932    actor: Option<Arc<User>>,
3933    text: String,
3934    notification: Option<Notification>,
3935    workspace: WeakEntity<Workspace>,
3936    collab_panel: WeakEntity<CollabPanel>,
3937    focus_handle: FocusHandle,
3938}
3939
3940impl Focusable for CollabNotificationToast {
3941    fn focus_handle(&self, _cx: &App) -> FocusHandle {
3942        self.focus_handle.clone()
3943    }
3944}
3945
3946impl WorkspaceNotification for CollabNotificationToast {}
3947
3948impl CollabNotificationToast {
3949    fn focus_collab_panel(&self, window: &mut Window, cx: &mut Context<Self>) {
3950        let workspace = self.workspace.clone();
3951        window.defer(cx, move |window, cx| {
3952            workspace
3953                .update(cx, |workspace, cx| {
3954                    workspace.focus_panel::<CollabPanel>(window, cx)
3955                })
3956                .ok();
3957        })
3958    }
3959
3960    fn respond(&mut self, accept: bool, window: &mut Window, cx: &mut Context<Self>) {
3961        if let Some(notification) = self.notification.take() {
3962            self.collab_panel
3963                .update(cx, |collab_panel, cx| match notification {
3964                    Notification::ContactRequest { sender_id } => {
3965                        collab_panel.respond_to_contact_request(sender_id, accept, window, cx);
3966                    }
3967                    Notification::ChannelInvitation { channel_id, .. } => {
3968                        collab_panel.respond_to_channel_invite(ChannelId(channel_id), accept, cx);
3969                    }
3970                    Notification::ContactRequestAccepted { .. } => {}
3971                })
3972                .ok();
3973        }
3974        cx.emit(DismissEvent);
3975    }
3976}
3977
3978impl Render for CollabNotificationToast {
3979    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3980        let needs_response = self.notification.is_some();
3981
3982        let accept_button = if needs_response {
3983            Button::new("accept", "Accept").on_click(cx.listener(|this, _, window, cx| {
3984                this.respond(true, window, cx);
3985                cx.stop_propagation();
3986            }))
3987        } else {
3988            Button::new("dismiss", "Dismiss").on_click(cx.listener(|_, _, _, cx| {
3989                cx.emit(DismissEvent);
3990            }))
3991        };
3992
3993        let decline_button = if needs_response {
3994            Button::new("decline", "Decline").on_click(cx.listener(|this, _, window, cx| {
3995                this.respond(false, window, cx);
3996                cx.stop_propagation();
3997            }))
3998        } else {
3999            Button::new("close", "Close").on_click(cx.listener(|_, _, _, cx| {
4000                cx.emit(DismissEvent);
4001            }))
4002        };
4003
4004        let avatar_uri = self
4005            .actor
4006            .as_ref()
4007            .map(|user| user.avatar_uri.clone())
4008            .unwrap_or_default();
4009
4010        div()
4011            .id("collab_notification_toast")
4012            .on_click(cx.listener(|this, _, window, cx| {
4013                this.focus_collab_panel(window, cx);
4014                cx.emit(DismissEvent);
4015            }))
4016            .child(
4017                CollabNotification::new(avatar_uri, accept_button, decline_button)
4018                    .child(Label::new(self.text.clone())),
4019            )
4020    }
4021}
4022
4023impl EventEmitter<DismissEvent> for CollabNotificationToast {}
4024impl EventEmitter<SuppressEvent> for CollabNotificationToast {}
4025
4026#[cfg(any(test, feature = "test-support"))]
4027impl CollabPanel {
4028    pub fn entries_as_strings(&self) -> Vec<String> {
4029        let mut string_entries = Vec::new();
4030        for (index, entry) in self.entries.iter().enumerate() {
4031            let selected_marker = if self.selection == Some(index) {
4032                "  <== selected"
4033            } else {
4034                ""
4035            };
4036            match entry {
4037                ListEntry::Header(section) => {
4038                    let name = match section {
4039                        Section::ActiveCall => "Active Call",
4040                        Section::FavoriteChannels => "Favorites",
4041                        Section::Channels => "Channels",
4042                        Section::ChannelInvites => "Channel Invites",
4043                        Section::ContactRequests => "Contact Requests",
4044                        Section::Contacts => "Contacts",
4045                        Section::Online => "Online",
4046                        Section::Offline => "Offline",
4047                    };
4048                    string_entries.push(format!("[{name}]"));
4049                }
4050                ListEntry::Channel {
4051                    channel,
4052                    depth,
4053                    has_children,
4054                    ..
4055                } => {
4056                    let indent = "  ".repeat(*depth + 1);
4057                    let icon = if *has_children {
4058                        "v "
4059                    } else if channel.visibility == proto::ChannelVisibility::Public {
4060                        "🛜 "
4061                    } else {
4062                        "#️⃣ "
4063                    };
4064                    string_entries.push(format!("{indent}{icon}{}{selected_marker}", channel.name));
4065                }
4066                ListEntry::ChannelNotes { .. } => {
4067                    string_entries.push(format!("  (notes){selected_marker}"));
4068                }
4069                ListEntry::ChannelEditor { depth } => {
4070                    let indent = "  ".repeat(*depth + 1);
4071                    string_entries.push(format!("{indent}[editor]{selected_marker}"));
4072                }
4073                ListEntry::ChannelInvite(channel) => {
4074                    string_entries.push(format!("  (invite) #{}{selected_marker}", channel.name));
4075                }
4076                ListEntry::CallParticipant { user, .. } => {
4077                    string_entries.push(format!("  {}{selected_marker}", user.github_login));
4078                }
4079                ListEntry::ParticipantProject {
4080                    worktree_root_names,
4081                    ..
4082                } => {
4083                    string_entries.push(format!(
4084                        "    {}{selected_marker}",
4085                        worktree_root_names.join(", ")
4086                    ));
4087                }
4088                ListEntry::ParticipantScreen { .. } => {
4089                    string_entries.push(format!("    (screen){selected_marker}"));
4090                }
4091                ListEntry::IncomingRequest(user) => {
4092                    string_entries.push(format!(
4093                        "  (incoming) {}{selected_marker}",
4094                        user.github_login
4095                    ));
4096                }
4097                ListEntry::OutgoingRequest(user) => {
4098                    string_entries.push(format!(
4099                        "  (outgoing) {}{selected_marker}",
4100                        user.github_login
4101                    ));
4102                }
4103                ListEntry::Contact { contact, .. } => {
4104                    string_entries
4105                        .push(format!("  {}{selected_marker}", contact.user.github_login));
4106                }
4107                ListEntry::ContactPlaceholder => {}
4108            }
4109        }
4110        string_entries
4111    }
4112}