Merge pull request #2114 from zed-industries/new-collaboration-ui

Max Brunsfeld created

New collaboration UI part 1/N

Change summary

assets/icons/leave_12.svg                                      |   3 
crates/call/src/call.rs                                        |  12 
crates/call/src/room.rs                                        |  45 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql |  14 
crates/collab/migrations/20230202155735_followers.sql          |  15 
crates/collab/src/db.rs                                        |  96 
crates/collab/src/db/follower.rs                               |  51 
crates/collab/src/db/room.rs                                   |   8 
crates/collab/src/rpc.rs                                       |  28 
crates/collab/src/tests/integration_tests.rs                   |  98 
crates/collab_ui/src/collab_titlebar_item.rs                   | 728 ++-
crates/collab_ui/src/collab_ui.rs                              |   6 
crates/collab_ui/src/collaborator_list_popover.rs              | 165 
crates/collab_ui/src/contact_list.rs                           |  26 
crates/collab_ui/src/contacts_popover.rs                       |   4 
crates/collab_ui/src/face_pile.rs                              | 101 
crates/gpui/src/elements.rs                                    |   1 
crates/gpui/src/elements/flex.rs                               |   2 
crates/rpc/proto/zed.proto                                     |  10 
crates/rpc/src/rpc.rs                                          |   2 
crates/theme/src/theme.rs                                      |  21 
crates/workspace/src/workspace.rs                              |  23 
crates/zed/src/zed.rs                                          |   6 
styles/src/styleTree/workspace.ts                              |  76 
24 files changed, 1,261 insertions(+), 280 deletions(-)

Detailed changes

assets/icons/leave_12.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1C0 0.585786 0.335786 0.25 0.75 0.25H7.25C7.66421 0.25 8 0.585786 8 1C8 1.41421 7.66421 1.75 7.25 1.75H1.5V10.25H7.25C7.66421 10.25 8 10.5858 8 11C8 11.4142 7.66421 11.75 7.25 11.75H0.75C0.335786 11.75 0 11.4142 0 11V1ZM8.78148 2.91435C9.10493 2.65559 9.57689 2.70803 9.83565 3.03148L11.8357 5.53148C12.0548 5.80539 12.0548 6.19461 11.8357 6.46852L9.83565 8.96852C9.57689 9.29197 9.10493 9.34441 8.78148 9.08565C8.45803 8.82689 8.40559 8.35493 8.66435 8.03148L9.68953 6.75H3.75C3.33579 6.75 3 6.41421 3 6C3 5.58579 3.33579 5.25 3.75 5.25H9.68953L8.66435 3.96852C8.40559 3.64507 8.45803 3.17311 8.78148 2.91435Z" fill="#ABB2BF"/>
+</svg>

crates/call/src/call.rs 🔗

@@ -284,6 +284,18 @@ impl ActiveCall {
         }
     }
 
+    pub fn unshare_project(
+        &mut self,
+        project: ModelHandle<Project>,
+        cx: &mut ModelContext<Self>,
+    ) -> Result<()> {
+        if let Some((room, _)) = self.room.as_ref() {
+            room.update(cx, |room, cx| room.unshare_project(project, cx))
+        } else {
+            Err(anyhow!("no active call"))
+        }
+    }
+
     pub fn set_location(
         &mut self,
         project: Option<&ModelHandle<Project>>,

crates/call/src/room.rs 🔗

@@ -55,6 +55,7 @@ pub struct Room {
     leave_when_empty: bool,
     client: Arc<Client>,
     user_store: ModelHandle<UserStore>,
+    follows_by_leader_id: HashMap<PeerId, Vec<PeerId>>,
     subscriptions: Vec<client::Subscription>,
     pending_room_update: Option<Task<()>>,
     maintain_connection: Option<Task<Option<()>>>,
@@ -148,6 +149,7 @@ impl Room {
             pending_room_update: None,
             client,
             user_store,
+            follows_by_leader_id: Default::default(),
             maintain_connection: Some(maintain_connection),
         }
     }
@@ -457,6 +459,12 @@ impl Room {
         self.participant_user_ids.contains(&user_id)
     }
 
+    pub fn followers_for(&self, leader_id: PeerId) -> &[PeerId] {
+        self.follows_by_leader_id
+            .get(&leader_id)
+            .map_or(&[], |v| v.as_slice())
+    }
+
     async fn handle_room_updated(
         this: ModelHandle<Self>,
         envelope: TypedEnvelope<proto::RoomUpdated>,
@@ -487,11 +495,13 @@ impl Room {
             .iter()
             .map(|p| p.user_id)
             .collect::<Vec<_>>();
+
         let remote_participant_user_ids = room
             .participants
             .iter()
             .map(|p| p.user_id)
             .collect::<Vec<_>>();
+
         let (remote_participants, pending_participants) =
             self.user_store.update(cx, move |user_store, cx| {
                 (
@@ -499,6 +509,7 @@ impl Room {
                     user_store.get_users(pending_participant_user_ids, cx),
                 )
             });
+
         self.pending_room_update = Some(cx.spawn(|this, mut cx| async move {
             let (remote_participants, pending_participants) =
                 futures::join!(remote_participants, pending_participants);
@@ -620,6 +631,26 @@ impl Room {
                     }
                 }
 
+                this.follows_by_leader_id.clear();
+                for follower in room.followers {
+                    let (leader, follower) = match (follower.leader_id, follower.follower_id) {
+                        (Some(leader), Some(follower)) => (leader, follower),
+
+                        _ => {
+                            log::error!("Follower message {follower:?} missing some state");
+                            continue;
+                        }
+                    };
+
+                    let list = this
+                        .follows_by_leader_id
+                        .entry(leader)
+                        .or_insert(Vec::new());
+                    if !list.contains(&follower) {
+                        list.push(follower);
+                    }
+                }
+
                 this.pending_room_update.take();
                 if this.should_leave() {
                     log::info!("room is empty, leaving");
@@ -793,6 +824,20 @@ impl Room {
         })
     }
 
+    pub(crate) fn unshare_project(
+        &mut self,
+        project: ModelHandle<Project>,
+        cx: &mut ModelContext<Self>,
+    ) -> Result<()> {
+        let project_id = match project.read(cx).remote_id() {
+            Some(project_id) => project_id,
+            None => return Ok(()),
+        };
+
+        self.client.send(proto::UnshareProject { project_id })?;
+        project.update(cx, |this, cx| this.unshare(cx))
+    }
+
     pub(crate) fn set_location(
         &mut self,
         project: Option<&ModelHandle<Project>>,

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -143,3 +143,17 @@ CREATE TABLE "servers" (
     "id" INTEGER PRIMARY KEY AUTOINCREMENT,
     "environment" VARCHAR NOT NULL
 );
+
+CREATE TABLE "followers" (
+    "id" INTEGER PRIMARY KEY AUTOINCREMENT,
+    "room_id" INTEGER NOT NULL REFERENCES rooms (id) ON DELETE CASCADE,
+    "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
+    "leader_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
+    "leader_connection_id" INTEGER NOT NULL,
+    "follower_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
+    "follower_connection_id" INTEGER NOT NULL
+);
+CREATE UNIQUE INDEX 
+    "index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id"
+ON "followers" ("project_id", "leader_connection_server_id", "leader_connection_id", "follower_connection_server_id", "follower_connection_id");
+CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id");

crates/collab/migrations/20230202155735_followers.sql 🔗

@@ -0,0 +1,15 @@
+CREATE TABLE IF NOT EXISTS "followers" (
+    "id" SERIAL PRIMARY KEY,
+    "room_id" INTEGER NOT NULL REFERENCES rooms (id) ON DELETE CASCADE,
+    "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
+    "leader_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
+    "leader_connection_id" INTEGER NOT NULL,
+    "follower_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
+    "follower_connection_id" INTEGER NOT NULL
+);
+
+CREATE UNIQUE INDEX 
+    "index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id"
+ON "followers" ("project_id", "leader_connection_server_id", "leader_connection_id", "follower_connection_server_id", "follower_connection_id");
+
+CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id");

crates/collab/src/db.rs 🔗

@@ -1,5 +1,6 @@
 mod access_token;
 mod contact;
+mod follower;
 mod language_server;
 mod project;
 mod project_collaborator;
@@ -1717,6 +1718,88 @@ impl Database {
         .await
     }
 
+    pub async fn follow(
+        &self,
+        project_id: ProjectId,
+        leader_connection: ConnectionId,
+        follower_connection: ConnectionId,
+    ) -> Result<RoomGuard<proto::Room>> {
+        self.room_transaction(|tx| async move {
+            let room_id = self.room_id_for_project(project_id, &*tx).await?;
+            follower::ActiveModel {
+                room_id: ActiveValue::set(room_id),
+                project_id: ActiveValue::set(project_id),
+                leader_connection_server_id: ActiveValue::set(ServerId(
+                    leader_connection.owner_id as i32,
+                )),
+                leader_connection_id: ActiveValue::set(leader_connection.id as i32),
+                follower_connection_server_id: ActiveValue::set(ServerId(
+                    follower_connection.owner_id as i32,
+                )),
+                follower_connection_id: ActiveValue::set(follower_connection.id as i32),
+                ..Default::default()
+            }
+            .insert(&*tx)
+            .await?;
+
+            Ok((room_id, self.get_room(room_id, &*tx).await?))
+        })
+        .await
+    }
+
+    pub async fn unfollow(
+        &self,
+        project_id: ProjectId,
+        leader_connection: ConnectionId,
+        follower_connection: ConnectionId,
+    ) -> Result<RoomGuard<proto::Room>> {
+        self.room_transaction(|tx| async move {
+            let room_id = self.room_id_for_project(project_id, &*tx).await?;
+            follower::Entity::delete_many()
+                .filter(
+                    Condition::all()
+                        .add(follower::Column::ProjectId.eq(project_id))
+                        .add(
+                            follower::Column::LeaderConnectionServerId
+                                .eq(leader_connection.owner_id)
+                                .and(follower::Column::LeaderConnectionId.eq(leader_connection.id)),
+                        )
+                        .add(
+                            follower::Column::FollowerConnectionServerId
+                                .eq(follower_connection.owner_id)
+                                .and(
+                                    follower::Column::FollowerConnectionId
+                                        .eq(follower_connection.id),
+                                ),
+                        ),
+                )
+                .exec(&*tx)
+                .await?;
+
+            Ok((room_id, self.get_room(room_id, &*tx).await?))
+        })
+        .await
+    }
+
+    async fn room_id_for_project(
+        &self,
+        project_id: ProjectId,
+        tx: &DatabaseTransaction,
+    ) -> Result<RoomId> {
+        #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+        enum QueryAs {
+            RoomId,
+        }
+
+        Ok(project::Entity::find_by_id(project_id)
+            .select_only()
+            .column(project::Column::RoomId)
+            .into_values::<_, QueryAs>()
+            .one(&*tx)
+            .await?
+            .ok_or_else(|| anyhow!("no such project"))?)
+    }
+
     pub async fn update_room_participant_location(
         &self,
         room_id: RoomId,
@@ -1926,12 +2009,24 @@ impl Database {
                 }
             }
         }
+        drop(db_projects);
+
+        let mut db_followers = db_room.find_related(follower::Entity).stream(tx).await?;
+        let mut followers = Vec::new();
+        while let Some(db_follower) = db_followers.next().await {
+            let db_follower = db_follower?;
+            followers.push(proto::Follower {
+                leader_id: Some(db_follower.leader_connection().into()),
+                follower_id: Some(db_follower.follower_connection().into()),
+            });
+        }
 
         Ok(proto::Room {
             id: db_room.id.to_proto(),
             live_kit_room: db_room.live_kit_room,
             participants: participants.into_values().collect(),
             pending_participants,
+            followers,
         })
     }
 
@@ -3011,6 +3106,7 @@ macro_rules! id_type {
 
 id_type!(AccessTokenId);
 id_type!(ContactId);
+id_type!(FollowerId);
 id_type!(RoomId);
 id_type!(RoomParticipantId);
 id_type!(ProjectId);

crates/collab/src/db/follower.rs 🔗

@@ -0,0 +1,51 @@
+use super::{FollowerId, ProjectId, RoomId, ServerId};
+use rpc::ConnectionId;
+use sea_orm::entity::prelude::*;
+use serde::Serialize;
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)]
+#[sea_orm(table_name = "followers")]
+pub struct Model {
+    #[sea_orm(primary_key)]
+    pub id: FollowerId,
+    pub room_id: RoomId,
+    pub project_id: ProjectId,
+    pub leader_connection_server_id: ServerId,
+    pub leader_connection_id: i32,
+    pub follower_connection_server_id: ServerId,
+    pub follower_connection_id: i32,
+}
+
+impl Model {
+    pub fn leader_connection(&self) -> ConnectionId {
+        ConnectionId {
+            owner_id: self.leader_connection_server_id.0 as u32,
+            id: self.leader_connection_id as u32,
+        }
+    }
+
+    pub fn follower_connection(&self) -> ConnectionId {
+        ConnectionId {
+            owner_id: self.follower_connection_server_id.0 as u32,
+            id: self.follower_connection_id as u32,
+        }
+    }
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+    #[sea_orm(
+        belongs_to = "super::room::Entity",
+        from = "Column::RoomId",
+        to = "super::room::Column::Id"
+    )]
+    Room,
+}
+
+impl Related<super::room::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::Room.def()
+    }
+}
+
+impl ActiveModelBehavior for ActiveModel {}

crates/collab/src/db/room.rs 🔗

@@ -15,6 +15,8 @@ pub enum Relation {
     RoomParticipant,
     #[sea_orm(has_many = "super::project::Entity")]
     Project,
+    #[sea_orm(has_many = "super::follower::Entity")]
+    Follower,
 }
 
 impl Related<super::room_participant::Entity> for Entity {
@@ -29,4 +31,10 @@ impl Related<super::project::Entity> for Entity {
     }
 }
 
+impl Related<super::follower::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::Follower.def()
+    }
+}
+
 impl ActiveModelBehavior for ActiveModel {}

crates/collab/src/rpc.rs 🔗

@@ -1312,6 +1312,7 @@ async fn join_project(
         .filter(|collaborator| collaborator.connection_id != session.connection_id)
         .map(|collaborator| collaborator.to_proto())
         .collect::<Vec<_>>();
+
     let worktrees = project
         .worktrees
         .iter()
@@ -1724,6 +1725,7 @@ async fn follow(
         .ok_or_else(|| anyhow!("invalid leader id"))?
         .into();
     let follower_id = session.connection_id;
+
     {
         let project_connection_ids = session
             .db()
@@ -1744,6 +1746,14 @@ async fn follow(
         .views
         .retain(|view| view.leader_id != Some(follower_id.into()));
     response.send(response_payload)?;
+
+    let room = session
+        .db()
+        .await
+        .follow(project_id, leader_id, follower_id)
+        .await?;
+    room_updated(&room, &session.peer);
+
     Ok(())
 }
 
@@ -1753,17 +1763,29 @@ async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> {
         .leader_id
         .ok_or_else(|| anyhow!("invalid leader id"))?
         .into();
-    let project_connection_ids = session
+    let follower_id = session.connection_id;
+
+    if !session
         .db()
         .await
         .project_connection_ids(project_id, session.connection_id)
-        .await?;
-    if !project_connection_ids.contains(&leader_id) {
+        .await?
+        .contains(&leader_id)
+    {
         Err(anyhow!("no such peer"))?;
     }
+
     session
         .peer
         .forward_send(session.connection_id, leader_id, request)?;
+
+    let room = session
+        .db()
+        .await
+        .unfollow(project_id, leader_id, follower_id)
+        .await?;
+    room_updated(&room, &session.peer);
+
     Ok(())
 }
 

crates/collab/src/tests/integration_tests.rs 🔗

@@ -5786,6 +5786,7 @@ async fn test_following(
     deterministic: Arc<Deterministic>,
     cx_a: &mut TestAppContext,
     cx_b: &mut TestAppContext,
+    cx_c: &mut TestAppContext,
 ) {
     deterministic.forbid_parking();
     cx_a.update(editor::init);
@@ -5794,9 +5795,13 @@ async fn test_following(
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
+    let client_c = server.create_client(cx_c, "user_c").await;
     server
         .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
         .await;
+    server
+        .make_contacts(&mut [(&client_a, cx_a), (&client_c, cx_c)])
+        .await;
     let active_call_a = cx_a.read(ActiveCall::global);
     let active_call_b = cx_b.read(ActiveCall::global);
 
@@ -5827,8 +5832,10 @@ async fn test_following(
         .await
         .unwrap();
 
-    // Client A opens some editors.
     let workspace_a = client_a.build_workspace(&project_a, cx_a);
+    let workspace_b = client_b.build_workspace(&project_b, cx_b);
+
+    // Client A opens some editors.
     let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone());
     let editor_a1 = workspace_a
         .update(cx_a, |workspace, cx| {
@@ -5848,7 +5855,6 @@ async fn test_following(
         .unwrap();
 
     // Client B opens an editor.
-    let workspace_b = client_b.build_workspace(&project_b, cx_b);
     let editor_b1 = workspace_b
         .update(cx_b, |workspace, cx| {
             workspace.open_path((worktree_id, "1.txt"), None, true, cx)
@@ -5858,29 +5864,97 @@ async fn test_following(
         .downcast::<Editor>()
         .unwrap();
 
-    let client_a_id = project_b.read_with(cx_b, |project, _| {
-        project.collaborators().values().next().unwrap().peer_id
-    });
-    let client_b_id = project_a.read_with(cx_a, |project, _| {
-        project.collaborators().values().next().unwrap().peer_id
-    });
+    let peer_id_a = client_a.peer_id().unwrap();
+    let peer_id_b = client_b.peer_id().unwrap();
+    let peer_id_c = client_c.peer_id().unwrap();
 
-    // When client B starts following client A, all visible view states are replicated to client B.
+    // Client A updates their selections in those editors
     editor_a1.update(cx_a, |editor, cx| {
         editor.change_selections(None, cx, |s| s.select_ranges([0..1]))
     });
     editor_a2.update(cx_a, |editor, cx| {
         editor.change_selections(None, cx, |s| s.select_ranges([2..3]))
     });
+
+    // When client B starts following client A, all visible view states are replicated to client B.
     workspace_b
         .update(cx_b, |workspace, cx| {
             workspace
-                .toggle_follow(&ToggleFollow(client_a_id), cx)
+                .toggle_follow(&ToggleFollow(peer_id_a), cx)
+                .unwrap()
+        })
+        .await
+        .unwrap();
+
+    // Client A invites client C to the call.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_c.current_user_id(cx_c).to_proto(), None, cx)
+        })
+        .await
+        .unwrap();
+    cx_c.foreground().run_until_parked();
+    let active_call_c = cx_c.read(ActiveCall::global);
+    active_call_c
+        .update(cx_c, |call, cx| call.accept_incoming(cx))
+        .await
+        .unwrap();
+    let project_c = client_c.build_remote_project(project_id, cx_c).await;
+    let workspace_c = client_c.build_workspace(&project_c, cx_c);
+    active_call_c
+        .update(cx_c, |call, cx| call.set_location(Some(&project_c), cx))
+        .await
+        .unwrap();
+
+    // Client C also follows client A.
+    workspace_c
+        .update(cx_c, |workspace, cx| {
+            workspace
+                .toggle_follow(&ToggleFollow(peer_id_a), cx)
                 .unwrap()
         })
         .await
         .unwrap();
 
+    // All clients see that clients B and C are following client A.
+    cx_c.foreground().run_until_parked();
+    for (name, active_call, cx) in [
+        ("A", &active_call_a, &cx_a),
+        ("B", &active_call_b, &cx_b),
+        ("C", &active_call_c, &cx_c),
+    ] {
+        active_call.read_with(*cx, |call, cx| {
+            let room = call.room().unwrap().read(cx);
+            assert_eq!(
+                room.followers_for(peer_id_a),
+                &[peer_id_b, peer_id_c],
+                "checking followers for A as {name}"
+            );
+        });
+    }
+
+    // Client C unfollows client A.
+    workspace_c.update(cx_c, |workspace, cx| {
+        workspace.toggle_follow(&ToggleFollow(peer_id_a), cx);
+    });
+
+    // All clients see that clients B is following client A.
+    cx_c.foreground().run_until_parked();
+    for (name, active_call, cx) in [
+        ("A", &active_call_a, &cx_a),
+        ("B", &active_call_b, &cx_b),
+        ("C", &active_call_c, &cx_c),
+    ] {
+        active_call.read_with(*cx, |call, cx| {
+            let room = call.room().unwrap().read(cx);
+            assert_eq!(
+                room.followers_for(peer_id_a),
+                &[peer_id_b],
+                "checking followers for A as {name}"
+            );
+        });
+    }
+
     let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| {
         workspace
             .active_item(cx)
@@ -6033,14 +6107,14 @@ async fn test_following(
     workspace_a
         .update(cx_a, |workspace, cx| {
             workspace
-                .toggle_follow(&ToggleFollow(client_b_id), cx)
+                .toggle_follow(&ToggleFollow(peer_id_b), cx)
                 .unwrap()
         })
         .await
         .unwrap();
     assert_eq!(
         workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)),
-        Some(client_b_id)
+        Some(peer_id_b)
     );
     assert_eq!(
         workspace_a.read_with(cx_a, |workspace, cx| workspace

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -1,5 +1,9 @@
-use crate::{contact_notification::ContactNotification, contacts_popover, ToggleScreenSharing};
-use call::{ActiveCall, ParticipantLocation};
+use crate::{
+    collaborator_list_popover, collaborator_list_popover::CollaboratorListPopover,
+    contact_notification::ContactNotification, contacts_popover, face_pile::FacePile,
+    ToggleScreenSharing,
+};
+use call::{ActiveCall, ParticipantLocation, Room};
 use client::{proto::PeerId, Authenticate, ContactEventKind, User, UserStore};
 use clock::ReplicaId;
 use contacts_popover::ContactsPopover;
@@ -8,26 +12,52 @@ use gpui::{
     color::Color,
     elements::*,
     geometry::{rect::RectF, vector::vec2f, PathBuilder},
+    impl_internal_actions,
     json::{self, ToJson},
-    Border, CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext,
+    CursorStyle, Entity, ImageData, ModelHandle, MouseButton, MutableAppContext, RenderContext,
     Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
 };
 use settings::Settings;
-use std::ops::Range;
-use theme::Theme;
+use std::{ops::Range, sync::Arc};
+use theme::{AvatarStyle, Theme};
+use util::ResultExt;
 use workspace::{FollowNextCollaborator, JoinProject, ToggleFollow, Workspace};
 
-actions!(collab, [ToggleCollaborationMenu, ShareProject]);
+actions!(
+    collab,
+    [
+        ToggleCollaboratorList,
+        ToggleContactsMenu,
+        ShareProject,
+        UnshareProject
+    ]
+);
+
+impl_internal_actions!(collab, [LeaveCall]);
+
+#[derive(Copy, Clone, PartialEq)]
+pub(crate) struct LeaveCall;
+
+#[derive(PartialEq, Eq)]
+enum ContactsPopoverSide {
+    Left,
+    Right,
+}
 
 pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(CollabTitlebarItem::toggle_collaborator_list_popover);
     cx.add_action(CollabTitlebarItem::toggle_contacts_popover);
     cx.add_action(CollabTitlebarItem::share_project);
+    cx.add_action(CollabTitlebarItem::unshare_project);
+    cx.add_action(CollabTitlebarItem::leave_call);
 }
 
 pub struct CollabTitlebarItem {
     workspace: WeakViewHandle<Workspace>,
     user_store: ModelHandle<UserStore>,
     contacts_popover: Option<ViewHandle<ContactsPopover>>,
+    contacts_popover_side: ContactsPopoverSide,
+    collaborator_list_popover: Option<ViewHandle<CollaboratorListPopover>>,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -47,27 +77,71 @@ impl View for CollabTitlebarItem {
             return Empty::new().boxed();
         };
 
+        let project = workspace.read(cx).project().read(cx);
+        let mut project_title = String::new();
+        for (i, name) in project.worktree_root_names(cx).enumerate() {
+            if i > 0 {
+                project_title.push_str(", ");
+            }
+            project_title.push_str(name);
+        }
+        if project_title.is_empty() {
+            project_title = "empty project".to_owned();
+        }
+
         let theme = cx.global::<Settings>().theme.clone();
+        let user = workspace.read(cx).user_store().read(cx).current_user();
 
-        let mut container = Flex::row();
+        let mut left_container = Flex::row();
 
-        container.add_children(self.render_toggle_screen_sharing_button(&theme, cx));
+        left_container.add_child(
+            Label::new(project_title, theme.workspace.titlebar.title.clone())
+                .contained()
+                .with_margin_right(theme.workspace.titlebar.item_spacing)
+                .aligned()
+                .left()
+                .boxed(),
+        );
 
-        if workspace.read(cx).client().status().borrow().is_connected() {
-            let project = workspace.read(cx).project().read(cx);
-            if project.is_shared()
-                || project.is_remote()
-                || ActiveCall::global(cx).read(cx).room().is_none()
-            {
-                container.add_child(self.render_toggle_contacts_button(&theme, cx));
-            } else {
-                container.add_child(self.render_share_button(&theme, cx));
-            }
+        if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
+            left_container.add_child(self.render_current_user(&workspace, &theme, &user, cx));
+            left_container.add_children(self.render_collaborators(&workspace, &theme, room, cx));
+            left_container.add_child(self.render_toggle_contacts_button(&theme, cx));
         }
-        container.add_children(self.render_collaborators(&workspace, &theme, cx));
-        container.add_children(self.render_current_user(&workspace, &theme, cx));
-        container.add_children(self.render_connection_status(&workspace, cx));
-        container.boxed()
+
+        let mut right_container = Flex::row();
+
+        if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
+            right_container.add_child(self.render_toggle_screen_sharing_button(&theme, &room, cx));
+            right_container.add_child(self.render_leave_call_button(&theme, cx));
+            right_container
+                .add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
+        } else {
+            right_container.add_child(self.render_outside_call_share_button(&theme, cx));
+        }
+
+        right_container.add_children(self.render_connection_status(&workspace, cx));
+
+        if let Some(user) = user {
+            //TODO: Add style
+            right_container.add_child(
+                Label::new(
+                    user.github_login.clone(),
+                    theme.workspace.titlebar.title.clone(),
+                )
+                .aligned()
+                .contained()
+                .with_margin_left(theme.workspace.titlebar.item_spacing)
+                .boxed(),
+            );
+        } else {
+            right_container.add_child(Self::render_authenticate(&theme, cx));
+        }
+
+        Stack::new()
+            .with_child(left_container.boxed())
+            .with_child(right_container.aligned().right().boxed())
+            .boxed()
     }
 }
 
@@ -80,7 +154,7 @@ impl CollabTitlebarItem {
         let active_call = ActiveCall::global(cx);
         let mut subscriptions = Vec::new();
         subscriptions.push(cx.observe(workspace, |_, _, cx| cx.notify()));
-        subscriptions.push(cx.observe(&active_call, |_, _, 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)
         }));
@@ -112,6 +186,8 @@ impl CollabTitlebarItem {
             workspace: workspace.downgrade(),
             user_store: user_store.clone(),
             contacts_popover: None,
+            contacts_popover_side: ContactsPopoverSide::Right,
+            collaborator_list_popover: None,
             _subscriptions: subscriptions,
         }
     }
@@ -129,6 +205,13 @@ impl CollabTitlebarItem {
         }
     }
 
+    fn active_call_changed(&mut self, cx: &mut ViewContext<Self>) {
+        if ActiveCall::global(cx).read(cx).room().is_none() {
+            self.contacts_popover = None;
+        }
+        cx.notify();
+    }
+
     fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext<Self>) {
         if let Some(workspace) = self.workspace.upgrade(cx) {
             let active_call = ActiveCall::global(cx);
@@ -139,41 +222,88 @@ impl CollabTitlebarItem {
         }
     }
 
-    pub fn toggle_contacts_popover(
+    fn unshare_project(&mut self, _: &UnshareProject, cx: &mut ViewContext<Self>) {
+        if let Some(workspace) = self.workspace.upgrade(cx) {
+            let active_call = ActiveCall::global(cx);
+            let project = workspace.read(cx).project().clone();
+            active_call
+                .update(cx, |call, cx| call.unshare_project(project, cx))
+                .log_err();
+        }
+    }
+
+    pub fn toggle_collaborator_list_popover(
         &mut self,
-        _: &ToggleCollaborationMenu,
+        _: &ToggleCollaboratorList,
         cx: &mut ViewContext<Self>,
     ) {
-        match self.contacts_popover.take() {
+        match self.collaborator_list_popover.take() {
             Some(_) => {}
             None => {
                 if let Some(workspace) = self.workspace.upgrade(cx) {
-                    let project = workspace.read(cx).project().clone();
                     let user_store = workspace.read(cx).user_store().clone();
-                    let view = cx.add_view(|cx| ContactsPopover::new(project, user_store, cx));
+                    let view = cx.add_view(|cx| CollaboratorListPopover::new(user_store, cx));
+
                     cx.subscribe(&view, |this, _, event, cx| {
                         match event {
-                            contacts_popover::Event::Dismissed => {
-                                this.contacts_popover = None;
+                            collaborator_list_popover::Event::Dismissed => {
+                                this.collaborator_list_popover = None;
                             }
                         }
 
                         cx.notify();
                     })
                     .detach();
-                    self.contacts_popover = Some(view);
+
+                    self.collaborator_list_popover = Some(view);
                 }
             }
         }
         cx.notify();
     }
 
+    pub fn toggle_contacts_popover(&mut self, _: &ToggleContactsMenu, cx: &mut ViewContext<Self>) {
+        if self.contacts_popover.take().is_none() {
+            if let Some(workspace) = self.workspace.upgrade(cx) {
+                let project = workspace.read(cx).project().clone();
+                let user_store = workspace.read(cx).user_store().clone();
+                let view = cx.add_view(|cx| ContactsPopover::new(project, user_store, cx));
+                cx.subscribe(&view, |this, _, event, cx| {
+                    match event {
+                        contacts_popover::Event::Dismissed => {
+                            this.contacts_popover = None;
+                        }
+                    }
+
+                    cx.notify();
+                })
+                .detach();
+
+                self.contacts_popover_side = match ActiveCall::global(cx).read(cx).room() {
+                    Some(_) => ContactsPopoverSide::Left,
+                    None => ContactsPopoverSide::Right,
+                };
+
+                self.contacts_popover = Some(view);
+            }
+        }
+
+        cx.notify();
+    }
+
+    fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext<Self>) {
+        ActiveCall::global(cx)
+            .update(cx, |call, cx| call.hang_up(cx))
+            .log_err();
+    }
+
     fn render_toggle_contacts_button(
         &self,
         theme: &Theme,
         cx: &mut RenderContext<Self>,
     ) -> ElementBox {
         let titlebar = &theme.workspace.titlebar;
+
         let badge = if self
             .user_store
             .read(cx)
@@ -194,12 +324,15 @@ impl CollabTitlebarItem {
                     .boxed(),
             )
         };
+
         Stack::new()
             .with_child(
-                MouseEventHandler::<ToggleCollaborationMenu>::new(0, cx, |state, _| {
-                    let style = titlebar
-                        .toggle_contacts_button
-                        .style_for(state, self.contacts_popover.is_some());
+                MouseEventHandler::<ToggleContactsMenu>::new(0, cx, |state, _| {
+                    let style = titlebar.toggle_contacts_button.style_for(
+                        state,
+                        self.contacts_popover.is_some()
+                            && self.contacts_popover_side == ContactsPopoverSide::Left,
+                    );
                     Svg::new("icons/plus_8.svg")
                         .with_color(style.color)
                         .constrained()
@@ -214,39 +347,28 @@ impl CollabTitlebarItem {
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
                 .on_click(MouseButton::Left, move |_, cx| {
-                    cx.dispatch_action(ToggleCollaborationMenu);
+                    cx.dispatch_action(ToggleContactsMenu);
                 })
                 .aligned()
                 .boxed(),
             )
             .with_children(badge)
-            .with_children(self.contacts_popover.as_ref().map(|popover| {
-                Overlay::new(
-                    ChildView::new(popover, cx)
-                        .contained()
-                        .with_margin_top(titlebar.height)
-                        .with_margin_left(titlebar.toggle_contacts_button.default.button_width)
-                        .with_margin_right(-titlebar.toggle_contacts_button.default.button_width)
-                        .boxed(),
-                )
-                .with_fit_mode(OverlayFitMode::SwitchAnchor)
-                .with_anchor_corner(AnchorCorner::BottomLeft)
-                .with_z_index(999)
-                .boxed()
-            }))
+            .with_children(self.render_contacts_popover_host(
+                ContactsPopoverSide::Left,
+                titlebar,
+                cx,
+            ))
             .boxed()
     }
 
     fn render_toggle_screen_sharing_button(
         &self,
         theme: &Theme,
+        room: &ModelHandle<Room>,
         cx: &mut RenderContext<Self>,
-    ) -> Option<ElementBox> {
-        let active_call = ActiveCall::global(cx);
-        let room = active_call.read(cx).room().cloned()?;
+    ) -> ElementBox {
         let icon;
         let tooltip;
-
         if room.read(cx).is_screen_sharing() {
             icon = "icons/disable_screen_sharing_12.svg";
             tooltip = "Stop Sharing Screen"
@@ -256,203 +378,409 @@ impl CollabTitlebarItem {
         }
 
         let titlebar = &theme.workspace.titlebar;
-        Some(
-            MouseEventHandler::<ToggleScreenSharing>::new(0, cx, |state, _| {
-                let style = titlebar.call_control.style_for(state, false);
-                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)
-                    .boxed()
-            })
-            .with_cursor_style(CursorStyle::PointingHand)
-            .on_click(MouseButton::Left, move |_, cx| {
-                cx.dispatch_action(ToggleScreenSharing);
-            })
-            .with_tooltip::<ToggleScreenSharing, _>(
-                0,
-                tooltip.into(),
-                Some(Box::new(ToggleScreenSharing)),
-                theme.tooltip.clone(),
-                cx,
-            )
-            .aligned()
-            .boxed(),
+        MouseEventHandler::<ToggleScreenSharing>::new(0, cx, |state, _| {
+            let style = titlebar.call_control.style_for(state, false);
+            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)
+                .boxed()
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, cx| {
+            cx.dispatch_action(ToggleScreenSharing);
+        })
+        .with_tooltip::<ToggleScreenSharing, _>(
+            0,
+            tooltip.into(),
+            Some(Box::new(ToggleScreenSharing)),
+            theme.tooltip.clone(),
+            cx,
         )
+        .aligned()
+        .boxed()
     }
 
-    fn render_share_button(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
-        enum Share {}
-
+    fn render_leave_call_button(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
         let titlebar = &theme.workspace.titlebar;
-        MouseEventHandler::<Share>::new(0, cx, |state, _| {
-            let style = titlebar.share_button.style_for(state, false);
-            Label::new("Share", style.text.clone())
+
+        MouseEventHandler::<LeaveCall>::new(0, cx, |state, _| {
+            let style = titlebar.call_control.style_for(state, false);
+            Svg::new("icons/leave_12.svg")
+                .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)
                 .boxed()
         })
         .with_cursor_style(CursorStyle::PointingHand)
-        .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(ShareProject))
-        .with_tooltip::<Share, _>(
+        .on_click(MouseButton::Left, move |_, cx| {
+            cx.dispatch_action(LeaveCall);
+        })
+        .with_tooltip::<LeaveCall, _>(
             0,
-            "Share project with call participants".into(),
-            None,
+            "Leave call".to_owned(),
+            Some(Box::new(LeaveCall)),
             theme.tooltip.clone(),
             cx,
         )
-        .aligned()
         .contained()
-        .with_margin_left(theme.workspace.titlebar.avatar_margin)
+        .with_margin_left(theme.workspace.titlebar.item_spacing)
+        .aligned()
         .boxed()
     }
 
+    fn render_in_call_share_unshare_button(
+        &self,
+        workspace: &ViewHandle<Workspace>,
+        theme: &Theme,
+        cx: &mut RenderContext<Self>,
+    ) -> Option<ElementBox> {
+        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 { "Unshare" } else { "Share" };
+        let tooltip = if is_shared {
+            "Unshare project from call participants"
+        } else {
+            "Share project with call participants"
+        };
+
+        let titlebar = &theme.workspace.titlebar;
+
+        enum ShareUnshare {}
+        Some(
+            Stack::new()
+                .with_child(
+                    MouseEventHandler::<ShareUnshare>::new(0, cx, |state, _| {
+                        //TODO: Ensure this button has consistant width for both text variations
+                        let style = titlebar.share_button.style_for(
+                            state,
+                            self.contacts_popover.is_some()
+                                && self.contacts_popover_side == ContactsPopoverSide::Right,
+                        );
+                        Label::new(label, style.text.clone())
+                            .contained()
+                            .with_style(style.container)
+                            .boxed()
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .on_click(MouseButton::Left, move |_, cx| {
+                        if is_shared {
+                            cx.dispatch_action(UnshareProject);
+                        } else {
+                            cx.dispatch_action(ShareProject);
+                        }
+                    })
+                    .with_tooltip::<ShareUnshare, _>(
+                        0,
+                        tooltip.to_owned(),
+                        None,
+                        theme.tooltip.clone(),
+                        cx,
+                    )
+                    .boxed(),
+                )
+                .with_children(self.render_contacts_popover_host(
+                    ContactsPopoverSide::Right,
+                    titlebar,
+                    cx,
+                ))
+                .aligned()
+                .contained()
+                .with_margin_left(theme.workspace.titlebar.item_spacing)
+                .boxed(),
+        )
+    }
+
+    fn render_outside_call_share_button(
+        &self,
+        theme: &Theme,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        let tooltip = "Share project with new call";
+        let titlebar = &theme.workspace.titlebar;
+
+        enum OutsideCallShare {}
+        Stack::new()
+            .with_child(
+                MouseEventHandler::<OutsideCallShare>::new(0, cx, |state, _| {
+                    //TODO: Ensure this button has consistant width for both text variations
+                    let style = titlebar.share_button.style_for(
+                        state,
+                        self.contacts_popover.is_some()
+                            && self.contacts_popover_side == ContactsPopoverSide::Right,
+                    );
+                    Label::new("Share".to_owned(), style.text.clone())
+                        .contained()
+                        .with_style(style.container)
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, cx| {
+                    cx.dispatch_action(ToggleContactsMenu);
+                })
+                .with_tooltip::<OutsideCallShare, _>(
+                    0,
+                    tooltip.to_owned(),
+                    None,
+                    theme.tooltip.clone(),
+                    cx,
+                )
+                .boxed(),
+            )
+            .with_children(self.render_contacts_popover_host(
+                ContactsPopoverSide::Right,
+                titlebar,
+                cx,
+            ))
+            .aligned()
+            .contained()
+            .with_margin_left(theme.workspace.titlebar.item_spacing)
+            .boxed()
+    }
+
+    fn render_contacts_popover_host<'a>(
+        &'a self,
+        side: ContactsPopoverSide,
+        theme: &'a theme::Titlebar,
+        cx: &'a RenderContext<Self>,
+    ) -> impl Iterator<Item = ElementBox> + 'a {
+        self.contacts_popover
+            .iter()
+            .filter(move |_| self.contacts_popover_side == side)
+            .map(|popover| {
+                Overlay::new(
+                    ChildView::new(popover, cx)
+                        .contained()
+                        .with_margin_top(theme.height)
+                        .with_margin_left(theme.toggle_contacts_button.default.button_width)
+                        .with_margin_right(-theme.toggle_contacts_button.default.button_width)
+                        .boxed(),
+                )
+                .with_fit_mode(OverlayFitMode::SwitchAnchor)
+                .with_anchor_corner(AnchorCorner::BottomLeft)
+                .with_z_index(999)
+                .boxed()
+            })
+    }
+
     fn render_collaborators(
         &self,
         workspace: &ViewHandle<Workspace>,
         theme: &Theme,
+        room: ModelHandle<Room>,
         cx: &mut RenderContext<Self>,
     ) -> Vec<ElementBox> {
-        let active_call = ActiveCall::global(cx);
-        if let Some(room) = active_call.read(cx).room().cloned() {
-            let project = workspace.read(cx).project().read(cx);
-            let mut participants = room
-                .read(cx)
-                .remote_participants()
-                .values()
-                .cloned()
-                .collect::<Vec<_>>();
-            participants.sort_by_key(|p| Some(project.collaborators().get(&p.peer_id)?.replica_id));
-            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(self.render_avatar(
+        let project = workspace.read(cx).project().read(cx);
+
+        let mut participants = room
+            .read(cx)
+            .remote_participants()
+            .values()
+            .cloned()
+            .collect::<Vec<_>>();
+        participants.sort_by_key(|p| Some(project.collaborators().get(&p.peer_id)?.replica_id));
+
+        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,
-                        Some((
-                            participant.peer_id,
-                            &user.github_login,
-                            participant.location,
-                        )),
+                        participant.peer_id,
+                        Some(participant.location),
                         workspace,
                         theme,
                         cx,
                     ))
-                })
-                .collect()
-        } else {
-            Default::default()
-        }
+                    .with_margin_left(theme.workspace.titlebar.face_pile_spacing)
+                    .boxed(),
+                )
+            })
+            .collect()
     }
 
     fn render_current_user(
         &self,
         workspace: &ViewHandle<Workspace>,
         theme: &Theme,
+        user: &Option<Arc<User>>,
         cx: &mut RenderContext<Self>,
-    ) -> Option<ElementBox> {
-        let user = workspace.read(cx).user_store().read(cx).current_user();
+    ) -> ElementBox {
+        let user = user.as_ref().expect("Active call without user");
         let replica_id = workspace.read(cx).project().read(cx).replica_id();
-        let status = *workspace.read(cx).client().status().borrow();
-        if let Some(user) = user {
-            Some(self.render_avatar(&user, Some(replica_id), None, workspace, theme, cx))
-        } else if matches!(status, client::Status::UpgradeRequired) {
-            None
-        } else {
-            Some(
-                MouseEventHandler::<Authenticate>::new(0, cx, |state, _| {
-                    let style = theme
-                        .workspace
-                        .titlebar
-                        .sign_in_prompt
-                        .style_for(state, false);
-                    Label::new("Sign in", style.text.clone())
-                        .contained()
-                        .with_style(style.container)
-                        .boxed()
-                })
-                .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate))
-                .with_cursor_style(CursorStyle::PointingHand)
-                .aligned()
-                .boxed(),
-            )
-        }
+        let peer_id = workspace
+            .read(cx)
+            .client()
+            .peer_id()
+            .expect("Active call without peer id");
+        self.render_face_pile(user, Some(replica_id), peer_id, None, workspace, theme, cx)
     }
 
-    fn render_avatar(
+    fn render_authenticate(theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
+        MouseEventHandler::<Authenticate>::new(0, cx, |state, _| {
+            let style = theme
+                .workspace
+                .titlebar
+                .sign_in_prompt
+                .style_for(state, false);
+            Label::new("Sign in", style.text.clone())
+                .contained()
+                .with_style(style.container)
+                .with_margin_left(theme.workspace.titlebar.item_spacing)
+                .boxed()
+        })
+        .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate))
+        .with_cursor_style(CursorStyle::PointingHand)
+        .aligned()
+        .boxed()
+    }
+
+    fn render_face_pile(
         &self,
         user: &User,
         replica_id: Option<ReplicaId>,
-        peer: Option<(PeerId, &str, ParticipantLocation)>,
+        peer_id: PeerId,
+        location: Option<ParticipantLocation>,
         workspace: &ViewHandle<Workspace>,
         theme: &Theme,
         cx: &mut RenderContext<Self>,
     ) -> ElementBox {
-        let is_followed = peer.map_or(false, |(peer_id, _, _)| {
-            workspace.read(cx).is_following(peer_id)
-        });
+        let room = ActiveCall::global(cx).read(cx).room();
+        let is_being_followed = workspace.read(cx).is_being_followed(peer_id);
+        let followed_by_self = room
+            .map(|room| {
+                is_being_followed
+                    && room
+                        .read(cx)
+                        .followers_for(peer_id)
+                        .iter()
+                        .any(|&follower| Some(follower) == workspace.read(cx).client().peer_id())
+            })
+            .unwrap_or(false);
 
-        let mut avatar_style;
-        if let Some((_, _, location)) = peer.as_ref() {
-            if let ParticipantLocation::SharedProject { project_id } = *location {
+        let avatar_style;
+        if let Some(location) = location {
+            if let ParticipantLocation::SharedProject { project_id } = location {
                 if Some(project_id) == workspace.read(cx).project().read(cx).remote_id() {
-                    avatar_style = theme.workspace.titlebar.avatar;
+                    avatar_style = &theme.workspace.titlebar.avatar;
                 } else {
-                    avatar_style = theme.workspace.titlebar.inactive_avatar;
+                    avatar_style = &theme.workspace.titlebar.inactive_avatar;
                 }
             } else {
-                avatar_style = theme.workspace.titlebar.inactive_avatar;
+                avatar_style = &theme.workspace.titlebar.inactive_avatar;
             }
         } else {
-            avatar_style = theme.workspace.titlebar.avatar;
+            avatar_style = &theme.workspace.titlebar.avatar;
         }
 
-        let mut replica_color = None;
+        let mut background_color = theme
+            .workspace
+            .titlebar
+            .container
+            .background_color
+            .unwrap_or_default();
         if let Some(replica_id) = replica_id {
-            let color = theme.editor.replica_selection_style(replica_id).cursor;
-            replica_color = Some(color);
-            if is_followed {
-                avatar_style.border = Border::all(1.0, color);
+            if followed_by_self {
+                let selection = theme.editor.replica_selection_style(replica_id).selection;
+                background_color = Color::blend(selection, background_color);
+                background_color.a = 255;
             }
         }
 
         let content = Stack::new()
             .with_children(user.avatar.as_ref().map(|avatar| {
-                Image::new(avatar.clone())
-                    .with_style(avatar_style)
-                    .constrained()
-                    .with_width(theme.workspace.titlebar.avatar_width)
-                    .aligned()
-                    .boxed()
-            }))
-            .with_children(replica_color.map(|replica_color| {
-                AvatarRibbon::new(replica_color)
-                    .constrained()
-                    .with_width(theme.workspace.titlebar.avatar_ribbon.width)
-                    .with_height(theme.workspace.titlebar.avatar_ribbon.height)
-                    .aligned()
-                    .bottom()
-                    .boxed()
+                let face_pile = FacePile::new(theme.workspace.titlebar.follower_avatar_overlap)
+                    .with_child(Self::render_face(
+                        avatar.clone(),
+                        avatar_style.clone(),
+                        background_color,
+                    ))
+                    .with_children(
+                        (|| {
+                            let room = room?.read(cx);
+                            let followers = room.followers_for(peer_id);
+
+                            Some(followers.into_iter().flat_map(|&follower| {
+                                let avatar = room
+                                    .remote_participant_for_peer_id(follower)
+                                    .and_then(|participant| participant.user.avatar.clone())
+                                    .or_else(|| {
+                                        if follower == workspace.read(cx).client().peer_id()? {
+                                            workspace
+                                                .read(cx)
+                                                .user_store()
+                                                .read(cx)
+                                                .current_user()?
+                                                .avatar
+                                                .clone()
+                                        } else {
+                                            None
+                                        }
+                                    })?;
+
+                                Some(Self::render_face(
+                                    avatar.clone(),
+                                    theme.workspace.titlebar.follower_avatar.clone(),
+                                    background_color,
+                                ))
+                            }))
+                        })()
+                        .into_iter()
+                        .flatten(),
+                    );
+
+                let mut container = face_pile
+                    .contained()
+                    .with_style(theme.workspace.titlebar.leader_selection);
+
+                if let Some(replica_id) = replica_id {
+                    if followed_by_self {
+                        let color = theme.editor.replica_selection_style(replica_id).selection;
+                        container = container.with_background_color(color);
+                    }
+                }
+
+                container.boxed()
             }))
-            .constrained()
-            .with_width(theme.workspace.titlebar.avatar_width)
-            .contained()
-            .with_margin_left(theme.workspace.titlebar.avatar_margin)
+            .with_children((|| {
+                let replica_id = replica_id?;
+                let color = theme.editor.replica_selection_style(replica_id).cursor;
+                Some(
+                    AvatarRibbon::new(color)
+                        .constrained()
+                        .with_width(theme.workspace.titlebar.avatar_ribbon.width)
+                        .with_height(theme.workspace.titlebar.avatar_ribbon.height)
+                        .aligned()
+                        .bottom()
+                        .boxed(),
+                )
+            })())
             .boxed();
 
-        if let Some((peer_id, peer_github_login, location)) = peer {
+        if let Some(location) = location {
             if let Some(replica_id) = replica_id {
                 MouseEventHandler::<ToggleFollow>::new(replica_id.into(), cx, move |_, _| content)
                     .with_cursor_style(CursorStyle::PointingHand)
@@ -461,10 +789,10 @@ impl CollabTitlebarItem {
                     })
                     .with_tooltip::<ToggleFollow, _>(
                         peer_id.as_u64() as usize,
-                        if is_followed {
-                            format!("Unfollow {}", peer_github_login)
+                        if is_being_followed {
+                            format!("Unfollow {}", user.github_login)
                         } else {
-                            format!("Follow {}", peer_github_login)
+                            format!("Follow {}", user.github_login)
                         },
                         Some(Box::new(FollowNextCollaborator)),
                         theme.tooltip.clone(),
@@ -485,7 +813,7 @@ impl CollabTitlebarItem {
                 })
                 .with_tooltip::<JoinProject, _>(
                     peer_id.as_u64() as usize,
-                    format!("Follow {} into external project", peer_github_login),
+                    format!("Follow {} into external project", user.github_login),
                     Some(Box::new(FollowNextCollaborator)),
                     theme.tooltip.clone(),
                     cx,
@@ -499,6 +827,24 @@ impl CollabTitlebarItem {
         }
     }
 
+    fn render_face(
+        avatar: Arc<ImageData>,
+        avatar_style: AvatarStyle,
+        background_color: Color,
+    ) -> ElementBox {
+        Image::new(avatar)
+            .with_style(avatar_style.image)
+            .aligned()
+            .contained()
+            .with_background_color(background_color)
+            .with_corner_radius(avatar_style.outer_corner_radius)
+            .constrained()
+            .with_width(avatar_style.outer_width)
+            .with_height(avatar_style.outer_width)
+            .aligned()
+            .boxed()
+    }
+
     fn render_connection_status(
         &self,
         workspace: &ViewHandle<Workspace>,

crates/collab_ui/src/collab_ui.rs 🔗

@@ -1,8 +1,10 @@
 mod collab_titlebar_item;
+mod collaborator_list_popover;
 mod contact_finder;
 mod contact_list;
 mod contact_notification;
 mod contacts_popover;
+mod face_pile;
 mod incoming_call_notification;
 mod notifications;
 mod project_shared_notification;
@@ -10,7 +12,7 @@ mod sharing_status_indicator;
 
 use anyhow::anyhow;
 use call::ActiveCall;
-pub use collab_titlebar_item::{CollabTitlebarItem, ToggleCollaborationMenu};
+pub use collab_titlebar_item::{CollabTitlebarItem, ToggleContactsMenu};
 use gpui::{actions, MutableAppContext, Task};
 use std::sync::Arc;
 use workspace::{AppState, JoinProject, ToggleFollow, Workspace};
@@ -116,7 +118,7 @@ fn join_project(action: &JoinProject, app_state: Arc<AppState>, cx: &mut Mutable
                     });
 
                 if let Some(follow_peer_id) = follow_peer_id {
-                    if !workspace.is_following(follow_peer_id) {
+                    if !workspace.is_being_followed(follow_peer_id) {
                         workspace
                             .toggle_follow(&ToggleFollow(follow_peer_id), cx)
                             .map(|follow| follow.detach_and_log_err(cx));

crates/collab_ui/src/collaborator_list_popover.rs 🔗

@@ -0,0 +1,165 @@
+use call::ActiveCall;
+use client::UserStore;
+use gpui::Action;
+use gpui::{
+    actions, elements::*, Entity, ModelHandle, MouseButton, RenderContext, View, ViewContext,
+};
+use settings::Settings;
+
+use crate::collab_titlebar_item::ToggleCollaboratorList;
+
+pub(crate) enum Event {
+    Dismissed,
+}
+
+enum Collaborator {
+    SelfUser { username: String },
+    RemoteUser { username: String },
+}
+
+actions!(collaborator_list_popover, [NoOp]);
+
+pub(crate) struct CollaboratorListPopover {
+    list_state: ListState,
+}
+
+impl Entity for CollaboratorListPopover {
+    type Event = Event;
+}
+
+impl View for CollaboratorListPopover {
+    fn ui_name() -> &'static str {
+        "CollaboratorListPopover"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        let theme = cx.global::<Settings>().theme.clone();
+
+        MouseEventHandler::<Self>::new(0, cx, |_, _| {
+            List::new(self.list_state.clone())
+                .contained()
+                .with_style(theme.contacts_popover.container) //TODO: Change the name of this theme key
+                .constrained()
+                .with_width(theme.contacts_popover.width)
+                .with_height(theme.contacts_popover.height)
+                .boxed()
+        })
+        .on_down_out(MouseButton::Left, move |_, cx| {
+            cx.dispatch_action(ToggleCollaboratorList);
+        })
+        .boxed()
+    }
+
+    fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        cx.emit(Event::Dismissed);
+    }
+}
+
+impl CollaboratorListPopover {
+    pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
+        let active_call = ActiveCall::global(cx);
+
+        let mut collaborators = user_store
+            .read(cx)
+            .current_user()
+            .map(|u| Collaborator::SelfUser {
+                username: u.github_login.clone(),
+            })
+            .into_iter()
+            .collect::<Vec<_>>();
+
+        //TODO: What should the canonical sort here look like, consult contacts list implementation
+        if let Some(room) = active_call.read(cx).room() {
+            for participant in room.read(cx).remote_participants() {
+                collaborators.push(Collaborator::RemoteUser {
+                    username: participant.1.user.github_login.clone(),
+                });
+            }
+        }
+
+        Self {
+            list_state: ListState::new(
+                collaborators.len(),
+                Orientation::Top,
+                0.,
+                cx,
+                move |_, index, cx| match &collaborators[index] {
+                    Collaborator::SelfUser { username } => render_collaborator_list_entry(
+                        index,
+                        username,
+                        None::<NoOp>,
+                        None,
+                        Svg::new("icons/chevron_right_12.svg"),
+                        NoOp,
+                        "Leave call".to_owned(),
+                        cx,
+                    ),
+
+                    Collaborator::RemoteUser { username } => render_collaborator_list_entry(
+                        index,
+                        username,
+                        Some(NoOp),
+                        Some(format!("Follow {username}")),
+                        Svg::new("icons/x_mark_12.svg"),
+                        NoOp,
+                        format!("Remove {username} from call"),
+                        cx,
+                    ),
+                },
+            ),
+        }
+    }
+}
+
+fn render_collaborator_list_entry<UA: Action + Clone, IA: Action + Clone>(
+    index: usize,
+    username: &str,
+    username_action: Option<UA>,
+    username_tooltip: Option<String>,
+    icon: Svg,
+    icon_action: IA,
+    icon_tooltip: String,
+    cx: &mut RenderContext<CollaboratorListPopover>,
+) -> ElementBox {
+    enum Username {}
+    enum UsernameTooltip {}
+    enum Icon {}
+    enum IconTooltip {}
+
+    let theme = &cx.global::<Settings>().theme;
+    let username_theme = theme.contact_list.contact_username.text.clone();
+    let tooltip_theme = theme.tooltip.clone();
+
+    let username = MouseEventHandler::<Username>::new(index, cx, |_, _| {
+        Label::new(username.to_owned(), username_theme.clone()).boxed()
+    })
+    .on_click(MouseButton::Left, move |_, cx| {
+        if let Some(username_action) = username_action.clone() {
+            cx.dispatch_action(username_action);
+        }
+    });
+
+    Flex::row()
+        .with_child(if let Some(username_tooltip) = username_tooltip {
+            username
+                .with_tooltip::<UsernameTooltip, _>(
+                    index,
+                    username_tooltip,
+                    None,
+                    tooltip_theme.clone(),
+                    cx,
+                )
+                .boxed()
+        } else {
+            username.boxed()
+        })
+        .with_child(
+            MouseEventHandler::<Icon>::new(index, cx, |_, _| icon.boxed())
+                .on_click(MouseButton::Left, move |_, cx| {
+                    cx.dispatch_action(icon_action.clone())
+                })
+                .with_tooltip::<IconTooltip, _>(index, icon_tooltip, None, tooltip_theme, cx)
+                .boxed(),
+        )
+        .boxed()
+}

crates/collab_ui/src/contact_list.rs 🔗

@@ -1,3 +1,4 @@
+use super::collab_titlebar_item::LeaveCall;
 use crate::contacts_popover;
 use call::ActiveCall;
 use client::{proto::PeerId, Contact, User, UserStore};
@@ -18,22 +19,20 @@ use serde::Deserialize;
 use settings::Settings;
 use std::{mem, sync::Arc};
 use theme::IconButton;
-use util::ResultExt;
 use workspace::{JoinProject, OpenSharedScreen};
 
 impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]);
-impl_internal_actions!(contact_list, [ToggleExpanded, Call, LeaveCall]);
+impl_internal_actions!(contact_list, [ToggleExpanded, Call]);
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(ContactList::remove_contact);
     cx.add_action(ContactList::respond_to_contact_request);
-    cx.add_action(ContactList::clear_filter);
+    cx.add_action(ContactList::cancel);
     cx.add_action(ContactList::select_next);
     cx.add_action(ContactList::select_prev);
     cx.add_action(ContactList::confirm);
     cx.add_action(ContactList::toggle_expanded);
     cx.add_action(ContactList::call);
-    cx.add_action(ContactList::leave_call);
 }
 
 #[derive(Clone, PartialEq)]
@@ -45,9 +44,6 @@ struct Call {
     initial_project: Option<ModelHandle<Project>>,
 }
 
-#[derive(Copy, Clone, PartialEq)]
-struct LeaveCall;
-
 #[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
 enum Section {
     ActiveCall,
@@ -326,7 +322,7 @@ impl ContactList {
             .detach();
     }
 
-    fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
+    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
         let did_clear = self.filter_editor.update(cx, |editor, cx| {
             if editor.buffer().read(cx).len(cx) > 0 {
                 editor.set_text("", cx);
@@ -335,6 +331,7 @@ impl ContactList {
                 false
             }
         });
+
         if !did_clear {
             cx.emit(Event::Dismissed);
         }
@@ -980,6 +977,7 @@ impl ContactList {
         cx: &mut RenderContext<Self>,
     ) -> ElementBox {
         enum Header {}
+        enum LeaveCallContactList {}
 
         let header_style = theme
             .header_row
@@ -992,9 +990,9 @@ impl ContactList {
         };
         let leave_call = if section == Section::ActiveCall {
             Some(
-                MouseEventHandler::<LeaveCall>::new(0, cx, |state, _| {
+                MouseEventHandler::<LeaveCallContactList>::new(0, cx, |state, _| {
                     let style = theme.leave_call.style_for(state, false);
-                    Label::new("Leave Session", style.text.clone())
+                    Label::new("Leave Call", style.text.clone())
                         .contained()
                         .with_style(style.container)
                         .boxed()
@@ -1283,12 +1281,6 @@ impl ContactList {
             })
             .detach_and_log_err(cx);
     }
-
-    fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext<Self>) {
-        ActiveCall::global(cx)
-            .update(cx, |call, cx| call.hang_up(cx))
-            .log_err();
-    }
 }
 
 impl Entity for ContactList {
@@ -1334,7 +1326,7 @@ impl View for ContactList {
                         })
                         .with_tooltip::<AddContact, _>(
                             0,
-                            "Add contact".into(),
+                            "Search for new contact".into(),
                             None,
                             theme.tooltip.clone(),
                             cx,

crates/collab_ui/src/contacts_popover.rs 🔗

@@ -1,4 +1,4 @@
-use crate::{contact_finder::ContactFinder, contact_list::ContactList, ToggleCollaborationMenu};
+use crate::{contact_finder::ContactFinder, contact_list::ContactList, ToggleContactsMenu};
 use client::UserStore;
 use gpui::{
     actions, elements::*, ClipboardItem, CursorStyle, Entity, ModelHandle, MouseButton,
@@ -155,7 +155,7 @@ impl View for ContactsPopover {
                 .boxed()
         })
         .on_down_out(MouseButton::Left, move |_, cx| {
-            cx.dispatch_action(ToggleCollaborationMenu);
+            cx.dispatch_action(ToggleContactsMenu);
         })
         .boxed()
     }

crates/collab_ui/src/face_pile.rs 🔗

@@ -0,0 +1,101 @@
+use std::ops::Range;
+
+use gpui::{
+    geometry::{
+        rect::RectF,
+        vector::{vec2f, Vector2F},
+    },
+    json::ToJson,
+    serde_json::{self, json},
+    Axis, DebugContext, Element, ElementBox, MeasurementContext, PaintContext,
+};
+
+pub(crate) struct FacePile {
+    overlap: f32,
+    faces: Vec<ElementBox>,
+}
+
+impl FacePile {
+    pub fn new(overlap: f32) -> FacePile {
+        FacePile {
+            overlap,
+            faces: Vec::new(),
+        }
+    }
+}
+
+impl Element for FacePile {
+    type LayoutState = ();
+    type PaintState = ();
+
+    fn layout(
+        &mut self,
+        constraint: gpui::SizeConstraint,
+        cx: &mut gpui::LayoutContext,
+    ) -> (Vector2F, Self::LayoutState) {
+        debug_assert!(constraint.max_along(Axis::Horizontal) == f32::INFINITY);
+
+        let mut width = 0.;
+        for face in &mut self.faces {
+            width += face.layout(constraint, cx).x();
+        }
+        width -= self.overlap * self.faces.len().saturating_sub(1) as f32;
+
+        (Vector2F::new(width, constraint.max.y()), ())
+    }
+
+    fn paint(
+        &mut self,
+        bounds: RectF,
+        visible_bounds: RectF,
+        _layout: &mut Self::LayoutState,
+        cx: &mut PaintContext,
+    ) -> 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();
+            cx.paint_layer(None, |cx| {
+                face.paint(vec2f(origin_x, origin_y), visible_bounds, cx);
+            });
+            origin_x += self.overlap;
+        }
+
+        ()
+    }
+
+    fn rect_for_text_range(
+        &self,
+        _: Range<usize>,
+        _: RectF,
+        _: RectF,
+        _: &Self::LayoutState,
+        _: &Self::PaintState,
+        _: &MeasurementContext,
+    ) -> Option<RectF> {
+        None
+    }
+
+    fn debug(
+        &self,
+        bounds: RectF,
+        _: &Self::LayoutState,
+        _: &Self::PaintState,
+        _: &DebugContext,
+    ) -> serde_json::Value {
+        json!({
+            "type": "FacePile",
+            "bounds": bounds.to_json()
+        })
+    }
+}
+
+impl Extend<ElementBox> for FacePile {
+    fn extend<T: IntoIterator<Item = ElementBox>>(&mut self, children: T) {
+        self.faces.extend(children);
+    }
+}

crates/gpui/src/elements.rs 🔗

@@ -363,6 +363,7 @@ impl<T: Element> AnyElement for Lifecycle<T> {
                     value
                 }
             }
+
             _ => panic!("invalid element lifecycle state"),
         }
     }

crates/gpui/src/elements/flex.rs 🔗

@@ -308,7 +308,9 @@ impl Element for Flex {
                     }
                 }
             }
+
             child.paint(child_origin, visible_bounds, cx);
+
             match self.axis {
                 Axis::Horizontal => child_origin += vec2f(child.size().x(), 0.0),
                 Axis::Vertical => child_origin += vec2f(0.0, child.size().y()),

crates/rpc/proto/zed.proto 🔗

@@ -16,7 +16,7 @@ message Envelope {
         Error error = 6;
         Ping ping = 7;
         Test test = 8;
-        
+
         CreateRoom create_room = 9;
         CreateRoomResponse create_room_response = 10;
         JoinRoom join_room = 11;
@@ -206,7 +206,8 @@ message Room {
     uint64 id = 1;
     repeated Participant participants = 2;
     repeated PendingParticipant pending_participants = 3;
-    string live_kit_room = 4;
+    repeated Follower followers = 4;
+    string live_kit_room = 5;
 }
 
 message Participant {
@@ -227,6 +228,11 @@ message ParticipantProject {
     repeated string worktree_root_names = 2;
 }
 
+message Follower {
+    PeerId leader_id = 1;
+    PeerId follower_id = 2;
+}
+
 message ParticipantLocation {
     oneof variant {
         SharedProject shared_project = 1;

crates/rpc/src/rpc.rs 🔗

@@ -6,4 +6,4 @@ pub use conn::Connection;
 pub use peer::*;
 mod macros;
 
-pub const PROTOCOL_VERSION: u32 = 46;
+pub const PROTOCOL_VERSION: u32 = 47;

crates/theme/src/theme.rs 🔗

@@ -74,12 +74,15 @@ pub struct Titlebar {
     pub container: ContainerStyle,
     pub height: f32,
     pub title: TextStyle,
-    pub avatar_width: f32,
-    pub avatar_margin: f32,
+    pub item_spacing: f32,
+    pub face_pile_spacing: f32,
     pub avatar_ribbon: AvatarRibbon,
+    pub follower_avatar_overlap: f32,
+    pub leader_selection: ContainerStyle,
     pub offline_icon: OfflineIcon,
-    pub avatar: ImageStyle,
-    pub inactive_avatar: ImageStyle,
+    pub avatar: AvatarStyle,
+    pub inactive_avatar: AvatarStyle,
+    pub follower_avatar: AvatarStyle,
     pub sign_in_prompt: Interactive<ContainedText>,
     pub outdated_warning: ContainedText,
     pub share_button: Interactive<ContainedText>,
@@ -88,6 +91,14 @@ pub struct Titlebar {
     pub toggle_contacts_badge: ContainerStyle,
 }
 
+#[derive(Clone, Deserialize, Default)]
+pub struct AvatarStyle {
+    #[serde(flatten)]
+    pub image: ImageStyle,
+    pub outer_width: f32,
+    pub outer_corner_radius: f32,
+}
+
 #[derive(Deserialize, Default)]
 pub struct ContactsPopover {
     #[serde(flatten)]
@@ -381,7 +392,7 @@ pub struct InviteLink {
     pub icon: Icon,
 }
 
-#[derive(Deserialize, Default)]
+#[derive(Deserialize, Clone, Copy, Default)]
 pub struct Icon {
     #[serde(flatten)]
     pub container: ContainerStyle,

crates/workspace/src/workspace.rs 🔗

@@ -837,7 +837,7 @@ impl Workspace {
         &self.project
     }
 
-    pub fn client(&self) -> &Arc<Client> {
+    pub fn client(&self) -> &Client {
         &self.client
     }
 
@@ -1832,24 +1832,15 @@ impl Workspace {
         None
     }
 
-    pub fn is_following(&self, peer_id: PeerId) -> bool {
+    pub fn is_being_followed(&self, peer_id: PeerId) -> bool {
         self.follower_states_by_leader.contains_key(&peer_id)
     }
 
-    pub fn is_followed(&self, peer_id: PeerId) -> bool {
+    pub fn is_followed_by(&self, peer_id: PeerId) -> bool {
         self.leader_state.followers.contains(&peer_id)
     }
 
     fn render_titlebar(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
-        let project = &self.project.read(cx);
-        let mut worktree_root_names = String::new();
-        for (i, name) in project.worktree_root_names(cx).enumerate() {
-            if i > 0 {
-                worktree_root_names.push_str(", ");
-            }
-            worktree_root_names.push_str(name);
-        }
-
         // TODO: There should be a better system in place for this
         // (https://github.com/zed-industries/zed/issues/1290)
         let is_fullscreen = cx.window_is_fullscreen(cx.window_id());
@@ -1866,16 +1857,10 @@ impl Workspace {
             MouseEventHandler::<TitleBar>::new(0, cx, |_, cx| {
                 Container::new(
                     Stack::new()
-                        .with_child(
-                            Label::new(worktree_root_names, theme.workspace.titlebar.title.clone())
-                                .aligned()
-                                .left()
-                                .boxed(),
-                        )
                         .with_children(
                             self.titlebar_item
                                 .as_ref()
-                                .map(|item| ChildView::new(item, cx).aligned().right().boxed()),
+                                .map(|item| ChildView::new(item, cx).boxed()),
                         )
                         .boxed(),
                 )

crates/zed/src/zed.rs 🔗

@@ -6,7 +6,7 @@ use anyhow::{anyhow, Context, Result};
 use assets::Assets;
 use breadcrumbs::Breadcrumbs;
 pub use client;
-use collab_ui::{CollabTitlebarItem, ToggleCollaborationMenu};
+use collab_ui::{CollabTitlebarItem, ToggleContactsMenu};
 use collections::VecDeque;
 pub use editor;
 use editor::{Editor, MultiBuffer};
@@ -99,9 +99,7 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
         },
     );
     cx.add_action(
-        |workspace: &mut Workspace,
-         _: &ToggleCollaborationMenu,
-         cx: &mut ViewContext<Workspace>| {
+        |workspace: &mut Workspace, _: &ToggleContactsMenu, cx: &mut ViewContext<Workspace>| {
             if let Some(item) = workspace
                 .titlebar_item()
                 .and_then(|item| item.downcast::<CollabTitlebarItem>())

styles/src/styleTree/workspace.ts 🔗

@@ -12,7 +12,7 @@ import tabBar from "./tabBar";
 
 export default function workspace(colorScheme: ColorScheme) {
   const layer = colorScheme.lowest;
-  const titlebarPadding = 6;
+  const itemSpacing = 8;
   const titlebarButton = {
     cornerRadius: 6,
     padding: {
@@ -29,8 +29,21 @@ export default function workspace(colorScheme: ColorScheme) {
       background: background(layer, "variant", "hovered"),
       border: border(layer, "variant", "hovered"),
     },
+    clicked: {
+      ...text(layer, "sans", "variant", "pressed", { size: "xs" }),
+      background: background(layer, "variant", "pressed"),
+      border: border(layer, "variant", "pressed"),
+    },
+    active: {
+      ...text(layer, "sans", "variant", "active", { size: "xs" }),
+      background: background(layer, "variant", "active"),
+      border: border(layer, "variant", "active"),
+    },
   };
   const avatarWidth = 18;
+  const avatarOuterWidth = avatarWidth + 4;
+  const followerAvatarWidth = 14;
+  const followerAvatarOuterWidth = followerAvatarWidth + 4;
 
   return {
     background: background(layer),
@@ -70,14 +83,14 @@ export default function workspace(colorScheme: ColorScheme) {
     },
     statusBar: statusBar(colorScheme),
     titlebar: {
-      avatarWidth,
-      avatarMargin: 8,
+      itemSpacing,
+      facePileSpacing: 2,
       height: 33, // 32px + 1px for overlaid border
       background: background(layer),
       border: border(layer, { bottom: true, overlay: true }),
       padding: {
         left: 80,
-        right: titlebarPadding,
+        right: itemSpacing,
       },
 
       // Project
@@ -85,20 +98,38 @@ export default function workspace(colorScheme: ColorScheme) {
 
       // Collaborators
       avatar: {
+        width: avatarWidth,
+        outerWidth: avatarOuterWidth,
         cornerRadius: avatarWidth / 2,
-        border: {
-          color: "#00000088",
-          width: 1,
-        },
+        outerCornerRadius: avatarOuterWidth / 2,
       },
       inactiveAvatar: {
+        width: avatarWidth,
+        outerWidth: avatarOuterWidth,
         cornerRadius: avatarWidth / 2,
-        border: {
-          color: "#00000088",
-          width: 1,
-        },
+        outerCornerRadius: avatarOuterWidth / 2,
         grayscale: true,
       },
+      followerAvatar: {
+        width: followerAvatarWidth,
+        outerWidth: followerAvatarOuterWidth,
+        cornerRadius: followerAvatarWidth / 2,
+        outerCornerRadius: followerAvatarOuterWidth / 2,
+      },
+      followerAvatarOverlap: 8,
+      leaderSelection: {
+        margin: {
+          top: 4,
+          bottom: 4,
+        },
+        padding: {
+          left: 2,
+          right: 2,
+          top: 4,
+          bottom: 4,
+        },
+        cornerRadius: 6,
+      },
       avatarRibbon: {
         height: 3,
         width: 12,
@@ -108,7 +139,7 @@ export default function workspace(colorScheme: ColorScheme) {
       // Sign in buttom
       // FlatButton, Variant
       signInPrompt: {
-        ...titlebarButton
+        ...titlebarButton,
       },
 
       // Offline Indicator
@@ -116,7 +147,7 @@ export default function workspace(colorScheme: ColorScheme) {
         color: foreground(layer, "variant"),
         width: 16,
         margin: {
-          left: titlebarPadding,
+          left: itemSpacing,
         },
         padding: {
           right: 4,
@@ -129,7 +160,7 @@ export default function workspace(colorScheme: ColorScheme) {
         background: withOpacity(background(layer, "warning"), 0.3),
         border: border(layer, "warning"),
         margin: {
-          left: titlebarPadding,
+          left: itemSpacing,
         },
         padding: {
           left: 8,
@@ -148,7 +179,7 @@ export default function workspace(colorScheme: ColorScheme) {
         },
       },
       toggleContactsButton: {
-        margin: { left: 6 },
+        margin: { left: itemSpacing },
         cornerRadius: 6,
         color: foreground(layer, "variant"),
         iconWidth: 8,
@@ -157,6 +188,10 @@ export default function workspace(colorScheme: ColorScheme) {
           background: background(layer, "variant", "active"),
           color: foreground(layer, "variant", "active"),
         },
+        clicked: {
+          background: background(layer, "variant", "pressed"),
+          color: foreground(layer, "variant", "pressed"),
+        },
         hover: {
           background: background(layer, "variant", "hovered"),
           color: foreground(layer, "variant", "hovered"),
@@ -170,8 +205,8 @@ export default function workspace(colorScheme: ColorScheme) {
         background: foreground(layer, "accent"),
       },
       shareButton: {
-        ...titlebarButton
-      }
+        ...titlebarButton,
+      },
     },
 
     toolbar: {
@@ -227,9 +262,6 @@ export default function workspace(colorScheme: ColorScheme) {
         shadow: colorScheme.modalShadow,
       },
     },
-    dropTargetOverlayColor: withOpacity(
-      foreground(layer, "variant"),
-      0.5
-    ),
+    dropTargetOverlayColor: withOpacity(foreground(layer, "variant"), 0.5),
   };
 }