Add collab_ui2

Conrad Irwin created

Change summary

Cargo.lock                                                         |   41 
crates/collab_ui2/Cargo.toml                                       |   80 
crates/collab_ui2/src/channel_view.rs                              |  454 
crates/collab_ui2/src/chat_panel.rs                                |  983 
crates/collab_ui2/src/chat_panel/message_editor.rs                 |  313 
crates/collab_ui2/src/collab_panel.rs                              | 3548 
crates/collab_ui2/src/collab_panel/channel_modal.rs                |  717 
crates/collab_ui2/src/collab_panel/contact_finder.rs               |  261 
crates/collab_ui2/src/collab_titlebar_item.rs                      | 1278 
crates/collab_ui2/src/collab_ui.rs                                 |  165 
crates/collab_ui2/src/face_pile.rs                                 |  113 
crates/collab_ui2/src/notification_panel.rs                        |  884 
crates/collab_ui2/src/notifications.rs                             |   11 
crates/collab_ui2/src/notifications/incoming_call_notification.rs  |  213 
crates/collab_ui2/src/notifications/project_shared_notification.rs |  217 
crates/collab_ui2/src/panel_settings.rs                            |   69 
crates/zed2/Cargo.toml                                             |    2 
17 files changed, 9,348 insertions(+), 1 deletion(-)

Detailed changes

Cargo.lock 🔗

@@ -1829,6 +1829,46 @@ dependencies = [
  "zed-actions",
 ]
 
+[[package]]
+name = "collab_ui2"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "call2",
+ "channel2",
+ "client2",
+ "clock",
+ "collections",
+ "db2",
+ "editor2",
+ "feature_flags2",
+ "futures 0.3.28",
+ "fuzzy",
+ "gpui2",
+ "language2",
+ "lazy_static",
+ "log",
+ "menu2",
+ "notifications2",
+ "picker2",
+ "postage",
+ "pretty_assertions",
+ "project2",
+ "rich_text2",
+ "rpc2",
+ "schemars",
+ "serde",
+ "serde_derive",
+ "settings2",
+ "smallvec",
+ "theme2",
+ "time",
+ "tree-sitter-markdown",
+ "util",
+ "workspace2",
+ "zed_actions2",
+]
+
 [[package]]
 name = "collections"
 version = "0.1.0"
@@ -11441,6 +11481,7 @@ dependencies = [
  "chrono",
  "cli",
  "client2",
+ "collab_ui2",
  "collections",
  "command_palette2",
  "copilot2",

crates/collab_ui2/Cargo.toml 🔗

@@ -0,0 +1,80 @@
+[package]
+name = "collab_ui2"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+path = "src/collab_ui.rs"
+doctest = false
+
+[features]
+test-support = [
+    "call/test-support",
+    "client/test-support",
+    "collections/test-support",
+    "editor/test-support",
+    "gpui/test-support",
+    "project/test-support",
+    "settings/test-support",
+    "util/test-support",
+    "workspace/test-support",
+]
+
+[dependencies]
+# auto_update = { path = "../auto_update" }
+db = { package = "db2", path = "../db2" }
+call = { package = "call2", path = "../call2" }
+client = { package = "client2", path = "../client2" }
+channel = { package = "channel2", path = "../channel2" }
+clock = { path = "../clock" }
+collections = { path = "../collections" }
+# context_menu = { path = "../context_menu" }
+# drag_and_drop = { path = "../drag_and_drop" }
+editor = { package="editor2", path = "../editor2" }
+#feedback = { path = "../feedback" }
+fuzzy = { path = "../fuzzy" }
+gpui = { package = "gpui2", path = "../gpui2" }
+language = { package = "language2", path = "../language2" }
+menu = { package = "menu2",  path = "../menu2" }
+notifications = { package = "notifications2",  path = "../notifications2" }
+rich_text = { package = "rich_text2", path = "../rich_text2" }
+picker = { package = "picker2", path = "../picker2" }
+project = { package = "project2", path = "../project2" }
+# recent_projects = { path = "../recent_projects" }
+rpc = { package ="rpc2",  path = "../rpc2" }
+settings = { package = "settings2", path = "../settings2" }
+feature_flags = { package = "feature_flags2", path = "../feature_flags2"}
+theme = { package = "theme2", path = "../theme2" }
+# theme_selector = { path = "../theme_selector" }
+# vcs_menu = { path = "../vcs_menu" }
+util = { path = "../util" }
+workspace = { package = "workspace2", path = "../workspace2" }
+zed-actions = { package="zed_actions2", path = "../zed_actions2"}
+
+anyhow.workspace = true
+futures.workspace = true
+lazy_static.workspace = true
+log.workspace = true
+schemars.workspace = true
+postage.workspace = true
+serde.workspace = true
+serde_derive.workspace = true
+time.workspace = true
+smallvec.workspace = true
+
+[dev-dependencies]
+call = { package = "call2", path = "../call2", features = ["test-support"] }
+client = { package = "client2", path = "../client2", features = ["test-support"] }
+collections = { path = "../collections", features = ["test-support"] }
+editor = { package = "editor2", path = "../editor2", features = ["test-support"] }
+gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] }
+notifications = { package = "notifications2", path = "../notifications2", features = ["test-support"] }
+project = { package = "project2", path = "../project2", features = ["test-support"] }
+rpc = { package = "rpc2", path = "../rpc2", features = ["test-support"] }
+settings = { package = "settings2", path = "../settings2", features = ["test-support"] }
+util = { path = "../util", features = ["test-support"] }
+workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] }
+
+pretty_assertions.workspace = true
+tree-sitter-markdown.workspace = true

crates/collab_ui2/src/channel_view.rs 🔗

@@ -0,0 +1,454 @@
+use anyhow::{anyhow, Result};
+use call::report_call_event_for_channel;
+use channel::{Channel, ChannelBuffer, ChannelBufferEvent, ChannelId, ChannelStore};
+use client::{
+    proto::{self, PeerId},
+    Collaborator, ParticipantIndex,
+};
+use collections::HashMap;
+use editor::{CollaborationHub, Editor};
+use gpui::{
+    actions,
+    elements::{ChildView, Label},
+    geometry::vector::Vector2F,
+    AnyElement, AnyViewHandle, AppContext, Element, Entity, ModelHandle, Subscription, Task, View,
+    ViewContext, ViewHandle,
+};
+use project::Project;
+use smallvec::SmallVec;
+use std::{
+    any::{Any, TypeId},
+    sync::Arc,
+};
+use util::ResultExt;
+use workspace::{
+    item::{FollowableItem, Item, ItemEvent, ItemHandle},
+    register_followable_item,
+    searchable::SearchableItemHandle,
+    ItemNavHistory, Pane, SaveIntent, ViewId, Workspace, WorkspaceId,
+};
+
+actions!(channel_view, [Deploy]);
+
+pub fn init(cx: &mut AppContext) {
+    register_followable_item::<ChannelView>(cx)
+}
+
+pub struct ChannelView {
+    pub editor: ViewHandle<Editor>,
+    project: ModelHandle<Project>,
+    channel_store: ModelHandle<ChannelStore>,
+    channel_buffer: ModelHandle<ChannelBuffer>,
+    remote_id: Option<ViewId>,
+    _editor_event_subscription: Subscription,
+}
+
+impl ChannelView {
+    pub fn open(
+        channel_id: ChannelId,
+        workspace: ViewHandle<Workspace>,
+        cx: &mut AppContext,
+    ) -> Task<Result<ViewHandle<Self>>> {
+        let pane = workspace.read(cx).active_pane().clone();
+        let channel_view = Self::open_in_pane(channel_id, pane.clone(), workspace.clone(), cx);
+        cx.spawn(|mut cx| async move {
+            let channel_view = channel_view.await?;
+            pane.update(&mut cx, |pane, cx| {
+                report_call_event_for_channel(
+                    "open channel notes",
+                    channel_id,
+                    &workspace.read(cx).app_state().client,
+                    cx,
+                );
+                pane.add_item(Box::new(channel_view.clone()), true, true, None, cx);
+            });
+            anyhow::Ok(channel_view)
+        })
+    }
+
+    pub fn open_in_pane(
+        channel_id: ChannelId,
+        pane: ViewHandle<Pane>,
+        workspace: ViewHandle<Workspace>,
+        cx: &mut AppContext,
+    ) -> Task<Result<ViewHandle<Self>>> {
+        let workspace = workspace.read(cx);
+        let project = workspace.project().to_owned();
+        let channel_store = ChannelStore::global(cx);
+        let language_registry = workspace.app_state().languages.clone();
+        let markdown = language_registry.language_for_name("Markdown");
+        let channel_buffer =
+            channel_store.update(cx, |store, cx| store.open_channel_buffer(channel_id, cx));
+
+        cx.spawn(|mut cx| async move {
+            let channel_buffer = channel_buffer.await?;
+            let markdown = markdown.await.log_err();
+
+            channel_buffer.update(&mut cx, |buffer, cx| {
+                buffer.buffer().update(cx, |buffer, cx| {
+                    buffer.set_language_registry(language_registry);
+                    if let Some(markdown) = markdown {
+                        buffer.set_language(Some(markdown), cx);
+                    }
+                })
+            });
+
+            pane.update(&mut cx, |pane, cx| {
+                let buffer_id = channel_buffer.read(cx).remote_id(cx);
+
+                let existing_view = pane
+                    .items_of_type::<Self>()
+                    .find(|view| view.read(cx).channel_buffer.read(cx).remote_id(cx) == buffer_id);
+
+                // If this channel buffer is already open in this pane, just return it.
+                if let Some(existing_view) = existing_view.clone() {
+                    if existing_view.read(cx).channel_buffer == channel_buffer {
+                        return existing_view;
+                    }
+                }
+
+                let view = cx.add_view(|cx| {
+                    let mut this = Self::new(project, channel_store, channel_buffer, cx);
+                    this.acknowledge_buffer_version(cx);
+                    this
+                });
+
+                // If the pane contained a disconnected view for this channel buffer,
+                // replace that.
+                if let Some(existing_item) = existing_view {
+                    if let Some(ix) = pane.index_for_item(&existing_item) {
+                        pane.close_item_by_id(existing_item.id(), SaveIntent::Skip, cx)
+                            .detach();
+                        pane.add_item(Box::new(view.clone()), true, true, Some(ix), cx);
+                    }
+                }
+
+                view
+            })
+            .ok_or_else(|| anyhow!("pane was dropped"))
+        })
+    }
+
+    pub fn new(
+        project: ModelHandle<Project>,
+        channel_store: ModelHandle<ChannelStore>,
+        channel_buffer: ModelHandle<ChannelBuffer>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let buffer = channel_buffer.read(cx).buffer();
+        let editor = cx.add_view(|cx| {
+            let mut editor = Editor::for_buffer(buffer, None, cx);
+            editor.set_collaboration_hub(Box::new(ChannelBufferCollaborationHub(
+                channel_buffer.clone(),
+            )));
+            editor.set_read_only(
+                !channel_buffer
+                    .read(cx)
+                    .channel(cx)
+                    .is_some_and(|c| c.can_edit_notes()),
+            );
+            editor
+        });
+        let _editor_event_subscription = cx.subscribe(&editor, |_, _, e, cx| cx.emit(e.clone()));
+
+        cx.subscribe(&channel_buffer, Self::handle_channel_buffer_event)
+            .detach();
+
+        Self {
+            editor,
+            project,
+            channel_store,
+            channel_buffer,
+            remote_id: None,
+            _editor_event_subscription,
+        }
+    }
+
+    pub fn channel(&self, cx: &AppContext) -> Option<Arc<Channel>> {
+        self.channel_buffer.read(cx).channel(cx)
+    }
+
+    fn handle_channel_buffer_event(
+        &mut self,
+        _: ModelHandle<ChannelBuffer>,
+        event: &ChannelBufferEvent,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match event {
+            ChannelBufferEvent::Disconnected => self.editor.update(cx, |editor, cx| {
+                editor.set_read_only(true);
+                cx.notify();
+            }),
+            ChannelBufferEvent::ChannelChanged => {
+                self.editor.update(cx, |editor, cx| {
+                    editor.set_read_only(!self.channel(cx).is_some_and(|c| c.can_edit_notes()));
+                    cx.emit(editor::Event::TitleChanged);
+                    cx.notify()
+                });
+            }
+            ChannelBufferEvent::BufferEdited => {
+                if cx.is_self_focused() || self.editor.is_focused(cx) {
+                    self.acknowledge_buffer_version(cx);
+                } else {
+                    self.channel_store.update(cx, |store, cx| {
+                        let channel_buffer = self.channel_buffer.read(cx);
+                        store.notes_changed(
+                            channel_buffer.channel_id,
+                            channel_buffer.epoch(),
+                            &channel_buffer.buffer().read(cx).version(),
+                            cx,
+                        )
+                    });
+                }
+            }
+            ChannelBufferEvent::CollaboratorsChanged => {}
+        }
+    }
+
+    fn acknowledge_buffer_version(&mut self, cx: &mut ViewContext<'_, '_, ChannelView>) {
+        self.channel_store.update(cx, |store, cx| {
+            let channel_buffer = self.channel_buffer.read(cx);
+            store.acknowledge_notes_version(
+                channel_buffer.channel_id,
+                channel_buffer.epoch(),
+                &channel_buffer.buffer().read(cx).version(),
+                cx,
+            )
+        });
+        self.channel_buffer.update(cx, |buffer, cx| {
+            buffer.acknowledge_buffer_version(cx);
+        });
+    }
+}
+
+impl Entity for ChannelView {
+    type Event = editor::Event;
+}
+
+impl View for ChannelView {
+    fn ui_name() -> &'static str {
+        "ChannelView"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        ChildView::new(self.editor.as_any(), cx).into_any()
+    }
+
+    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if cx.is_self_focused() {
+            self.acknowledge_buffer_version(cx);
+            cx.focus(self.editor.as_any())
+        }
+    }
+}
+
+impl Item for ChannelView {
+    fn act_as_type<'a>(
+        &'a self,
+        type_id: TypeId,
+        self_handle: &'a ViewHandle<Self>,
+        _: &'a AppContext,
+    ) -> Option<&'a AnyViewHandle> {
+        if type_id == TypeId::of::<Self>() {
+            Some(self_handle)
+        } else if type_id == TypeId::of::<Editor>() {
+            Some(&self.editor)
+        } else {
+            None
+        }
+    }
+
+    fn tab_content<V: 'static>(
+        &self,
+        _: Option<usize>,
+        style: &theme::Tab,
+        cx: &gpui::AppContext,
+    ) -> AnyElement<V> {
+        let label = if let Some(channel) = self.channel(cx) {
+            match (
+                channel.can_edit_notes(),
+                self.channel_buffer.read(cx).is_connected(),
+            ) {
+                (true, true) => format!("#{}", channel.name),
+                (false, true) => format!("#{} (read-only)", channel.name),
+                (_, false) => format!("#{} (disconnected)", channel.name),
+            }
+        } else {
+            format!("channel notes (disconnected)")
+        };
+        Label::new(label, style.label.to_owned()).into_any()
+    }
+
+    fn clone_on_split(&self, _: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<Self> {
+        Some(Self::new(
+            self.project.clone(),
+            self.channel_store.clone(),
+            self.channel_buffer.clone(),
+            cx,
+        ))
+    }
+
+    fn is_singleton(&self, _cx: &AppContext) -> bool {
+        false
+    }
+
+    fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
+        self.editor
+            .update(cx, |editor, cx| editor.navigate(data, cx))
+    }
+
+    fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
+        self.editor
+            .update(cx, |editor, cx| Item::deactivated(editor, cx))
+    }
+
+    fn set_nav_history(&mut self, history: ItemNavHistory, cx: &mut ViewContext<Self>) {
+        self.editor
+            .update(cx, |editor, cx| Item::set_nav_history(editor, history, cx))
+    }
+
+    fn as_searchable(&self, _: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+        Some(Box::new(self.editor.clone()))
+    }
+
+    fn show_toolbar(&self) -> bool {
+        true
+    }
+
+    fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option<Vector2F> {
+        self.editor.read(cx).pixel_position_of_cursor(cx)
+    }
+
+    fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
+        editor::Editor::to_item_events(event)
+    }
+}
+
+impl FollowableItem for ChannelView {
+    fn remote_id(&self) -> Option<workspace::ViewId> {
+        self.remote_id
+    }
+
+    fn to_state_proto(&self, cx: &AppContext) -> Option<proto::view::Variant> {
+        let channel_buffer = self.channel_buffer.read(cx);
+        if !channel_buffer.is_connected() {
+            return None;
+        }
+
+        Some(proto::view::Variant::ChannelView(
+            proto::view::ChannelView {
+                channel_id: channel_buffer.channel_id,
+                editor: if let Some(proto::view::Variant::Editor(proto)) =
+                    self.editor.read(cx).to_state_proto(cx)
+                {
+                    Some(proto)
+                } else {
+                    None
+                },
+            },
+        ))
+    }
+
+    fn from_state_proto(
+        pane: ViewHandle<workspace::Pane>,
+        workspace: ViewHandle<workspace::Workspace>,
+        remote_id: workspace::ViewId,
+        state: &mut Option<proto::view::Variant>,
+        cx: &mut AppContext,
+    ) -> Option<gpui::Task<anyhow::Result<ViewHandle<Self>>>> {
+        let Some(proto::view::Variant::ChannelView(_)) = state else {
+            return None;
+        };
+        let Some(proto::view::Variant::ChannelView(state)) = state.take() else {
+            unreachable!()
+        };
+
+        let open = ChannelView::open_in_pane(state.channel_id, pane, workspace, cx);
+
+        Some(cx.spawn(|mut cx| async move {
+            let this = open.await?;
+
+            let task = this
+                .update(&mut cx, |this, cx| {
+                    this.remote_id = Some(remote_id);
+
+                    if let Some(state) = state.editor {
+                        Some(this.editor.update(cx, |editor, cx| {
+                            editor.apply_update_proto(
+                                &this.project,
+                                proto::update_view::Variant::Editor(proto::update_view::Editor {
+                                    selections: state.selections,
+                                    pending_selection: state.pending_selection,
+                                    scroll_top_anchor: state.scroll_top_anchor,
+                                    scroll_x: state.scroll_x,
+                                    scroll_y: state.scroll_y,
+                                    ..Default::default()
+                                }),
+                                cx,
+                            )
+                        }))
+                    } else {
+                        None
+                    }
+                })
+                .ok_or_else(|| anyhow!("window was closed"))?;
+
+            if let Some(task) = task {
+                task.await?;
+            }
+
+            Ok(this)
+        }))
+    }
+
+    fn add_event_to_update_proto(
+        &self,
+        event: &Self::Event,
+        update: &mut Option<proto::update_view::Variant>,
+        cx: &AppContext,
+    ) -> bool {
+        self.editor
+            .read(cx)
+            .add_event_to_update_proto(event, update, cx)
+    }
+
+    fn apply_update_proto(
+        &mut self,
+        project: &ModelHandle<Project>,
+        message: proto::update_view::Variant,
+        cx: &mut ViewContext<Self>,
+    ) -> gpui::Task<anyhow::Result<()>> {
+        self.editor.update(cx, |editor, cx| {
+            editor.apply_update_proto(project, message, cx)
+        })
+    }
+
+    fn set_leader_peer_id(&mut self, leader_peer_id: Option<PeerId>, cx: &mut ViewContext<Self>) {
+        self.editor.update(cx, |editor, cx| {
+            editor.set_leader_peer_id(leader_peer_id, cx)
+        })
+    }
+
+    fn should_unfollow_on_event(event: &Self::Event, cx: &AppContext) -> bool {
+        Editor::should_unfollow_on_event(event, cx)
+    }
+
+    fn is_project_item(&self, _cx: &AppContext) -> bool {
+        false
+    }
+}
+
+struct ChannelBufferCollaborationHub(ModelHandle<ChannelBuffer>);
+
+impl CollaborationHub for ChannelBufferCollaborationHub {
+    fn collaborators<'a>(&self, cx: &'a AppContext) -> &'a HashMap<PeerId, Collaborator> {
+        self.0.read(cx).collaborators()
+    }
+
+    fn user_participant_indices<'a>(
+        &self,
+        cx: &'a AppContext,
+    ) -> &'a HashMap<u64, ParticipantIndex> {
+        self.0.read(cx).user_store().read(cx).participant_indices()
+    }
+}

crates/collab_ui2/src/chat_panel.rs 🔗

@@ -0,0 +1,983 @@
+use crate::{
+    channel_view::ChannelView, is_channels_feature_enabled, render_avatar, ChatPanelSettings,
+};
+use anyhow::Result;
+use call::ActiveCall;
+use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore};
+use client::Client;
+use collections::HashMap;
+use db::kvp::KEY_VALUE_STORE;
+use editor::Editor;
+use gpui::{
+    actions,
+    elements::*,
+    platform::{CursorStyle, MouseButton},
+    serde_json,
+    views::{ItemType, Select, SelectStyle},
+    AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View,
+    ViewContext, ViewHandle, WeakViewHandle,
+};
+use language::LanguageRegistry;
+use menu::Confirm;
+use message_editor::MessageEditor;
+use project::Fs;
+use rich_text::RichText;
+use serde::{Deserialize, Serialize};
+use settings::SettingsStore;
+use std::sync::Arc;
+use theme::{IconButton, Theme};
+use time::{OffsetDateTime, UtcOffset};
+use util::{ResultExt, TryFutureExt};
+use workspace::{
+    dock::{DockPosition, Panel},
+    Workspace,
+};
+
+mod message_editor;
+
+const MESSAGE_LOADING_THRESHOLD: usize = 50;
+const CHAT_PANEL_KEY: &'static str = "ChatPanel";
+
+pub struct ChatPanel {
+    client: Arc<Client>,
+    channel_store: ModelHandle<ChannelStore>,
+    languages: Arc<LanguageRegistry>,
+    active_chat: Option<(ModelHandle<ChannelChat>, Subscription)>,
+    message_list: ListState<ChatPanel>,
+    input_editor: ViewHandle<MessageEditor>,
+    channel_select: ViewHandle<Select>,
+    local_timezone: UtcOffset,
+    fs: Arc<dyn Fs>,
+    width: Option<f32>,
+    active: bool,
+    pending_serialization: Task<Option<()>>,
+    subscriptions: Vec<gpui::Subscription>,
+    workspace: WeakViewHandle<Workspace>,
+    is_scrolled_to_bottom: bool,
+    has_focus: bool,
+    markdown_data: HashMap<ChannelMessageId, RichText>,
+}
+
+#[derive(Serialize, Deserialize)]
+struct SerializedChatPanel {
+    width: Option<f32>,
+}
+
+#[derive(Debug)]
+pub enum Event {
+    DockPositionChanged,
+    Focus,
+    Dismissed,
+}
+
+actions!(
+    chat_panel,
+    [LoadMoreMessages, ToggleFocus, OpenChannelNotes, JoinCall]
+);
+
+pub fn init(cx: &mut AppContext) {
+    cx.add_action(ChatPanel::send);
+    cx.add_action(ChatPanel::load_more_messages);
+    cx.add_action(ChatPanel::open_notes);
+    cx.add_action(ChatPanel::join_call);
+}
+
+impl ChatPanel {
+    pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
+        let fs = workspace.app_state().fs.clone();
+        let client = workspace.app_state().client.clone();
+        let channel_store = ChannelStore::global(cx);
+        let languages = workspace.app_state().languages.clone();
+
+        let input_editor = cx.add_view(|cx| {
+            MessageEditor::new(
+                languages.clone(),
+                channel_store.clone(),
+                cx.add_view(|cx| {
+                    Editor::auto_height(
+                        4,
+                        Some(Arc::new(|theme| theme.chat_panel.input_editor.clone())),
+                        cx,
+                    )
+                }),
+                cx,
+            )
+        });
+
+        let workspace_handle = workspace.weak_handle();
+
+        let channel_select = cx.add_view(|cx| {
+            let channel_store = channel_store.clone();
+            let workspace = workspace_handle.clone();
+            Select::new(0, cx, {
+                move |ix, item_type, is_hovered, cx| {
+                    Self::render_channel_name(
+                        &channel_store,
+                        ix,
+                        item_type,
+                        is_hovered,
+                        workspace,
+                        cx,
+                    )
+                }
+            })
+            .with_style(move |cx| {
+                let style = &theme::current(cx).chat_panel.channel_select;
+                SelectStyle {
+                    header: Default::default(),
+                    menu: style.menu,
+                }
+            })
+        });
+
+        let mut message_list =
+            ListState::<Self>::new(0, Orientation::Bottom, 10., move |this, ix, cx| {
+                this.render_message(ix, cx)
+            });
+        message_list.set_scroll_handler(|visible_range, count, this, cx| {
+            if visible_range.start < MESSAGE_LOADING_THRESHOLD {
+                this.load_more_messages(&LoadMoreMessages, cx);
+            }
+            this.is_scrolled_to_bottom = visible_range.end == count;
+        });
+
+        cx.add_view(|cx| {
+            let mut this = Self {
+                fs,
+                client,
+                channel_store,
+                languages,
+                active_chat: Default::default(),
+                pending_serialization: Task::ready(None),
+                message_list,
+                input_editor,
+                channel_select,
+                local_timezone: cx.platform().local_timezone(),
+                has_focus: false,
+                subscriptions: Vec::new(),
+                workspace: workspace_handle,
+                is_scrolled_to_bottom: true,
+                active: false,
+                width: None,
+                markdown_data: Default::default(),
+            };
+
+            let mut old_dock_position = this.position(cx);
+            this.subscriptions
+                .push(
+                    cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
+                        let new_dock_position = this.position(cx);
+                        if new_dock_position != old_dock_position {
+                            old_dock_position = new_dock_position;
+                            cx.emit(Event::DockPositionChanged);
+                        }
+                        cx.notify();
+                    }),
+                );
+
+            this.update_channel_count(cx);
+            cx.observe(&this.channel_store, |this, _, cx| {
+                this.update_channel_count(cx)
+            })
+            .detach();
+
+            cx.observe(&this.channel_select, |this, channel_select, cx| {
+                let selected_ix = channel_select.read(cx).selected_index();
+
+                let selected_channel_id = this
+                    .channel_store
+                    .read(cx)
+                    .channel_at(selected_ix)
+                    .map(|e| e.id);
+                if let Some(selected_channel_id) = selected_channel_id {
+                    this.select_channel(selected_channel_id, None, cx)
+                        .detach_and_log_err(cx);
+                }
+            })
+            .detach();
+
+            this
+        })
+    }
+
+    pub fn is_scrolled_to_bottom(&self) -> bool {
+        self.is_scrolled_to_bottom
+    }
+
+    pub fn active_chat(&self) -> Option<ModelHandle<ChannelChat>> {
+        self.active_chat.as_ref().map(|(chat, _)| chat.clone())
+    }
+
+    pub fn load(
+        workspace: WeakViewHandle<Workspace>,
+        cx: AsyncAppContext,
+    ) -> Task<Result<ViewHandle<Self>>> {
+        cx.spawn(|mut cx| async move {
+            let serialized_panel = if let Some(panel) = cx
+                .background()
+                .spawn(async move { KEY_VALUE_STORE.read_kvp(CHAT_PANEL_KEY) })
+                .await
+                .log_err()
+                .flatten()
+            {
+                Some(serde_json::from_str::<SerializedChatPanel>(&panel)?)
+            } else {
+                None
+            };
+
+            workspace.update(&mut cx, |workspace, cx| {
+                let panel = Self::new(workspace, cx);
+                if let Some(serialized_panel) = serialized_panel {
+                    panel.update(cx, |panel, cx| {
+                        panel.width = serialized_panel.width;
+                        cx.notify();
+                    });
+                }
+                panel
+            })
+        })
+    }
+
+    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
+        let width = self.width;
+        self.pending_serialization = cx.background().spawn(
+            async move {
+                KEY_VALUE_STORE
+                    .write_kvp(
+                        CHAT_PANEL_KEY.into(),
+                        serde_json::to_string(&SerializedChatPanel { width })?,
+                    )
+                    .await?;
+                anyhow::Ok(())
+            }
+            .log_err(),
+        );
+    }
+
+    fn update_channel_count(&mut self, cx: &mut ViewContext<Self>) {
+        let channel_count = self.channel_store.read(cx).channel_count();
+        self.channel_select.update(cx, |select, cx| {
+            select.set_item_count(channel_count, cx);
+        });
+    }
+
+    fn set_active_chat(&mut self, chat: ModelHandle<ChannelChat>, cx: &mut ViewContext<Self>) {
+        if self.active_chat.as_ref().map(|e| &e.0) != Some(&chat) {
+            let channel_id = chat.read(cx).channel_id;
+            {
+                self.markdown_data.clear();
+                let chat = chat.read(cx);
+                self.message_list.reset(chat.message_count());
+
+                let channel_name = chat.channel(cx).map(|channel| channel.name.clone());
+                self.input_editor.update(cx, |editor, cx| {
+                    editor.set_channel(channel_id, channel_name, cx);
+                });
+            };
+            let subscription = cx.subscribe(&chat, Self::channel_did_change);
+            self.active_chat = Some((chat, subscription));
+            self.acknowledge_last_message(cx);
+            self.channel_select.update(cx, |select, cx| {
+                if let Some(ix) = self.channel_store.read(cx).index_of_channel(channel_id) {
+                    select.set_selected_index(ix, cx);
+                }
+            });
+            cx.notify();
+        }
+    }
+
+    fn channel_did_change(
+        &mut self,
+        _: ModelHandle<ChannelChat>,
+        event: &ChannelChatEvent,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match event {
+            ChannelChatEvent::MessagesUpdated {
+                old_range,
+                new_count,
+            } => {
+                self.message_list.splice(old_range.clone(), *new_count);
+                if self.active {
+                    self.acknowledge_last_message(cx);
+                }
+            }
+            ChannelChatEvent::NewMessage {
+                channel_id,
+                message_id,
+            } => {
+                if !self.active {
+                    self.channel_store.update(cx, |store, cx| {
+                        store.new_message(*channel_id, *message_id, cx)
+                    })
+                }
+            }
+        }
+        cx.notify();
+    }
+
+    fn acknowledge_last_message(&mut self, cx: &mut ViewContext<'_, '_, ChatPanel>) {
+        if self.active && self.is_scrolled_to_bottom {
+            if let Some((chat, _)) = &self.active_chat {
+                chat.update(cx, |chat, cx| {
+                    chat.acknowledge_last_message(cx);
+                });
+            }
+        }
+    }
+
+    fn render_channel(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let theme = theme::current(cx);
+        Flex::column()
+            .with_child(
+                ChildView::new(&self.channel_select, cx)
+                    .contained()
+                    .with_style(theme.chat_panel.channel_select.container),
+            )
+            .with_child(self.render_active_channel_messages(&theme))
+            .with_child(self.render_input_box(&theme, cx))
+            .into_any()
+    }
+
+    fn render_active_channel_messages(&self, theme: &Arc<Theme>) -> AnyElement<Self> {
+        let messages = if self.active_chat.is_some() {
+            List::new(self.message_list.clone())
+                .contained()
+                .with_style(theme.chat_panel.list)
+                .into_any()
+        } else {
+            Empty::new().into_any()
+        };
+
+        messages.flex(1., true).into_any()
+    }
+
+    fn render_message(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let (message, is_continuation, is_last, is_admin) = self
+            .active_chat
+            .as_ref()
+            .unwrap()
+            .0
+            .update(cx, |active_chat, cx| {
+                let is_admin = self
+                    .channel_store
+                    .read(cx)
+                    .is_channel_admin(active_chat.channel_id);
+
+                let last_message = active_chat.message(ix.saturating_sub(1));
+                let this_message = active_chat.message(ix).clone();
+                let is_continuation = last_message.id != this_message.id
+                    && this_message.sender.id == last_message.sender.id;
+
+                if let ChannelMessageId::Saved(id) = this_message.id {
+                    if this_message
+                        .mentions
+                        .iter()
+                        .any(|(_, user_id)| Some(*user_id) == self.client.user_id())
+                    {
+                        active_chat.acknowledge_message(id);
+                    }
+                }
+
+                (
+                    this_message,
+                    is_continuation,
+                    active_chat.message_count() == ix + 1,
+                    is_admin,
+                )
+            });
+
+        let is_pending = message.is_pending();
+        let theme = theme::current(cx);
+        let text = self.markdown_data.entry(message.id).or_insert_with(|| {
+            Self::render_markdown_with_mentions(&self.languages, self.client.id(), &message)
+        });
+
+        let now = OffsetDateTime::now_utc();
+
+        let style = if is_pending {
+            &theme.chat_panel.pending_message
+        } else if is_continuation {
+            &theme.chat_panel.continuation_message
+        } else {
+            &theme.chat_panel.message
+        };
+
+        let belongs_to_user = Some(message.sender.id) == self.client.user_id();
+        let message_id_to_remove = if let (ChannelMessageId::Saved(id), true) =
+            (message.id, belongs_to_user || is_admin)
+        {
+            Some(id)
+        } else {
+            None
+        };
+
+        enum MessageBackgroundHighlight {}
+        MouseEventHandler::new::<MessageBackgroundHighlight, _>(ix, cx, |state, cx| {
+            let container = style.style_for(state);
+            if is_continuation {
+                Flex::row()
+                    .with_child(
+                        text.element(
+                            theme.editor.syntax.clone(),
+                            theme.chat_panel.rich_text.clone(),
+                            cx,
+                        )
+                        .flex(1., true),
+                    )
+                    .with_child(render_remove(message_id_to_remove, cx, &theme))
+                    .contained()
+                    .with_style(*container)
+                    .with_margin_bottom(if is_last {
+                        theme.chat_panel.last_message_bottom_spacing
+                    } else {
+                        0.
+                    })
+                    .into_any()
+            } else {
+                Flex::column()
+                    .with_child(
+                        Flex::row()
+                            .with_child(
+                                Flex::row()
+                                    .with_child(render_avatar(
+                                        message.sender.avatar.clone(),
+                                        &theme.chat_panel.avatar,
+                                        theme.chat_panel.avatar_container,
+                                    ))
+                                    .with_child(
+                                        Label::new(
+                                            message.sender.github_login.clone(),
+                                            theme.chat_panel.message_sender.text.clone(),
+                                        )
+                                        .contained()
+                                        .with_style(theme.chat_panel.message_sender.container),
+                                    )
+                                    .with_child(
+                                        Label::new(
+                                            format_timestamp(
+                                                message.timestamp,
+                                                now,
+                                                self.local_timezone,
+                                            ),
+                                            theme.chat_panel.message_timestamp.text.clone(),
+                                        )
+                                        .contained()
+                                        .with_style(theme.chat_panel.message_timestamp.container),
+                                    )
+                                    .align_children_center()
+                                    .flex(1., true),
+                            )
+                            .with_child(render_remove(message_id_to_remove, cx, &theme))
+                            .align_children_center(),
+                    )
+                    .with_child(
+                        Flex::row()
+                            .with_child(
+                                text.element(
+                                    theme.editor.syntax.clone(),
+                                    theme.chat_panel.rich_text.clone(),
+                                    cx,
+                                )
+                                .flex(1., true),
+                            )
+                            // Add a spacer to make everything line up
+                            .with_child(render_remove(None, cx, &theme)),
+                    )
+                    .contained()
+                    .with_style(*container)
+                    .with_margin_bottom(if is_last {
+                        theme.chat_panel.last_message_bottom_spacing
+                    } else {
+                        0.
+                    })
+                    .into_any()
+            }
+        })
+        .into_any()
+    }
+
+    fn render_markdown_with_mentions(
+        language_registry: &Arc<LanguageRegistry>,
+        current_user_id: u64,
+        message: &channel::ChannelMessage,
+    ) -> RichText {
+        let mentions = message
+            .mentions
+            .iter()
+            .map(|(range, user_id)| rich_text::Mention {
+                range: range.clone(),
+                is_self_mention: *user_id == current_user_id,
+            })
+            .collect::<Vec<_>>();
+
+        rich_text::render_markdown(message.body.clone(), &mentions, language_registry, None)
+    }
+
+    fn render_input_box(&self, theme: &Arc<Theme>, cx: &AppContext) -> AnyElement<Self> {
+        ChildView::new(&self.input_editor, cx)
+            .contained()
+            .with_style(theme.chat_panel.input_editor.container)
+            .into_any()
+    }
+
+    fn render_channel_name(
+        channel_store: &ModelHandle<ChannelStore>,
+        ix: usize,
+        item_type: ItemType,
+        is_hovered: bool,
+        workspace: WeakViewHandle<Workspace>,
+        cx: &mut ViewContext<Select>,
+    ) -> AnyElement<Select> {
+        let theme = theme::current(cx);
+        let tooltip_style = &theme.tooltip;
+        let theme = &theme.chat_panel;
+        let style = match (&item_type, is_hovered) {
+            (ItemType::Header, _) => &theme.channel_select.header,
+            (ItemType::Selected, _) => &theme.channel_select.active_item,
+            (ItemType::Unselected, false) => &theme.channel_select.item,
+            (ItemType::Unselected, true) => &theme.channel_select.hovered_item,
+        };
+
+        let channel = &channel_store.read(cx).channel_at(ix).unwrap();
+        let channel_id = channel.id;
+
+        let mut row = Flex::row()
+            .with_child(
+                Label::new("#".to_string(), style.hash.text.clone())
+                    .contained()
+                    .with_style(style.hash.container),
+            )
+            .with_child(Label::new(channel.name.clone(), style.name.clone()));
+
+        if matches!(item_type, ItemType::Header) {
+            row.add_children([
+                MouseEventHandler::new::<OpenChannelNotes, _>(0, cx, |mouse_state, _| {
+                    render_icon_button(theme.icon_button.style_for(mouse_state), "icons/file.svg")
+                })
+                .on_click(MouseButton::Left, move |_, _, cx| {
+                    if let Some(workspace) = workspace.upgrade(cx) {
+                        ChannelView::open(channel_id, workspace, cx).detach();
+                    }
+                })
+                .with_tooltip::<OpenChannelNotes>(
+                    channel_id as usize,
+                    "Open Notes",
+                    Some(Box::new(OpenChannelNotes)),
+                    tooltip_style.clone(),
+                    cx,
+                )
+                .flex_float(),
+                MouseEventHandler::new::<ActiveCall, _>(0, cx, |mouse_state, _| {
+                    render_icon_button(
+                        theme.icon_button.style_for(mouse_state),
+                        "icons/speaker-loud.svg",
+                    )
+                })
+                .on_click(MouseButton::Left, move |_, _, cx| {
+                    ActiveCall::global(cx)
+                        .update(cx, |call, cx| call.join_channel(channel_id, cx))
+                        .detach_and_log_err(cx);
+                })
+                .with_tooltip::<ActiveCall>(
+                    channel_id as usize,
+                    "Join Call",
+                    Some(Box::new(JoinCall)),
+                    tooltip_style.clone(),
+                    cx,
+                )
+                .flex_float(),
+            ]);
+        }
+
+        row.align_children_center()
+            .contained()
+            .with_style(style.container)
+            .into_any()
+    }
+
+    fn render_sign_in_prompt(
+        &self,
+        theme: &Arc<Theme>,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum SignInPromptLabel {}
+
+        MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
+            Label::new(
+                "Sign in to use chat".to_string(),
+                theme
+                    .chat_panel
+                    .sign_in_prompt
+                    .style_for(mouse_state)
+                    .clone(),
+            )
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, this, cx| {
+            let client = this.client.clone();
+            cx.spawn(|this, mut cx| async move {
+                if client
+                    .authenticate_and_connect(true, &cx)
+                    .log_err()
+                    .await
+                    .is_some()
+                {
+                    this.update(&mut cx, |this, cx| {
+                        if cx.handle().is_focused(cx) {
+                            cx.focus(&this.input_editor);
+                        }
+                    })
+                    .ok();
+                }
+            })
+            .detach();
+        })
+        .aligned()
+        .into_any()
+    }
+
+    fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
+        if let Some((chat, _)) = self.active_chat.as_ref() {
+            let message = self
+                .input_editor
+                .update(cx, |editor, cx| editor.take_message(cx));
+
+            if let Some(task) = chat
+                .update(cx, |chat, cx| chat.send_message(message, cx))
+                .log_err()
+            {
+                task.detach();
+            }
+        }
+    }
+
+    fn remove_message(&mut self, id: u64, cx: &mut ViewContext<Self>) {
+        if let Some((chat, _)) = self.active_chat.as_ref() {
+            chat.update(cx, |chat, cx| chat.remove_message(id, cx).detach())
+        }
+    }
+
+    fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext<Self>) {
+        if let Some((chat, _)) = self.active_chat.as_ref() {
+            chat.update(cx, |channel, cx| {
+                if let Some(task) = channel.load_more_messages(cx) {
+                    task.detach();
+                }
+            })
+        }
+    }
+
+    pub fn select_channel(
+        &mut self,
+        selected_channel_id: u64,
+        scroll_to_message_id: Option<u64>,
+        cx: &mut ViewContext<ChatPanel>,
+    ) -> Task<Result<()>> {
+        let open_chat = self
+            .active_chat
+            .as_ref()
+            .and_then(|(chat, _)| {
+                (chat.read(cx).channel_id == selected_channel_id)
+                    .then(|| Task::ready(anyhow::Ok(chat.clone())))
+            })
+            .unwrap_or_else(|| {
+                self.channel_store.update(cx, |store, cx| {
+                    store.open_channel_chat(selected_channel_id, cx)
+                })
+            });
+
+        cx.spawn(|this, mut cx| async move {
+            let chat = open_chat.await?;
+            this.update(&mut cx, |this, cx| {
+                this.set_active_chat(chat.clone(), cx);
+            })?;
+
+            if let Some(message_id) = scroll_to_message_id {
+                if let Some(item_ix) =
+                    ChannelChat::load_history_since_message(chat.clone(), message_id, cx.clone())
+                        .await
+                {
+                    this.update(&mut cx, |this, cx| {
+                        if this.active_chat.as_ref().map_or(false, |(c, _)| *c == chat) {
+                            this.message_list.scroll_to(ListOffset {
+                                item_ix,
+                                offset_in_item: 0.,
+                            });
+                            cx.notify();
+                        }
+                    })?;
+                }
+            }
+
+            Ok(())
+        })
+    }
+
+    fn open_notes(&mut self, _: &OpenChannelNotes, cx: &mut ViewContext<Self>) {
+        if let Some((chat, _)) = &self.active_chat {
+            let channel_id = chat.read(cx).channel_id;
+            if let Some(workspace) = self.workspace.upgrade(cx) {
+                ChannelView::open(channel_id, workspace, cx).detach();
+            }
+        }
+    }
+
+    fn join_call(&mut self, _: &JoinCall, cx: &mut ViewContext<Self>) {
+        if let Some((chat, _)) = &self.active_chat {
+            let channel_id = chat.read(cx).channel_id;
+            ActiveCall::global(cx)
+                .update(cx, |call, cx| call.join_channel(channel_id, cx))
+                .detach_and_log_err(cx);
+        }
+    }
+}
+
+fn render_remove(
+    message_id_to_remove: Option<u64>,
+    cx: &mut ViewContext<'_, '_, ChatPanel>,
+    theme: &Arc<Theme>,
+) -> AnyElement<ChatPanel> {
+    enum DeleteMessage {}
+
+    message_id_to_remove
+        .map(|id| {
+            MouseEventHandler::new::<DeleteMessage, _>(id as usize, cx, |mouse_state, _| {
+                let button_style = theme.chat_panel.icon_button.style_for(mouse_state);
+                render_icon_button(button_style, "icons/x.svg")
+                    .aligned()
+                    .into_any()
+            })
+            .with_padding(Padding::uniform(2.))
+            .with_cursor_style(CursorStyle::PointingHand)
+            .on_click(MouseButton::Left, move |_, this, cx| {
+                this.remove_message(id, cx);
+            })
+            .flex_float()
+            .into_any()
+        })
+        .unwrap_or_else(|| {
+            let style = theme.chat_panel.icon_button.default;
+
+            Empty::new()
+                .constrained()
+                .with_width(style.icon_width)
+                .aligned()
+                .constrained()
+                .with_width(style.button_width)
+                .with_height(style.button_width)
+                .contained()
+                .with_uniform_padding(2.)
+                .flex_float()
+                .into_any()
+        })
+}
+
+impl Entity for ChatPanel {
+    type Event = Event;
+}
+
+impl View for ChatPanel {
+    fn ui_name() -> &'static str {
+        "ChatPanel"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let theme = theme::current(cx);
+        let element = if self.client.user_id().is_some() {
+            self.render_channel(cx)
+        } else {
+            self.render_sign_in_prompt(&theme, cx)
+        };
+        element
+            .contained()
+            .with_style(theme.chat_panel.container)
+            .constrained()
+            .with_min_width(150.)
+            .into_any()
+    }
+
+    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
+        self.has_focus = true;
+        if matches!(
+            *self.client.status().borrow(),
+            client::Status::Connected { .. }
+        ) {
+            let editor = self.input_editor.read(cx).editor.clone();
+            cx.focus(&editor);
+        }
+    }
+
+    fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
+        self.has_focus = false;
+    }
+}
+
+impl Panel for ChatPanel {
+    fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
+        settings::get::<ChatPanelSettings>(cx).dock
+    }
+
+    fn position_is_valid(&self, position: DockPosition) -> bool {
+        matches!(position, DockPosition::Left | DockPosition::Right)
+    }
+
+    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
+        settings::update_settings_file::<ChatPanelSettings>(self.fs.clone(), cx, move |settings| {
+            settings.dock = Some(position)
+        });
+    }
+
+    fn size(&self, cx: &gpui::WindowContext) -> f32 {
+        self.width
+            .unwrap_or_else(|| settings::get::<ChatPanelSettings>(cx).default_width)
+    }
+
+    fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
+        self.width = size;
+        self.serialize(cx);
+        cx.notify();
+    }
+
+    fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
+        self.active = active;
+        if active {
+            self.acknowledge_last_message(cx);
+            if !is_channels_feature_enabled(cx) {
+                cx.emit(Event::Dismissed);
+            }
+        }
+    }
+
+    fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
+        (settings::get::<ChatPanelSettings>(cx).button && is_channels_feature_enabled(cx))
+            .then(|| "icons/conversations.svg")
+    }
+
+    fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
+        ("Chat Panel".to_string(), Some(Box::new(ToggleFocus)))
+    }
+
+    fn should_change_position_on_event(event: &Self::Event) -> bool {
+        matches!(event, Event::DockPositionChanged)
+    }
+
+    fn should_close_on_event(event: &Self::Event) -> bool {
+        matches!(event, Event::Dismissed)
+    }
+
+    fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
+        self.has_focus
+    }
+
+    fn is_focus_event(event: &Self::Event) -> bool {
+        matches!(event, Event::Focus)
+    }
+}
+
+fn format_timestamp(
+    mut timestamp: OffsetDateTime,
+    mut now: OffsetDateTime,
+    local_timezone: UtcOffset,
+) -> String {
+    timestamp = timestamp.to_offset(local_timezone);
+    now = now.to_offset(local_timezone);
+
+    let today = now.date();
+    let date = timestamp.date();
+    let mut hour = timestamp.hour();
+    let mut part = "am";
+    if hour > 12 {
+        hour -= 12;
+        part = "pm";
+    }
+    if date == today {
+        format!("{:02}:{:02}{}", hour, timestamp.minute(), part)
+    } else if date.next_day() == Some(today) {
+        format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part)
+    } else {
+        format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
+    }
+}
+
+fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> impl Element<V> {
+    Svg::new(svg_path)
+        .with_color(style.color)
+        .constrained()
+        .with_width(style.icon_width)
+        .aligned()
+        .constrained()
+        .with_width(style.button_width)
+        .with_height(style.button_width)
+        .contained()
+        .with_style(style.container)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use gpui::fonts::HighlightStyle;
+    use pretty_assertions::assert_eq;
+    use rich_text::{BackgroundKind, Highlight, RenderedRegion};
+    use util::test::marked_text_ranges;
+
+    #[gpui::test]
+    fn test_render_markdown_with_mentions() {
+        let language_registry = Arc::new(LanguageRegistry::test());
+        let (body, ranges) = marked_text_ranges("*hi*, «@abc», let's **call** «@fgh»", false);
+        let message = channel::ChannelMessage {
+            id: ChannelMessageId::Saved(0),
+            body,
+            timestamp: OffsetDateTime::now_utc(),
+            sender: Arc::new(client::User {
+                github_login: "fgh".into(),
+                avatar: None,
+                id: 103,
+            }),
+            nonce: 5,
+            mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
+        };
+
+        let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message);
+
+        // Note that the "'" was replaced with ’ due to smart punctuation.
+        let (body, ranges) = marked_text_ranges("«hi», «@abc», let’s «call» «@fgh»", false);
+        assert_eq!(message.text, body);
+        assert_eq!(
+            message.highlights,
+            vec![
+                (
+                    ranges[0].clone(),
+                    HighlightStyle {
+                        italic: Some(true),
+                        ..Default::default()
+                    }
+                    .into()
+                ),
+                (ranges[1].clone(), Highlight::Mention),
+                (
+                    ranges[2].clone(),
+                    HighlightStyle {
+                        weight: Some(gpui::fonts::Weight::BOLD),
+                        ..Default::default()
+                    }
+                    .into()
+                ),
+                (ranges[3].clone(), Highlight::SelfMention)
+            ]
+        );
+        assert_eq!(
+            message.regions,
+            vec![
+                RenderedRegion {
+                    background_kind: Some(BackgroundKind::Mention),
+                    link_url: None
+                },
+                RenderedRegion {
+                    background_kind: Some(BackgroundKind::SelfMention),
+                    link_url: None
+                },
+            ]
+        );
+    }
+}

crates/collab_ui2/src/chat_panel/message_editor.rs 🔗

@@ -0,0 +1,313 @@
+use channel::{ChannelId, ChannelMembership, ChannelStore, MessageParams};
+use client::UserId;
+use collections::HashMap;
+use editor::{AnchorRangeExt, Editor};
+use gpui::{
+    elements::ChildView, AnyElement, AsyncAppContext, Element, Entity, ModelHandle, Task, View,
+    ViewContext, ViewHandle, WeakViewHandle,
+};
+use language::{language_settings::SoftWrap, Buffer, BufferSnapshot, LanguageRegistry};
+use lazy_static::lazy_static;
+use project::search::SearchQuery;
+use std::{sync::Arc, time::Duration};
+
+const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
+
+lazy_static! {
+    static ref MENTIONS_SEARCH: SearchQuery = SearchQuery::regex(
+        "@[-_\\w]+",
+        false,
+        false,
+        Default::default(),
+        Default::default()
+    )
+    .unwrap();
+}
+
+pub struct MessageEditor {
+    pub editor: ViewHandle<Editor>,
+    channel_store: ModelHandle<ChannelStore>,
+    users: HashMap<String, UserId>,
+    mentions: Vec<UserId>,
+    mentions_task: Option<Task<()>>,
+    channel_id: Option<ChannelId>,
+}
+
+impl MessageEditor {
+    pub fn new(
+        language_registry: Arc<LanguageRegistry>,
+        channel_store: ModelHandle<ChannelStore>,
+        editor: ViewHandle<Editor>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        editor.update(cx, |editor, cx| {
+            editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
+        });
+
+        let buffer = editor
+            .read(cx)
+            .buffer()
+            .read(cx)
+            .as_singleton()
+            .expect("message editor must be singleton");
+
+        cx.subscribe(&buffer, Self::on_buffer_event).detach();
+
+        let markdown = language_registry.language_for_name("Markdown");
+        cx.app_context()
+            .spawn(|mut cx| async move {
+                let markdown = markdown.await?;
+                buffer.update(&mut cx, |buffer, cx| {
+                    buffer.set_language(Some(markdown), cx)
+                });
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
+
+        Self {
+            editor,
+            channel_store,
+            users: HashMap::default(),
+            channel_id: None,
+            mentions: Vec::new(),
+            mentions_task: None,
+        }
+    }
+
+    pub fn set_channel(
+        &mut self,
+        channel_id: u64,
+        channel_name: Option<String>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.editor.update(cx, |editor, cx| {
+            if let Some(channel_name) = channel_name {
+                editor.set_placeholder_text(format!("Message #{}", channel_name), cx);
+            } else {
+                editor.set_placeholder_text(format!("Message Channel"), cx);
+            }
+        });
+        self.channel_id = Some(channel_id);
+        self.refresh_users(cx);
+    }
+
+    pub fn refresh_users(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(channel_id) = self.channel_id {
+            let members = self.channel_store.update(cx, |store, cx| {
+                store.get_channel_member_details(channel_id, cx)
+            });
+            cx.spawn(|this, mut cx| async move {
+                let members = members.await?;
+                this.update(&mut cx, |this, cx| this.set_members(members, cx))?;
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
+        }
+    }
+
+    pub fn set_members(&mut self, members: Vec<ChannelMembership>, _: &mut ViewContext<Self>) {
+        self.users.clear();
+        self.users.extend(
+            members
+                .into_iter()
+                .map(|member| (member.user.github_login.clone(), member.user.id)),
+        );
+    }
+
+    pub fn take_message(&mut self, cx: &mut ViewContext<Self>) -> MessageParams {
+        self.editor.update(cx, |editor, cx| {
+            let highlights = editor.text_highlights::<Self>(cx);
+            let text = editor.text(cx);
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            let mentions = if let Some((_, ranges)) = highlights {
+                ranges
+                    .iter()
+                    .map(|range| range.to_offset(&snapshot))
+                    .zip(self.mentions.iter().copied())
+                    .collect()
+            } else {
+                Vec::new()
+            };
+
+            editor.clear(cx);
+            self.mentions.clear();
+
+            MessageParams { text, mentions }
+        })
+    }
+
+    fn on_buffer_event(
+        &mut self,
+        buffer: ModelHandle<Buffer>,
+        event: &language::Event,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if let language::Event::Reparsed | language::Event::Edited = event {
+            let buffer = buffer.read(cx).snapshot();
+            self.mentions_task = Some(cx.spawn(|this, cx| async move {
+                cx.background().timer(MENTIONS_DEBOUNCE_INTERVAL).await;
+                Self::find_mentions(this, buffer, cx).await;
+            }));
+        }
+    }
+
+    async fn find_mentions(
+        this: WeakViewHandle<MessageEditor>,
+        buffer: BufferSnapshot,
+        mut cx: AsyncAppContext,
+    ) {
+        let (buffer, ranges) = cx
+            .background()
+            .spawn(async move {
+                let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
+                (buffer, ranges)
+            })
+            .await;
+
+        this.update(&mut cx, |this, cx| {
+            let mut anchor_ranges = Vec::new();
+            let mut mentioned_user_ids = Vec::new();
+            let mut text = String::new();
+
+            this.editor.update(cx, |editor, cx| {
+                let multi_buffer = editor.buffer().read(cx).snapshot(cx);
+                for range in ranges {
+                    text.clear();
+                    text.extend(buffer.text_for_range(range.clone()));
+                    if let Some(username) = text.strip_prefix("@") {
+                        if let Some(user_id) = this.users.get(username) {
+                            let start = multi_buffer.anchor_after(range.start);
+                            let end = multi_buffer.anchor_after(range.end);
+
+                            mentioned_user_ids.push(*user_id);
+                            anchor_ranges.push(start..end);
+                        }
+                    }
+                }
+
+                editor.clear_highlights::<Self>(cx);
+                editor.highlight_text::<Self>(
+                    anchor_ranges,
+                    theme::current(cx).chat_panel.rich_text.mention_highlight,
+                    cx,
+                )
+            });
+
+            this.mentions = mentioned_user_ids;
+            this.mentions_task.take();
+        })
+        .ok();
+    }
+}
+
+impl Entity for MessageEditor {
+    type Event = ();
+}
+
+impl View for MessageEditor {
+    fn render(&mut self, cx: &mut ViewContext<'_, '_, Self>) -> AnyElement<Self> {
+        ChildView::new(&self.editor, cx).into_any()
+    }
+
+    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if cx.is_self_focused() {
+            cx.focus(&self.editor);
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use client::{Client, User, UserStore};
+    use gpui::{TestAppContext, WindowHandle};
+    use language::{Language, LanguageConfig};
+    use rpc::proto;
+    use settings::SettingsStore;
+    use util::{http::FakeHttpClient, test::marked_text_ranges};
+
+    #[gpui::test]
+    async fn test_message_editor(cx: &mut TestAppContext) {
+        let editor = init_test(cx);
+        let editor = editor.root(cx);
+
+        editor.update(cx, |editor, cx| {
+            editor.set_members(
+                vec![
+                    ChannelMembership {
+                        user: Arc::new(User {
+                            github_login: "a-b".into(),
+                            id: 101,
+                            avatar: None,
+                        }),
+                        kind: proto::channel_member::Kind::Member,
+                        role: proto::ChannelRole::Member,
+                    },
+                    ChannelMembership {
+                        user: Arc::new(User {
+                            github_login: "C_D".into(),
+                            id: 102,
+                            avatar: None,
+                        }),
+                        kind: proto::channel_member::Kind::Member,
+                        role: proto::ChannelRole::Member,
+                    },
+                ],
+                cx,
+            );
+
+            editor.editor.update(cx, |editor, cx| {
+                editor.set_text("Hello, @a-b! Have you met @C_D?", cx)
+            });
+        });
+
+        cx.foreground().advance_clock(MENTIONS_DEBOUNCE_INTERVAL);
+
+        editor.update(cx, |editor, cx| {
+            let (text, ranges) = marked_text_ranges("Hello, «@a-b»! Have you met «@C_D»?", false);
+            assert_eq!(
+                editor.take_message(cx),
+                MessageParams {
+                    text,
+                    mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
+                }
+            );
+        });
+    }
+
+    fn init_test(cx: &mut TestAppContext) -> WindowHandle<MessageEditor> {
+        cx.foreground().forbid_parking();
+
+        cx.update(|cx| {
+            let http = FakeHttpClient::with_404_response();
+            let client = Client::new(http.clone(), cx);
+            let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
+            cx.set_global(SettingsStore::test(cx));
+            theme::init((), cx);
+            language::init(cx);
+            editor::init(cx);
+            client::init(&client, cx);
+            channel::init(&client, user_store, cx);
+        });
+
+        let language_registry = Arc::new(LanguageRegistry::test());
+        language_registry.add(Arc::new(Language::new(
+            LanguageConfig {
+                name: "Markdown".into(),
+                ..Default::default()
+            },
+            Some(tree_sitter_markdown::language()),
+        )));
+
+        let editor = cx.add_window(|cx| {
+            MessageEditor::new(
+                language_registry,
+                ChannelStore::global(cx),
+                cx.add_view(|cx| Editor::auto_height(4, None, cx)),
+                cx,
+            )
+        });
+        cx.foreground().run_until_parked();
+        editor
+    }
+}

crates/collab_ui2/src/collab_panel.rs 🔗

@@ -0,0 +1,3548 @@
+mod channel_modal;
+mod contact_finder;
+
+use crate::{
+    channel_view::{self, ChannelView},
+    chat_panel::ChatPanel,
+    face_pile::FacePile,
+    panel_settings, CollaborationPanelSettings,
+};
+use anyhow::Result;
+use call::ActiveCall;
+use channel::{Channel, ChannelEvent, ChannelId, ChannelStore};
+use channel_modal::ChannelModal;
+use client::{
+    proto::{self, PeerId},
+    Client, Contact, User, UserStore,
+};
+use contact_finder::ContactFinder;
+use context_menu::{ContextMenu, ContextMenuItem};
+use db::kvp::KEY_VALUE_STORE;
+use drag_and_drop::{DragAndDrop, Draggable};
+use editor::{Cancel, Editor};
+use feature_flags::{ChannelsAlpha, FeatureFlagAppExt, FeatureFlagViewExt};
+use futures::StreamExt;
+use fuzzy::{match_strings, StringMatchCandidate};
+use gpui::{
+    actions,
+    elements::{
+        Canvas, ChildView, Component, ContainerStyle, Empty, Flex, Image, Label, List, ListOffset,
+        ListState, MouseEventHandler, Orientation, OverlayPositionMode, Padding, ParentElement,
+        SafeStylable, Stack, Svg,
+    },
+    fonts::TextStyle,
+    geometry::{
+        rect::RectF,
+        vector::{vec2f, Vector2F},
+    },
+    impl_actions,
+    platform::{CursorStyle, MouseButton, PromptLevel},
+    serde_json, AnyElement, AppContext, AsyncAppContext, ClipboardItem, Element, Entity, FontCache,
+    ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
+};
+use menu::{Confirm, SelectNext, SelectPrev};
+use project::{Fs, Project};
+use serde_derive::{Deserialize, Serialize};
+use settings::SettingsStore;
+use std::{borrow::Cow, hash::Hash, mem, sync::Arc};
+use theme::{components::ComponentExt, IconButton, Interactive};
+use util::{maybe, ResultExt, TryFutureExt};
+use workspace::{
+    dock::{DockPosition, Panel},
+    item::ItemHandle,
+    FollowNextCollaborator, Workspace,
+};
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct ToggleCollapse {
+    location: ChannelId,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct NewChannel {
+    location: ChannelId,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct RenameChannel {
+    channel_id: ChannelId,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct ToggleSelectedIx {
+    ix: usize,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct RemoveChannel {
+    channel_id: ChannelId,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct InviteMembers {
+    channel_id: ChannelId,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct ManageMembers {
+    channel_id: ChannelId,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct OpenChannelNotes {
+    pub channel_id: ChannelId,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct JoinChannelCall {
+    pub channel_id: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct JoinChannelChat {
+    pub channel_id: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct CopyChannelLink {
+    pub channel_id: u64,
+}
+
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct StartMoveChannelFor {
+    channel_id: ChannelId,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct MoveChannel {
+    to: ChannelId,
+}
+
+actions!(
+    collab_panel,
+    [
+        ToggleFocus,
+        Remove,
+        Secondary,
+        CollapseSelectedChannel,
+        ExpandSelectedChannel,
+        StartMoveChannel,
+        MoveSelected,
+        InsertSpace,
+    ]
+);
+
+impl_actions!(
+    collab_panel,
+    [
+        RemoveChannel,
+        NewChannel,
+        InviteMembers,
+        ManageMembers,
+        RenameChannel,
+        ToggleCollapse,
+        OpenChannelNotes,
+        JoinChannelCall,
+        JoinChannelChat,
+        CopyChannelLink,
+        StartMoveChannelFor,
+        MoveChannel,
+        ToggleSelectedIx
+    ]
+);
+
+#[derive(Debug, Copy, Clone, PartialEq, Eq)]
+struct ChannelMoveClipboard {
+    channel_id: ChannelId,
+}
+
+const COLLABORATION_PANEL_KEY: &'static str = "CollaborationPanel";
+
+pub fn init(cx: &mut AppContext) {
+    settings::register::<panel_settings::CollaborationPanelSettings>(cx);
+    contact_finder::init(cx);
+    channel_modal::init(cx);
+    channel_view::init(cx);
+
+    cx.add_action(CollabPanel::cancel);
+    cx.add_action(CollabPanel::select_next);
+    cx.add_action(CollabPanel::select_prev);
+    cx.add_action(CollabPanel::confirm);
+    cx.add_action(CollabPanel::insert_space);
+    cx.add_action(CollabPanel::remove);
+    cx.add_action(CollabPanel::remove_selected_channel);
+    cx.add_action(CollabPanel::show_inline_context_menu);
+    cx.add_action(CollabPanel::new_subchannel);
+    cx.add_action(CollabPanel::invite_members);
+    cx.add_action(CollabPanel::manage_members);
+    cx.add_action(CollabPanel::rename_selected_channel);
+    cx.add_action(CollabPanel::rename_channel);
+    cx.add_action(CollabPanel::toggle_channel_collapsed_action);
+    cx.add_action(CollabPanel::collapse_selected_channel);
+    cx.add_action(CollabPanel::expand_selected_channel);
+    cx.add_action(CollabPanel::open_channel_notes);
+    cx.add_action(CollabPanel::join_channel_chat);
+    cx.add_action(CollabPanel::copy_channel_link);
+
+    cx.add_action(
+        |panel: &mut CollabPanel, action: &ToggleSelectedIx, cx: &mut ViewContext<CollabPanel>| {
+            if panel.selection.take() != Some(action.ix) {
+                panel.selection = Some(action.ix)
+            }
+
+            cx.notify();
+        },
+    );
+
+    cx.add_action(
+        |panel: &mut CollabPanel,
+         action: &StartMoveChannelFor,
+         _: &mut ViewContext<CollabPanel>| {
+            panel.channel_clipboard = Some(ChannelMoveClipboard {
+                channel_id: action.channel_id,
+            });
+        },
+    );
+
+    cx.add_action(
+        |panel: &mut CollabPanel, _: &StartMoveChannel, _: &mut ViewContext<CollabPanel>| {
+            if let Some(channel) = panel.selected_channel() {
+                panel.channel_clipboard = Some(ChannelMoveClipboard {
+                    channel_id: channel.id,
+                })
+            }
+        },
+    );
+
+    cx.add_action(
+        |panel: &mut CollabPanel, _: &MoveSelected, cx: &mut ViewContext<CollabPanel>| {
+            let Some(clipboard) = panel.channel_clipboard.take() else {
+                return;
+            };
+            let Some(selected_channel) = panel.selected_channel() else {
+                return;
+            };
+
+            panel
+                .channel_store
+                .update(cx, |channel_store, cx| {
+                    channel_store.move_channel(clipboard.channel_id, Some(selected_channel.id), cx)
+                })
+                .detach_and_log_err(cx)
+        },
+    );
+
+    cx.add_action(
+        |panel: &mut CollabPanel, action: &MoveChannel, cx: &mut ViewContext<CollabPanel>| {
+            if let Some(clipboard) = panel.channel_clipboard.take() {
+                panel.channel_store.update(cx, |channel_store, cx| {
+                    channel_store
+                        .move_channel(clipboard.channel_id, Some(action.to), cx)
+                        .detach_and_log_err(cx)
+                })
+            }
+        },
+    );
+}
+
+#[derive(Debug)]
+pub enum ChannelEditingState {
+    Create {
+        location: Option<ChannelId>,
+        pending_name: Option<String>,
+    },
+    Rename {
+        location: ChannelId,
+        pending_name: Option<String>,
+    },
+}
+
+impl ChannelEditingState {
+    fn pending_name(&self) -> Option<&str> {
+        match self {
+            ChannelEditingState::Create { pending_name, .. } => pending_name.as_deref(),
+            ChannelEditingState::Rename { pending_name, .. } => pending_name.as_deref(),
+        }
+    }
+}
+
+pub struct CollabPanel {
+    width: Option<f32>,
+    fs: Arc<dyn Fs>,
+    has_focus: bool,
+    channel_clipboard: Option<ChannelMoveClipboard>,
+    pending_serialization: Task<Option<()>>,
+    context_menu: ViewHandle<ContextMenu>,
+    filter_editor: ViewHandle<Editor>,
+    channel_name_editor: ViewHandle<Editor>,
+    channel_editing_state: Option<ChannelEditingState>,
+    entries: Vec<ListEntry>,
+    selection: Option<usize>,
+    user_store: ModelHandle<UserStore>,
+    client: Arc<Client>,
+    channel_store: ModelHandle<ChannelStore>,
+    project: ModelHandle<Project>,
+    match_candidates: Vec<StringMatchCandidate>,
+    list_state: ListState<Self>,
+    subscriptions: Vec<Subscription>,
+    collapsed_sections: Vec<Section>,
+    collapsed_channels: Vec<ChannelId>,
+    drag_target_channel: ChannelDragTarget,
+    workspace: WeakViewHandle<Workspace>,
+    context_menu_on_selected: bool,
+}
+
+#[derive(PartialEq, Eq)]
+enum ChannelDragTarget {
+    None,
+    Root,
+    Channel(ChannelId),
+}
+
+#[derive(Serialize, Deserialize)]
+struct SerializedCollabPanel {
+    width: Option<f32>,
+    collapsed_channels: Option<Vec<ChannelId>>,
+}
+
+#[derive(Debug)]
+pub enum Event {
+    DockPositionChanged,
+    Focus,
+    Dismissed,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
+enum Section {
+    ActiveCall,
+    Channels,
+    ChannelInvites,
+    ContactRequests,
+    Contacts,
+    Online,
+    Offline,
+}
+
+#[derive(Clone, Debug)]
+enum ListEntry {
+    Header(Section),
+    CallParticipant {
+        user: Arc<User>,
+        peer_id: Option<PeerId>,
+        is_pending: bool,
+    },
+    ParticipantProject {
+        project_id: u64,
+        worktree_root_names: Vec<String>,
+        host_user_id: u64,
+        is_last: bool,
+    },
+    ParticipantScreen {
+        peer_id: Option<PeerId>,
+        is_last: bool,
+    },
+    IncomingRequest(Arc<User>),
+    OutgoingRequest(Arc<User>),
+    ChannelInvite(Arc<Channel>),
+    Channel {
+        channel: Arc<Channel>,
+        depth: usize,
+        has_children: bool,
+    },
+    ChannelNotes {
+        channel_id: ChannelId,
+    },
+    ChannelChat {
+        channel_id: ChannelId,
+    },
+    ChannelEditor {
+        depth: usize,
+    },
+    Contact {
+        contact: Arc<Contact>,
+        calling: bool,
+    },
+    ContactPlaceholder,
+}
+
+impl Entity for CollabPanel {
+    type Event = Event;
+}
+
+impl CollabPanel {
+    pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
+        cx.add_view::<Self, _>(|cx| {
+            let view_id = cx.view_id();
+
+            let filter_editor = cx.add_view(|cx| {
+                let mut editor = Editor::single_line(
+                    Some(Arc::new(|theme| {
+                        theme.collab_panel.user_query_editor.clone()
+                    })),
+                    cx,
+                );
+                editor.set_placeholder_text("Filter channels, contacts", cx);
+                editor
+            });
+
+            cx.subscribe(&filter_editor, |this, _, event, cx| {
+                if let editor::Event::BufferEdited = event {
+                    let query = this.filter_editor.read(cx).text(cx);
+                    if !query.is_empty() {
+                        this.selection.take();
+                    }
+                    this.update_entries(true, cx);
+                    if !query.is_empty() {
+                        this.selection = this
+                            .entries
+                            .iter()
+                            .position(|entry| !matches!(entry, ListEntry::Header(_)));
+                    }
+                } else if let editor::Event::Blurred = event {
+                    let query = this.filter_editor.read(cx).text(cx);
+                    if query.is_empty() {
+                        this.selection.take();
+                        this.update_entries(true, cx);
+                    }
+                }
+            })
+            .detach();
+
+            let channel_name_editor = cx.add_view(|cx| {
+                Editor::single_line(
+                    Some(Arc::new(|theme| {
+                        theme.collab_panel.user_query_editor.clone()
+                    })),
+                    cx,
+                )
+            });
+
+            cx.subscribe(&channel_name_editor, |this, _, event, cx| {
+                if let editor::Event::Blurred = event {
+                    if let Some(state) = &this.channel_editing_state {
+                        if state.pending_name().is_some() {
+                            return;
+                        }
+                    }
+                    this.take_editing_state(cx);
+                    this.update_entries(false, cx);
+                    cx.notify();
+                }
+            })
+            .detach();
+
+            let list_state =
+                ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
+                    let theme = theme::current(cx).clone();
+                    let is_selected = this.selection == Some(ix);
+                    let current_project_id = this.project.read(cx).remote_id();
+
+                    match &this.entries[ix] {
+                        ListEntry::Header(section) => {
+                            let is_collapsed = this.collapsed_sections.contains(section);
+                            this.render_header(*section, &theme, is_selected, is_collapsed, cx)
+                        }
+                        ListEntry::CallParticipant {
+                            user,
+                            peer_id,
+                            is_pending,
+                        } => Self::render_call_participant(
+                            user,
+                            *peer_id,
+                            this.user_store.clone(),
+                            *is_pending,
+                            is_selected,
+                            &theme,
+                            cx,
+                        ),
+                        ListEntry::ParticipantProject {
+                            project_id,
+                            worktree_root_names,
+                            host_user_id,
+                            is_last,
+                        } => Self::render_participant_project(
+                            *project_id,
+                            worktree_root_names,
+                            *host_user_id,
+                            Some(*project_id) == current_project_id,
+                            *is_last,
+                            is_selected,
+                            &theme,
+                            cx,
+                        ),
+                        ListEntry::ParticipantScreen { peer_id, is_last } => {
+                            Self::render_participant_screen(
+                                *peer_id,
+                                *is_last,
+                                is_selected,
+                                &theme.collab_panel,
+                                cx,
+                            )
+                        }
+                        ListEntry::Channel {
+                            channel,
+                            depth,
+                            has_children,
+                        } => {
+                            let channel_row = this.render_channel(
+                                &*channel,
+                                *depth,
+                                &theme,
+                                is_selected,
+                                *has_children,
+                                ix,
+                                cx,
+                            );
+
+                            if is_selected && this.context_menu_on_selected {
+                                Stack::new()
+                                    .with_child(channel_row)
+                                    .with_child(
+                                        ChildView::new(&this.context_menu, cx)
+                                            .aligned()
+                                            .bottom()
+                                            .right(),
+                                    )
+                                    .into_any()
+                            } else {
+                                return channel_row;
+                            }
+                        }
+                        ListEntry::ChannelNotes { channel_id } => this.render_channel_notes(
+                            *channel_id,
+                            &theme.collab_panel,
+                            is_selected,
+                            ix,
+                            cx,
+                        ),
+                        ListEntry::ChannelChat { channel_id } => this.render_channel_chat(
+                            *channel_id,
+                            &theme.collab_panel,
+                            is_selected,
+                            ix,
+                            cx,
+                        ),
+                        ListEntry::ChannelInvite(channel) => Self::render_channel_invite(
+                            channel.clone(),
+                            this.channel_store.clone(),
+                            &theme.collab_panel,
+                            is_selected,
+                            cx,
+                        ),
+                        ListEntry::IncomingRequest(user) => Self::render_contact_request(
+                            user.clone(),
+                            this.user_store.clone(),
+                            &theme.collab_panel,
+                            true,
+                            is_selected,
+                            cx,
+                        ),
+                        ListEntry::OutgoingRequest(user) => Self::render_contact_request(
+                            user.clone(),
+                            this.user_store.clone(),
+                            &theme.collab_panel,
+                            false,
+                            is_selected,
+                            cx,
+                        ),
+                        ListEntry::Contact { contact, calling } => Self::render_contact(
+                            contact,
+                            *calling,
+                            &this.project,
+                            &theme,
+                            is_selected,
+                            cx,
+                        ),
+                        ListEntry::ChannelEditor { depth } => {
+                            this.render_channel_editor(&theme, *depth, cx)
+                        }
+                        ListEntry::ContactPlaceholder => {
+                            this.render_contact_placeholder(&theme.collab_panel, is_selected, cx)
+                        }
+                    }
+                });
+
+            let mut this = Self {
+                width: None,
+                has_focus: false,
+                channel_clipboard: None,
+                fs: workspace.app_state().fs.clone(),
+                pending_serialization: Task::ready(None),
+                context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
+                channel_name_editor,
+                filter_editor,
+                entries: Vec::default(),
+                channel_editing_state: None,
+                selection: None,
+                user_store: workspace.user_store().clone(),
+                channel_store: ChannelStore::global(cx),
+                project: workspace.project().clone(),
+                subscriptions: Vec::default(),
+                match_candidates: Vec::default(),
+                collapsed_sections: vec![Section::Offline],
+                collapsed_channels: Vec::default(),
+                workspace: workspace.weak_handle(),
+                client: workspace.app_state().client.clone(),
+                context_menu_on_selected: true,
+                drag_target_channel: ChannelDragTarget::None,
+                list_state,
+            };
+
+            this.update_entries(false, cx);
+
+            // Update the dock position when the setting changes.
+            let mut old_dock_position = this.position(cx);
+            this.subscriptions
+                .push(
+                    cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
+                        let new_dock_position = this.position(cx);
+                        if new_dock_position != old_dock_position {
+                            old_dock_position = new_dock_position;
+                            cx.emit(Event::DockPositionChanged);
+                        }
+                        cx.notify();
+                    }),
+                );
+
+            let active_call = ActiveCall::global(cx);
+            this.subscriptions
+                .push(cx.observe(&this.user_store, |this, _, cx| {
+                    this.update_entries(true, cx)
+                }));
+            this.subscriptions
+                .push(cx.observe(&this.channel_store, |this, _, cx| {
+                    this.update_entries(true, cx)
+                }));
+            this.subscriptions
+                .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
+            this.subscriptions
+                .push(cx.observe_flag::<ChannelsAlpha, _>(move |_, this, cx| {
+                    this.update_entries(true, cx)
+                }));
+            this.subscriptions.push(cx.subscribe(
+                &this.channel_store,
+                |this, _channel_store, e, cx| match e {
+                    ChannelEvent::ChannelCreated(channel_id)
+                    | ChannelEvent::ChannelRenamed(channel_id) => {
+                        if this.take_editing_state(cx) {
+                            this.update_entries(false, cx);
+                            this.selection = this.entries.iter().position(|entry| {
+                                if let ListEntry::Channel { channel, .. } = entry {
+                                    channel.id == *channel_id
+                                } else {
+                                    false
+                                }
+                            });
+                        }
+                    }
+                },
+            ));
+
+            this
+        })
+    }
+
+    pub fn load(
+        workspace: WeakViewHandle<Workspace>,
+        cx: AsyncAppContext,
+    ) -> Task<Result<ViewHandle<Self>>> {
+        cx.spawn(|mut cx| async move {
+            let serialized_panel = if let Some(panel) = cx
+                .background()
+                .spawn(async move { KEY_VALUE_STORE.read_kvp(COLLABORATION_PANEL_KEY) })
+                .await
+                .log_err()
+                .flatten()
+            {
+                match serde_json::from_str::<SerializedCollabPanel>(&panel) {
+                    Ok(panel) => Some(panel),
+                    Err(err) => {
+                        log::error!("Failed to deserialize collaboration panel: {}", err);
+                        None
+                    }
+                }
+            } else {
+                None
+            };
+
+            workspace.update(&mut cx, |workspace, cx| {
+                let panel = CollabPanel::new(workspace, cx);
+                if let Some(serialized_panel) = serialized_panel {
+                    panel.update(cx, |panel, cx| {
+                        panel.width = serialized_panel.width;
+                        panel.collapsed_channels = serialized_panel
+                            .collapsed_channels
+                            .unwrap_or_else(|| Vec::new());
+                        cx.notify();
+                    });
+                }
+                panel
+            })
+        })
+    }
+
+    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
+        let width = self.width;
+        let collapsed_channels = self.collapsed_channels.clone();
+        self.pending_serialization = cx.background().spawn(
+            async move {
+                KEY_VALUE_STORE
+                    .write_kvp(
+                        COLLABORATION_PANEL_KEY.into(),
+                        serde_json::to_string(&SerializedCollabPanel {
+                            width,
+                            collapsed_channels: Some(collapsed_channels),
+                        })?,
+                    )
+                    .await?;
+                anyhow::Ok(())
+            }
+            .log_err(),
+        );
+    }
+
+    fn update_entries(&mut self, select_same_item: bool, cx: &mut ViewContext<Self>) {
+        let channel_store = self.channel_store.read(cx);
+        let user_store = self.user_store.read(cx);
+        let query = self.filter_editor.read(cx).text(cx);
+        let executor = cx.background().clone();
+
+        let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
+        let old_entries = mem::take(&mut self.entries);
+        let mut scroll_to_top = false;
+
+        if let Some(room) = ActiveCall::global(cx).read(cx).room() {
+            self.entries.push(ListEntry::Header(Section::ActiveCall));
+            if !old_entries
+                .iter()
+                .any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall)))
+            {
+                scroll_to_top = true;
+            }
+
+            if !self.collapsed_sections.contains(&Section::ActiveCall) {
+                let room = room.read(cx);
+
+                if let Some(channel_id) = room.channel_id() {
+                    self.entries.push(ListEntry::ChannelNotes { channel_id });
+                    self.entries.push(ListEntry::ChannelChat { channel_id })
+                }
+
+                // Populate the active user.
+                if let Some(user) = user_store.current_user() {
+                    self.match_candidates.clear();
+                    self.match_candidates.push(StringMatchCandidate {
+                        id: 0,
+                        string: user.github_login.clone(),
+                        char_bag: user.github_login.chars().collect(),
+                    });
+                    let matches = executor.block(match_strings(
+                        &self.match_candidates,
+                        &query,
+                        true,
+                        usize::MAX,
+                        &Default::default(),
+                        executor.clone(),
+                    ));
+                    if !matches.is_empty() {
+                        let user_id = user.id;
+                        self.entries.push(ListEntry::CallParticipant {
+                            user,
+                            peer_id: None,
+                            is_pending: false,
+                        });
+                        let mut projects = room.local_participant().projects.iter().peekable();
+                        while let Some(project) = projects.next() {
+                            self.entries.push(ListEntry::ParticipantProject {
+                                project_id: project.id,
+                                worktree_root_names: project.worktree_root_names.clone(),
+                                host_user_id: user_id,
+                                is_last: projects.peek().is_none() && !room.is_screen_sharing(),
+                            });
+                        }
+                        if room.is_screen_sharing() {
+                            self.entries.push(ListEntry::ParticipantScreen {
+                                peer_id: None,
+                                is_last: true,
+                            });
+                        }
+                    }
+                }
+
+                // Populate remote participants.
+                self.match_candidates.clear();
+                self.match_candidates
+                    .extend(room.remote_participants().iter().map(|(_, participant)| {
+                        StringMatchCandidate {
+                            id: participant.user.id as usize,
+                            string: participant.user.github_login.clone(),
+                            char_bag: participant.user.github_login.chars().collect(),
+                        }
+                    }));
+                let matches = executor.block(match_strings(
+                    &self.match_candidates,
+                    &query,
+                    true,
+                    usize::MAX,
+                    &Default::default(),
+                    executor.clone(),
+                ));
+                for mat in matches {
+                    let user_id = mat.candidate_id as u64;
+                    let participant = &room.remote_participants()[&user_id];
+                    self.entries.push(ListEntry::CallParticipant {
+                        user: participant.user.clone(),
+                        peer_id: Some(participant.peer_id),
+                        is_pending: false,
+                    });
+                    let mut projects = participant.projects.iter().peekable();
+                    while let Some(project) = projects.next() {
+                        self.entries.push(ListEntry::ParticipantProject {
+                            project_id: project.id,
+                            worktree_root_names: project.worktree_root_names.clone(),
+                            host_user_id: participant.user.id,
+                            is_last: projects.peek().is_none()
+                                && participant.video_tracks.is_empty(),
+                        });
+                    }
+                    if !participant.video_tracks.is_empty() {
+                        self.entries.push(ListEntry::ParticipantScreen {
+                            peer_id: Some(participant.peer_id),
+                            is_last: true,
+                        });
+                    }
+                }
+
+                // Populate pending participants.
+                self.match_candidates.clear();
+                self.match_candidates
+                    .extend(room.pending_participants().iter().enumerate().map(
+                        |(id, participant)| StringMatchCandidate {
+                            id,
+                            string: participant.github_login.clone(),
+                            char_bag: participant.github_login.chars().collect(),
+                        },
+                    ));
+                let matches = executor.block(match_strings(
+                    &self.match_candidates,
+                    &query,
+                    true,
+                    usize::MAX,
+                    &Default::default(),
+                    executor.clone(),
+                ));
+                self.entries
+                    .extend(matches.iter().map(|mat| ListEntry::CallParticipant {
+                        user: room.pending_participants()[mat.candidate_id].clone(),
+                        peer_id: None,
+                        is_pending: true,
+                    }));
+            }
+        }
+
+        let mut request_entries = Vec::new();
+
+        if cx.has_flag::<ChannelsAlpha>() {
+            self.entries.push(ListEntry::Header(Section::Channels));
+
+            if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
+                self.match_candidates.clear();
+                self.match_candidates
+                    .extend(channel_store.ordered_channels().enumerate().map(
+                        |(ix, (_, channel))| StringMatchCandidate {
+                            id: ix,
+                            string: channel.name.clone(),
+                            char_bag: channel.name.chars().collect(),
+                        },
+                    ));
+                let matches = executor.block(match_strings(
+                    &self.match_candidates,
+                    &query,
+                    true,
+                    usize::MAX,
+                    &Default::default(),
+                    executor.clone(),
+                ));
+                if let Some(state) = &self.channel_editing_state {
+                    if matches!(state, ChannelEditingState::Create { location: None, .. }) {
+                        self.entries.push(ListEntry::ChannelEditor { depth: 0 });
+                    }
+                }
+                let mut collapse_depth = None;
+                for mat in matches {
+                    let channel = channel_store.channel_at_index(mat.candidate_id).unwrap();
+                    let depth = channel.parent_path.len();
+
+                    if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) {
+                        collapse_depth = Some(depth);
+                    } else if let Some(collapsed_depth) = collapse_depth {
+                        if depth > collapsed_depth {
+                            continue;
+                        }
+                        if self.is_channel_collapsed(channel.id) {
+                            collapse_depth = Some(depth);
+                        } else {
+                            collapse_depth = None;
+                        }
+                    }
+
+                    let has_children = channel_store
+                        .channel_at_index(mat.candidate_id + 1)
+                        .map_or(false, |next_channel| {
+                            next_channel.parent_path.ends_with(&[channel.id])
+                        });
+
+                    match &self.channel_editing_state {
+                        Some(ChannelEditingState::Create {
+                            location: parent_id,
+                            ..
+                        }) if *parent_id == Some(channel.id) => {
+                            self.entries.push(ListEntry::Channel {
+                                channel: channel.clone(),
+                                depth,
+                                has_children: false,
+                            });
+                            self.entries
+                                .push(ListEntry::ChannelEditor { depth: depth + 1 });
+                        }
+                        Some(ChannelEditingState::Rename {
+                            location: parent_id,
+                            ..
+                        }) if parent_id == &channel.id => {
+                            self.entries.push(ListEntry::ChannelEditor { depth });
+                        }
+                        _ => {
+                            self.entries.push(ListEntry::Channel {
+                                channel: channel.clone(),
+                                depth,
+                                has_children,
+                            });
+                        }
+                    }
+                }
+            }
+
+            let channel_invites = channel_store.channel_invitations();
+            if !channel_invites.is_empty() {
+                self.match_candidates.clear();
+                self.match_candidates
+                    .extend(channel_invites.iter().enumerate().map(|(ix, channel)| {
+                        StringMatchCandidate {
+                            id: ix,
+                            string: channel.name.clone(),
+                            char_bag: channel.name.chars().collect(),
+                        }
+                    }));
+                let matches = executor.block(match_strings(
+                    &self.match_candidates,
+                    &query,
+                    true,
+                    usize::MAX,
+                    &Default::default(),
+                    executor.clone(),
+                ));
+                request_entries.extend(matches.iter().map(|mat| {
+                    ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())
+                }));
+
+                if !request_entries.is_empty() {
+                    self.entries
+                        .push(ListEntry::Header(Section::ChannelInvites));
+                    if !self.collapsed_sections.contains(&Section::ChannelInvites) {
+                        self.entries.append(&mut request_entries);
+                    }
+                }
+            }
+        }
+
+        self.entries.push(ListEntry::Header(Section::Contacts));
+
+        request_entries.clear();
+        let incoming = user_store.incoming_contact_requests();
+        if !incoming.is_empty() {
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    incoming
+                        .iter()
+                        .enumerate()
+                        .map(|(ix, user)| StringMatchCandidate {
+                            id: ix,
+                            string: user.github_login.clone(),
+                            char_bag: user.github_login.chars().collect(),
+                        }),
+                );
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+            request_entries.extend(
+                matches
+                    .iter()
+                    .map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
+            );
+        }
+
+        let outgoing = user_store.outgoing_contact_requests();
+        if !outgoing.is_empty() {
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    outgoing
+                        .iter()
+                        .enumerate()
+                        .map(|(ix, user)| StringMatchCandidate {
+                            id: ix,
+                            string: user.github_login.clone(),
+                            char_bag: user.github_login.chars().collect(),
+                        }),
+                );
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+            request_entries.extend(
+                matches
+                    .iter()
+                    .map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
+            );
+        }
+
+        if !request_entries.is_empty() {
+            self.entries
+                .push(ListEntry::Header(Section::ContactRequests));
+            if !self.collapsed_sections.contains(&Section::ContactRequests) {
+                self.entries.append(&mut request_entries);
+            }
+        }
+
+        let contacts = user_store.contacts();
+        if !contacts.is_empty() {
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    contacts
+                        .iter()
+                        .enumerate()
+                        .map(|(ix, contact)| StringMatchCandidate {
+                            id: ix,
+                            string: contact.user.github_login.clone(),
+                            char_bag: contact.user.github_login.chars().collect(),
+                        }),
+                );
+
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+
+            let (online_contacts, offline_contacts) = matches
+                .iter()
+                .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
+
+            for (matches, section) in [
+                (online_contacts, Section::Online),
+                (offline_contacts, Section::Offline),
+            ] {
+                if !matches.is_empty() {
+                    self.entries.push(ListEntry::Header(section));
+                    if !self.collapsed_sections.contains(&section) {
+                        let active_call = &ActiveCall::global(cx).read(cx);
+                        for mat in matches {
+                            let contact = &contacts[mat.candidate_id];
+                            self.entries.push(ListEntry::Contact {
+                                contact: contact.clone(),
+                                calling: active_call.pending_invites().contains(&contact.user.id),
+                            });
+                        }
+                    }
+                }
+            }
+        }
+
+        if incoming.is_empty() && outgoing.is_empty() && contacts.is_empty() {
+            self.entries.push(ListEntry::ContactPlaceholder);
+        }
+
+        if select_same_item {
+            if let Some(prev_selected_entry) = prev_selected_entry {
+                self.selection.take();
+                for (ix, entry) in self.entries.iter().enumerate() {
+                    if *entry == prev_selected_entry {
+                        self.selection = Some(ix);
+                        break;
+                    }
+                }
+            }
+        } else {
+            self.selection = self.selection.and_then(|prev_selection| {
+                if self.entries.is_empty() {
+                    None
+                } else {
+                    Some(prev_selection.min(self.entries.len() - 1))
+                }
+            });
+        }
+
+        let old_scroll_top = self.list_state.logical_scroll_top();
+
+        self.list_state.reset(self.entries.len());
+
+        if scroll_to_top {
+            self.list_state.scroll_to(ListOffset::default());
+        } else {
+            // Attempt to maintain the same scroll position.
+            if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
+                let new_scroll_top = self
+                    .entries
+                    .iter()
+                    .position(|entry| entry == old_top_entry)
+                    .map(|item_ix| ListOffset {
+                        item_ix,
+                        offset_in_item: old_scroll_top.offset_in_item,
+                    })
+                    .or_else(|| {
+                        let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
+                        let item_ix = self
+                            .entries
+                            .iter()
+                            .position(|entry| entry == entry_after_old_top)?;
+                        Some(ListOffset {
+                            item_ix,
+                            offset_in_item: 0.,
+                        })
+                    })
+                    .or_else(|| {
+                        let entry_before_old_top =
+                            old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
+                        let item_ix = self
+                            .entries
+                            .iter()
+                            .position(|entry| entry == entry_before_old_top)?;
+                        Some(ListOffset {
+                            item_ix,
+                            offset_in_item: 0.,
+                        })
+                    });
+
+                self.list_state
+                    .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
+            }
+        }
+
+        cx.notify();
+    }
+
+    fn render_call_participant(
+        user: &User,
+        peer_id: Option<PeerId>,
+        user_store: ModelHandle<UserStore>,
+        is_pending: bool,
+        is_selected: bool,
+        theme: &theme::Theme,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum CallParticipant {}
+        enum CallParticipantTooltip {}
+        enum LeaveCallButton {}
+        enum LeaveCallTooltip {}
+
+        let collab_theme = &theme.collab_panel;
+
+        let is_current_user =
+            user_store.read(cx).current_user().map(|user| user.id) == Some(user.id);
+
+        let content = MouseEventHandler::new::<CallParticipant, _>(
+            user.id as usize,
+            cx,
+            |mouse_state, cx| {
+                let style = if is_current_user {
+                    *collab_theme
+                        .contact_row
+                        .in_state(is_selected)
+                        .style_for(&mut Default::default())
+                } else {
+                    *collab_theme
+                        .contact_row
+                        .in_state(is_selected)
+                        .style_for(mouse_state)
+                };
+
+                Flex::row()
+                    .with_children(user.avatar.clone().map(|avatar| {
+                        Image::from_data(avatar)
+                            .with_style(collab_theme.contact_avatar)
+                            .aligned()
+                            .left()
+                    }))
+                    .with_child(
+                        Label::new(
+                            user.github_login.clone(),
+                            collab_theme.contact_username.text.clone(),
+                        )
+                        .contained()
+                        .with_style(collab_theme.contact_username.container)
+                        .aligned()
+                        .left()
+                        .flex(1., true),
+                    )
+                    .with_children(if is_pending {
+                        Some(
+                            Label::new("Calling", collab_theme.calling_indicator.text.clone())
+                                .contained()
+                                .with_style(collab_theme.calling_indicator.container)
+                                .aligned()
+                                .into_any(),
+                        )
+                    } else if is_current_user {
+                        Some(
+                            MouseEventHandler::new::<LeaveCallButton, _>(0, cx, |state, _| {
+                                render_icon_button(
+                                    theme
+                                        .collab_panel
+                                        .leave_call_button
+                                        .style_for(is_selected, state),
+                                    "icons/exit.svg",
+                                )
+                            })
+                            .with_cursor_style(CursorStyle::PointingHand)
+                            .on_click(MouseButton::Left, |_, _, cx| {
+                                Self::leave_call(cx);
+                            })
+                            .with_tooltip::<LeaveCallTooltip>(
+                                0,
+                                "Leave call",
+                                None,
+                                theme.tooltip.clone(),
+                                cx,
+                            )
+                            .into_any(),
+                        )
+                    } else {
+                        None
+                    })
+                    .constrained()
+                    .with_height(collab_theme.row_height)
+                    .contained()
+                    .with_style(style)
+            },
+        );
+
+        if is_current_user || is_pending || peer_id.is_none() {
+            return content.into_any();
+        }
+
+        let tooltip = format!("Follow {}", user.github_login);
+
+        content
+            .on_click(MouseButton::Left, move |_, this, cx| {
+                if let Some(workspace) = this.workspace.upgrade(cx) {
+                    workspace
+                        .update(cx, |workspace, cx| workspace.follow(peer_id.unwrap(), cx))
+                        .map(|task| task.detach_and_log_err(cx));
+                }
+            })
+            .with_cursor_style(CursorStyle::PointingHand)
+            .with_tooltip::<CallParticipantTooltip>(
+                user.id as usize,
+                tooltip,
+                Some(Box::new(FollowNextCollaborator)),
+                theme.tooltip.clone(),
+                cx,
+            )
+            .into_any()
+    }
+
+    fn render_participant_project(
+        project_id: u64,
+        worktree_root_names: &[String],
+        host_user_id: u64,
+        is_current: bool,
+        is_last: bool,
+        is_selected: bool,
+        theme: &theme::Theme,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum JoinProject {}
+        enum JoinProjectTooltip {}
+
+        let collab_theme = &theme.collab_panel;
+        let host_avatar_width = collab_theme
+            .contact_avatar
+            .width
+            .or(collab_theme.contact_avatar.height)
+            .unwrap_or(0.);
+        let tree_branch = collab_theme.tree_branch;
+        let project_name = if worktree_root_names.is_empty() {
+            "untitled".to_string()
+        } else {
+            worktree_root_names.join(", ")
+        };
+
+        let content =
+            MouseEventHandler::new::<JoinProject, _>(project_id as usize, cx, |mouse_state, cx| {
+                let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
+                let row = if is_current {
+                    collab_theme
+                        .project_row
+                        .in_state(true)
+                        .style_for(&mut Default::default())
+                } else {
+                    collab_theme
+                        .project_row
+                        .in_state(is_selected)
+                        .style_for(mouse_state)
+                };
+
+                Flex::row()
+                    .with_child(render_tree_branch(
+                        tree_branch,
+                        &row.name.text,
+                        is_last,
+                        vec2f(host_avatar_width, collab_theme.row_height),
+                        cx.font_cache(),
+                    ))
+                    .with_child(
+                        Svg::new("icons/file_icons/folder.svg")
+                            .with_color(collab_theme.channel_hash.color)
+                            .constrained()
+                            .with_width(collab_theme.channel_hash.width)
+                            .aligned()
+                            .left(),
+                    )
+                    .with_child(
+                        Label::new(project_name.clone(), row.name.text.clone())
+                            .aligned()
+                            .left()
+                            .contained()
+                            .with_style(row.name.container)
+                            .flex(1., false),
+                    )
+                    .constrained()
+                    .with_height(collab_theme.row_height)
+                    .contained()
+                    .with_style(row.container)
+            });
+
+        if is_current {
+            return content.into_any();
+        }
+
+        content
+            .with_cursor_style(CursorStyle::PointingHand)
+            .on_click(MouseButton::Left, move |_, this, cx| {
+                if let Some(workspace) = this.workspace.upgrade(cx) {
+                    let app_state = workspace.read(cx).app_state().clone();
+                    workspace::join_remote_project(project_id, host_user_id, app_state, cx)
+                        .detach_and_log_err(cx);
+                }
+            })
+            .with_tooltip::<JoinProjectTooltip>(
+                project_id as usize,
+                format!("Open {}", project_name),
+                None,
+                theme.tooltip.clone(),
+                cx,
+            )
+            .into_any()
+    }
+
+    fn render_participant_screen(
+        peer_id: Option<PeerId>,
+        is_last: bool,
+        is_selected: bool,
+        theme: &theme::CollabPanel,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum OpenSharedScreen {}
+
+        let host_avatar_width = theme
+            .contact_avatar
+            .width
+            .or(theme.contact_avatar.height)
+            .unwrap_or(0.);
+        let tree_branch = theme.tree_branch;
+
+        let handler = MouseEventHandler::new::<OpenSharedScreen, _>(
+            peer_id.map(|id| id.as_u64()).unwrap_or(0) as usize,
+            cx,
+            |mouse_state, cx| {
+                let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
+                let row = theme
+                    .project_row
+                    .in_state(is_selected)
+                    .style_for(mouse_state);
+
+                Flex::row()
+                    .with_child(render_tree_branch(
+                        tree_branch,
+                        &row.name.text,
+                        is_last,
+                        vec2f(host_avatar_width, theme.row_height),
+                        cx.font_cache(),
+                    ))
+                    .with_child(
+                        Svg::new("icons/desktop.svg")
+                            .with_color(theme.channel_hash.color)
+                            .constrained()
+                            .with_width(theme.channel_hash.width)
+                            .aligned()
+                            .left(),
+                    )
+                    .with_child(
+                        Label::new("Screen", row.name.text.clone())
+                            .aligned()
+                            .left()
+                            .contained()
+                            .with_style(row.name.container)
+                            .flex(1., false),
+                    )
+                    .constrained()
+                    .with_height(theme.row_height)
+                    .contained()
+                    .with_style(row.container)
+            },
+        );
+        if peer_id.is_none() {
+            return handler.into_any();
+        }
+        handler
+            .with_cursor_style(CursorStyle::PointingHand)
+            .on_click(MouseButton::Left, move |_, this, cx| {
+                if let Some(workspace) = this.workspace.upgrade(cx) {
+                    workspace.update(cx, |workspace, cx| {
+                        workspace.open_shared_screen(peer_id.unwrap(), cx)
+                    });
+                }
+            })
+            .into_any()
+    }
+
+    fn take_editing_state(&mut self, cx: &mut ViewContext<Self>) -> bool {
+        if let Some(_) = self.channel_editing_state.take() {
+            self.channel_name_editor.update(cx, |editor, cx| {
+                editor.set_text("", cx);
+            });
+            true
+        } else {
+            false
+        }
+    }
+
+    fn render_header(
+        &self,
+        section: Section,
+        theme: &theme::Theme,
+        is_selected: bool,
+        is_collapsed: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum Header {}
+        enum LeaveCallContactList {}
+        enum AddChannel {}
+
+        let tooltip_style = &theme.tooltip;
+        let mut channel_link = None;
+        let mut channel_tooltip_text = None;
+        let mut channel_icon = None;
+        let mut is_dragged_over = false;
+
+        let text = match section {
+            Section::ActiveCall => {
+                let channel_name = maybe!({
+                    let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
+
+                    let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
+
+                    channel_link = Some(channel.link());
+                    (channel_icon, channel_tooltip_text) = match channel.visibility {
+                        proto::ChannelVisibility::Public => {
+                            (Some("icons/public.svg"), Some("Copy public channel link."))
+                        }
+                        proto::ChannelVisibility::Members => {
+                            (Some("icons/hash.svg"), Some("Copy private channel link."))
+                        }
+                    };
+
+                    Some(channel.name.as_str())
+                });
+
+                if let Some(name) = channel_name {
+                    Cow::Owned(format!("{}", name))
+                } else {
+                    Cow::Borrowed("Current Call")
+                }
+            }
+            Section::ContactRequests => Cow::Borrowed("Requests"),
+            Section::Contacts => Cow::Borrowed("Contacts"),
+            Section::Channels => Cow::Borrowed("Channels"),
+            Section::ChannelInvites => Cow::Borrowed("Invites"),
+            Section::Online => Cow::Borrowed("Online"),
+            Section::Offline => Cow::Borrowed("Offline"),
+        };
+
+        enum AddContact {}
+        let button = match section {
+            Section::ActiveCall => channel_link.map(|channel_link| {
+                let channel_link_copy = channel_link.clone();
+                MouseEventHandler::new::<AddContact, _>(0, cx, |state, _| {
+                    render_icon_button(
+                        theme
+                            .collab_panel
+                            .leave_call_button
+                            .style_for(is_selected, state),
+                        "icons/link.svg",
+                    )
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, _, cx| {
+                    let item = ClipboardItem::new(channel_link_copy.clone());
+                    cx.write_to_clipboard(item)
+                })
+                .with_tooltip::<AddContact>(
+                    0,
+                    channel_tooltip_text.unwrap(),
+                    None,
+                    tooltip_style.clone(),
+                    cx,
+                )
+            }),
+            Section::Contacts => Some(
+                MouseEventHandler::new::<LeaveCallContactList, _>(0, cx, |state, _| {
+                    render_icon_button(
+                        theme
+                            .collab_panel
+                            .add_contact_button
+                            .style_for(is_selected, state),
+                        "icons/plus.svg",
+                    )
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, |_, this, cx| {
+                    this.toggle_contact_finder(cx);
+                })
+                .with_tooltip::<LeaveCallContactList>(
+                    0,
+                    "Search for new contact",
+                    None,
+                    tooltip_style.clone(),
+                    cx,
+                ),
+            ),
+            Section::Channels => {
+                if cx
+                    .global::<DragAndDrop<Workspace>>()
+                    .currently_dragged::<Channel>(cx.window())
+                    .is_some()
+                    && self.drag_target_channel == ChannelDragTarget::Root
+                {
+                    is_dragged_over = true;
+                }
+
+                Some(
+                    MouseEventHandler::new::<AddChannel, _>(0, cx, |state, _| {
+                        render_icon_button(
+                            theme
+                                .collab_panel
+                                .add_contact_button
+                                .style_for(is_selected, state),
+                            "icons/plus.svg",
+                        )
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx))
+                    .with_tooltip::<AddChannel>(
+                        0,
+                        "Create a channel",
+                        None,
+                        tooltip_style.clone(),
+                        cx,
+                    ),
+                )
+            }
+            _ => None,
+        };
+
+        let can_collapse = match section {
+            Section::ActiveCall | Section::Channels | Section::Contacts => false,
+            Section::ChannelInvites
+            | Section::ContactRequests
+            | Section::Online
+            | Section::Offline => true,
+        };
+        let icon_size = (&theme.collab_panel).section_icon_size;
+        let mut result = MouseEventHandler::new::<Header, _>(section as usize, cx, |state, _| {
+            let header_style = if can_collapse {
+                theme
+                    .collab_panel
+                    .subheader_row
+                    .in_state(is_selected)
+                    .style_for(state)
+            } else {
+                &theme.collab_panel.header_row
+            };
+
+            Flex::row()
+                .with_children(if can_collapse {
+                    Some(
+                        Svg::new(if is_collapsed {
+                            "icons/chevron_right.svg"
+                        } else {
+                            "icons/chevron_down.svg"
+                        })
+                        .with_color(header_style.text.color)
+                        .constrained()
+                        .with_max_width(icon_size)
+                        .with_max_height(icon_size)
+                        .aligned()
+                        .constrained()
+                        .with_width(icon_size)
+                        .contained()
+                        .with_margin_right(
+                            theme.collab_panel.contact_username.container.margin.left,
+                        ),
+                    )
+                } else if let Some(channel_icon) = channel_icon {
+                    Some(
+                        Svg::new(channel_icon)
+                            .with_color(header_style.text.color)
+                            .constrained()
+                            .with_max_width(icon_size)
+                            .with_max_height(icon_size)
+                            .aligned()
+                            .constrained()
+                            .with_width(icon_size)
+                            .contained()
+                            .with_margin_right(
+                                theme.collab_panel.contact_username.container.margin.left,
+                            ),
+                    )
+                } else {
+                    None
+                })
+                .with_child(
+                    Label::new(text, header_style.text.clone())
+                        .aligned()
+                        .left()
+                        .flex(1., true),
+                )
+                .with_children(button.map(|button| button.aligned().right()))
+                .constrained()
+                .with_height(theme.collab_panel.row_height)
+                .contained()
+                .with_style(if is_dragged_over {
+                    theme.collab_panel.dragged_over_header
+                } else {
+                    header_style.container
+                })
+        });
+
+        result = result
+            .on_move(move |_, this, cx| {
+                if cx
+                    .global::<DragAndDrop<Workspace>>()
+                    .currently_dragged::<Channel>(cx.window())
+                    .is_some()
+                {
+                    this.drag_target_channel = ChannelDragTarget::Root;
+                    cx.notify()
+                }
+            })
+            .on_up(MouseButton::Left, move |_, this, cx| {
+                if let Some((_, dragged_channel)) = cx
+                    .global::<DragAndDrop<Workspace>>()
+                    .currently_dragged::<Channel>(cx.window())
+                {
+                    this.channel_store
+                        .update(cx, |channel_store, cx| {
+                            channel_store.move_channel(dragged_channel.id, None, cx)
+                        })
+                        .detach_and_log_err(cx)
+                }
+            });
+
+        if can_collapse {
+            result = result
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    if can_collapse {
+                        this.toggle_section_expanded(section, cx);
+                    }
+                })
+        }
+
+        result.into_any()
+    }
+
+    fn render_contact(
+        contact: &Contact,
+        calling: bool,
+        project: &ModelHandle<Project>,
+        theme: &theme::Theme,
+        is_selected: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum ContactTooltip {}
+
+        let collab_theme = &theme.collab_panel;
+        let online = contact.online;
+        let busy = contact.busy || calling;
+        let user_id = contact.user.id;
+        let github_login = contact.user.github_login.clone();
+        let initial_project = project.clone();
+
+        let event_handler =
+            MouseEventHandler::new::<Contact, _>(contact.user.id as usize, cx, |state, cx| {
+                Flex::row()
+                    .with_children(contact.user.avatar.clone().map(|avatar| {
+                        let status_badge = if contact.online {
+                            Some(
+                                Empty::new()
+                                    .collapsed()
+                                    .contained()
+                                    .with_style(if busy {
+                                        collab_theme.contact_status_busy
+                                    } else {
+                                        collab_theme.contact_status_free
+                                    })
+                                    .aligned(),
+                            )
+                        } else {
+                            None
+                        };
+                        Stack::new()
+                            .with_child(
+                                Image::from_data(avatar)
+                                    .with_style(collab_theme.contact_avatar)
+                                    .aligned()
+                                    .left(),
+                            )
+                            .with_children(status_badge)
+                    }))
+                    .with_child(
+                        Label::new(
+                            contact.user.github_login.clone(),
+                            collab_theme.contact_username.text.clone(),
+                        )
+                        .contained()
+                        .with_style(collab_theme.contact_username.container)
+                        .aligned()
+                        .left()
+                        .flex(1., true),
+                    )
+                    .with_children(if state.hovered() {
+                        Some(
+                            MouseEventHandler::new::<Cancel, _>(
+                                contact.user.id as usize,
+                                cx,
+                                |mouse_state, _| {
+                                    let button_style =
+                                        collab_theme.contact_button.style_for(mouse_state);
+                                    render_icon_button(button_style, "icons/x.svg")
+                                        .aligned()
+                                        .flex_float()
+                                },
+                            )
+                            .with_padding(Padding::uniform(2.))
+                            .with_cursor_style(CursorStyle::PointingHand)
+                            .on_click(MouseButton::Left, move |_, this, cx| {
+                                this.remove_contact(user_id, &github_login, cx);
+                            })
+                            .flex_float(),
+                        )
+                    } else {
+                        None
+                    })
+                    .with_children(if calling {
+                        Some(
+                            Label::new("Calling", collab_theme.calling_indicator.text.clone())
+                                .contained()
+                                .with_style(collab_theme.calling_indicator.container)
+                                .aligned(),
+                        )
+                    } else {
+                        None
+                    })
+                    .constrained()
+                    .with_height(collab_theme.row_height)
+                    .contained()
+                    .with_style(
+                        *collab_theme
+                            .contact_row
+                            .in_state(is_selected)
+                            .style_for(state),
+                    )
+            });
+
+        if online && !busy {
+            let room = ActiveCall::global(cx).read(cx).room();
+            let label = if room.is_some() {
+                format!("Invite {} to join call", contact.user.github_login)
+            } else {
+                format!("Call {}", contact.user.github_login)
+            };
+
+            event_handler
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    this.call(user_id, Some(initial_project.clone()), cx);
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .with_tooltip::<ContactTooltip>(
+                    contact.user.id as usize,
+                    label,
+                    None,
+                    theme.tooltip.clone(),
+                    cx,
+                )
+                .into_any()
+        } else {
+            event_handler
+                .with_tooltip::<ContactTooltip>(
+                    contact.user.id as usize,
+                    format!(
+                        "{} is {}",
+                        contact.user.github_login,
+                        if busy { "on a call" } else { "offline" }
+                    ),
+                    None,
+                    theme.tooltip.clone(),
+                    cx,
+                )
+                .into_any()
+        }
+    }
+
+    fn render_contact_placeholder(
+        &self,
+        theme: &theme::CollabPanel,
+        is_selected: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum AddContacts {}
+        MouseEventHandler::new::<AddContacts, _>(0, cx, |state, _| {
+            let style = theme.list_empty_state.style_for(is_selected, state);
+            Flex::row()
+                .with_child(
+                    Svg::new("icons/plus.svg")
+                        .with_color(theme.list_empty_icon.color)
+                        .constrained()
+                        .with_width(theme.list_empty_icon.width)
+                        .aligned()
+                        .left(),
+                )
+                .with_child(
+                    Label::new("Add a contact", style.text.clone())
+                        .contained()
+                        .with_style(theme.list_empty_label_container),
+                )
+                .align_children_center()
+                .contained()
+                .with_style(style.container)
+                .into_any()
+        })
+        .on_click(MouseButton::Left, |_, this, cx| {
+            this.toggle_contact_finder(cx);
+        })
+        .into_any()
+    }
+
+    fn render_channel_editor(
+        &self,
+        theme: &theme::Theme,
+        depth: usize,
+        cx: &AppContext,
+    ) -> AnyElement<Self> {
+        Flex::row()
+            .with_child(
+                Empty::new()
+                    .constrained()
+                    .with_width(theme.collab_panel.disclosure.button_space()),
+            )
+            .with_child(
+                Svg::new("icons/hash.svg")
+                    .with_color(theme.collab_panel.channel_hash.color)
+                    .constrained()
+                    .with_width(theme.collab_panel.channel_hash.width)
+                    .aligned()
+                    .left(),
+            )
+            .with_child(
+                if let Some(pending_name) = self
+                    .channel_editing_state
+                    .as_ref()
+                    .and_then(|state| state.pending_name())
+                {
+                    Label::new(
+                        pending_name.to_string(),
+                        theme.collab_panel.contact_username.text.clone(),
+                    )
+                    .contained()
+                    .with_style(theme.collab_panel.contact_username.container)
+                    .aligned()
+                    .left()
+                    .flex(1., true)
+                    .into_any()
+                } else {
+                    ChildView::new(&self.channel_name_editor, cx)
+                        .aligned()
+                        .left()
+                        .contained()
+                        .with_style(theme.collab_panel.channel_editor)
+                        .flex(1.0, true)
+                        .into_any()
+                },
+            )
+            .align_children_center()
+            .constrained()
+            .with_height(theme.collab_panel.row_height)
+            .contained()
+            .with_style(ContainerStyle {
+                background_color: Some(theme.editor.background),
+                ..*theme.collab_panel.contact_row.default_style()
+            })
+            .with_padding_left(
+                theme.collab_panel.contact_row.default_style().padding.left
+                    + theme.collab_panel.channel_indent * depth as f32,
+            )
+            .into_any()
+    }
+
+    fn render_channel(
+        &self,
+        channel: &Channel,
+        depth: usize,
+        theme: &theme::Theme,
+        is_selected: bool,
+        has_children: bool,
+        ix: usize,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        let channel_id = channel.id;
+        let collab_theme = &theme.collab_panel;
+        let is_public = self
+            .channel_store
+            .read(cx)
+            .channel_for_id(channel_id)
+            .map(|channel| channel.visibility)
+            == Some(proto::ChannelVisibility::Public);
+        let other_selected = self.selected_channel().map(|channel| channel.id) == Some(channel.id);
+        let disclosed =
+            has_children.then(|| !self.collapsed_channels.binary_search(&channel.id).is_ok());
+
+        let is_active = maybe!({
+            let call_channel = ActiveCall::global(cx)
+                .read(cx)
+                .room()?
+                .read(cx)
+                .channel_id()?;
+            Some(call_channel == channel_id)
+        })
+        .unwrap_or(false);
+
+        const FACEPILE_LIMIT: usize = 3;
+
+        enum ChannelCall {}
+        enum ChannelNote {}
+        enum NotesTooltip {}
+        enum ChatTooltip {}
+        enum ChannelTooltip {}
+
+        let mut is_dragged_over = false;
+        if cx
+            .global::<DragAndDrop<Workspace>>()
+            .currently_dragged::<Channel>(cx.window())
+            .is_some()
+            && self.drag_target_channel == ChannelDragTarget::Channel(channel_id)
+        {
+            is_dragged_over = true;
+        }
+
+        let has_messages_notification = channel.unseen_message_id.is_some();
+
+        MouseEventHandler::new::<Channel, _>(ix, cx, |state, cx| {
+            let row_hovered = state.hovered();
+
+            let mut select_state = |interactive: &Interactive<ContainerStyle>| {
+                if state.clicked() == Some(MouseButton::Left) && interactive.clicked.is_some() {
+                    interactive.clicked.as_ref().unwrap().clone()
+                } else if state.hovered() || other_selected {
+                    interactive
+                        .hovered
+                        .as_ref()
+                        .unwrap_or(&interactive.default)
+                        .clone()
+                } else {
+                    interactive.default.clone()
+                }
+            };
+
+            Flex::<Self>::row()
+                .with_child(
+                    Svg::new(if is_public {
+                        "icons/public.svg"
+                    } else {
+                        "icons/hash.svg"
+                    })
+                    .with_color(collab_theme.channel_hash.color)
+                    .constrained()
+                    .with_width(collab_theme.channel_hash.width)
+                    .aligned()
+                    .left(),
+                )
+                .with_child({
+                    let style = collab_theme.channel_name.inactive_state();
+                    Flex::row()
+                        .with_child(
+                            Label::new(channel.name.clone(), style.text.clone())
+                                .contained()
+                                .with_style(style.container)
+                                .aligned()
+                                .left()
+                                .with_tooltip::<ChannelTooltip>(
+                                    ix,
+                                    "Join channel",
+                                    None,
+                                    theme.tooltip.clone(),
+                                    cx,
+                                ),
+                        )
+                        .with_children({
+                            let participants =
+                                self.channel_store.read(cx).channel_participants(channel_id);
+
+                            if !participants.is_empty() {
+                                let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
+
+                                let result = FacePile::new(collab_theme.face_overlap)
+                                    .with_children(
+                                        participants
+                                            .iter()
+                                            .filter_map(|user| {
+                                                Some(
+                                                    Image::from_data(user.avatar.clone()?)
+                                                        .with_style(collab_theme.channel_avatar),
+                                                )
+                                            })
+                                            .take(FACEPILE_LIMIT),
+                                    )
+                                    .with_children((extra_count > 0).then(|| {
+                                        Label::new(
+                                            format!("+{}", extra_count),
+                                            collab_theme.extra_participant_label.text.clone(),
+                                        )
+                                        .contained()
+                                        .with_style(collab_theme.extra_participant_label.container)
+                                    }));
+
+                                Some(result)
+                            } else {
+                                None
+                            }
+                        })
+                        .with_spacing(8.)
+                        .align_children_center()
+                        .flex(1., true)
+                })
+                .with_child(
+                    MouseEventHandler::new::<ChannelNote, _>(ix, cx, move |mouse_state, _| {
+                        let container_style = collab_theme
+                            .disclosure
+                            .button
+                            .style_for(mouse_state)
+                            .container;
+
+                        if channel.unseen_message_id.is_some() {
+                            Svg::new("icons/conversations.svg")
+                                .with_color(collab_theme.channel_note_active_color)
+                                .constrained()
+                                .with_width(collab_theme.channel_hash.width)
+                                .contained()
+                                .with_style(container_style)
+                                .with_uniform_padding(4.)
+                                .into_any()
+                        } else if row_hovered {
+                            Svg::new("icons/conversations.svg")
+                                .with_color(collab_theme.channel_hash.color)
+                                .constrained()
+                                .with_width(collab_theme.channel_hash.width)
+                                .contained()
+                                .with_style(container_style)
+                                .with_uniform_padding(4.)
+                                .into_any()
+                        } else {
+                            Empty::new().into_any()
+                        }
+                    })
+                    .on_click(MouseButton::Left, move |_, this, cx| {
+                        this.join_channel_chat(&JoinChannelChat { channel_id }, cx);
+                    })
+                    .with_tooltip::<ChatTooltip>(
+                        ix,
+                        "Open channel chat",
+                        None,
+                        theme.tooltip.clone(),
+                        cx,
+                    )
+                    .contained()
+                    .with_margin_right(4.),
+                )
+                .with_child(
+                    MouseEventHandler::new::<ChannelCall, _>(ix, cx, move |mouse_state, cx| {
+                        let container_style = collab_theme
+                            .disclosure
+                            .button
+                            .style_for(mouse_state)
+                            .container;
+                        if row_hovered || channel.unseen_note_version.is_some() {
+                            Svg::new("icons/file.svg")
+                                .with_color(if channel.unseen_note_version.is_some() {
+                                    collab_theme.channel_note_active_color
+                                } else {
+                                    collab_theme.channel_hash.color
+                                })
+                                .constrained()
+                                .with_width(collab_theme.channel_hash.width)
+                                .contained()
+                                .with_style(container_style)
+                                .with_uniform_padding(4.)
+                                .with_margin_right(collab_theme.channel_hash.container.margin.left)
+                                .with_tooltip::<NotesTooltip>(
+                                    ix as usize,
+                                    "Open channel notes",
+                                    None,
+                                    theme.tooltip.clone(),
+                                    cx,
+                                )
+                                .into_any()
+                        } else if has_messages_notification {
+                            Empty::new()
+                                .constrained()
+                                .with_width(collab_theme.channel_hash.width)
+                                .contained()
+                                .with_uniform_padding(4.)
+                                .with_margin_right(collab_theme.channel_hash.container.margin.left)
+                                .into_any()
+                        } else {
+                            Empty::new().into_any()
+                        }
+                    })
+                    .on_click(MouseButton::Left, move |_, this, cx| {
+                        this.open_channel_notes(&OpenChannelNotes { channel_id }, cx);
+                    }),
+                )
+                .align_children_center()
+                .styleable_component()
+                .disclosable(
+                    disclosed,
+                    Box::new(ToggleCollapse {
+                        location: channel.id.clone(),
+                    }),
+                )
+                .with_id(ix)
+                .with_style(collab_theme.disclosure.clone())
+                .element()
+                .constrained()
+                .with_height(collab_theme.row_height)
+                .contained()
+                .with_style(select_state(
+                    collab_theme
+                        .channel_row
+                        .in_state(is_selected || is_active || is_dragged_over),
+                ))
+                .with_padding_left(
+                    collab_theme.channel_row.default_style().padding.left
+                        + collab_theme.channel_indent * depth as f32,
+                )
+        })
+        .on_click(MouseButton::Left, move |_, this, cx| {
+            if this.drag_target_channel == ChannelDragTarget::None {
+                if is_active {
+                    this.open_channel_notes(&OpenChannelNotes { channel_id }, cx)
+                } else {
+                    this.join_channel(channel_id, cx)
+                }
+            }
+        })
+        .on_click(MouseButton::Right, {
+            let channel = channel.clone();
+            move |e, this, cx| {
+                this.deploy_channel_context_menu(Some(e.position), &channel, ix, cx);
+            }
+        })
+        .on_up(MouseButton::Left, move |_, this, cx| {
+            if let Some((_, dragged_channel)) = cx
+                .global::<DragAndDrop<Workspace>>()
+                .currently_dragged::<Channel>(cx.window())
+            {
+                this.channel_store
+                    .update(cx, |channel_store, cx| {
+                        channel_store.move_channel(dragged_channel.id, Some(channel_id), cx)
+                    })
+                    .detach_and_log_err(cx)
+            }
+        })
+        .on_move({
+            let channel = channel.clone();
+            move |_, this, cx| {
+                if let Some((_, dragged_channel)) = cx
+                    .global::<DragAndDrop<Workspace>>()
+                    .currently_dragged::<Channel>(cx.window())
+                {
+                    if channel.id != dragged_channel.id {
+                        this.drag_target_channel = ChannelDragTarget::Channel(channel.id);
+                    }
+                    cx.notify()
+                }
+            }
+        })
+        .as_draggable::<_, Channel>(
+            channel.clone(),
+            move |_, channel, cx: &mut ViewContext<Workspace>| {
+                let theme = &theme::current(cx).collab_panel;
+
+                Flex::<Workspace>::row()
+                    .with_child(
+                        Svg::new("icons/hash.svg")
+                            .with_color(theme.channel_hash.color)
+                            .constrained()
+                            .with_width(theme.channel_hash.width)
+                            .aligned()
+                            .left(),
+                    )
+                    .with_child(
+                        Label::new(channel.name.clone(), theme.channel_name.text.clone())
+                            .contained()
+                            .with_style(theme.channel_name.container)
+                            .aligned()
+                            .left(),
+                    )
+                    .align_children_center()
+                    .contained()
+                    .with_background_color(
+                        theme
+                            .container
+                            .background_color
+                            .unwrap_or(gpui::color::Color::transparent_black()),
+                    )
+                    .contained()
+                    .with_padding_left(
+                        theme.channel_row.default_style().padding.left
+                            + theme.channel_indent * depth as f32,
+                    )
+                    .into_any()
+            },
+        )
+        .with_cursor_style(CursorStyle::PointingHand)
+        .into_any()
+    }
+
+    fn render_channel_notes(
+        &self,
+        channel_id: ChannelId,
+        theme: &theme::CollabPanel,
+        is_selected: bool,
+        ix: usize,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum ChannelNotes {}
+        let host_avatar_width = theme
+            .contact_avatar
+            .width
+            .or(theme.contact_avatar.height)
+            .unwrap_or(0.);
+
+        MouseEventHandler::new::<ChannelNotes, _>(ix as usize, cx, |state, cx| {
+            let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state);
+            let row = theme.project_row.in_state(is_selected).style_for(state);
+
+            Flex::<Self>::row()
+                .with_child(render_tree_branch(
+                    tree_branch,
+                    &row.name.text,
+                    false,
+                    vec2f(host_avatar_width, theme.row_height),
+                    cx.font_cache(),
+                ))
+                .with_child(
+                    Svg::new("icons/file.svg")
+                        .with_color(theme.channel_hash.color)
+                        .constrained()
+                        .with_width(theme.channel_hash.width)
+                        .aligned()
+                        .left(),
+                )
+                .with_child(
+                    Label::new("notes", theme.channel_name.text.clone())
+                        .contained()
+                        .with_style(theme.channel_name.container)
+                        .aligned()
+                        .left()
+                        .flex(1., true),
+                )
+                .constrained()
+                .with_height(theme.row_height)
+                .contained()
+                .with_style(*theme.channel_row.style_for(is_selected, state))
+                .with_padding_left(theme.channel_row.default_style().padding.left)
+        })
+        .on_click(MouseButton::Left, move |_, this, cx| {
+            this.open_channel_notes(&OpenChannelNotes { channel_id }, cx);
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .into_any()
+    }
+
+    fn render_channel_chat(
+        &self,
+        channel_id: ChannelId,
+        theme: &theme::CollabPanel,
+        is_selected: bool,
+        ix: usize,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum ChannelChat {}
+        let host_avatar_width = theme
+            .contact_avatar
+            .width
+            .or(theme.contact_avatar.height)
+            .unwrap_or(0.);
+
+        MouseEventHandler::new::<ChannelChat, _>(ix as usize, cx, |state, cx| {
+            let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state);
+            let row = theme.project_row.in_state(is_selected).style_for(state);
+
+            Flex::<Self>::row()
+                .with_child(render_tree_branch(
+                    tree_branch,
+                    &row.name.text,
+                    true,
+                    vec2f(host_avatar_width, theme.row_height),
+                    cx.font_cache(),
+                ))
+                .with_child(
+                    Svg::new("icons/conversations.svg")
+                        .with_color(theme.channel_hash.color)
+                        .constrained()
+                        .with_width(theme.channel_hash.width)
+                        .aligned()
+                        .left(),
+                )
+                .with_child(
+                    Label::new("chat", theme.channel_name.text.clone())
+                        .contained()
+                        .with_style(theme.channel_name.container)
+                        .aligned()
+                        .left()
+                        .flex(1., true),
+                )
+                .constrained()
+                .with_height(theme.row_height)
+                .contained()
+                .with_style(*theme.channel_row.style_for(is_selected, state))
+                .with_padding_left(theme.channel_row.default_style().padding.left)
+        })
+        .on_click(MouseButton::Left, move |_, this, cx| {
+            this.join_channel_chat(&JoinChannelChat { channel_id }, cx);
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .into_any()
+    }
+
+    fn render_channel_invite(
+        channel: Arc<Channel>,
+        channel_store: ModelHandle<ChannelStore>,
+        theme: &theme::CollabPanel,
+        is_selected: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum Decline {}
+        enum Accept {}
+
+        let channel_id = channel.id;
+        let is_invite_pending = channel_store
+            .read(cx)
+            .has_pending_channel_invite_response(&channel);
+        let button_spacing = theme.contact_button_spacing;
+
+        Flex::row()
+            .with_child(
+                Svg::new("icons/hash.svg")
+                    .with_color(theme.channel_hash.color)
+                    .constrained()
+                    .with_width(theme.channel_hash.width)
+                    .aligned()
+                    .left(),
+            )
+            .with_child(
+                Label::new(channel.name.clone(), theme.contact_username.text.clone())
+                    .contained()
+                    .with_style(theme.contact_username.container)
+                    .aligned()
+                    .left()
+                    .flex(1., true),
+            )
+            .with_child(
+                MouseEventHandler::new::<Decline, _>(channel.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_invite_pending {
+                        &theme.disabled_button
+                    } else {
+                        theme.contact_button.style_for(mouse_state)
+                    };
+                    render_icon_button(button_style, "icons/x.svg").aligned()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    this.respond_to_channel_invite(channel_id, false, cx);
+                })
+                .contained()
+                .with_margin_right(button_spacing),
+            )
+            .with_child(
+                MouseEventHandler::new::<Accept, _>(channel.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_invite_pending {
+                        &theme.disabled_button
+                    } else {
+                        theme.contact_button.style_for(mouse_state)
+                    };
+                    render_icon_button(button_style, "icons/check.svg")
+                        .aligned()
+                        .flex_float()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    this.respond_to_channel_invite(channel_id, true, cx);
+                }),
+            )
+            .constrained()
+            .with_height(theme.row_height)
+            .contained()
+            .with_style(
+                *theme
+                    .contact_row
+                    .in_state(is_selected)
+                    .style_for(&mut Default::default()),
+            )
+            .with_padding_left(
+                theme.contact_row.default_style().padding.left + theme.channel_indent,
+            )
+            .into_any()
+    }
+
+    fn render_contact_request(
+        user: Arc<User>,
+        user_store: ModelHandle<UserStore>,
+        theme: &theme::CollabPanel,
+        is_incoming: bool,
+        is_selected: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum Decline {}
+        enum Accept {}
+        enum Cancel {}
+
+        let mut row = Flex::row()
+            .with_children(user.avatar.clone().map(|avatar| {
+                Image::from_data(avatar)
+                    .with_style(theme.contact_avatar)
+                    .aligned()
+                    .left()
+            }))
+            .with_child(
+                Label::new(
+                    user.github_login.clone(),
+                    theme.contact_username.text.clone(),
+                )
+                .contained()
+                .with_style(theme.contact_username.container)
+                .aligned()
+                .left()
+                .flex(1., true),
+            );
+
+        let user_id = user.id;
+        let github_login = user.github_login.clone();
+        let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
+        let button_spacing = theme.contact_button_spacing;
+
+        if is_incoming {
+            row.add_child(
+                MouseEventHandler::new::<Decline, _>(user.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_contact_request_pending {
+                        &theme.disabled_button
+                    } else {
+                        theme.contact_button.style_for(mouse_state)
+                    };
+                    render_icon_button(button_style, "icons/x.svg").aligned()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    this.respond_to_contact_request(user_id, false, cx);
+                })
+                .contained()
+                .with_margin_right(button_spacing),
+            );
+
+            row.add_child(
+                MouseEventHandler::new::<Accept, _>(user.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_contact_request_pending {
+                        &theme.disabled_button
+                    } else {
+                        theme.contact_button.style_for(mouse_state)
+                    };
+                    render_icon_button(button_style, "icons/check.svg")
+                        .aligned()
+                        .flex_float()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    this.respond_to_contact_request(user_id, true, cx);
+                }),
+            );
+        } else {
+            row.add_child(
+                MouseEventHandler::new::<Cancel, _>(user.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_contact_request_pending {
+                        &theme.disabled_button
+                    } else {
+                        theme.contact_button.style_for(mouse_state)
+                    };
+                    render_icon_button(button_style, "icons/x.svg")
+                        .aligned()
+                        .flex_float()
+                })
+                .with_padding(Padding::uniform(2.))
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    this.remove_contact(user_id, &github_login, cx);
+                })
+                .flex_float(),
+            );
+        }
+
+        row.constrained()
+            .with_height(theme.row_height)
+            .contained()
+            .with_style(
+                *theme
+                    .contact_row
+                    .in_state(is_selected)
+                    .style_for(&mut Default::default()),
+            )
+            .into_any()
+    }
+
+    fn has_subchannels(&self, ix: usize) -> bool {
+        self.entries.get(ix).map_or(false, |entry| {
+            if let ListEntry::Channel { has_children, .. } = entry {
+                *has_children
+            } else {
+                false
+            }
+        })
+    }
+
+    fn deploy_channel_context_menu(
+        &mut self,
+        position: Option<Vector2F>,
+        channel: &Channel,
+        ix: usize,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.context_menu_on_selected = position.is_none();
+
+        let clipboard_channel_name = self.channel_clipboard.as_ref().and_then(|clipboard| {
+            self.channel_store
+                .read(cx)
+                .channel_for_id(clipboard.channel_id)
+                .map(|channel| channel.name.clone())
+        });
+
+        self.context_menu.update(cx, |context_menu, cx| {
+            context_menu.set_position_mode(if self.context_menu_on_selected {
+                OverlayPositionMode::Local
+            } else {
+                OverlayPositionMode::Window
+            });
+
+            let mut items = Vec::new();
+
+            let select_action_name = if self.selection == Some(ix) {
+                "Unselect"
+            } else {
+                "Select"
+            };
+
+            items.push(ContextMenuItem::action(
+                select_action_name,
+                ToggleSelectedIx { ix },
+            ));
+
+            if self.has_subchannels(ix) {
+                let expand_action_name = if self.is_channel_collapsed(channel.id) {
+                    "Expand Subchannels"
+                } else {
+                    "Collapse Subchannels"
+                };
+                items.push(ContextMenuItem::action(
+                    expand_action_name,
+                    ToggleCollapse {
+                        location: channel.id,
+                    },
+                ));
+            }
+
+            items.push(ContextMenuItem::action(
+                "Open Notes",
+                OpenChannelNotes {
+                    channel_id: channel.id,
+                },
+            ));
+
+            items.push(ContextMenuItem::action(
+                "Open Chat",
+                JoinChannelChat {
+                    channel_id: channel.id,
+                },
+            ));
+
+            items.push(ContextMenuItem::action(
+                "Copy Channel Link",
+                CopyChannelLink {
+                    channel_id: channel.id,
+                },
+            ));
+
+            if self.channel_store.read(cx).is_channel_admin(channel.id) {
+                items.extend([
+                    ContextMenuItem::Separator,
+                    ContextMenuItem::action(
+                        "New Subchannel",
+                        NewChannel {
+                            location: channel.id,
+                        },
+                    ),
+                    ContextMenuItem::action(
+                        "Rename",
+                        RenameChannel {
+                            channel_id: channel.id,
+                        },
+                    ),
+                    ContextMenuItem::action(
+                        "Move this channel",
+                        StartMoveChannelFor {
+                            channel_id: channel.id,
+                        },
+                    ),
+                ]);
+
+                if let Some(channel_name) = clipboard_channel_name {
+                    items.push(ContextMenuItem::Separator);
+                    items.push(ContextMenuItem::action(
+                        format!("Move '#{}' here", channel_name),
+                        MoveChannel { to: channel.id },
+                    ));
+                }
+
+                items.extend([
+                    ContextMenuItem::Separator,
+                    ContextMenuItem::action(
+                        "Invite Members",
+                        InviteMembers {
+                            channel_id: channel.id,
+                        },
+                    ),
+                    ContextMenuItem::action(
+                        "Manage Members",
+                        ManageMembers {
+                            channel_id: channel.id,
+                        },
+                    ),
+                    ContextMenuItem::Separator,
+                    ContextMenuItem::action(
+                        "Delete",
+                        RemoveChannel {
+                            channel_id: channel.id,
+                        },
+                    ),
+                ]);
+            }
+
+            context_menu.show(
+                position.unwrap_or_default(),
+                if self.context_menu_on_selected {
+                    gpui::elements::AnchorCorner::TopRight
+                } else {
+                    gpui::elements::AnchorCorner::BottomLeft
+                },
+                items,
+                cx,
+            );
+        });
+
+        cx.notify();
+    }
+
+    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
+        if self.take_editing_state(cx) {
+            cx.focus(&self.filter_editor);
+        } else {
+            self.filter_editor.update(cx, |editor, cx| {
+                if editor.buffer().read(cx).len(cx) > 0 {
+                    editor.set_text("", cx);
+                }
+            });
+        }
+
+        self.update_entries(false, cx);
+    }
+
+    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
+        let ix = self.selection.map_or(0, |ix| ix + 1);
+        if ix < self.entries.len() {
+            self.selection = Some(ix);
+        }
+
+        self.list_state.reset(self.entries.len());
+        if let Some(ix) = self.selection {
+            self.list_state.scroll_to(ListOffset {
+                item_ix: ix,
+                offset_in_item: 0.,
+            });
+        }
+        cx.notify();
+    }
+
+    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
+        let ix = self.selection.take().unwrap_or(0);
+        if ix > 0 {
+            self.selection = Some(ix - 1);
+        }
+
+        self.list_state.reset(self.entries.len());
+        if let Some(ix) = self.selection {
+            self.list_state.scroll_to(ListOffset {
+                item_ix: ix,
+                offset_in_item: 0.,
+            });
+        }
+        cx.notify();
+    }
+
+    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
+        if self.confirm_channel_edit(cx) {
+            return;
+        }
+
+        if let Some(selection) = self.selection {
+            if let Some(entry) = self.entries.get(selection) {
+                match entry {
+                    ListEntry::Header(section) => match section {
+                        Section::ActiveCall => Self::leave_call(cx),
+                        Section::Channels => self.new_root_channel(cx),
+                        Section::Contacts => self.toggle_contact_finder(cx),
+                        Section::ContactRequests
+                        | Section::Online
+                        | Section::Offline
+                        | Section::ChannelInvites => {
+                            self.toggle_section_expanded(*section, cx);
+                        }
+                    },
+                    ListEntry::Contact { contact, calling } => {
+                        if contact.online && !contact.busy && !calling {
+                            self.call(contact.user.id, Some(self.project.clone()), cx);
+                        }
+                    }
+                    ListEntry::ParticipantProject {
+                        project_id,
+                        host_user_id,
+                        ..
+                    } => {
+                        if let Some(workspace) = self.workspace.upgrade(cx) {
+                            let app_state = workspace.read(cx).app_state().clone();
+                            workspace::join_remote_project(
+                                *project_id,
+                                *host_user_id,
+                                app_state,
+                                cx,
+                            )
+                            .detach_and_log_err(cx);
+                        }
+                    }
+                    ListEntry::ParticipantScreen { peer_id, .. } => {
+                        let Some(peer_id) = peer_id else {
+                            return;
+                        };
+                        if let Some(workspace) = self.workspace.upgrade(cx) {
+                            workspace.update(cx, |workspace, cx| {
+                                workspace.open_shared_screen(*peer_id, cx)
+                            });
+                        }
+                    }
+                    ListEntry::Channel { channel, .. } => {
+                        let is_active = maybe!({
+                            let call_channel = ActiveCall::global(cx)
+                                .read(cx)
+                                .room()?
+                                .read(cx)
+                                .channel_id()?;
+
+                            Some(call_channel == channel.id)
+                        })
+                        .unwrap_or(false);
+                        if is_active {
+                            self.open_channel_notes(
+                                &OpenChannelNotes {
+                                    channel_id: channel.id,
+                                },
+                                cx,
+                            )
+                        } else {
+                            self.join_channel(channel.id, cx)
+                        }
+                    }
+                    ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
+                    _ => {}
+                }
+            }
+        }
+    }
+
+    fn insert_space(&mut self, _: &InsertSpace, cx: &mut ViewContext<Self>) {
+        if self.channel_editing_state.is_some() {
+            self.channel_name_editor.update(cx, |editor, cx| {
+                editor.insert(" ", cx);
+            });
+        }
+    }
+
+    fn confirm_channel_edit(&mut self, cx: &mut ViewContext<CollabPanel>) -> bool {
+        if let Some(editing_state) = &mut self.channel_editing_state {
+            match editing_state {
+                ChannelEditingState::Create {
+                    location,
+                    pending_name,
+                    ..
+                } => {
+                    if pending_name.is_some() {
+                        return false;
+                    }
+                    let channel_name = self.channel_name_editor.read(cx).text(cx);
+
+                    *pending_name = Some(channel_name.clone());
+
+                    self.channel_store
+                        .update(cx, |channel_store, cx| {
+                            channel_store.create_channel(&channel_name, *location, cx)
+                        })
+                        .detach();
+                    cx.notify();
+                }
+                ChannelEditingState::Rename {
+                    location,
+                    pending_name,
+                } => {
+                    if pending_name.is_some() {
+                        return false;
+                    }
+                    let channel_name = self.channel_name_editor.read(cx).text(cx);
+                    *pending_name = Some(channel_name.clone());
+
+                    self.channel_store
+                        .update(cx, |channel_store, cx| {
+                            channel_store.rename(*location, &channel_name, cx)
+                        })
+                        .detach();
+                    cx.notify();
+                }
+            }
+            cx.focus_self();
+            true
+        } else {
+            false
+        }
+    }
+
+    fn toggle_section_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
+            self.collapsed_sections.remove(ix);
+        } else {
+            self.collapsed_sections.push(section);
+        }
+        self.update_entries(false, cx);
+    }
+
+    fn collapse_selected_channel(
+        &mut self,
+        _: &CollapseSelectedChannel,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
+            return;
+        };
+
+        if self.is_channel_collapsed(channel_id) {
+            return;
+        }
+
+        self.toggle_channel_collapsed(channel_id, cx);
+    }
+
+    fn expand_selected_channel(&mut self, _: &ExpandSelectedChannel, cx: &mut ViewContext<Self>) {
+        let Some(id) = self.selected_channel().map(|channel| channel.id) else {
+            return;
+        };
+
+        if !self.is_channel_collapsed(id) {
+            return;
+        }
+
+        self.toggle_channel_collapsed(id, cx)
+    }
+
+    fn toggle_channel_collapsed_action(
+        &mut self,
+        action: &ToggleCollapse,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.toggle_channel_collapsed(action.location, cx);
+    }
+
+    fn toggle_channel_collapsed<'a>(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
+        match self.collapsed_channels.binary_search(&channel_id) {
+            Ok(ix) => {
+                self.collapsed_channels.remove(ix);
+            }
+            Err(ix) => {
+                self.collapsed_channels.insert(ix, channel_id);
+            }
+        };
+        self.serialize(cx);
+        self.update_entries(true, cx);
+        cx.notify();
+        cx.focus_self();
+    }
+
+    fn is_channel_collapsed(&self, channel_id: ChannelId) -> bool {
+        self.collapsed_channels.binary_search(&channel_id).is_ok()
+    }
+
+    fn leave_call(cx: &mut ViewContext<Self>) {
+        ActiveCall::global(cx)
+            .update(cx, |call, cx| call.hang_up(cx))
+            .detach_and_log_err(cx);
+    }
+
+    fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(workspace) = self.workspace.upgrade(cx) {
+            workspace.update(cx, |workspace, cx| {
+                workspace.toggle_modal(cx, |_, cx| {
+                    cx.add_view(|cx| {
+                        let mut finder = ContactFinder::new(self.user_store.clone(), cx);
+                        finder.set_query(self.filter_editor.read(cx).text(cx), cx);
+                        finder
+                    })
+                });
+            });
+        }
+    }
+
+    fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
+        self.channel_editing_state = Some(ChannelEditingState::Create {
+            location: None,
+            pending_name: None,
+        });
+        self.update_entries(false, cx);
+        self.select_channel_editor();
+        cx.focus(self.channel_name_editor.as_any());
+        cx.notify();
+    }
+
+    fn select_channel_editor(&mut self) {
+        self.selection = self.entries.iter().position(|entry| match entry {
+            ListEntry::ChannelEditor { .. } => true,
+            _ => false,
+        });
+    }
+
+    fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext<Self>) {
+        self.collapsed_channels
+            .retain(|channel| *channel != action.location);
+        self.channel_editing_state = Some(ChannelEditingState::Create {
+            location: Some(action.location.to_owned()),
+            pending_name: None,
+        });
+        self.update_entries(false, cx);
+        self.select_channel_editor();
+        cx.focus(self.channel_name_editor.as_any());
+        cx.notify();
+    }
+
+    fn invite_members(&mut self, action: &InviteMembers, cx: &mut ViewContext<Self>) {
+        self.show_channel_modal(action.channel_id, channel_modal::Mode::InviteMembers, cx);
+    }
+
+    fn manage_members(&mut self, action: &ManageMembers, cx: &mut ViewContext<Self>) {
+        self.show_channel_modal(action.channel_id, channel_modal::Mode::ManageMembers, cx);
+    }
+
+    fn remove(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
+        if let Some(channel) = self.selected_channel() {
+            self.remove_channel(channel.id, cx)
+        }
+    }
+
+    fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
+        if let Some(channel) = self.selected_channel() {
+            self.rename_channel(
+                &RenameChannel {
+                    channel_id: channel.id,
+                },
+                cx,
+            );
+        }
+    }
+
+    fn rename_channel(&mut self, action: &RenameChannel, cx: &mut ViewContext<Self>) {
+        let channel_store = self.channel_store.read(cx);
+        if !channel_store.is_channel_admin(action.channel_id) {
+            return;
+        }
+        if let Some(channel) = channel_store.channel_for_id(action.channel_id).cloned() {
+            self.channel_editing_state = Some(ChannelEditingState::Rename {
+                location: action.channel_id.to_owned(),
+                pending_name: None,
+            });
+            self.channel_name_editor.update(cx, |editor, cx| {
+                editor.set_text(channel.name.clone(), cx);
+                editor.select_all(&Default::default(), cx);
+            });
+            cx.focus(self.channel_name_editor.as_any());
+            self.update_entries(false, cx);
+            self.select_channel_editor();
+        }
+    }
+
+    fn open_channel_notes(&mut self, action: &OpenChannelNotes, cx: &mut ViewContext<Self>) {
+        if let Some(workspace) = self.workspace.upgrade(cx) {
+            ChannelView::open(action.channel_id, workspace, cx).detach();
+        }
+    }
+
+    fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
+        let Some(channel) = self.selected_channel() else {
+            return;
+        };
+
+        self.deploy_channel_context_menu(None, &channel.clone(), self.selection.unwrap(), cx);
+    }
+
+    fn selected_channel(&self) -> Option<&Arc<Channel>> {
+        self.selection
+            .and_then(|ix| self.entries.get(ix))
+            .and_then(|entry| match entry {
+                ListEntry::Channel { channel, .. } => Some(channel),
+                _ => None,
+            })
+    }
+
+    fn show_channel_modal(
+        &mut self,
+        channel_id: ChannelId,
+        mode: channel_modal::Mode,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let workspace = self.workspace.clone();
+        let user_store = self.user_store.clone();
+        let channel_store = self.channel_store.clone();
+        let members = self.channel_store.update(cx, |channel_store, cx| {
+            channel_store.get_channel_member_details(channel_id, cx)
+        });
+
+        cx.spawn(|_, mut cx| async move {
+            let members = members.await?;
+            workspace.update(&mut cx, |workspace, cx| {
+                workspace.toggle_modal(cx, |_, cx| {
+                    cx.add_view(|cx| {
+                        ChannelModal::new(
+                            user_store.clone(),
+                            channel_store.clone(),
+                            channel_id,
+                            mode,
+                            members,
+                            cx,
+                        )
+                    })
+                });
+            })
+        })
+        .detach();
+    }
+
+    fn remove_selected_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext<Self>) {
+        self.remove_channel(action.channel_id, cx)
+    }
+
+    fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
+        let channel_store = self.channel_store.clone();
+        if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
+            let prompt_message = format!(
+                "Are you sure you want to remove the channel \"{}\"?",
+                channel.name
+            );
+            let mut answer =
+                cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
+            let window = cx.window();
+            cx.spawn(|this, mut cx| async move {
+                if answer.next().await == Some(0) {
+                    if let Err(e) = channel_store
+                        .update(&mut cx, |channels, _| channels.remove_channel(channel_id))
+                        .await
+                    {
+                        window.prompt(
+                            PromptLevel::Info,
+                            &format!("Failed to remove channel: {}", e),
+                            &["Ok"],
+                            &mut cx,
+                        );
+                    }
+                    this.update(&mut cx, |_, cx| cx.focus_self()).ok();
+                }
+            })
+            .detach();
+        }
+    }
+
+    // Should move to the filter editor if clicking on it
+    // Should move selection to the channel editor if activating it
+
+    fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
+        let user_store = self.user_store.clone();
+        let prompt_message = format!(
+            "Are you sure you want to remove \"{}\" from your contacts?",
+            github_login
+        );
+        let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
+        let window = cx.window();
+        cx.spawn(|_, mut cx| async move {
+            if answer.next().await == Some(0) {
+                if let Err(e) = user_store
+                    .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
+                    .await
+                {
+                    window.prompt(
+                        PromptLevel::Info,
+                        &format!("Failed to remove contact: {}", e),
+                        &["Ok"],
+                        &mut cx,
+                    );
+                }
+            }
+        })
+        .detach();
+    }
+
+    fn respond_to_contact_request(
+        &mut self,
+        user_id: u64,
+        accept: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.user_store
+            .update(cx, |store, cx| {
+                store.respond_to_contact_request(user_id, accept, cx)
+            })
+            .detach();
+    }
+
+    fn respond_to_channel_invite(
+        &mut self,
+        channel_id: u64,
+        accept: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.channel_store
+            .update(cx, |store, cx| {
+                store.respond_to_channel_invite(channel_id, accept, cx)
+            })
+            .detach();
+    }
+
+    fn call(
+        &mut self,
+        recipient_user_id: u64,
+        initial_project: Option<ModelHandle<Project>>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        ActiveCall::global(cx)
+            .update(cx, |call, cx| {
+                call.invite(recipient_user_id, initial_project, cx)
+            })
+            .detach_and_log_err(cx);
+    }
+
+    fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
+        let Some(workspace) = self.workspace.upgrade(cx) else {
+            return;
+        };
+        let Some(handle) = cx.window().downcast::<Workspace>() else {
+            return;
+        };
+        workspace::join_channel(
+            channel_id,
+            workspace.read(cx).app_state().clone(),
+            Some(handle),
+            cx,
+        )
+        .detach_and_log_err(cx)
+    }
+
+    fn join_channel_chat(&mut self, action: &JoinChannelChat, cx: &mut ViewContext<Self>) {
+        let channel_id = action.channel_id;
+        if let Some(workspace) = self.workspace.upgrade(cx) {
+            cx.app_context().defer(move |cx| {
+                workspace.update(cx, |workspace, cx| {
+                    if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
+                        panel.update(cx, |panel, cx| {
+                            panel
+                                .select_channel(channel_id, None, cx)
+                                .detach_and_log_err(cx);
+                        });
+                    }
+                });
+            });
+        }
+    }
+
+    fn copy_channel_link(&mut self, action: &CopyChannelLink, cx: &mut ViewContext<Self>) {
+        let channel_store = self.channel_store.read(cx);
+        let Some(channel) = channel_store.channel_for_id(action.channel_id) else {
+            return;
+        };
+        let item = ClipboardItem::new(channel.link());
+        cx.write_to_clipboard(item)
+    }
+}
+
+fn render_tree_branch(
+    branch_style: theme::TreeBranch,
+    row_style: &TextStyle,
+    is_last: bool,
+    size: Vector2F,
+    font_cache: &FontCache,
+) -> gpui::elements::ConstrainedBox<CollabPanel> {
+    let line_height = row_style.line_height(font_cache);
+    let cap_height = row_style.cap_height(font_cache);
+    let baseline_offset = row_style.baseline_offset(font_cache) + (size.y() - line_height) / 2.;
+
+    Canvas::new(move |bounds, _, _, cx| {
+        cx.paint_layer(None, |cx| {
+            let start_x = bounds.min_x() + (bounds.width() / 2.) - (branch_style.width / 2.);
+            let end_x = bounds.max_x();
+            let start_y = bounds.min_y();
+            let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
+
+            cx.scene().push_quad(gpui::Quad {
+                bounds: RectF::from_points(
+                    vec2f(start_x, start_y),
+                    vec2f(
+                        start_x + branch_style.width,
+                        if is_last { end_y } else { bounds.max_y() },
+                    ),
+                ),
+                background: Some(branch_style.color),
+                border: gpui::Border::default(),
+                corner_radii: (0.).into(),
+            });
+            cx.scene().push_quad(gpui::Quad {
+                bounds: RectF::from_points(
+                    vec2f(start_x, end_y),
+                    vec2f(end_x, end_y + branch_style.width),
+                ),
+                background: Some(branch_style.color),
+                border: gpui::Border::default(),
+                corner_radii: (0.).into(),
+            });
+        })
+    })
+    .constrained()
+    .with_width(size.x())
+}
+
+impl View for CollabPanel {
+    fn ui_name() -> &'static str {
+        "CollabPanel"
+    }
+
+    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if !self.has_focus {
+            self.has_focus = true;
+            if !self.context_menu.is_focused(cx) {
+                if let Some(editing_state) = &self.channel_editing_state {
+                    if editing_state.pending_name().is_none() {
+                        cx.focus(&self.channel_name_editor);
+                    } else {
+                        cx.focus(&self.filter_editor);
+                    }
+                } else {
+                    cx.focus(&self.filter_editor);
+                }
+            }
+            cx.emit(Event::Focus);
+        }
+    }
+
+    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
+        self.has_focus = false;
+    }
+
+    fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement<Self> {
+        let theme = &theme::current(cx).collab_panel;
+
+        if self.user_store.read(cx).current_user().is_none() {
+            enum LogInButton {}
+
+            return Flex::column()
+                .with_child(
+                    MouseEventHandler::new::<LogInButton, _>(0, cx, |state, _| {
+                        let button = theme.log_in_button.style_for(state);
+                        Label::new("Sign in to collaborate", button.text.clone())
+                            .aligned()
+                            .left()
+                            .contained()
+                            .with_style(button.container)
+                    })
+                    .on_click(MouseButton::Left, |_, this, cx| {
+                        let client = this.client.clone();
+                        cx.spawn(|_, cx| async move {
+                            client.authenticate_and_connect(true, &cx).await.log_err();
+                        })
+                        .detach();
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand),
+                )
+                .contained()
+                .with_style(theme.container)
+                .into_any();
+        }
+
+        enum PanelFocus {}
+        MouseEventHandler::new::<PanelFocus, _>(0, cx, |_, cx| {
+            Stack::new()
+                .with_child(
+                    Flex::column()
+                        .with_child(
+                            Flex::row().with_child(
+                                ChildView::new(&self.filter_editor, cx)
+                                    .contained()
+                                    .with_style(theme.user_query_editor.container)
+                                    .flex(1.0, true),
+                            ),
+                        )
+                        .with_child(List::new(self.list_state.clone()).flex(1., true).into_any())
+                        .contained()
+                        .with_style(theme.container)
+                        .into_any(),
+                )
+                .with_children(
+                    (!self.context_menu_on_selected)
+                        .then(|| ChildView::new(&self.context_menu, cx)),
+                )
+                .into_any()
+        })
+        .on_click(MouseButton::Left, |_, _, cx| cx.focus_self())
+        .into_any_named("collab panel")
+    }
+
+    fn update_keymap_context(
+        &self,
+        keymap: &mut gpui::keymap_matcher::KeymapContext,
+        _: &AppContext,
+    ) {
+        Self::reset_to_default_keymap_context(keymap);
+        if self.channel_editing_state.is_some() {
+            keymap.add_identifier("editing");
+        } else {
+            keymap.add_identifier("not_editing");
+        }
+    }
+}
+
+impl Panel for CollabPanel {
+    fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
+        settings::get::<CollaborationPanelSettings>(cx).dock
+    }
+
+    fn position_is_valid(&self, position: DockPosition) -> bool {
+        matches!(position, DockPosition::Left | DockPosition::Right)
+    }
+
+    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
+        settings::update_settings_file::<CollaborationPanelSettings>(
+            self.fs.clone(),
+            cx,
+            move |settings| settings.dock = Some(position),
+        );
+    }
+
+    fn size(&self, cx: &gpui::WindowContext) -> f32 {
+        self.width
+            .unwrap_or_else(|| settings::get::<CollaborationPanelSettings>(cx).default_width)
+    }
+
+    fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
+        self.width = size;
+        self.serialize(cx);
+        cx.notify();
+    }
+
+    fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
+        settings::get::<CollaborationPanelSettings>(cx)
+            .button
+            .then(|| "icons/user_group_16.svg")
+    }
+
+    fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
+        (
+            "Collaboration Panel".to_string(),
+            Some(Box::new(ToggleFocus)),
+        )
+    }
+
+    fn should_change_position_on_event(event: &Self::Event) -> bool {
+        matches!(event, Event::DockPositionChanged)
+    }
+
+    fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
+        self.has_focus
+    }
+
+    fn is_focus_event(event: &Self::Event) -> bool {
+        matches!(event, Event::Focus)
+    }
+}
+
+impl PartialEq for ListEntry {
+    fn eq(&self, other: &Self) -> bool {
+        match self {
+            ListEntry::Header(section_1) => {
+                if let ListEntry::Header(section_2) = other {
+                    return section_1 == section_2;
+                }
+            }
+            ListEntry::CallParticipant { user: user_1, .. } => {
+                if let ListEntry::CallParticipant { user: user_2, .. } = other {
+                    return user_1.id == user_2.id;
+                }
+            }
+            ListEntry::ParticipantProject {
+                project_id: project_id_1,
+                ..
+            } => {
+                if let ListEntry::ParticipantProject {
+                    project_id: project_id_2,
+                    ..
+                } = other
+                {
+                    return project_id_1 == project_id_2;
+                }
+            }
+            ListEntry::ParticipantScreen {
+                peer_id: peer_id_1, ..
+            } => {
+                if let ListEntry::ParticipantScreen {
+                    peer_id: peer_id_2, ..
+                } = other
+                {
+                    return peer_id_1 == peer_id_2;
+                }
+            }
+            ListEntry::Channel {
+                channel: channel_1, ..
+            } => {
+                if let ListEntry::Channel {
+                    channel: channel_2, ..
+                } = other
+                {
+                    return channel_1.id == channel_2.id;
+                }
+            }
+            ListEntry::ChannelNotes { channel_id } => {
+                if let ListEntry::ChannelNotes {
+                    channel_id: other_id,
+                } = other
+                {
+                    return channel_id == other_id;
+                }
+            }
+            ListEntry::ChannelChat { channel_id } => {
+                if let ListEntry::ChannelChat {
+                    channel_id: other_id,
+                } = other
+                {
+                    return channel_id == other_id;
+                }
+            }
+            ListEntry::ChannelInvite(channel_1) => {
+                if let ListEntry::ChannelInvite(channel_2) = other {
+                    return channel_1.id == channel_2.id;
+                }
+            }
+            ListEntry::IncomingRequest(user_1) => {
+                if let ListEntry::IncomingRequest(user_2) = other {
+                    return user_1.id == user_2.id;
+                }
+            }
+            ListEntry::OutgoingRequest(user_1) => {
+                if let ListEntry::OutgoingRequest(user_2) = other {
+                    return user_1.id == user_2.id;
+                }
+            }
+            ListEntry::Contact {
+                contact: contact_1, ..
+            } => {
+                if let ListEntry::Contact {
+                    contact: contact_2, ..
+                } = other
+                {
+                    return contact_1.user.id == contact_2.user.id;
+                }
+            }
+            ListEntry::ChannelEditor { depth } => {
+                if let ListEntry::ChannelEditor { depth: other_depth } = other {
+                    return depth == other_depth;
+                }
+            }
+            ListEntry::ContactPlaceholder => {
+                if let ListEntry::ContactPlaceholder = other {
+                    return true;
+                }
+            }
+        }
+        false
+    }
+}
+
+fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<CollabPanel> {
+    Svg::new(svg_path)
+        .with_color(style.color)
+        .constrained()
+        .with_width(style.icon_width)
+        .aligned()
+        .constrained()
+        .with_width(style.button_width)
+        .with_height(style.button_width)
+        .contained()
+        .with_style(style.container)
+}

crates/collab_ui2/src/collab_panel/channel_modal.rs 🔗

@@ -0,0 +1,717 @@
+use channel::{ChannelId, ChannelMembership, ChannelStore};
+use client::{
+    proto::{self, ChannelRole, ChannelVisibility},
+    User, UserId, UserStore,
+};
+use context_menu::{ContextMenu, ContextMenuItem};
+use fuzzy::{match_strings, StringMatchCandidate};
+use gpui::{
+    actions,
+    elements::*,
+    platform::{CursorStyle, MouseButton},
+    AppContext, ClipboardItem, Entity, ModelHandle, MouseState, Task, View, ViewContext,
+    ViewHandle,
+};
+use picker::{Picker, PickerDelegate, PickerEvent};
+use std::sync::Arc;
+use util::TryFutureExt;
+use workspace::Modal;
+
+actions!(
+    channel_modal,
+    [
+        SelectNextControl,
+        ToggleMode,
+        ToggleMemberAdmin,
+        RemoveMember
+    ]
+);
+
+pub fn init(cx: &mut AppContext) {
+    Picker::<ChannelModalDelegate>::init(cx);
+    cx.add_action(ChannelModal::toggle_mode);
+    cx.add_action(ChannelModal::toggle_member_admin);
+    cx.add_action(ChannelModal::remove_member);
+    cx.add_action(ChannelModal::dismiss);
+}
+
+pub struct ChannelModal {
+    picker: ViewHandle<Picker<ChannelModalDelegate>>,
+    channel_store: ModelHandle<ChannelStore>,
+    channel_id: ChannelId,
+    has_focus: bool,
+}
+
+impl ChannelModal {
+    pub fn new(
+        user_store: ModelHandle<UserStore>,
+        channel_store: ModelHandle<ChannelStore>,
+        channel_id: ChannelId,
+        mode: Mode,
+        members: Vec<ChannelMembership>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        cx.observe(&channel_store, |_, _, cx| cx.notify()).detach();
+        let picker = cx.add_view(|cx| {
+            Picker::new(
+                ChannelModalDelegate {
+                    matching_users: Vec::new(),
+                    matching_member_indices: Vec::new(),
+                    selected_index: 0,
+                    user_store: user_store.clone(),
+                    channel_store: channel_store.clone(),
+                    channel_id,
+                    match_candidates: Vec::new(),
+                    members,
+                    mode,
+                    context_menu: cx.add_view(|cx| {
+                        let mut menu = ContextMenu::new(cx.view_id(), cx);
+                        menu.set_position_mode(OverlayPositionMode::Local);
+                        menu
+                    }),
+                },
+                cx,
+            )
+            .with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone())
+        });
+
+        cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach();
+
+        let has_focus = picker.read(cx).has_focus();
+
+        Self {
+            picker,
+            channel_store,
+            channel_id,
+            has_focus,
+        }
+    }
+
+    fn toggle_mode(&mut self, _: &ToggleMode, cx: &mut ViewContext<Self>) {
+        let mode = match self.picker.read(cx).delegate().mode {
+            Mode::ManageMembers => Mode::InviteMembers,
+            Mode::InviteMembers => Mode::ManageMembers,
+        };
+        self.set_mode(mode, cx);
+    }
+
+    fn set_mode(&mut self, mode: Mode, cx: &mut ViewContext<Self>) {
+        let channel_store = self.channel_store.clone();
+        let channel_id = self.channel_id;
+        cx.spawn(|this, mut cx| async move {
+            if mode == Mode::ManageMembers {
+                let mut members = channel_store
+                    .update(&mut cx, |channel_store, cx| {
+                        channel_store.get_channel_member_details(channel_id, cx)
+                    })
+                    .await?;
+
+                members.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
+
+                this.update(&mut cx, |this, cx| {
+                    this.picker
+                        .update(cx, |picker, _| picker.delegate_mut().members = members);
+                })?;
+            }
+
+            this.update(&mut cx, |this, cx| {
+                this.picker.update(cx, |picker, cx| {
+                    let delegate = picker.delegate_mut();
+                    delegate.mode = mode;
+                    delegate.selected_index = 0;
+                    picker.set_query("", cx);
+                    picker.update_matches(picker.query(cx), cx);
+                    cx.notify()
+                });
+                cx.notify()
+            })
+        })
+        .detach();
+    }
+
+    fn toggle_member_admin(&mut self, _: &ToggleMemberAdmin, cx: &mut ViewContext<Self>) {
+        self.picker.update(cx, |picker, cx| {
+            picker.delegate_mut().toggle_selected_member_admin(cx);
+        })
+    }
+
+    fn remove_member(&mut self, _: &RemoveMember, cx: &mut ViewContext<Self>) {
+        self.picker.update(cx, |picker, cx| {
+            picker.delegate_mut().remove_selected_member(cx);
+        });
+    }
+
+    fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
+        cx.emit(PickerEvent::Dismiss);
+    }
+}
+
+impl Entity for ChannelModal {
+    type Event = PickerEvent;
+}
+
+impl View for ChannelModal {
+    fn ui_name() -> &'static str {
+        "ChannelModal"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let theme = &theme::current(cx).collab_panel.tabbed_modal;
+
+        let mode = self.picker.read(cx).delegate().mode;
+        let Some(channel) = self.channel_store.read(cx).channel_for_id(self.channel_id) else {
+            return Empty::new().into_any();
+        };
+
+        enum InviteMembers {}
+        enum ManageMembers {}
+
+        fn render_mode_button<T: 'static>(
+            mode: Mode,
+            text: &'static str,
+            current_mode: Mode,
+            theme: &theme::TabbedModal,
+            cx: &mut ViewContext<ChannelModal>,
+        ) -> AnyElement<ChannelModal> {
+            let active = mode == current_mode;
+            MouseEventHandler::new::<T, _>(0, cx, move |state, _| {
+                let contained_text = theme.tab_button.style_for(active, state);
+                Label::new(text, contained_text.text.clone())
+                    .contained()
+                    .with_style(contained_text.container.clone())
+            })
+            .on_click(MouseButton::Left, move |_, this, cx| {
+                if !active {
+                    this.set_mode(mode, cx);
+                }
+            })
+            .with_cursor_style(CursorStyle::PointingHand)
+            .into_any()
+        }
+
+        fn render_visibility(
+            channel_id: ChannelId,
+            visibility: ChannelVisibility,
+            theme: &theme::TabbedModal,
+            cx: &mut ViewContext<ChannelModal>,
+        ) -> AnyElement<ChannelModal> {
+            enum TogglePublic {}
+
+            if visibility == ChannelVisibility::Members {
+                return Flex::row()
+                    .with_child(
+                        MouseEventHandler::new::<TogglePublic, _>(0, cx, move |state, _| {
+                            let style = theme.visibility_toggle.style_for(state);
+                            Label::new(format!("{}", "Public access: OFF"), style.text.clone())
+                                .contained()
+                                .with_style(style.container.clone())
+                        })
+                        .on_click(MouseButton::Left, move |_, this, cx| {
+                            this.channel_store
+                                .update(cx, |channel_store, cx| {
+                                    channel_store.set_channel_visibility(
+                                        channel_id,
+                                        ChannelVisibility::Public,
+                                        cx,
+                                    )
+                                })
+                                .detach_and_log_err(cx);
+                        })
+                        .with_cursor_style(CursorStyle::PointingHand),
+                    )
+                    .into_any();
+            }
+
+            Flex::row()
+                .with_child(
+                    MouseEventHandler::new::<TogglePublic, _>(0, cx, move |state, _| {
+                        let style = theme.visibility_toggle.style_for(state);
+                        Label::new(format!("{}", "Public access: ON"), style.text.clone())
+                            .contained()
+                            .with_style(style.container.clone())
+                    })
+                    .on_click(MouseButton::Left, move |_, this, cx| {
+                        this.channel_store
+                            .update(cx, |channel_store, cx| {
+                                channel_store.set_channel_visibility(
+                                    channel_id,
+                                    ChannelVisibility::Members,
+                                    cx,
+                                )
+                            })
+                            .detach_and_log_err(cx);
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand),
+                )
+                .with_spacing(14.0)
+                .with_child(
+                    MouseEventHandler::new::<TogglePublic, _>(1, cx, move |state, _| {
+                        let style = theme.channel_link.style_for(state);
+                        Label::new(format!("{}", "copy link"), style.text.clone())
+                            .contained()
+                            .with_style(style.container.clone())
+                    })
+                    .on_click(MouseButton::Left, move |_, this, cx| {
+                        if let Some(channel) =
+                            this.channel_store.read(cx).channel_for_id(channel_id)
+                        {
+                            let item = ClipboardItem::new(channel.link());
+                            cx.write_to_clipboard(item);
+                        }
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand),
+                )
+                .into_any()
+        }
+
+        Flex::column()
+            .with_child(
+                Flex::column()
+                    .with_child(
+                        Label::new(format!("#{}", channel.name), theme.title.text.clone())
+                            .contained()
+                            .with_style(theme.title.container.clone()),
+                    )
+                    .with_child(render_visibility(channel.id, channel.visibility, theme, cx))
+                    .with_child(Flex::row().with_children([
+                        render_mode_button::<InviteMembers>(
+                            Mode::InviteMembers,
+                            "Invite members",
+                            mode,
+                            theme,
+                            cx,
+                        ),
+                        render_mode_button::<ManageMembers>(
+                            Mode::ManageMembers,
+                            "Manage members",
+                            mode,
+                            theme,
+                            cx,
+                        ),
+                    ]))
+                    .expanded()
+                    .contained()
+                    .with_style(theme.header),
+            )
+            .with_child(
+                ChildView::new(&self.picker, cx)
+                    .contained()
+                    .with_style(theme.body),
+            )
+            .constrained()
+            .with_max_height(theme.max_height)
+            .with_max_width(theme.max_width)
+            .contained()
+            .with_style(theme.modal)
+            .into_any()
+    }
+
+    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        self.has_focus = true;
+        if cx.is_self_focused() {
+            cx.focus(&self.picker)
+        }
+    }
+
+    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
+        self.has_focus = false;
+    }
+}
+
+impl Modal for ChannelModal {
+    fn has_focus(&self) -> bool {
+        self.has_focus
+    }
+
+    fn dismiss_on_event(event: &Self::Event) -> bool {
+        match event {
+            PickerEvent::Dismiss => true,
+        }
+    }
+}
+
+#[derive(Copy, Clone, PartialEq)]
+pub enum Mode {
+    ManageMembers,
+    InviteMembers,
+}
+
+pub struct ChannelModalDelegate {
+    matching_users: Vec<Arc<User>>,
+    matching_member_indices: Vec<usize>,
+    user_store: ModelHandle<UserStore>,
+    channel_store: ModelHandle<ChannelStore>,
+    channel_id: ChannelId,
+    selected_index: usize,
+    mode: Mode,
+    match_candidates: Vec<StringMatchCandidate>,
+    members: Vec<ChannelMembership>,
+    context_menu: ViewHandle<ContextMenu>,
+}
+
+impl PickerDelegate for ChannelModalDelegate {
+    fn placeholder_text(&self) -> Arc<str> {
+        "Search collaborator by username...".into()
+    }
+
+    fn match_count(&self) -> usize {
+        match self.mode {
+            Mode::ManageMembers => self.matching_member_indices.len(),
+            Mode::InviteMembers => self.matching_users.len(),
+        }
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
+        self.selected_index = ix;
+    }
+
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
+        match self.mode {
+            Mode::ManageMembers => {
+                self.match_candidates.clear();
+                self.match_candidates
+                    .extend(self.members.iter().enumerate().map(|(id, member)| {
+                        StringMatchCandidate {
+                            id,
+                            string: member.user.github_login.clone(),
+                            char_bag: member.user.github_login.chars().collect(),
+                        }
+                    }));
+
+                let matches = cx.background().block(match_strings(
+                    &self.match_candidates,
+                    &query,
+                    true,
+                    usize::MAX,
+                    &Default::default(),
+                    cx.background().clone(),
+                ));
+
+                cx.spawn(|picker, mut cx| async move {
+                    picker
+                        .update(&mut cx, |picker, cx| {
+                            let delegate = picker.delegate_mut();
+                            delegate.matching_member_indices.clear();
+                            delegate
+                                .matching_member_indices
+                                .extend(matches.into_iter().map(|m| m.candidate_id));
+                            cx.notify();
+                        })
+                        .ok();
+                })
+            }
+            Mode::InviteMembers => {
+                let search_users = self
+                    .user_store
+                    .update(cx, |store, cx| store.fuzzy_search_users(query, cx));
+                cx.spawn(|picker, mut cx| async move {
+                    async {
+                        let users = search_users.await?;
+                        picker.update(&mut cx, |picker, cx| {
+                            let delegate = picker.delegate_mut();
+                            delegate.matching_users = users;
+                            cx.notify();
+                        })?;
+                        anyhow::Ok(())
+                    }
+                    .log_err()
+                    .await;
+                })
+            }
+        }
+    }
+
+    fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
+        if let Some((selected_user, role)) = self.user_at_index(self.selected_index) {
+            match self.mode {
+                Mode::ManageMembers => {
+                    self.show_context_menu(role.unwrap_or(ChannelRole::Member), cx)
+                }
+                Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
+                    Some(proto::channel_member::Kind::Invitee) => {
+                        self.remove_selected_member(cx);
+                    }
+                    Some(proto::channel_member::Kind::AncestorMember) | None => {
+                        self.invite_member(selected_user, cx)
+                    }
+                    Some(proto::channel_member::Kind::Member) => {}
+                },
+            }
+        }
+    }
+
+    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
+        cx.emit(PickerEvent::Dismiss);
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        mouse_state: &mut MouseState,
+        selected: bool,
+        cx: &gpui::AppContext,
+    ) -> AnyElement<Picker<Self>> {
+        let full_theme = &theme::current(cx);
+        let theme = &full_theme.collab_panel.channel_modal;
+        let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
+        let (user, role) = self.user_at_index(ix).unwrap();
+        let request_status = self.member_status(user.id, cx);
+
+        let style = tabbed_modal
+            .picker
+            .item
+            .in_state(selected)
+            .style_for(mouse_state);
+
+        let in_manage = matches!(self.mode, Mode::ManageMembers);
+
+        let mut result = Flex::row()
+            .with_children(user.avatar.clone().map(|avatar| {
+                Image::from_data(avatar)
+                    .with_style(theme.contact_avatar)
+                    .aligned()
+                    .left()
+            }))
+            .with_child(
+                Label::new(user.github_login.clone(), style.label.clone())
+                    .contained()
+                    .with_style(theme.contact_username)
+                    .aligned()
+                    .left(),
+            )
+            .with_children({
+                (in_manage && request_status == Some(proto::channel_member::Kind::Invitee)).then(
+                    || {
+                        Label::new("Invited", theme.member_tag.text.clone())
+                            .contained()
+                            .with_style(theme.member_tag.container)
+                            .aligned()
+                            .left()
+                    },
+                )
+            })
+            .with_children(if in_manage && role == Some(ChannelRole::Admin) {
+                Some(
+                    Label::new("Admin", theme.member_tag.text.clone())
+                        .contained()
+                        .with_style(theme.member_tag.container)
+                        .aligned()
+                        .left(),
+                )
+            } else if in_manage && role == Some(ChannelRole::Guest) {
+                Some(
+                    Label::new("Guest", theme.member_tag.text.clone())
+                        .contained()
+                        .with_style(theme.member_tag.container)
+                        .aligned()
+                        .left(),
+                )
+            } else {
+                None
+            })
+            .with_children({
+                let svg = match self.mode {
+                    Mode::ManageMembers => Some(
+                        Svg::new("icons/ellipsis.svg")
+                            .with_color(theme.member_icon.color)
+                            .constrained()
+                            .with_width(theme.member_icon.icon_width)
+                            .aligned()
+                            .constrained()
+                            .with_width(theme.member_icon.button_width)
+                            .with_height(theme.member_icon.button_width)
+                            .contained()
+                            .with_style(theme.member_icon.container),
+                    ),
+                    Mode::InviteMembers => match request_status {
+                        Some(proto::channel_member::Kind::Member) => Some(
+                            Svg::new("icons/check.svg")
+                                .with_color(theme.member_icon.color)
+                                .constrained()
+                                .with_width(theme.member_icon.icon_width)
+                                .aligned()
+                                .constrained()
+                                .with_width(theme.member_icon.button_width)
+                                .with_height(theme.member_icon.button_width)
+                                .contained()
+                                .with_style(theme.member_icon.container),
+                        ),
+                        Some(proto::channel_member::Kind::Invitee) => Some(
+                            Svg::new("icons/check.svg")
+                                .with_color(theme.invitee_icon.color)
+                                .constrained()
+                                .with_width(theme.invitee_icon.icon_width)
+                                .aligned()
+                                .constrained()
+                                .with_width(theme.invitee_icon.button_width)
+                                .with_height(theme.invitee_icon.button_width)
+                                .contained()
+                                .with_style(theme.invitee_icon.container),
+                        ),
+                        Some(proto::channel_member::Kind::AncestorMember) | None => None,
+                    },
+                };
+
+                svg.map(|svg| svg.aligned().flex_float().into_any())
+            })
+            .contained()
+            .with_style(style.container)
+            .constrained()
+            .with_height(tabbed_modal.row_height)
+            .into_any();
+
+        if selected {
+            result = Stack::new()
+                .with_child(result)
+                .with_child(
+                    ChildView::new(&self.context_menu, cx)
+                        .aligned()
+                        .top()
+                        .right(),
+                )
+                .into_any();
+        }
+
+        result
+    }
+}
+
+impl ChannelModalDelegate {
+    fn member_status(
+        &self,
+        user_id: UserId,
+        cx: &AppContext,
+    ) -> Option<proto::channel_member::Kind> {
+        self.members
+            .iter()
+            .find_map(|membership| (membership.user.id == user_id).then_some(membership.kind))
+            .or_else(|| {
+                self.channel_store
+                    .read(cx)
+                    .has_pending_channel_invite(self.channel_id, user_id)
+                    .then_some(proto::channel_member::Kind::Invitee)
+            })
+    }
+
+    fn user_at_index(&self, ix: usize) -> Option<(Arc<User>, Option<ChannelRole>)> {
+        match self.mode {
+            Mode::ManageMembers => self.matching_member_indices.get(ix).and_then(|ix| {
+                let channel_membership = self.members.get(*ix)?;
+                Some((
+                    channel_membership.user.clone(),
+                    Some(channel_membership.role),
+                ))
+            }),
+            Mode::InviteMembers => Some((self.matching_users.get(ix).cloned()?, None)),
+        }
+    }
+
+    fn toggle_selected_member_admin(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
+        let (user, role) = self.user_at_index(self.selected_index)?;
+        let new_role = if role == Some(ChannelRole::Admin) {
+            ChannelRole::Member
+        } else {
+            ChannelRole::Admin
+        };
+        let update = self.channel_store.update(cx, |store, cx| {
+            store.set_member_role(self.channel_id, user.id, new_role, cx)
+        });
+        cx.spawn(|picker, mut cx| async move {
+            update.await?;
+            picker.update(&mut cx, |picker, cx| {
+                let this = picker.delegate_mut();
+                if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user.id) {
+                    member.role = new_role;
+                }
+                cx.focus_self();
+                cx.notify();
+            })
+        })
+        .detach_and_log_err(cx);
+        Some(())
+    }
+
+    fn remove_selected_member(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
+        let (user, _) = self.user_at_index(self.selected_index)?;
+        let user_id = user.id;
+        let update = self.channel_store.update(cx, |store, cx| {
+            store.remove_member(self.channel_id, user_id, cx)
+        });
+        cx.spawn(|picker, mut cx| async move {
+            update.await?;
+            picker.update(&mut cx, |picker, cx| {
+                let this = picker.delegate_mut();
+                if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) {
+                    this.members.remove(ix);
+                    this.matching_member_indices.retain_mut(|member_ix| {
+                        if *member_ix == ix {
+                            return false;
+                        } else if *member_ix > ix {
+                            *member_ix -= 1;
+                        }
+                        true
+                    })
+                }
+
+                this.selected_index = this
+                    .selected_index
+                    .min(this.matching_member_indices.len().saturating_sub(1));
+
+                cx.focus_self();
+                cx.notify();
+            })
+        })
+        .detach_and_log_err(cx);
+        Some(())
+    }
+
+    fn invite_member(&mut self, user: Arc<User>, cx: &mut ViewContext<Picker<Self>>) {
+        let invite_member = self.channel_store.update(cx, |store, cx| {
+            store.invite_member(self.channel_id, user.id, ChannelRole::Member, cx)
+        });
+
+        cx.spawn(|this, mut cx| async move {
+            invite_member.await?;
+
+            this.update(&mut cx, |this, cx| {
+                let new_member = ChannelMembership {
+                    user,
+                    kind: proto::channel_member::Kind::Invitee,
+                    role: ChannelRole::Member,
+                };
+                let members = &mut this.delegate_mut().members;
+                match members.binary_search_by_key(&new_member.sort_key(), |k| k.sort_key()) {
+                    Ok(ix) | Err(ix) => members.insert(ix, new_member),
+                }
+
+                cx.notify();
+            })
+        })
+        .detach_and_log_err(cx);
+    }
+
+    fn show_context_menu(&mut self, role: ChannelRole, cx: &mut ViewContext<Picker<Self>>) {
+        self.context_menu.update(cx, |context_menu, cx| {
+            context_menu.show(
+                Default::default(),
+                AnchorCorner::TopRight,
+                vec![
+                    ContextMenuItem::action("Remove", RemoveMember),
+                    ContextMenuItem::action(
+                        if role == ChannelRole::Admin {
+                            "Make non-admin"
+                        } else {
+                            "Make admin"
+                        },
+                        ToggleMemberAdmin,
+                    ),
+                ],
+                cx,
+            )
+        })
+    }
+}

crates/collab_ui2/src/collab_panel/contact_finder.rs 🔗

@@ -0,0 +1,261 @@
+use client::{ContactRequestStatus, User, UserStore};
+use gpui::{
+    elements::*, AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle,
+};
+use picker::{Picker, PickerDelegate, PickerEvent};
+use std::sync::Arc;
+use util::TryFutureExt;
+use workspace::Modal;
+
+pub fn init(cx: &mut AppContext) {
+    Picker::<ContactFinderDelegate>::init(cx);
+    cx.add_action(ContactFinder::dismiss)
+}
+
+pub struct ContactFinder {
+    picker: ViewHandle<Picker<ContactFinderDelegate>>,
+    has_focus: bool,
+}
+
+impl ContactFinder {
+    pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
+        let picker = cx.add_view(|cx| {
+            Picker::new(
+                ContactFinderDelegate {
+                    user_store,
+                    potential_contacts: Arc::from([]),
+                    selected_index: 0,
+                },
+                cx,
+            )
+            .with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone())
+        });
+
+        cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach();
+
+        Self {
+            picker,
+            has_focus: false,
+        }
+    }
+
+    pub fn set_query(&mut self, query: String, cx: &mut ViewContext<Self>) {
+        self.picker.update(cx, |picker, cx| {
+            picker.set_query(query, cx);
+        });
+    }
+
+    fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
+        cx.emit(PickerEvent::Dismiss);
+    }
+}
+
+impl Entity for ContactFinder {
+    type Event = PickerEvent;
+}
+
+impl View for ContactFinder {
+    fn ui_name() -> &'static str {
+        "ContactFinder"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let full_theme = &theme::current(cx);
+        let theme = &full_theme.collab_panel.tabbed_modal;
+
+        fn render_mode_button(
+            text: &'static str,
+            theme: &theme::TabbedModal,
+            _cx: &mut ViewContext<ContactFinder>,
+        ) -> AnyElement<ContactFinder> {
+            let contained_text = &theme.tab_button.active_state().default;
+            Label::new(text, contained_text.text.clone())
+                .contained()
+                .with_style(contained_text.container.clone())
+                .into_any()
+        }
+
+        Flex::column()
+            .with_child(
+                Flex::column()
+                    .with_child(
+                        Label::new("Contacts", theme.title.text.clone())
+                            .contained()
+                            .with_style(theme.title.container.clone()),
+                    )
+                    .with_child(Flex::row().with_children([render_mode_button(
+                        "Invite new contacts",
+                        &theme,
+                        cx,
+                    )]))
+                    .expanded()
+                    .contained()
+                    .with_style(theme.header),
+            )
+            .with_child(
+                ChildView::new(&self.picker, cx)
+                    .contained()
+                    .with_style(theme.body),
+            )
+            .constrained()
+            .with_max_height(theme.max_height)
+            .with_max_width(theme.max_width)
+            .contained()
+            .with_style(theme.modal)
+            .into_any()
+    }
+
+    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        self.has_focus = true;
+        if cx.is_self_focused() {
+            cx.focus(&self.picker)
+        }
+    }
+
+    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
+        self.has_focus = false;
+    }
+}
+
+impl Modal for ContactFinder {
+    fn has_focus(&self) -> bool {
+        self.has_focus
+    }
+
+    fn dismiss_on_event(event: &Self::Event) -> bool {
+        match event {
+            PickerEvent::Dismiss => true,
+        }
+    }
+}
+
+pub struct ContactFinderDelegate {
+    potential_contacts: Arc<[Arc<User>]>,
+    user_store: ModelHandle<UserStore>,
+    selected_index: usize,
+}
+
+impl PickerDelegate for ContactFinderDelegate {
+    fn placeholder_text(&self) -> Arc<str> {
+        "Search collaborator by username...".into()
+    }
+
+    fn match_count(&self) -> usize {
+        self.potential_contacts.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
+        self.selected_index = ix;
+    }
+
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
+        let search_users = self
+            .user_store
+            .update(cx, |store, cx| store.fuzzy_search_users(query, cx));
+
+        cx.spawn(|picker, mut cx| async move {
+            async {
+                let potential_contacts = search_users.await?;
+                picker.update(&mut cx, |picker, cx| {
+                    picker.delegate_mut().potential_contacts = potential_contacts.into();
+                    cx.notify();
+                })?;
+                anyhow::Ok(())
+            }
+            .log_err()
+            .await;
+        })
+    }
+
+    fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
+        if let Some(user) = self.potential_contacts.get(self.selected_index) {
+            let user_store = self.user_store.read(cx);
+            match user_store.contact_request_status(user) {
+                ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
+                    self.user_store
+                        .update(cx, |store, cx| store.request_contact(user.id, cx))
+                        .detach();
+                }
+                ContactRequestStatus::RequestSent => {
+                    self.user_store
+                        .update(cx, |store, cx| store.remove_contact(user.id, cx))
+                        .detach();
+                }
+                _ => {}
+            }
+        }
+    }
+
+    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
+        cx.emit(PickerEvent::Dismiss);
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        mouse_state: &mut MouseState,
+        selected: bool,
+        cx: &gpui::AppContext,
+    ) -> AnyElement<Picker<Self>> {
+        let full_theme = &theme::current(cx);
+        let theme = &full_theme.collab_panel.contact_finder;
+        let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
+        let user = &self.potential_contacts[ix];
+        let request_status = self.user_store.read(cx).contact_request_status(user);
+
+        let icon_path = match request_status {
+            ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
+                Some("icons/check.svg")
+            }
+            ContactRequestStatus::RequestSent => Some("icons/x.svg"),
+            ContactRequestStatus::RequestAccepted => None,
+        };
+        let button_style = if self.user_store.read(cx).is_contact_request_pending(user) {
+            &theme.disabled_contact_button
+        } else {
+            &theme.contact_button
+        };
+        let style = tabbed_modal
+            .picker
+            .item
+            .in_state(selected)
+            .style_for(mouse_state);
+        Flex::row()
+            .with_children(user.avatar.clone().map(|avatar| {
+                Image::from_data(avatar)
+                    .with_style(theme.contact_avatar)
+                    .aligned()
+                    .left()
+            }))
+            .with_child(
+                Label::new(user.github_login.clone(), style.label.clone())
+                    .contained()
+                    .with_style(theme.contact_username)
+                    .aligned()
+                    .left(),
+            )
+            .with_children(icon_path.map(|icon_path| {
+                Svg::new(icon_path)
+                    .with_color(button_style.color)
+                    .constrained()
+                    .with_width(button_style.icon_width)
+                    .aligned()
+                    .contained()
+                    .with_style(button_style.container)
+                    .constrained()
+                    .with_width(button_style.button_width)
+                    .with_height(button_style.button_width)
+                    .aligned()
+                    .flex_float()
+            }))
+            .contained()
+            .with_style(style.container)
+            .constrained()
+            .with_height(tabbed_modal.row_height)
+            .into_any()
+    }
+}

crates/collab_ui2/src/collab_titlebar_item.rs 🔗

@@ -0,0 +1,1278 @@
+use crate::{
+    face_pile::FacePile, toggle_deafen, toggle_mute, toggle_screen_sharing, LeaveCall,
+    ToggleDeafen, ToggleMute, ToggleScreenSharing,
+};
+use auto_update::AutoUpdateStatus;
+use call::{ActiveCall, ParticipantLocation, Room};
+use client::{proto::PeerId, Client, SignIn, SignOut, User, UserStore};
+use clock::ReplicaId;
+use context_menu::{ContextMenu, ContextMenuItem};
+use gpui::{
+    actions,
+    color::Color,
+    elements::*,
+    geometry::{rect::RectF, vector::vec2f, PathBuilder},
+    json::{self, ToJson},
+    platform::{CursorStyle, MouseButton},
+    AppContext, Entity, ImageData, ModelHandle, Subscription, View, ViewContext, ViewHandle,
+    WeakViewHandle,
+};
+use picker::PickerEvent;
+use project::{Project, RepositoryEntry};
+use recent_projects::{build_recent_projects, RecentProjects};
+use std::{ops::Range, sync::Arc};
+use theme::{AvatarStyle, Theme};
+use util::ResultExt;
+use vcs_menu::{build_branch_list, BranchList, OpenRecent as ToggleVcsMenu};
+use workspace::{FollowNextCollaborator, Workspace, WORKSPACE_DB};
+
+const MAX_PROJECT_NAME_LENGTH: usize = 40;
+const MAX_BRANCH_NAME_LENGTH: usize = 40;
+
+actions!(
+    collab,
+    [
+        ToggleUserMenu,
+        ToggleProjectMenu,
+        SwitchBranch,
+        ShareProject,
+        UnshareProject,
+    ]
+);
+
+pub fn init(cx: &mut AppContext) {
+    cx.add_action(CollabTitlebarItem::share_project);
+    cx.add_action(CollabTitlebarItem::unshare_project);
+    cx.add_action(CollabTitlebarItem::toggle_user_menu);
+    cx.add_action(CollabTitlebarItem::toggle_vcs_menu);
+    cx.add_action(CollabTitlebarItem::toggle_project_menu);
+}
+
+pub struct CollabTitlebarItem {
+    project: ModelHandle<Project>,
+    user_store: ModelHandle<UserStore>,
+    client: Arc<Client>,
+    workspace: WeakViewHandle<Workspace>,
+    branch_popover: Option<ViewHandle<BranchList>>,
+    project_popover: Option<ViewHandle<recent_projects::RecentProjects>>,
+    user_menu: ViewHandle<ContextMenu>,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl Entity for CollabTitlebarItem {
+    type Event = ();
+}
+
+impl View for CollabTitlebarItem {
+    fn ui_name() -> &'static str {
+        "CollabTitlebarItem"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let workspace = if let Some(workspace) = self.workspace.upgrade(cx) {
+            workspace
+        } else {
+            return Empty::new().into_any();
+        };
+
+        let theme = theme::current(cx).clone();
+        let mut left_container = Flex::row();
+        let mut right_container = Flex::row().align_children_center();
+
+        left_container.add_child(self.collect_title_root_names(theme.clone(), cx));
+
+        let user = self.user_store.read(cx).current_user();
+        let peer_id = self.client.peer_id();
+        if let Some(((user, peer_id), room)) = user
+            .as_ref()
+            .zip(peer_id)
+            .zip(ActiveCall::global(cx).read(cx).room().cloned())
+        {
+            if room.read(cx).can_publish() {
+                right_container
+                    .add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
+            }
+            right_container.add_child(self.render_leave_call(&theme, cx));
+            let muted = room.read(cx).is_muted(cx);
+            let speaking = room.read(cx).is_speaking();
+            left_container.add_child(
+                self.render_current_user(&workspace, &theme, &user, peer_id, muted, speaking, cx),
+            );
+            left_container.add_children(self.render_collaborators(&workspace, &theme, &room, cx));
+            if room.read(cx).can_publish() {
+                right_container.add_child(self.render_toggle_mute(&theme, &room, cx));
+            }
+            right_container.add_child(self.render_toggle_deafen(&theme, &room, cx));
+            if room.read(cx).can_publish() {
+                right_container
+                    .add_child(self.render_toggle_screen_sharing_button(&theme, &room, cx));
+            }
+        }
+
+        let status = workspace.read(cx).client().status();
+        let status = &*status.borrow();
+        if matches!(status, client::Status::Connected { .. }) {
+            let avatar = user.as_ref().and_then(|user| user.avatar.clone());
+            right_container.add_child(self.render_user_menu_button(&theme, avatar, cx));
+        } else {
+            right_container.add_children(self.render_connection_status(status, cx));
+            right_container.add_child(self.render_sign_in_button(&theme, cx));
+            right_container.add_child(self.render_user_menu_button(&theme, None, cx));
+        }
+
+        Stack::new()
+            .with_child(left_container)
+            .with_child(
+                Flex::row()
+                    .with_child(
+                        right_container.contained().with_background_color(
+                            theme
+                                .titlebar
+                                .container
+                                .background_color
+                                .unwrap_or_else(|| Color::transparent_black()),
+                        ),
+                    )
+                    .aligned()
+                    .right(),
+            )
+            .into_any()
+    }
+}
+
+impl CollabTitlebarItem {
+    pub fn new(
+        workspace: &Workspace,
+        workspace_handle: &ViewHandle<Workspace>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let project = workspace.project().clone();
+        let user_store = workspace.app_state().user_store.clone();
+        let client = workspace.app_state().client.clone();
+        let active_call = ActiveCall::global(cx);
+        let mut subscriptions = Vec::new();
+        subscriptions.push(cx.observe(workspace_handle, |_, _, cx| cx.notify()));
+        subscriptions.push(cx.observe(&project, |_, _, cx| cx.notify()));
+        subscriptions.push(cx.observe(&active_call, |this, _, cx| this.active_call_changed(cx)));
+        subscriptions.push(cx.observe_window_activation(|this, active, cx| {
+            this.window_activation_changed(active, cx)
+        }));
+        subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify()));
+
+        Self {
+            workspace: workspace.weak_handle(),
+            project,
+            user_store,
+            client,
+            user_menu: cx.add_view(|cx| {
+                let view_id = cx.view_id();
+                let mut menu = ContextMenu::new(view_id, cx);
+                menu.set_position_mode(OverlayPositionMode::Local);
+                menu
+            }),
+            branch_popover: None,
+            project_popover: None,
+            _subscriptions: subscriptions,
+        }
+    }
+
+    fn collect_title_root_names(
+        &self,
+        theme: Arc<Theme>,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        let project = self.project.read(cx);
+
+        let (name, entry) = {
+            let mut names_and_branches = project.visible_worktrees(cx).map(|worktree| {
+                let worktree = worktree.read(cx);
+                (worktree.root_name(), worktree.root_git_entry())
+            });
+
+            names_and_branches.next().unwrap_or(("", None))
+        };
+
+        let name = util::truncate_and_trailoff(name, MAX_PROJECT_NAME_LENGTH);
+        let branch_prepended = entry
+            .as_ref()
+            .and_then(RepositoryEntry::branch)
+            .map(|branch| util::truncate_and_trailoff(&branch, MAX_BRANCH_NAME_LENGTH));
+        let project_style = theme.titlebar.project_menu_button.clone();
+        let git_style = theme.titlebar.git_menu_button.clone();
+        let item_spacing = theme.titlebar.item_spacing;
+
+        let mut ret = Flex::row();
+
+        if let Some(project_host) = self.collect_project_host(theme.clone(), cx) {
+            ret = ret.with_child(project_host)
+        }
+
+        ret = ret.with_child(
+            Stack::new()
+                .with_child(
+                    MouseEventHandler::new::<ToggleProjectMenu, _>(0, cx, |mouse_state, cx| {
+                        let style = project_style
+                            .in_state(self.project_popover.is_some())
+                            .style_for(mouse_state);
+                        enum RecentProjectsTooltip {}
+                        Label::new(name, style.text.clone())
+                            .contained()
+                            .with_style(style.container)
+                            .aligned()
+                            .left()
+                            .with_tooltip::<RecentProjectsTooltip>(
+                                0,
+                                "Recent projects",
+                                Some(Box::new(recent_projects::OpenRecent)),
+                                theme.tooltip.clone(),
+                                cx,
+                            )
+                            .into_any_named("title-project-name")
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .on_down(MouseButton::Left, move |_, this, cx| {
+                        this.toggle_project_menu(&Default::default(), cx)
+                    })
+                    .on_click(MouseButton::Left, move |_, _, _| {}),
+                )
+                .with_children(self.render_project_popover_host(&theme.titlebar, cx)),
+        );
+        if let Some(git_branch) = branch_prepended {
+            ret = ret.with_child(
+                Flex::row().with_child(
+                    Stack::new()
+                        .with_child(
+                            MouseEventHandler::new::<ToggleVcsMenu, _>(0, cx, |mouse_state, cx| {
+                                enum BranchPopoverTooltip {}
+                                let style = git_style
+                                    .in_state(self.branch_popover.is_some())
+                                    .style_for(mouse_state);
+                                Label::new(git_branch, style.text.clone())
+                                    .contained()
+                                    .with_style(style.container.clone())
+                                    .with_margin_right(item_spacing)
+                                    .aligned()
+                                    .left()
+                                    .with_tooltip::<BranchPopoverTooltip>(
+                                        0,
+                                        "Recent branches",
+                                        Some(Box::new(ToggleVcsMenu)),
+                                        theme.tooltip.clone(),
+                                        cx,
+                                    )
+                                    .into_any_named("title-project-branch")
+                            })
+                            .with_cursor_style(CursorStyle::PointingHand)
+                            .on_down(MouseButton::Left, move |_, this, cx| {
+                                this.toggle_vcs_menu(&Default::default(), cx)
+                            })
+                            .on_click(MouseButton::Left, move |_, _, _| {}),
+                        )
+                        .with_children(self.render_branches_popover_host(&theme.titlebar, cx)),
+                ),
+            )
+        }
+        ret.into_any()
+    }
+
+    fn collect_project_host(
+        &self,
+        theme: Arc<Theme>,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<AnyElement<Self>> {
+        if ActiveCall::global(cx).read(cx).room().is_none() {
+            return None;
+        }
+        let project = self.project.read(cx);
+        let user_store = self.user_store.read(cx);
+
+        if project.is_local() {
+            return None;
+        }
+
+        let Some(host) = project.host() else {
+            return None;
+        };
+        let (Some(host_user), Some(participant_index)) = (
+            user_store.get_cached_user(host.user_id),
+            user_store.participant_indices().get(&host.user_id),
+        ) else {
+            return None;
+        };
+
+        enum ProjectHost {}
+        enum ProjectHostTooltip {}
+
+        let host_style = theme.titlebar.project_host.clone();
+        let selection_style = theme
+            .editor
+            .selection_style_for_room_participant(participant_index.0);
+        let peer_id = host.peer_id.clone();
+
+        Some(
+            MouseEventHandler::new::<ProjectHost, _>(0, cx, |mouse_state, _| {
+                let mut host_style = host_style.style_for(mouse_state).clone();
+                host_style.text.color = selection_style.cursor;
+                Label::new(host_user.github_login.clone(), host_style.text)
+                    .contained()
+                    .with_style(host_style.container)
+                    .aligned()
+                    .left()
+            })
+            .with_cursor_style(CursorStyle::PointingHand)
+            .on_click(MouseButton::Left, move |_, this, cx| {
+                if let Some(workspace) = this.workspace.upgrade(cx) {
+                    if let Some(task) =
+                        workspace.update(cx, |workspace, cx| workspace.follow(peer_id, cx))
+                    {
+                        task.detach_and_log_err(cx);
+                    }
+                }
+            })
+            .with_tooltip::<ProjectHostTooltip>(
+                0,
+                host_user.github_login.clone() + " is sharing this project. Click to follow.",
+                None,
+                theme.tooltip.clone(),
+                cx,
+            )
+            .into_any_named("project-host"),
+        )
+    }
+
+    fn window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
+        let project = if active {
+            Some(self.project.clone())
+        } else {
+            None
+        };
+        ActiveCall::global(cx)
+            .update(cx, |call, cx| call.set_location(project.as_ref(), cx))
+            .detach_and_log_err(cx);
+    }
+
+    fn active_call_changed(&mut self, cx: &mut ViewContext<Self>) {
+        cx.notify();
+    }
+
+    fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext<Self>) {
+        let active_call = ActiveCall::global(cx);
+        let project = self.project.clone();
+        active_call
+            .update(cx, |call, cx| call.share_project(project, cx))
+            .detach_and_log_err(cx);
+    }
+
+    fn unshare_project(&mut self, _: &UnshareProject, cx: &mut ViewContext<Self>) {
+        let active_call = ActiveCall::global(cx);
+        let project = self.project.clone();
+        active_call
+            .update(cx, |call, cx| call.unshare_project(project, cx))
+            .log_err();
+    }
+
+    pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext<Self>) {
+        self.user_menu.update(cx, |user_menu, cx| {
+            let items = if let Some(_) = self.user_store.read(cx).current_user() {
+                vec![
+                    ContextMenuItem::action("Settings", zed_actions::OpenSettings),
+                    ContextMenuItem::action("Theme", theme_selector::Toggle),
+                    ContextMenuItem::separator(),
+                    ContextMenuItem::action(
+                        "Share Feedback",
+                        feedback::feedback_editor::GiveFeedback,
+                    ),
+                    ContextMenuItem::action("Sign Out", SignOut),
+                ]
+            } else {
+                vec![
+                    ContextMenuItem::action("Settings", zed_actions::OpenSettings),
+                    ContextMenuItem::action("Theme", theme_selector::Toggle),
+                    ContextMenuItem::separator(),
+                    ContextMenuItem::action(
+                        "Share Feedback",
+                        feedback::feedback_editor::GiveFeedback,
+                    ),
+                ]
+            };
+            user_menu.toggle(Default::default(), AnchorCorner::TopRight, items, cx);
+        });
+    }
+
+    fn render_branches_popover_host<'a>(
+        &'a self,
+        _theme: &'a theme::Titlebar,
+        cx: &'a mut ViewContext<Self>,
+    ) -> Option<AnyElement<Self>> {
+        self.branch_popover.as_ref().map(|child| {
+            let theme = theme::current(cx).clone();
+            let child = ChildView::new(child, cx);
+            let child = MouseEventHandler::new::<BranchList, _>(0, cx, |_, _| {
+                child
+                    .flex(1., true)
+                    .contained()
+                    .constrained()
+                    .with_width(theme.titlebar.menu.width)
+                    .with_height(theme.titlebar.menu.height)
+            })
+            .on_click(MouseButton::Left, |_, _, _| {})
+            .on_down_out(MouseButton::Left, move |_, this, cx| {
+                this.branch_popover.take();
+                cx.emit(());
+                cx.notify();
+            })
+            .contained()
+            .into_any();
+
+            Overlay::new(child)
+                .with_fit_mode(OverlayFitMode::SwitchAnchor)
+                .with_anchor_corner(AnchorCorner::TopLeft)
+                .with_z_index(999)
+                .aligned()
+                .bottom()
+                .left()
+                .into_any()
+        })
+    }
+
+    fn render_project_popover_host<'a>(
+        &'a self,
+        _theme: &'a theme::Titlebar,
+        cx: &'a mut ViewContext<Self>,
+    ) -> Option<AnyElement<Self>> {
+        self.project_popover.as_ref().map(|child| {
+            let theme = theme::current(cx).clone();
+            let child = ChildView::new(child, cx);
+            let child = MouseEventHandler::new::<RecentProjects, _>(0, cx, |_, _| {
+                child
+                    .flex(1., true)
+                    .contained()
+                    .constrained()
+                    .with_width(theme.titlebar.menu.width)
+                    .with_height(theme.titlebar.menu.height)
+            })
+            .on_click(MouseButton::Left, |_, _, _| {})
+            .on_down_out(MouseButton::Left, move |_, this, cx| {
+                this.project_popover.take();
+                cx.emit(());
+                cx.notify();
+            })
+            .into_any();
+
+            Overlay::new(child)
+                .with_fit_mode(OverlayFitMode::SwitchAnchor)
+                .with_anchor_corner(AnchorCorner::TopLeft)
+                .with_z_index(999)
+                .aligned()
+                .bottom()
+                .left()
+                .into_any()
+        })
+    }
+
+    pub fn toggle_vcs_menu(&mut self, _: &ToggleVcsMenu, cx: &mut ViewContext<Self>) {
+        if self.branch_popover.take().is_none() {
+            if let Some(workspace) = self.workspace.upgrade(cx) {
+                let Some(view) =
+                    cx.add_option_view(|cx| build_branch_list(workspace, cx).log_err())
+                else {
+                    return;
+                };
+                cx.subscribe(&view, |this, _, event, cx| {
+                    match event {
+                        PickerEvent::Dismiss => {
+                            this.branch_popover = None;
+                        }
+                    }
+
+                    cx.notify();
+                })
+                .detach();
+                self.project_popover.take();
+                cx.focus(&view);
+                self.branch_popover = Some(view);
+            }
+        }
+
+        cx.notify();
+    }
+
+    pub fn toggle_project_menu(&mut self, _: &ToggleProjectMenu, cx: &mut ViewContext<Self>) {
+        let workspace = self.workspace.clone();
+        if self.project_popover.take().is_none() {
+            cx.spawn(|this, mut cx| async move {
+                let workspaces = WORKSPACE_DB
+                    .recent_workspaces_on_disk()
+                    .await
+                    .unwrap_or_default()
+                    .into_iter()
+                    .map(|(_, location)| location)
+                    .collect();
+
+                let workspace = workspace.clone();
+                this.update(&mut cx, move |this, cx| {
+                    let view = cx.add_view(|cx| build_recent_projects(workspace, workspaces, cx));
+
+                    cx.subscribe(&view, |this, _, event, cx| {
+                        match event {
+                            PickerEvent::Dismiss => {
+                                this.project_popover = None;
+                            }
+                        }
+
+                        cx.notify();
+                    })
+                    .detach();
+                    cx.focus(&view);
+                    this.branch_popover.take();
+                    this.project_popover = Some(view);
+                    cx.notify();
+                })
+                .log_err();
+            })
+            .detach();
+        }
+        cx.notify();
+    }
+
+    fn render_toggle_screen_sharing_button(
+        &self,
+        theme: &Theme,
+        room: &ModelHandle<Room>,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        let icon;
+        let tooltip;
+        if room.read(cx).is_screen_sharing() {
+            icon = "icons/desktop.svg";
+            tooltip = "Stop Sharing Screen"
+        } else {
+            icon = "icons/desktop.svg";
+            tooltip = "Share Screen";
+        }
+
+        let active = room.read(cx).is_screen_sharing();
+        let titlebar = &theme.titlebar;
+        MouseEventHandler::new::<ToggleScreenSharing, _>(0, cx, |state, _| {
+            let style = titlebar
+                .screen_share_button
+                .in_state(active)
+                .style_for(state);
+
+            Svg::new(icon)
+                .with_color(style.color)
+                .constrained()
+                .with_width(style.icon_width)
+                .aligned()
+                .constrained()
+                .with_width(style.button_width)
+                .with_height(style.button_width)
+                .contained()
+                .with_style(style.container)
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, _, cx| {
+            toggle_screen_sharing(&Default::default(), cx)
+        })
+        .with_tooltip::<ToggleScreenSharing>(
+            0,
+            tooltip,
+            Some(Box::new(ToggleScreenSharing)),
+            theme.tooltip.clone(),
+            cx,
+        )
+        .aligned()
+        .into_any()
+    }
+    fn render_toggle_mute(
+        &self,
+        theme: &Theme,
+        room: &ModelHandle<Room>,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        let icon;
+        let tooltip;
+        let is_muted = room.read(cx).is_muted(cx);
+        if is_muted {
+            icon = "icons/mic-mute.svg";
+            tooltip = "Unmute microphone";
+        } else {
+            icon = "icons/mic.svg";
+            tooltip = "Mute microphone";
+        }
+
+        let titlebar = &theme.titlebar;
+        MouseEventHandler::new::<ToggleMute, _>(0, cx, |state, _| {
+            let style = titlebar
+                .toggle_microphone_button
+                .in_state(is_muted)
+                .style_for(state);
+            let image = Svg::new(icon)
+                .with_color(style.color)
+                .constrained()
+                .with_width(style.icon_width)
+                .aligned()
+                .constrained()
+                .with_width(style.button_width)
+                .with_height(style.button_width)
+                .contained()
+                .with_style(style.container);
+            if let Some(color) = style.container.background_color {
+                image.with_background_color(color)
+            } else {
+                image
+            }
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, _, cx| {
+            toggle_mute(&Default::default(), cx)
+        })
+        .with_tooltip::<ToggleMute>(
+            0,
+            tooltip,
+            Some(Box::new(ToggleMute)),
+            theme.tooltip.clone(),
+            cx,
+        )
+        .aligned()
+        .into_any()
+    }
+    fn render_toggle_deafen(
+        &self,
+        theme: &Theme,
+        room: &ModelHandle<Room>,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        let icon;
+        let tooltip;
+        let is_deafened = room.read(cx).is_deafened().unwrap_or(false);
+        if is_deafened {
+            icon = "icons/speaker-off.svg";
+            tooltip = "Unmute speakers";
+        } else {
+            icon = "icons/speaker-loud.svg";
+            tooltip = "Mute speakers";
+        }
+
+        let titlebar = &theme.titlebar;
+        MouseEventHandler::new::<ToggleDeafen, _>(0, cx, |state, _| {
+            let style = titlebar
+                .toggle_speakers_button
+                .in_state(is_deafened)
+                .style_for(state);
+            Svg::new(icon)
+                .with_color(style.color)
+                .constrained()
+                .with_width(style.icon_width)
+                .aligned()
+                .constrained()
+                .with_width(style.button_width)
+                .with_height(style.button_width)
+                .contained()
+                .with_style(style.container)
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, _, cx| {
+            toggle_deafen(&Default::default(), cx)
+        })
+        .with_tooltip::<ToggleDeafen>(
+            0,
+            tooltip,
+            Some(Box::new(ToggleDeafen)),
+            theme.tooltip.clone(),
+            cx,
+        )
+        .aligned()
+        .into_any()
+    }
+    fn render_leave_call(&self, theme: &Theme, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let icon = "icons/exit.svg";
+        let tooltip = "Leave call";
+
+        let titlebar = &theme.titlebar;
+        MouseEventHandler::new::<LeaveCall, _>(0, cx, |state, _| {
+            let style = titlebar.leave_call_button.style_for(state);
+            Svg::new(icon)
+                .with_color(style.color)
+                .constrained()
+                .with_width(style.icon_width)
+                .aligned()
+                .constrained()
+                .with_width(style.button_width)
+                .with_height(style.button_width)
+                .contained()
+                .with_style(style.container)
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, _, cx| {
+            ActiveCall::global(cx)
+                .update(cx, |call, cx| call.hang_up(cx))
+                .detach_and_log_err(cx);
+        })
+        .with_tooltip::<LeaveCall>(
+            0,
+            tooltip,
+            Some(Box::new(LeaveCall)),
+            theme.tooltip.clone(),
+            cx,
+        )
+        .aligned()
+        .into_any()
+    }
+    fn render_in_call_share_unshare_button(
+        &self,
+        workspace: &ViewHandle<Workspace>,
+        theme: &Theme,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<AnyElement<Self>> {
+        let project = workspace.read(cx).project();
+        if project.read(cx).is_remote() {
+            return None;
+        }
+
+        let is_shared = project.read(cx).is_shared();
+        let label = if is_shared { "Stop Sharing" } else { "Share" };
+        let tooltip = if is_shared {
+            "Stop sharing project with call participants"
+        } else {
+            "Share project with call participants"
+        };
+
+        let titlebar = &theme.titlebar;
+
+        enum ShareUnshare {}
+        Some(
+            Stack::new()
+                .with_child(
+                    MouseEventHandler::new::<ShareUnshare, _>(0, cx, |state, _| {
+                        //TODO: Ensure this button has consistent width for both text variations
+                        let style = titlebar.share_button.inactive_state().style_for(state);
+                        Label::new(label, style.text.clone())
+                            .contained()
+                            .with_style(style.container)
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .on_click(MouseButton::Left, move |_, this, cx| {
+                        if is_shared {
+                            this.unshare_project(&Default::default(), cx);
+                        } else {
+                            this.share_project(&Default::default(), cx);
+                        }
+                    })
+                    .with_tooltip::<ShareUnshare>(
+                        0,
+                        tooltip.to_owned(),
+                        None,
+                        theme.tooltip.clone(),
+                        cx,
+                    ),
+                )
+                .aligned()
+                .contained()
+                .with_margin_left(theme.titlebar.item_spacing)
+                .into_any(),
+        )
+    }
+
+    fn render_user_menu_button(
+        &self,
+        theme: &Theme,
+        avatar: Option<Arc<ImageData>>,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        let tooltip = theme.tooltip.clone();
+        let user_menu_button_style = if avatar.is_some() {
+            &theme.titlebar.user_menu.user_menu_button_online
+        } else {
+            &theme.titlebar.user_menu.user_menu_button_offline
+        };
+
+        let avatar_style = &user_menu_button_style.avatar;
+        Stack::new()
+            .with_child(
+                MouseEventHandler::new::<ToggleUserMenu, _>(0, cx, |state, _| {
+                    let style = user_menu_button_style
+                        .user_menu
+                        .inactive_state()
+                        .style_for(state);
+
+                    let mut dropdown = Flex::row().align_children_center();
+
+                    if let Some(avatar_img) = avatar {
+                        dropdown = dropdown.with_child(Self::render_face(
+                            avatar_img,
+                            *avatar_style,
+                            Color::transparent_black(),
+                            None,
+                        ));
+                    };
+
+                    dropdown
+                        .with_child(
+                            Svg::new("icons/caret_down.svg")
+                                .with_color(user_menu_button_style.icon.color)
+                                .constrained()
+                                .with_width(user_menu_button_style.icon.width)
+                                .contained()
+                                .into_any(),
+                        )
+                        .aligned()
+                        .constrained()
+                        .with_height(style.width)
+                        .contained()
+                        .with_style(style.container)
+                        .into_any()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_down(MouseButton::Left, move |_, this, cx| {
+                    this.user_menu.update(cx, |menu, _| menu.delay_cancel());
+                })
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    this.toggle_user_menu(&Default::default(), cx)
+                })
+                .with_tooltip::<ToggleUserMenu>(
+                    0,
+                    "Toggle User Menu".to_owned(),
+                    Some(Box::new(ToggleUserMenu)),
+                    tooltip,
+                    cx,
+                )
+                .contained(),
+            )
+            .with_child(
+                ChildView::new(&self.user_menu, cx)
+                    .aligned()
+                    .bottom()
+                    .right(),
+            )
+            .into_any()
+    }
+
+    fn render_sign_in_button(&self, theme: &Theme, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let titlebar = &theme.titlebar;
+        MouseEventHandler::new::<SignIn, _>(0, cx, |state, _| {
+            let style = titlebar.sign_in_button.inactive_state().style_for(state);
+            Label::new("Sign In", style.text.clone())
+                .contained()
+                .with_style(style.container)
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, this, cx| {
+            let client = this.client.clone();
+            cx.app_context()
+                .spawn(|cx| async move { client.authenticate_and_connect(true, &cx).await })
+                .detach_and_log_err(cx);
+        })
+        .into_any()
+    }
+
+    fn render_collaborators(
+        &self,
+        workspace: &ViewHandle<Workspace>,
+        theme: &Theme,
+        room: &ModelHandle<Room>,
+        cx: &mut ViewContext<Self>,
+    ) -> Vec<Container<Self>> {
+        let mut participants = room
+            .read(cx)
+            .remote_participants()
+            .values()
+            .cloned()
+            .collect::<Vec<_>>();
+        participants.sort_by_cached_key(|p| p.user.github_login.clone());
+
+        participants
+            .into_iter()
+            .filter_map(|participant| {
+                let project = workspace.read(cx).project().read(cx);
+                let replica_id = project
+                    .collaborators()
+                    .get(&participant.peer_id)
+                    .map(|collaborator| collaborator.replica_id);
+                let user = participant.user.clone();
+                Some(
+                    Container::new(self.render_face_pile(
+                        &user,
+                        replica_id,
+                        participant.peer_id,
+                        Some(participant.location),
+                        participant.muted,
+                        participant.speaking,
+                        workspace,
+                        theme,
+                        cx,
+                    ))
+                    .with_margin_right(theme.titlebar.face_pile_spacing),
+                )
+            })
+            .collect()
+    }
+
+    fn render_current_user(
+        &self,
+        workspace: &ViewHandle<Workspace>,
+        theme: &Theme,
+        user: &Arc<User>,
+        peer_id: PeerId,
+        muted: bool,
+        speaking: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        let replica_id = workspace.read(cx).project().read(cx).replica_id();
+
+        Container::new(self.render_face_pile(
+            user,
+            Some(replica_id),
+            peer_id,
+            None,
+            muted,
+            speaking,
+            workspace,
+            theme,
+            cx,
+        ))
+        .with_margin_right(theme.titlebar.item_spacing)
+        .into_any()
+    }
+
+    fn render_face_pile(
+        &self,
+        user: &User,
+        _replica_id: Option<ReplicaId>,
+        peer_id: PeerId,
+        location: Option<ParticipantLocation>,
+        muted: bool,
+        speaking: bool,
+        workspace: &ViewHandle<Workspace>,
+        theme: &Theme,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        let user_id = user.id;
+        let project_id = workspace.read(cx).project().read(cx).remote_id();
+        let room = ActiveCall::global(cx).read(cx).room().cloned();
+        let self_peer_id = workspace.read(cx).client().peer_id();
+        let self_following = workspace.read(cx).is_being_followed(peer_id);
+        let self_following_initialized = self_following
+            && room.as_ref().map_or(false, |room| match project_id {
+                None => true,
+                Some(project_id) => room
+                    .read(cx)
+                    .followers_for(peer_id, project_id)
+                    .iter()
+                    .any(|&follower| Some(follower) == self_peer_id),
+            });
+
+        let leader_style = theme.titlebar.leader_avatar;
+        let follower_style = theme.titlebar.follower_avatar;
+
+        let microphone_state = if muted {
+            Some(theme.titlebar.muted)
+        } else if speaking {
+            Some(theme.titlebar.speaking)
+        } else {
+            None
+        };
+
+        let mut background_color = theme
+            .titlebar
+            .container
+            .background_color
+            .unwrap_or_default();
+
+        let participant_index = self
+            .user_store
+            .read(cx)
+            .participant_indices()
+            .get(&user_id)
+            .copied();
+        if let Some(participant_index) = participant_index {
+            if self_following_initialized {
+                let selection = theme
+                    .editor
+                    .selection_style_for_room_participant(participant_index.0)
+                    .selection;
+                background_color = Color::blend(selection, background_color);
+                background_color.a = 255;
+            }
+        }
+
+        enum TitlebarParticipant {}
+
+        let content = MouseEventHandler::new::<TitlebarParticipant, _>(
+            peer_id.as_u64() as usize,
+            cx,
+            move |_, cx| {
+                Stack::new()
+                    .with_children(user.avatar.as_ref().map(|avatar| {
+                        let face_pile = FacePile::new(theme.titlebar.follower_avatar_overlap)
+                            .with_child(Self::render_face(
+                                avatar.clone(),
+                                Self::location_style(workspace, location, leader_style, cx),
+                                background_color,
+                                microphone_state,
+                            ))
+                            .with_children(
+                                (|| {
+                                    let project_id = project_id?;
+                                    let room = room?.read(cx);
+                                    let followers = room.followers_for(peer_id, project_id);
+                                    Some(followers.into_iter().filter_map(|&follower| {
+                                        if Some(follower) == self_peer_id {
+                                            return None;
+                                        }
+                                        let participant =
+                                            room.remote_participant_for_peer_id(follower)?;
+                                        Some(Self::render_face(
+                                            participant.user.avatar.clone()?,
+                                            follower_style,
+                                            background_color,
+                                            None,
+                                        ))
+                                    }))
+                                })()
+                                .into_iter()
+                                .flatten(),
+                            )
+                            .with_children(
+                                self_following_initialized
+                                    .then(|| self.user_store.read(cx).current_user())
+                                    .and_then(|user| {
+                                        Some(Self::render_face(
+                                            user?.avatar.clone()?,
+                                            follower_style,
+                                            background_color,
+                                            None,
+                                        ))
+                                    }),
+                            );
+
+                        let mut container = face_pile
+                            .contained()
+                            .with_style(theme.titlebar.leader_selection);
+
+                        if let Some(participant_index) = participant_index {
+                            if self_following_initialized {
+                                let color = theme
+                                    .editor
+                                    .selection_style_for_room_participant(participant_index.0)
+                                    .selection;
+                                container = container.with_background_color(color);
+                            }
+                        }
+
+                        container
+                    }))
+                    .with_children((|| {
+                        let participant_index = participant_index?;
+                        let color = theme
+                            .editor
+                            .selection_style_for_room_participant(participant_index.0)
+                            .cursor;
+                        Some(
+                            AvatarRibbon::new(color)
+                                .constrained()
+                                .with_width(theme.titlebar.avatar_ribbon.width)
+                                .with_height(theme.titlebar.avatar_ribbon.height)
+                                .aligned()
+                                .bottom(),
+                        )
+                    })())
+            },
+        );
+
+        if Some(peer_id) == self_peer_id {
+            return content.into_any();
+        }
+
+        content
+            .with_cursor_style(CursorStyle::PointingHand)
+            .on_click(MouseButton::Left, move |_, this, cx| {
+                let Some(workspace) = this.workspace.upgrade(cx) else {
+                    return;
+                };
+                if let Some(task) =
+                    workspace.update(cx, |workspace, cx| workspace.follow(peer_id, cx))
+                {
+                    task.detach_and_log_err(cx);
+                }
+            })
+            .with_tooltip::<TitlebarParticipant>(
+                peer_id.as_u64() as usize,
+                format!("Follow {}", user.github_login),
+                Some(Box::new(FollowNextCollaborator)),
+                theme.tooltip.clone(),
+                cx,
+            )
+            .into_any()
+    }
+
+    fn location_style(
+        workspace: &ViewHandle<Workspace>,
+        location: Option<ParticipantLocation>,
+        mut style: AvatarStyle,
+        cx: &ViewContext<Self>,
+    ) -> AvatarStyle {
+        if let Some(location) = location {
+            if let ParticipantLocation::SharedProject { project_id } = location {
+                if Some(project_id) != workspace.read(cx).project().read(cx).remote_id() {
+                    style.image.grayscale = true;
+                }
+            } else {
+                style.image.grayscale = true;
+            }
+        }
+
+        style
+    }
+
+    fn render_face<V: 'static>(
+        avatar: Arc<ImageData>,
+        avatar_style: AvatarStyle,
+        background_color: Color,
+        microphone_state: Option<Color>,
+    ) -> AnyElement<V> {
+        Image::from_data(avatar)
+            .with_style(avatar_style.image)
+            .aligned()
+            .contained()
+            .with_background_color(microphone_state.unwrap_or(background_color))
+            .with_corner_radius(avatar_style.outer_corner_radius)
+            .constrained()
+            .with_width(avatar_style.outer_width)
+            .with_height(avatar_style.outer_width)
+            .aligned()
+            .into_any()
+    }
+
+    fn render_connection_status(
+        &self,
+        status: &client::Status,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<AnyElement<Self>> {
+        enum ConnectionStatusButton {}
+
+        let theme = &theme::current(cx).clone();
+        match status {
+            client::Status::ConnectionError
+            | client::Status::ConnectionLost
+            | client::Status::Reauthenticating { .. }
+            | client::Status::Reconnecting { .. }
+            | client::Status::ReconnectionError { .. } => Some(
+                Svg::new("icons/disconnected.svg")
+                    .with_color(theme.titlebar.offline_icon.color)
+                    .constrained()
+                    .with_width(theme.titlebar.offline_icon.width)
+                    .aligned()
+                    .contained()
+                    .with_style(theme.titlebar.offline_icon.container)
+                    .into_any(),
+            ),
+            client::Status::UpgradeRequired => {
+                let auto_updater = auto_update::AutoUpdater::get(cx);
+                let label = match auto_updater.map(|auto_update| auto_update.read(cx).status()) {
+                    Some(AutoUpdateStatus::Updated) => "Please restart Zed to Collaborate",
+                    Some(AutoUpdateStatus::Installing)
+                    | Some(AutoUpdateStatus::Downloading)
+                    | Some(AutoUpdateStatus::Checking) => "Updating...",
+                    Some(AutoUpdateStatus::Idle) | Some(AutoUpdateStatus::Errored) | None => {
+                        "Please update Zed to Collaborate"
+                    }
+                };
+
+                Some(
+                    MouseEventHandler::new::<ConnectionStatusButton, _>(0, cx, |_, _| {
+                        Label::new(label, theme.titlebar.outdated_warning.text.clone())
+                            .contained()
+                            .with_style(theme.titlebar.outdated_warning.container)
+                            .aligned()
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .on_click(MouseButton::Left, |_, _, cx| {
+                        if let Some(auto_updater) = auto_update::AutoUpdater::get(cx) {
+                            if auto_updater.read(cx).status() == AutoUpdateStatus::Updated {
+                                workspace::restart(&Default::default(), cx);
+                                return;
+                            }
+                        }
+                        auto_update::check(&Default::default(), cx);
+                    })
+                    .into_any(),
+                )
+            }
+            _ => None,
+        }
+    }
+}
+
+pub struct AvatarRibbon {
+    color: Color,
+}
+
+impl AvatarRibbon {
+    pub fn new(color: Color) -> AvatarRibbon {
+        AvatarRibbon { color }
+    }
+}
+
+impl Element<CollabTitlebarItem> for AvatarRibbon {
+    type LayoutState = ();
+
+    type PaintState = ();
+
+    fn layout(
+        &mut self,
+        constraint: gpui::SizeConstraint,
+        _: &mut CollabTitlebarItem,
+        _: &mut ViewContext<CollabTitlebarItem>,
+    ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
+        (constraint.max, ())
+    }
+
+    fn paint(
+        &mut self,
+        bounds: RectF,
+        _: RectF,
+        _: &mut Self::LayoutState,
+        _: &mut CollabTitlebarItem,
+        cx: &mut ViewContext<CollabTitlebarItem>,
+    ) -> Self::PaintState {
+        let mut path = PathBuilder::new();
+        path.reset(bounds.lower_left());
+        path.curve_to(
+            bounds.origin() + vec2f(bounds.height(), 0.),
+            bounds.origin(),
+        );
+        path.line_to(bounds.upper_right() - vec2f(bounds.height(), 0.));
+        path.curve_to(bounds.lower_right(), bounds.upper_right());
+        path.line_to(bounds.lower_left());
+        cx.scene().push_path(path.build(self.color, None));
+    }
+
+    fn rect_for_text_range(
+        &self,
+        _: Range<usize>,
+        _: RectF,
+        _: RectF,
+        _: &Self::LayoutState,
+        _: &Self::PaintState,
+        _: &CollabTitlebarItem,
+        _: &ViewContext<CollabTitlebarItem>,
+    ) -> Option<RectF> {
+        None
+    }
+
+    fn debug(
+        &self,
+        bounds: RectF,
+        _: &Self::LayoutState,
+        _: &Self::PaintState,
+        _: &CollabTitlebarItem,
+        _: &ViewContext<CollabTitlebarItem>,
+    ) -> gpui::json::Value {
+        json::json!({
+            "type": "AvatarRibbon",
+            "bounds": bounds.to_json(),
+            "color": self.color.to_json(),
+        })
+    }
+}

crates/collab_ui2/src/collab_ui.rs 🔗

@@ -0,0 +1,165 @@
+pub mod channel_view;
+pub mod chat_panel;
+pub mod collab_panel;
+mod collab_titlebar_item;
+mod face_pile;
+pub mod notification_panel;
+pub mod notifications;
+mod panel_settings;
+
+use call::{report_call_event_for_room, ActiveCall, Room};
+use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
+use gpui::{
+    actions,
+    elements::{ContainerStyle, Empty, Image},
+    geometry::{
+        rect::RectF,
+        vector::{vec2f, Vector2F},
+    },
+    platform::{Screen, WindowBounds, WindowKind, WindowOptions},
+    AnyElement, AppContext, Element, ImageData, Task,
+};
+use std::{rc::Rc, sync::Arc};
+use theme::AvatarStyle;
+use util::ResultExt;
+use workspace::AppState;
+
+pub use collab_titlebar_item::CollabTitlebarItem;
+pub use panel_settings::{
+    ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings,
+};
+
+actions!(
+    collab,
+    [ToggleScreenSharing, ToggleMute, ToggleDeafen, LeaveCall]
+);
+
+pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
+    settings::register::<CollaborationPanelSettings>(cx);
+    settings::register::<ChatPanelSettings>(cx);
+    settings::register::<NotificationPanelSettings>(cx);
+
+    vcs_menu::init(cx);
+    collab_titlebar_item::init(cx);
+    collab_panel::init(cx);
+    chat_panel::init(cx);
+    notifications::init(&app_state, cx);
+
+    cx.add_global_action(toggle_screen_sharing);
+    cx.add_global_action(toggle_mute);
+    cx.add_global_action(toggle_deafen);
+}
+
+pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) {
+    let call = ActiveCall::global(cx).read(cx);
+    if let Some(room) = call.room().cloned() {
+        let client = call.client();
+        let toggle_screen_sharing = room.update(cx, |room, cx| {
+            if room.is_screen_sharing() {
+                report_call_event_for_room(
+                    "disable screen share",
+                    room.id(),
+                    room.channel_id(),
+                    &client,
+                    cx,
+                );
+                Task::ready(room.unshare_screen(cx))
+            } else {
+                report_call_event_for_room(
+                    "enable screen share",
+                    room.id(),
+                    room.channel_id(),
+                    &client,
+                    cx,
+                );
+                room.share_screen(cx)
+            }
+        });
+        toggle_screen_sharing.detach_and_log_err(cx);
+    }
+}
+
+pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) {
+    let call = ActiveCall::global(cx).read(cx);
+    if let Some(room) = call.room().cloned() {
+        let client = call.client();
+        room.update(cx, |room, cx| {
+            let operation = if room.is_muted(cx) {
+                "enable microphone"
+            } else {
+                "disable microphone"
+            };
+            report_call_event_for_room(operation, room.id(), room.channel_id(), &client, cx);
+
+            room.toggle_mute(cx)
+        })
+        .map(|task| task.detach_and_log_err(cx))
+        .log_err();
+    }
+}
+
+pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) {
+    if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
+        room.update(cx, Room::toggle_deafen)
+            .map(|task| task.detach_and_log_err(cx))
+            .log_err();
+    }
+}
+
+fn notification_window_options(
+    screen: Rc<dyn Screen>,
+    window_size: Vector2F,
+) -> WindowOptions<'static> {
+    const NOTIFICATION_PADDING: f32 = 16.;
+
+    let screen_bounds = screen.content_bounds();
+    WindowOptions {
+        bounds: WindowBounds::Fixed(RectF::new(
+            screen_bounds.upper_right()
+                + vec2f(
+                    -NOTIFICATION_PADDING - window_size.x(),
+                    NOTIFICATION_PADDING,
+                ),
+            window_size,
+        )),
+        titlebar: None,
+        center: false,
+        focus: false,
+        show: true,
+        kind: WindowKind::PopUp,
+        is_movable: false,
+        screen: Some(screen),
+    }
+}
+
+fn render_avatar<T: 'static>(
+    avatar: Option<Arc<ImageData>>,
+    avatar_style: &AvatarStyle,
+    container: ContainerStyle,
+) -> AnyElement<T> {
+    avatar
+        .map(|avatar| {
+            Image::from_data(avatar)
+                .with_style(avatar_style.image)
+                .aligned()
+                .contained()
+                .with_corner_radius(avatar_style.outer_corner_radius)
+                .constrained()
+                .with_width(avatar_style.outer_width)
+                .with_height(avatar_style.outer_width)
+                .into_any()
+        })
+        .unwrap_or_else(|| {
+            Empty::new()
+                .constrained()
+                .with_width(avatar_style.outer_width)
+                .into_any()
+        })
+        .contained()
+        .with_style(container)
+        .into_any()
+}
+
+fn is_channels_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool {
+    cx.is_staff() || cx.has_flag::<ChannelsAlpha>()
+}

crates/collab_ui2/src/face_pile.rs 🔗

@@ -0,0 +1,113 @@
+use std::ops::Range;
+
+use gpui::{
+    geometry::{
+        rect::RectF,
+        vector::{vec2f, Vector2F},
+    },
+    json::ToJson,
+    serde_json::{self, json},
+    AnyElement, Axis, Element, View, ViewContext,
+};
+
+pub(crate) struct FacePile<V: View> {
+    overlap: f32,
+    faces: Vec<AnyElement<V>>,
+}
+
+impl<V: View> FacePile<V> {
+    pub fn new(overlap: f32) -> Self {
+        Self {
+            overlap,
+            faces: Vec::new(),
+        }
+    }
+}
+
+impl<V: View> Element<V> for FacePile<V> {
+    type LayoutState = ();
+    type PaintState = ();
+
+    fn layout(
+        &mut self,
+        constraint: gpui::SizeConstraint,
+        view: &mut V,
+        cx: &mut ViewContext<V>,
+    ) -> (Vector2F, Self::LayoutState) {
+        debug_assert!(constraint.max_along(Axis::Horizontal) == f32::INFINITY);
+
+        let mut width = 0.;
+        let mut max_height = 0.;
+        for face in &mut self.faces {
+            let layout = face.layout(constraint, view, cx);
+            width += layout.x();
+            max_height = f32::max(max_height, layout.y());
+        }
+        width -= self.overlap * self.faces.len().saturating_sub(1) as f32;
+
+        (
+            Vector2F::new(width, max_height.clamp(1., constraint.max.y())),
+            (),
+        )
+    }
+
+    fn paint(
+        &mut self,
+        bounds: RectF,
+        visible_bounds: RectF,
+        _layout: &mut Self::LayoutState,
+        view: &mut V,
+        cx: &mut ViewContext<V>,
+    ) -> Self::PaintState {
+        let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
+
+        let origin_y = bounds.upper_right().y();
+        let mut origin_x = bounds.upper_right().x();
+
+        for face in self.faces.iter_mut().rev() {
+            let size = face.size();
+            origin_x -= size.x();
+            let origin_y = origin_y + (bounds.height() - size.y()) / 2.0;
+
+            cx.scene().push_layer(None);
+            face.paint(vec2f(origin_x, origin_y), visible_bounds, view, cx);
+            cx.scene().pop_layer();
+            origin_x += self.overlap;
+        }
+
+        ()
+    }
+
+    fn rect_for_text_range(
+        &self,
+        _: Range<usize>,
+        _: RectF,
+        _: RectF,
+        _: &Self::LayoutState,
+        _: &Self::PaintState,
+        _: &V,
+        _: &ViewContext<V>,
+    ) -> Option<RectF> {
+        None
+    }
+
+    fn debug(
+        &self,
+        bounds: RectF,
+        _: &Self::LayoutState,
+        _: &Self::PaintState,
+        _: &V,
+        _: &ViewContext<V>,
+    ) -> serde_json::Value {
+        json!({
+            "type": "FacePile",
+            "bounds": bounds.to_json()
+        })
+    }
+}
+
+impl<V: View> Extend<AnyElement<V>> for FacePile<V> {
+    fn extend<T: IntoIterator<Item = AnyElement<V>>>(&mut self, children: T) {
+        self.faces.extend(children);
+    }
+}

crates/collab_ui2/src/notification_panel.rs 🔗

@@ -0,0 +1,884 @@
+use crate::{chat_panel::ChatPanel, render_avatar, NotificationPanelSettings};
+use anyhow::Result;
+use channel::ChannelStore;
+use client::{Client, Notification, User, UserStore};
+use collections::HashMap;
+use db::kvp::KEY_VALUE_STORE;
+use futures::StreamExt;
+use gpui::{
+    actions,
+    elements::*,
+    platform::{CursorStyle, MouseButton},
+    serde_json, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Task, View,
+    ViewContext, ViewHandle, WeakViewHandle, WindowContext,
+};
+use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
+use project::Fs;
+use rpc::proto;
+use serde::{Deserialize, Serialize};
+use settings::SettingsStore;
+use std::{sync::Arc, time::Duration};
+use theme::{ui, Theme};
+use time::{OffsetDateTime, UtcOffset};
+use util::{ResultExt, TryFutureExt};
+use workspace::{
+    dock::{DockPosition, Panel},
+    Workspace,
+};
+
+const LOADING_THRESHOLD: usize = 30;
+const MARK_AS_READ_DELAY: Duration = Duration::from_secs(1);
+const TOAST_DURATION: Duration = Duration::from_secs(5);
+const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel";
+
+pub struct NotificationPanel {
+    client: Arc<Client>,
+    user_store: ModelHandle<UserStore>,
+    channel_store: ModelHandle<ChannelStore>,
+    notification_store: ModelHandle<NotificationStore>,
+    fs: Arc<dyn Fs>,
+    width: Option<f32>,
+    active: bool,
+    notification_list: ListState<Self>,
+    pending_serialization: Task<Option<()>>,
+    subscriptions: Vec<gpui::Subscription>,
+    workspace: WeakViewHandle<Workspace>,
+    current_notification_toast: Option<(u64, Task<()>)>,
+    local_timezone: UtcOffset,
+    has_focus: bool,
+    mark_as_read_tasks: HashMap<u64, Task<Result<()>>>,
+}
+
+#[derive(Serialize, Deserialize)]
+struct SerializedNotificationPanel {
+    width: Option<f32>,
+}
+
+#[derive(Debug)]
+pub enum Event {
+    DockPositionChanged,
+    Focus,
+    Dismissed,
+}
+
+pub struct NotificationPresenter {
+    pub actor: Option<Arc<client::User>>,
+    pub text: String,
+    pub icon: &'static str,
+    pub needs_response: bool,
+    pub can_navigate: bool,
+}
+
+actions!(notification_panel, [ToggleFocus]);
+
+pub fn init(_cx: &mut AppContext) {}
+
+impl NotificationPanel {
+    pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
+        let fs = workspace.app_state().fs.clone();
+        let client = workspace.app_state().client.clone();
+        let user_store = workspace.app_state().user_store.clone();
+        let workspace_handle = workspace.weak_handle();
+
+        cx.add_view(|cx| {
+            let mut status = client.status();
+            cx.spawn(|this, mut cx| async move {
+                while let Some(_) = status.next().await {
+                    if this
+                        .update(&mut cx, |_, cx| {
+                            cx.notify();
+                        })
+                        .is_err()
+                    {
+                        break;
+                    }
+                }
+            })
+            .detach();
+
+            let mut notification_list =
+                ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
+                    this.render_notification(ix, cx)
+                        .unwrap_or_else(|| Empty::new().into_any())
+                });
+            notification_list.set_scroll_handler(|visible_range, count, this, cx| {
+                if count.saturating_sub(visible_range.end) < LOADING_THRESHOLD {
+                    if let Some(task) = this
+                        .notification_store
+                        .update(cx, |store, cx| store.load_more_notifications(false, cx))
+                    {
+                        task.detach();
+                    }
+                }
+            });
+
+            let mut this = Self {
+                fs,
+                client,
+                user_store,
+                local_timezone: cx.platform().local_timezone(),
+                channel_store: ChannelStore::global(cx),
+                notification_store: NotificationStore::global(cx),
+                notification_list,
+                pending_serialization: Task::ready(None),
+                workspace: workspace_handle,
+                has_focus: false,
+                current_notification_toast: None,
+                subscriptions: Vec::new(),
+                active: false,
+                mark_as_read_tasks: HashMap::default(),
+                width: None,
+            };
+
+            let mut old_dock_position = this.position(cx);
+            this.subscriptions.extend([
+                cx.observe(&this.notification_store, |_, _, cx| cx.notify()),
+                cx.subscribe(&this.notification_store, Self::on_notification_event),
+                cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
+                    let new_dock_position = this.position(cx);
+                    if new_dock_position != old_dock_position {
+                        old_dock_position = new_dock_position;
+                        cx.emit(Event::DockPositionChanged);
+                    }
+                    cx.notify();
+                }),
+            ]);
+            this
+        })
+    }
+
+    pub fn load(
+        workspace: WeakViewHandle<Workspace>,
+        cx: AsyncAppContext,
+    ) -> Task<Result<ViewHandle<Self>>> {
+        cx.spawn(|mut cx| async move {
+            let serialized_panel = if let Some(panel) = cx
+                .background()
+                .spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) })
+                .await
+                .log_err()
+                .flatten()
+            {
+                Some(serde_json::from_str::<SerializedNotificationPanel>(&panel)?)
+            } else {
+                None
+            };
+
+            workspace.update(&mut cx, |workspace, cx| {
+                let panel = Self::new(workspace, cx);
+                if let Some(serialized_panel) = serialized_panel {
+                    panel.update(cx, |panel, cx| {
+                        panel.width = serialized_panel.width;
+                        cx.notify();
+                    });
+                }
+                panel
+            })
+        })
+    }
+
+    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
+        let width = self.width;
+        self.pending_serialization = cx.background().spawn(
+            async move {
+                KEY_VALUE_STORE
+                    .write_kvp(
+                        NOTIFICATION_PANEL_KEY.into(),
+                        serde_json::to_string(&SerializedNotificationPanel { width })?,
+                    )
+                    .await?;
+                anyhow::Ok(())
+            }
+            .log_err(),
+        );
+    }
+
+    fn render_notification(
+        &mut self,
+        ix: usize,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<AnyElement<Self>> {
+        let entry = self.notification_store.read(cx).notification_at(ix)?;
+        let notification_id = entry.id;
+        let now = OffsetDateTime::now_utc();
+        let timestamp = entry.timestamp;
+        let NotificationPresenter {
+            actor,
+            text,
+            needs_response,
+            can_navigate,
+            ..
+        } = self.present_notification(entry, cx)?;
+
+        let theme = theme::current(cx);
+        let style = &theme.notification_panel;
+        let response = entry.response;
+        let notification = entry.notification.clone();
+
+        let message_style = if entry.is_read {
+            style.read_text.clone()
+        } else {
+            style.unread_text.clone()
+        };
+
+        if self.active && !entry.is_read {
+            self.did_render_notification(notification_id, &notification, cx);
+        }
+
+        enum Decline {}
+        enum Accept {}
+
+        Some(
+            MouseEventHandler::new::<NotificationEntry, _>(ix, cx, |_, cx| {
+                let container = message_style.container;
+
+                Flex::row()
+                    .with_children(actor.map(|actor| {
+                        render_avatar(actor.avatar.clone(), &style.avatar, style.avatar_container)
+                    }))
+                    .with_child(
+                        Flex::column()
+                            .with_child(Text::new(text, message_style.text.clone()))
+                            .with_child(
+                                Flex::row()
+                                    .with_child(
+                                        Label::new(
+                                            format_timestamp(timestamp, now, self.local_timezone),
+                                            style.timestamp.text.clone(),
+                                        )
+                                        .contained()
+                                        .with_style(style.timestamp.container),
+                                    )
+                                    .with_children(if let Some(is_accepted) = response {
+                                        Some(
+                                            Label::new(
+                                                if is_accepted {
+                                                    "You accepted"
+                                                } else {
+                                                    "You declined"
+                                                },
+                                                style.read_text.text.clone(),
+                                            )
+                                            .flex_float()
+                                            .into_any(),
+                                        )
+                                    } else if needs_response {
+                                        Some(
+                                            Flex::row()
+                                                .with_children([
+                                                    MouseEventHandler::new::<Decline, _>(
+                                                        ix,
+                                                        cx,
+                                                        |state, _| {
+                                                            let button =
+                                                                style.button.style_for(state);
+                                                            Label::new(
+                                                                "Decline",
+                                                                button.text.clone(),
+                                                            )
+                                                            .contained()
+                                                            .with_style(button.container)
+                                                        },
+                                                    )
+                                                    .with_cursor_style(CursorStyle::PointingHand)
+                                                    .on_click(MouseButton::Left, {
+                                                        let notification = notification.clone();
+                                                        move |_, view, cx| {
+                                                            view.respond_to_notification(
+                                                                notification.clone(),
+                                                                false,
+                                                                cx,
+                                                            );
+                                                        }
+                                                    }),
+                                                    MouseEventHandler::new::<Accept, _>(
+                                                        ix,
+                                                        cx,
+                                                        |state, _| {
+                                                            let button =
+                                                                style.button.style_for(state);
+                                                            Label::new(
+                                                                "Accept",
+                                                                button.text.clone(),
+                                                            )
+                                                            .contained()
+                                                            .with_style(button.container)
+                                                        },
+                                                    )
+                                                    .with_cursor_style(CursorStyle::PointingHand)
+                                                    .on_click(MouseButton::Left, {
+                                                        let notification = notification.clone();
+                                                        move |_, view, cx| {
+                                                            view.respond_to_notification(
+                                                                notification.clone(),
+                                                                true,
+                                                                cx,
+                                                            );
+                                                        }
+                                                    }),
+                                                ])
+                                                .flex_float()
+                                                .into_any(),
+                                        )
+                                    } else {
+                                        None
+                                    }),
+                            )
+                            .flex(1.0, true),
+                    )
+                    .contained()
+                    .with_style(container)
+                    .into_any()
+            })
+            .with_cursor_style(if can_navigate {
+                CursorStyle::PointingHand
+            } else {
+                CursorStyle::default()
+            })
+            .on_click(MouseButton::Left, {
+                let notification = notification.clone();
+                move |_, this, cx| this.did_click_notification(&notification, cx)
+            })
+            .into_any(),
+        )
+    }
+
+    fn present_notification(
+        &self,
+        entry: &NotificationEntry,
+        cx: &AppContext,
+    ) -> Option<NotificationPresenter> {
+        let user_store = self.user_store.read(cx);
+        let channel_store = self.channel_store.read(cx);
+        match entry.notification {
+            Notification::ContactRequest { sender_id } => {
+                let requester = user_store.get_cached_user(sender_id)?;
+                Some(NotificationPresenter {
+                    icon: "icons/plus.svg",
+                    text: format!("{} wants to add you as a contact", requester.github_login),
+                    needs_response: user_store.has_incoming_contact_request(requester.id),
+                    actor: Some(requester),
+                    can_navigate: false,
+                })
+            }
+            Notification::ContactRequestAccepted { responder_id } => {
+                let responder = user_store.get_cached_user(responder_id)?;
+                Some(NotificationPresenter {
+                    icon: "icons/plus.svg",
+                    text: format!("{} accepted your contact invite", responder.github_login),
+                    needs_response: false,
+                    actor: Some(responder),
+                    can_navigate: false,
+                })
+            }
+            Notification::ChannelInvitation {
+                ref channel_name,
+                channel_id,
+                inviter_id,
+            } => {
+                let inviter = user_store.get_cached_user(inviter_id)?;
+                Some(NotificationPresenter {
+                    icon: "icons/hash.svg",
+                    text: format!(
+                        "{} invited you to join the #{channel_name} channel",
+                        inviter.github_login
+                    ),
+                    needs_response: channel_store.has_channel_invitation(channel_id),
+                    actor: Some(inviter),
+                    can_navigate: false,
+                })
+            }
+            Notification::ChannelMessageMention {
+                sender_id,
+                channel_id,
+                message_id,
+            } => {
+                let sender = user_store.get_cached_user(sender_id)?;
+                let channel = channel_store.channel_for_id(channel_id)?;
+                let message = self
+                    .notification_store
+                    .read(cx)
+                    .channel_message_for_id(message_id)?;
+                Some(NotificationPresenter {
+                    icon: "icons/conversations.svg",
+                    text: format!(
+                        "{} mentioned you in #{}:\n{}",
+                        sender.github_login, channel.name, message.body,
+                    ),
+                    needs_response: false,
+                    actor: Some(sender),
+                    can_navigate: true,
+                })
+            }
+        }
+    }
+
+    fn did_render_notification(
+        &mut self,
+        notification_id: u64,
+        notification: &Notification,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let should_mark_as_read = match notification {
+            Notification::ContactRequestAccepted { .. } => true,
+            Notification::ContactRequest { .. }
+            | Notification::ChannelInvitation { .. }
+            | Notification::ChannelMessageMention { .. } => false,
+        };
+
+        if should_mark_as_read {
+            self.mark_as_read_tasks
+                .entry(notification_id)
+                .or_insert_with(|| {
+                    let client = self.client.clone();
+                    cx.spawn(|this, mut cx| async move {
+                        cx.background().timer(MARK_AS_READ_DELAY).await;
+                        client
+                            .request(proto::MarkNotificationRead { notification_id })
+                            .await?;
+                        this.update(&mut cx, |this, _| {
+                            this.mark_as_read_tasks.remove(&notification_id);
+                        })?;
+                        Ok(())
+                    })
+                });
+        }
+    }
+
+    fn did_click_notification(&mut self, notification: &Notification, cx: &mut ViewContext<Self>) {
+        if let Notification::ChannelMessageMention {
+            message_id,
+            channel_id,
+            ..
+        } = notification.clone()
+        {
+            if let Some(workspace) = self.workspace.upgrade(cx) {
+                cx.app_context().defer(move |cx| {
+                    workspace.update(cx, |workspace, cx| {
+                        if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
+                            panel.update(cx, |panel, cx| {
+                                panel
+                                    .select_channel(channel_id, Some(message_id), cx)
+                                    .detach_and_log_err(cx);
+                            });
+                        }
+                    });
+                });
+            }
+        }
+    }
+
+    fn is_showing_notification(&self, notification: &Notification, cx: &AppContext) -> bool {
+        if let Notification::ChannelMessageMention { channel_id, .. } = &notification {
+            if let Some(workspace) = self.workspace.upgrade(cx) {
+                return workspace
+                    .read_with(cx, |workspace, cx| {
+                        if let Some(panel) = workspace.panel::<ChatPanel>(cx) {
+                            return panel.read_with(cx, |panel, cx| {
+                                panel.is_scrolled_to_bottom()
+                                    && panel.active_chat().map_or(false, |chat| {
+                                        chat.read(cx).channel_id == *channel_id
+                                    })
+                            });
+                        }
+                        false
+                    })
+                    .unwrap_or_default();
+            }
+        }
+
+        false
+    }
+
+    fn render_sign_in_prompt(
+        &self,
+        theme: &Arc<Theme>,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum SignInPromptLabel {}
+
+        MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
+            Label::new(
+                "Sign in to view your notifications".to_string(),
+                theme
+                    .chat_panel
+                    .sign_in_prompt
+                    .style_for(mouse_state)
+                    .clone(),
+            )
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, this, cx| {
+            let client = this.client.clone();
+            cx.spawn(|_, cx| async move {
+                client.authenticate_and_connect(true, &cx).log_err().await;
+            })
+            .detach();
+        })
+        .aligned()
+        .into_any()
+    }
+
+    fn render_empty_state(
+        &self,
+        theme: &Arc<Theme>,
+        _cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        Label::new(
+            "You have no notifications".to_string(),
+            theme.chat_panel.sign_in_prompt.default.clone(),
+        )
+        .aligned()
+        .into_any()
+    }
+
+    fn on_notification_event(
+        &mut self,
+        _: ModelHandle<NotificationStore>,
+        event: &NotificationEvent,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match event {
+            NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx),
+            NotificationEvent::NotificationRemoved { entry }
+            | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry.id, cx),
+            NotificationEvent::NotificationsUpdated {
+                old_range,
+                new_count,
+            } => {
+                self.notification_list.splice(old_range.clone(), *new_count);
+                cx.notify();
+            }
+        }
+    }
+
+    fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
+        if self.is_showing_notification(&entry.notification, cx) {
+            return;
+        }
+
+        let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
+        else {
+            return;
+        };
+
+        let notification_id = entry.id;
+        self.current_notification_toast = Some((
+            notification_id,
+            cx.spawn(|this, mut cx| async move {
+                cx.background().timer(TOAST_DURATION).await;
+                this.update(&mut cx, |this, cx| this.remove_toast(notification_id, cx))
+                    .ok();
+            }),
+        ));
+
+        self.workspace
+            .update(cx, |workspace, cx| {
+                workspace.dismiss_notification::<NotificationToast>(0, cx);
+                workspace.show_notification(0, cx, |cx| {
+                    let workspace = cx.weak_handle();
+                    cx.add_view(|_| NotificationToast {
+                        notification_id,
+                        actor,
+                        text,
+                        workspace,
+                    })
+                })
+            })
+            .ok();
+    }
+
+    fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext<Self>) {
+        if let Some((current_id, _)) = &self.current_notification_toast {
+            if *current_id == notification_id {
+                self.current_notification_toast.take();
+                self.workspace
+                    .update(cx, |workspace, cx| {
+                        workspace.dismiss_notification::<NotificationToast>(0, cx)
+                    })
+                    .ok();
+            }
+        }
+    }
+
+    fn respond_to_notification(
+        &mut self,
+        notification: Notification,
+        response: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.notification_store.update(cx, |store, cx| {
+            store.respond_to_notification(notification, response, cx);
+        });
+    }
+}
+
+impl Entity for NotificationPanel {
+    type Event = Event;
+}
+
+impl View for NotificationPanel {
+    fn ui_name() -> &'static str {
+        "NotificationPanel"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let theme = theme::current(cx);
+        let style = &theme.notification_panel;
+        let element = if self.client.user_id().is_none() {
+            self.render_sign_in_prompt(&theme, cx)
+        } else if self.notification_list.item_count() == 0 {
+            self.render_empty_state(&theme, cx)
+        } else {
+            Flex::column()
+                .with_child(
+                    Flex::row()
+                        .with_child(Label::new("Notifications", style.title.text.clone()))
+                        .with_child(ui::svg(&style.title_icon).flex_float())
+                        .align_children_center()
+                        .contained()
+                        .with_style(style.title.container)
+                        .constrained()
+                        .with_height(style.title_height),
+                )
+                .with_child(
+                    List::new(self.notification_list.clone())
+                        .contained()
+                        .with_style(style.list)
+                        .flex(1., true),
+                )
+                .into_any()
+        };
+        element
+            .contained()
+            .with_style(style.container)
+            .constrained()
+            .with_min_width(150.)
+            .into_any()
+    }
+
+    fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
+        self.has_focus = true;
+    }
+
+    fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
+        self.has_focus = false;
+    }
+}
+
+impl Panel for NotificationPanel {
+    fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
+        settings::get::<NotificationPanelSettings>(cx).dock
+    }
+
+    fn position_is_valid(&self, position: DockPosition) -> bool {
+        matches!(position, DockPosition::Left | DockPosition::Right)
+    }
+
+    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
+        settings::update_settings_file::<NotificationPanelSettings>(
+            self.fs.clone(),
+            cx,
+            move |settings| settings.dock = Some(position),
+        );
+    }
+
+    fn size(&self, cx: &gpui::WindowContext) -> f32 {
+        self.width
+            .unwrap_or_else(|| settings::get::<NotificationPanelSettings>(cx).default_width)
+    }
+
+    fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
+        self.width = size;
+        self.serialize(cx);
+        cx.notify();
+    }
+
+    fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
+        self.active = active;
+        if self.notification_store.read(cx).notification_count() == 0 {
+            cx.emit(Event::Dismissed);
+        }
+    }
+
+    fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
+        (settings::get::<NotificationPanelSettings>(cx).button
+            && self.notification_store.read(cx).notification_count() > 0)
+            .then(|| "icons/bell.svg")
+    }
+
+    fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
+        (
+            "Notification Panel".to_string(),
+            Some(Box::new(ToggleFocus)),
+        )
+    }
+
+    fn icon_label(&self, cx: &WindowContext) -> Option<String> {
+        let count = self.notification_store.read(cx).unread_notification_count();
+        if count == 0 {
+            None
+        } else {
+            Some(count.to_string())
+        }
+    }
+
+    fn should_change_position_on_event(event: &Self::Event) -> bool {
+        matches!(event, Event::DockPositionChanged)
+    }
+
+    fn should_close_on_event(event: &Self::Event) -> bool {
+        matches!(event, Event::Dismissed)
+    }
+
+    fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
+        self.has_focus
+    }
+
+    fn is_focus_event(event: &Self::Event) -> bool {
+        matches!(event, Event::Focus)
+    }
+}
+
+pub struct NotificationToast {
+    notification_id: u64,
+    actor: Option<Arc<User>>,
+    text: String,
+    workspace: WeakViewHandle<Workspace>,
+}
+
+pub enum ToastEvent {
+    Dismiss,
+}
+
+impl NotificationToast {
+    fn focus_notification_panel(&self, cx: &mut AppContext) {
+        let workspace = self.workspace.clone();
+        let notification_id = self.notification_id;
+        cx.defer(move |cx| {
+            workspace
+                .update(cx, |workspace, cx| {
+                    if let Some(panel) = workspace.focus_panel::<NotificationPanel>(cx) {
+                        panel.update(cx, |panel, cx| {
+                            let store = panel.notification_store.read(cx);
+                            if let Some(entry) = store.notification_for_id(notification_id) {
+                                panel.did_click_notification(&entry.clone().notification, cx);
+                            }
+                        });
+                    }
+                })
+                .ok();
+        })
+    }
+}
+
+impl Entity for NotificationToast {
+    type Event = ToastEvent;
+}
+
+impl View for NotificationToast {
+    fn ui_name() -> &'static str {
+        "ContactNotification"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let user = self.actor.clone();
+        let theme = theme::current(cx).clone();
+        let theme = &theme.contact_notification;
+
+        MouseEventHandler::new::<Self, _>(0, cx, |_, cx| {
+            Flex::row()
+                .with_children(user.and_then(|user| {
+                    Some(
+                        Image::from_data(user.avatar.clone()?)
+                            .with_style(theme.header_avatar)
+                            .aligned()
+                            .constrained()
+                            .with_height(
+                                cx.font_cache()
+                                    .line_height(theme.header_message.text.font_size),
+                            )
+                            .aligned()
+                            .top(),
+                    )
+                }))
+                .with_child(
+                    Text::new(self.text.clone(), theme.header_message.text.clone())
+                        .contained()
+                        .with_style(theme.header_message.container)
+                        .aligned()
+                        .top()
+                        .left()
+                        .flex(1., true),
+                )
+                .with_child(
+                    MouseEventHandler::new::<ToastEvent, _>(0, cx, |state, _| {
+                        let style = theme.dismiss_button.style_for(state);
+                        Svg::new("icons/x.svg")
+                            .with_color(style.color)
+                            .constrained()
+                            .with_width(style.icon_width)
+                            .aligned()
+                            .contained()
+                            .with_style(style.container)
+                            .constrained()
+                            .with_width(style.button_width)
+                            .with_height(style.button_width)
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .with_padding(Padding::uniform(5.))
+                    .on_click(MouseButton::Left, move |_, _, cx| {
+                        cx.emit(ToastEvent::Dismiss)
+                    })
+                    .aligned()
+                    .constrained()
+                    .with_height(
+                        cx.font_cache()
+                            .line_height(theme.header_message.text.font_size),
+                    )
+                    .aligned()
+                    .top()
+                    .flex_float(),
+                )
+                .contained()
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, this, cx| {
+            this.focus_notification_panel(cx);
+            cx.emit(ToastEvent::Dismiss);
+        })
+        .into_any()
+    }
+}
+
+impl workspace::notifications::Notification for NotificationToast {
+    fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
+        matches!(event, ToastEvent::Dismiss)
+    }
+}
+
+fn format_timestamp(
+    mut timestamp: OffsetDateTime,
+    mut now: OffsetDateTime,
+    local_timezone: UtcOffset,
+) -> String {
+    timestamp = timestamp.to_offset(local_timezone);
+    now = now.to_offset(local_timezone);
+
+    let today = now.date();
+    let date = timestamp.date();
+    if date == today {
+        let difference = now - timestamp;
+        if difference >= Duration::from_secs(3600) {
+            format!("{}h", difference.whole_seconds() / 3600)
+        } else if difference >= Duration::from_secs(60) {
+            format!("{}m", difference.whole_seconds() / 60)
+        } else {
+            "just now".to_string()
+        }
+    } else if date.next_day() == Some(today) {
+        format!("yesterday")
+    } else {
+        format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
+    }
+}

crates/collab_ui2/src/notifications.rs 🔗

@@ -0,0 +1,11 @@
+use gpui::AppContext;
+use std::sync::Arc;
+use workspace::AppState;
+
+pub mod incoming_call_notification;
+pub mod project_shared_notification;
+
+pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
+    incoming_call_notification::init(app_state, cx);
+    project_shared_notification::init(app_state, cx);
+}

crates/collab_ui2/src/notifications/incoming_call_notification.rs 🔗

@@ -0,0 +1,213 @@
+use crate::notification_window_options;
+use call::{ActiveCall, IncomingCall};
+use client::proto;
+use futures::StreamExt;
+use gpui::{
+    elements::*,
+    geometry::vector::vec2f,
+    platform::{CursorStyle, MouseButton},
+    AnyElement, AppContext, Entity, View, ViewContext, WindowHandle,
+};
+use std::sync::{Arc, Weak};
+use util::ResultExt;
+use workspace::AppState;
+
+pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
+    let app_state = Arc::downgrade(app_state);
+    let mut incoming_call = ActiveCall::global(cx).read(cx).incoming();
+    cx.spawn(|mut cx| async move {
+        let mut notification_windows: Vec<WindowHandle<IncomingCallNotification>> = Vec::new();
+        while let Some(incoming_call) = incoming_call.next().await {
+            for window in notification_windows.drain(..) {
+                window.remove(&mut cx);
+            }
+
+            if let Some(incoming_call) = incoming_call {
+                let window_size = cx.read(|cx| {
+                    let theme = &theme::current(cx).incoming_call_notification;
+                    vec2f(theme.window_width, theme.window_height)
+                });
+
+                for screen in cx.platform().screens() {
+                    let window = cx
+                        .add_window(notification_window_options(screen, window_size), |_| {
+                            IncomingCallNotification::new(incoming_call.clone(), app_state.clone())
+                        });
+
+                    notification_windows.push(window);
+                }
+            }
+        }
+    })
+    .detach();
+}
+
+#[derive(Clone, PartialEq)]
+struct RespondToCall {
+    accept: bool,
+}
+
+pub struct IncomingCallNotification {
+    call: IncomingCall,
+    app_state: Weak<AppState>,
+}
+
+impl IncomingCallNotification {
+    pub fn new(call: IncomingCall, app_state: Weak<AppState>) -> Self {
+        Self { call, app_state }
+    }
+
+    fn respond(&mut self, accept: bool, cx: &mut ViewContext<Self>) {
+        let active_call = ActiveCall::global(cx);
+        if accept {
+            let join = active_call.update(cx, |active_call, cx| active_call.accept_incoming(cx));
+            let caller_user_id = self.call.calling_user.id;
+            let initial_project_id = self.call.initial_project.as_ref().map(|project| project.id);
+            let app_state = self.app_state.clone();
+            cx.app_context()
+                .spawn(|mut cx| async move {
+                    join.await?;
+                    if let Some(project_id) = initial_project_id {
+                        cx.update(|cx| {
+                            if let Some(app_state) = app_state.upgrade() {
+                                workspace::join_remote_project(
+                                    project_id,
+                                    caller_user_id,
+                                    app_state,
+                                    cx,
+                                )
+                                .detach_and_log_err(cx);
+                            }
+                        });
+                    }
+                    anyhow::Ok(())
+                })
+                .detach_and_log_err(cx);
+        } else {
+            active_call.update(cx, |active_call, cx| {
+                active_call.decline_incoming(cx).log_err();
+            });
+        }
+    }
+
+    fn render_caller(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let theme = &theme::current(cx).incoming_call_notification;
+        let default_project = proto::ParticipantProject::default();
+        let initial_project = self
+            .call
+            .initial_project
+            .as_ref()
+            .unwrap_or(&default_project);
+        Flex::row()
+            .with_children(self.call.calling_user.avatar.clone().map(|avatar| {
+                Image::from_data(avatar)
+                    .with_style(theme.caller_avatar)
+                    .aligned()
+            }))
+            .with_child(
+                Flex::column()
+                    .with_child(
+                        Label::new(
+                            self.call.calling_user.github_login.clone(),
+                            theme.caller_username.text.clone(),
+                        )
+                        .contained()
+                        .with_style(theme.caller_username.container),
+                    )
+                    .with_child(
+                        Label::new(
+                            format!(
+                                "is sharing a project in Zed{}",
+                                if initial_project.worktree_root_names.is_empty() {
+                                    ""
+                                } else {
+                                    ":"
+                                }
+                            ),
+                            theme.caller_message.text.clone(),
+                        )
+                        .contained()
+                        .with_style(theme.caller_message.container),
+                    )
+                    .with_children(if initial_project.worktree_root_names.is_empty() {
+                        None
+                    } else {
+                        Some(
+                            Label::new(
+                                initial_project.worktree_root_names.join(", "),
+                                theme.worktree_roots.text.clone(),
+                            )
+                            .contained()
+                            .with_style(theme.worktree_roots.container),
+                        )
+                    })
+                    .contained()
+                    .with_style(theme.caller_metadata)
+                    .aligned(),
+            )
+            .contained()
+            .with_style(theme.caller_container)
+            .flex(1., true)
+            .into_any()
+    }
+
+    fn render_buttons(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        enum Accept {}
+        enum Decline {}
+
+        let theme = theme::current(cx);
+        Flex::column()
+            .with_child(
+                MouseEventHandler::new::<Accept, _>(0, cx, |_, _| {
+                    let theme = &theme.incoming_call_notification;
+                    Label::new("Accept", theme.accept_button.text.clone())
+                        .aligned()
+                        .contained()
+                        .with_style(theme.accept_button.container)
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, |_, this, cx| {
+                    this.respond(true, cx);
+                })
+                .flex(1., true),
+            )
+            .with_child(
+                MouseEventHandler::new::<Decline, _>(0, cx, |_, _| {
+                    let theme = &theme.incoming_call_notification;
+                    Label::new("Decline", theme.decline_button.text.clone())
+                        .aligned()
+                        .contained()
+                        .with_style(theme.decline_button.container)
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, |_, this, cx| {
+                    this.respond(false, cx);
+                })
+                .flex(1., true),
+            )
+            .constrained()
+            .with_width(theme.incoming_call_notification.button_width)
+            .into_any()
+    }
+}
+
+impl Entity for IncomingCallNotification {
+    type Event = ();
+}
+
+impl View for IncomingCallNotification {
+    fn ui_name() -> &'static str {
+        "IncomingCallNotification"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let background = theme::current(cx).incoming_call_notification.background;
+        Flex::row()
+            .with_child(self.render_caller(cx))
+            .with_child(self.render_buttons(cx))
+            .contained()
+            .with_background_color(background)
+            .expanded()
+            .into_any()
+    }
+}

crates/collab_ui2/src/notifications/project_shared_notification.rs 🔗

@@ -0,0 +1,217 @@
+use crate::notification_window_options;
+use call::{room, ActiveCall};
+use client::User;
+use collections::HashMap;
+use gpui::{
+    elements::*,
+    geometry::vector::vec2f,
+    platform::{CursorStyle, MouseButton},
+    AppContext, Entity, View, ViewContext,
+};
+use std::sync::{Arc, Weak};
+use workspace::AppState;
+
+pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
+    let app_state = Arc::downgrade(app_state);
+    let active_call = ActiveCall::global(cx);
+    let mut notification_windows = HashMap::default();
+    cx.subscribe(&active_call, move |_, event, cx| match event {
+        room::Event::RemoteProjectShared {
+            owner,
+            project_id,
+            worktree_root_names,
+        } => {
+            let theme = &theme::current(cx).project_shared_notification;
+            let window_size = vec2f(theme.window_width, theme.window_height);
+
+            for screen in cx.platform().screens() {
+                let window =
+                    cx.add_window(notification_window_options(screen, window_size), |_| {
+                        ProjectSharedNotification::new(
+                            owner.clone(),
+                            *project_id,
+                            worktree_root_names.clone(),
+                            app_state.clone(),
+                        )
+                    });
+                notification_windows
+                    .entry(*project_id)
+                    .or_insert(Vec::new())
+                    .push(window);
+            }
+        }
+        room::Event::RemoteProjectUnshared { project_id }
+        | room::Event::RemoteProjectJoined { project_id }
+        | room::Event::RemoteProjectInvitationDiscarded { project_id } => {
+            if let Some(windows) = notification_windows.remove(&project_id) {
+                for window in windows {
+                    window.remove(cx);
+                }
+            }
+        }
+        room::Event::Left => {
+            for (_, windows) in notification_windows.drain() {
+                for window in windows {
+                    window.remove(cx);
+                }
+            }
+        }
+        _ => {}
+    })
+    .detach();
+}
+
+pub struct ProjectSharedNotification {
+    project_id: u64,
+    worktree_root_names: Vec<String>,
+    owner: Arc<User>,
+    app_state: Weak<AppState>,
+}
+
+impl ProjectSharedNotification {
+    fn new(
+        owner: Arc<User>,
+        project_id: u64,
+        worktree_root_names: Vec<String>,
+        app_state: Weak<AppState>,
+    ) -> Self {
+        Self {
+            project_id,
+            worktree_root_names,
+            owner,
+            app_state,
+        }
+    }
+
+    fn join(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(app_state) = self.app_state.upgrade() {
+            workspace::join_remote_project(self.project_id, self.owner.id, app_state, cx)
+                .detach_and_log_err(cx);
+        }
+    }
+
+    fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(active_room) =
+            ActiveCall::global(cx).read_with(cx, |call, _| call.room().cloned())
+        {
+            active_room.update(cx, |_, cx| {
+                cx.emit(room::Event::RemoteProjectInvitationDiscarded {
+                    project_id: self.project_id,
+                });
+            });
+        }
+    }
+
+    fn render_owner(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let theme = &theme::current(cx).project_shared_notification;
+        Flex::row()
+            .with_children(self.owner.avatar.clone().map(|avatar| {
+                Image::from_data(avatar)
+                    .with_style(theme.owner_avatar)
+                    .aligned()
+            }))
+            .with_child(
+                Flex::column()
+                    .with_child(
+                        Label::new(
+                            self.owner.github_login.clone(),
+                            theme.owner_username.text.clone(),
+                        )
+                        .contained()
+                        .with_style(theme.owner_username.container),
+                    )
+                    .with_child(
+                        Label::new(
+                            format!(
+                                "is sharing a project in Zed{}",
+                                if self.worktree_root_names.is_empty() {
+                                    ""
+                                } else {
+                                    ":"
+                                }
+                            ),
+                            theme.message.text.clone(),
+                        )
+                        .contained()
+                        .with_style(theme.message.container),
+                    )
+                    .with_children(if self.worktree_root_names.is_empty() {
+                        None
+                    } else {
+                        Some(
+                            Label::new(
+                                self.worktree_root_names.join(", "),
+                                theme.worktree_roots.text.clone(),
+                            )
+                            .contained()
+                            .with_style(theme.worktree_roots.container),
+                        )
+                    })
+                    .contained()
+                    .with_style(theme.owner_metadata)
+                    .aligned(),
+            )
+            .contained()
+            .with_style(theme.owner_container)
+            .flex(1., true)
+            .into_any()
+    }
+
+    fn render_buttons(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        enum Open {}
+        enum Dismiss {}
+
+        let theme = theme::current(cx);
+        Flex::column()
+            .with_child(
+                MouseEventHandler::new::<Open, _>(0, cx, |_, _| {
+                    let theme = &theme.project_shared_notification;
+                    Label::new("Open", theme.open_button.text.clone())
+                        .aligned()
+                        .contained()
+                        .with_style(theme.open_button.container)
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, this, cx| this.join(cx))
+                .flex(1., true),
+            )
+            .with_child(
+                MouseEventHandler::new::<Dismiss, _>(0, cx, |_, _| {
+                    let theme = &theme.project_shared_notification;
+                    Label::new("Dismiss", theme.dismiss_button.text.clone())
+                        .aligned()
+                        .contained()
+                        .with_style(theme.dismiss_button.container)
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, |_, this, cx| {
+                    this.dismiss(cx);
+                })
+                .flex(1., true),
+            )
+            .constrained()
+            .with_width(theme.project_shared_notification.button_width)
+            .into_any()
+    }
+}
+
+impl Entity for ProjectSharedNotification {
+    type Event = ();
+}
+
+impl View for ProjectSharedNotification {
+    fn ui_name() -> &'static str {
+        "ProjectSharedNotification"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> gpui::AnyElement<Self> {
+        let background = theme::current(cx).project_shared_notification.background;
+        Flex::row()
+            .with_child(self.render_owner(cx))
+            .with_child(self.render_buttons(cx))
+            .contained()
+            .with_background_color(background)
+            .expanded()
+            .into_any()
+    }
+}

crates/collab_ui2/src/panel_settings.rs 🔗

@@ -0,0 +1,69 @@
+use anyhow;
+use schemars::JsonSchema;
+use serde_derive::{Deserialize, Serialize};
+use settings::Setting;
+use workspace::dock::DockPosition;
+
+#[derive(Deserialize, Debug)]
+pub struct CollaborationPanelSettings {
+    pub button: bool,
+    pub dock: DockPosition,
+    pub default_width: f32,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct ChatPanelSettings {
+    pub button: bool,
+    pub dock: DockPosition,
+    pub default_width: f32,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct NotificationPanelSettings {
+    pub button: bool,
+    pub dock: DockPosition,
+    pub default_width: f32,
+}
+
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+pub struct PanelSettingsContent {
+    pub button: Option<bool>,
+    pub dock: Option<DockPosition>,
+    pub default_width: Option<f32>,
+}
+
+impl Setting for CollaborationPanelSettings {
+    const KEY: Option<&'static str> = Some("collaboration_panel");
+    type FileContent = PanelSettingsContent;
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        _: &gpui::AppContext,
+    ) -> anyhow::Result<Self> {
+        Self::load_via_json_merge(default_value, user_values)
+    }
+}
+
+impl Setting for ChatPanelSettings {
+    const KEY: Option<&'static str> = Some("chat_panel");
+    type FileContent = PanelSettingsContent;
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        _: &gpui::AppContext,
+    ) -> anyhow::Result<Self> {
+        Self::load_via_json_merge(default_value, user_values)
+    }
+}
+
+impl Setting for NotificationPanelSettings {
+    const KEY: Option<&'static str> = Some("notification_panel");
+    type FileContent = PanelSettingsContent;
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        _: &gpui::AppContext,
+    ) -> anyhow::Result<Self> {
+        Self::load_via_json_merge(default_value, user_values)
+    }
+}

crates/zed2/Cargo.toml 🔗

@@ -23,7 +23,7 @@ ai = { package = "ai2", path = "../ai2"}
 call = { package = "call2", path = "../call2" }
 # channel = { path = "../channel" }
 cli = { path = "../cli" }
-# collab_ui = { path = "../collab_ui" }
+collab_ui = { package = "collab_ui2", path = "../collab_ui2" }
 collections = { path = "../collections" }
 command_palette = { package="command_palette2", path = "../command_palette2" }
 # component_test = { path = "../component_test" }