Merge pull request #1700 from zed-industries/room

Antonio Scandurra created

Introduce call-based collaboration

Change summary

Cargo.lock                                              |   94 
assets/icons/zed_22.svg                                 |    4 
assets/keymaps/default.json                             |    1 
crates/call/Cargo.toml                                  |   35 
crates/call/src/call.rs                                 |  261 +
crates/call/src/participant.rs                          |   42 
crates/call/src/room.rs                                 |  474 +++
crates/client/src/channel.rs                            |    2 
crates/client/src/client.rs                             |   23 
crates/client/src/user.rs                               |  100 
crates/collab/Cargo.toml                                |    3 
crates/collab/src/bin/seed.rs                           |   18 
crates/collab/src/db.rs                                 |   10 
crates/collab/src/db_tests.rs                           |  212 -
crates/collab/src/integration_tests.rs                  | 1210 ++++---
crates/collab/src/rpc.rs                                |  753 ++--
crates/collab/src/rpc/store.rs                          |  818 +++-
crates/collab_ui/Cargo.toml                             |   53 
crates/collab_ui/src/collab_titlebar_item.rs            |  566 +++
crates/collab_ui/src/collab_ui.rs                       |   97 
crates/collab_ui/src/contact_finder.rs                  |   66 
crates/collab_ui/src/contact_list.rs                    | 1140 +++++++
crates/collab_ui/src/contact_notification.rs            |    5 
crates/collab_ui/src/contacts_popover.rs                |  162 +
crates/collab_ui/src/incoming_call_notification.rs      |  232 +
crates/collab_ui/src/notifications.rs                   |   23 
crates/collab_ui/src/project_shared_notification.rs     |  232 +
crates/contacts_panel/Cargo.toml                        |   32 
crates/contacts_panel/src/contacts_panel.rs             | 1653 -----------
crates/contacts_panel/src/join_project_notification.rs  |   80 
crates/contacts_status_item/Cargo.toml                  |   32 
crates/contacts_status_item/src/contacts_popover.rs     |   94 
crates/contacts_status_item/src/contacts_status_item.rs |   94 
crates/editor/src/element.rs                            |    3 
crates/gpui/src/app.rs                                  |   43 
crates/gpui/src/elements.rs                             |    6 
crates/gpui/src/elements/flex.rs                        |    5 
crates/gpui/src/elements/image.rs                       |    3 
crates/gpui/src/elements/list.rs                        |    3 
crates/gpui/src/elements/mouse_event_handler.rs         |    1 
crates/gpui/src/elements/overlay.rs                     |    6 
crates/gpui/src/elements/uniform_list.rs                |    4 
crates/gpui/src/platform.rs                             |    2 
crates/gpui/src/platform/mac/platform.rs                |   14 
crates/gpui/src/platform/mac/renderer.rs                |    2 
crates/gpui/src/platform/mac/shaders/shaders.h          |    1 
crates/gpui/src/platform/mac/shaders/shaders.metal      |   11 
crates/gpui/src/platform/test.rs                        |    4 
crates/gpui/src/scene.rs                                |    1 
crates/gpui/src/test.rs                                 |    2 
crates/gpui_macros/src/gpui_macros.rs                   |    2 
crates/picker/src/picker.rs                             |   25 
crates/project/src/project.rs                           |  684 +---
crates/rpc/proto/zed.proto                              |  358 +-
crates/rpc/src/peer.rs                                  |    8 
crates/rpc/src/proto.rs                                 |   39 
crates/rpc/src/rpc.rs                                   |    2 
crates/terminal/src/terminal_element.rs                 |    2 
crates/theme/src/theme.rs                               |  150 
crates/workspace/Cargo.toml                             |   10 
crates/workspace/src/pane_group.rs                      |  138 
crates/workspace/src/waiting_room.rs                    |  185 -
crates/workspace/src/workspace.rs                       |  425 --
crates/zed/Cargo.toml                                   |   10 
crates/zed/src/main.rs                                  |    4 
crates/zed/src/menus.rs                                 |    4 
crates/zed/src/zed.rs                                   |   40 
styles/src/styleTree/app.ts                             |    8 
styles/src/styleTree/contactFinder.ts                   |   29 
styles/src/styleTree/contactList.ts                     |  113 
styles/src/styleTree/contactsPopover.ts                 |   26 
styles/src/styleTree/incomingCallNotification.ts        |   44 
styles/src/styleTree/projectSharedNotification.ts       |   44 
styles/src/styleTree/workspace.ts                       |   84 
74 files changed, 6,347 insertions(+), 4,819 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -684,6 +684,20 @@ version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c1db59621ec70f09c5e9b597b220c7a2b43611f4710dc03ceb8748637775692c"
 
+[[package]]
+name = "call"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "client",
+ "collections",
+ "futures",
+ "gpui",
+ "postage",
+ "project",
+ "util",
+]
+
 [[package]]
 name = "cap-fs-ext"
 version = "0.24.4"
@@ -1023,6 +1037,7 @@ dependencies = [
  "axum",
  "axum-extra",
  "base64",
+ "call",
  "clap 3.2.8",
  "client",
  "collections",
@@ -1067,6 +1082,31 @@ dependencies = [
  "workspace",
 ]
 
+[[package]]
+name = "collab_ui"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "call",
+ "client",
+ "clock",
+ "collections",
+ "editor",
+ "futures",
+ "fuzzy",
+ "gpui",
+ "log",
+ "menu",
+ "picker",
+ "postage",
+ "project",
+ "serde",
+ "settings",
+ "theme",
+ "util",
+ "workspace",
+]
+
 [[package]]
 name = "collections"
 version = "0.1.0"
@@ -1108,54 +1148,6 @@ dependencies = [
  "cache-padded",
 ]
 
-[[package]]
-name = "contacts_panel"
-version = "0.1.0"
-dependencies = [
- "anyhow",
- "client",
- "collections",
- "editor",
- "futures",
- "fuzzy",
- "gpui",
- "language",
- "log",
- "menu",
- "picker",
- "postage",
- "project",
- "serde",
- "settings",
- "theme",
- "util",
- "workspace",
-]
-
-[[package]]
-name = "contacts_status_item"
-version = "0.1.0"
-dependencies = [
- "anyhow",
- "client",
- "collections",
- "editor",
- "futures",
- "fuzzy",
- "gpui",
- "language",
- "log",
- "menu",
- "picker",
- "postage",
- "project",
- "serde",
- "settings",
- "theme",
- "util",
- "workspace",
-]
-
 [[package]]
 name = "context_menu"
 version = "0.1.0"
@@ -7176,8 +7168,8 @@ name = "workspace"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "call",
  "client",
- "clock",
  "collections",
  "context_menu",
  "drag_and_drop",
@@ -7247,15 +7239,15 @@ dependencies = [
  "auto_update",
  "backtrace",
  "breadcrumbs",
+ "call",
  "chat_panel",
  "chrono",
  "cli",
  "client",
  "clock",
+ "collab_ui",
  "collections",
  "command_palette",
- "contacts_panel",
- "contacts_status_item",
  "context_menu",
  "ctor",
  "diagnostics",

assets/icons/zed_22.svg 🔗

@@ -1,4 +0,0 @@
-<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M5 11C5 14.3137 7.68629 17 11 17C14.3137 17 17 14.3137 17 11C17 7.68629 14.3137 5 11 5C7.68629 5 5 7.68629 5 11ZM11 3C6.58172 3 3 6.58172 3 11C3 15.4183 6.58172 19 11 19C15.4183 19 19 15.4183 19 11C19 6.58172 15.4183 3 11 3Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M8.09092 8.09088H14.6364L10.5511 12.4545H12.4546L13.9091 13.9091H7.36365L11.7273 9.54543H9.54547L8.09092 8.09088Z" fill="white"/>
-</svg>

assets/keymaps/default.json 🔗

@@ -395,7 +395,6 @@
         "context": "Workspace",
         "bindings": {
             "shift-escape": "dock::FocusDock",
-            "cmd-shift-c": "contacts_panel::ToggleFocus",
             "cmd-shift-b": "workspace::ToggleRightSidebar"
         }
     },

crates/call/Cargo.toml 🔗

@@ -0,0 +1,35 @@
+[package]
+name = "call"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/call.rs"
+doctest = false
+
+[features]
+test-support = [
+    "client/test-support",
+    "collections/test-support",
+    "gpui/test-support",
+    "project/test-support",
+    "util/test-support"
+]
+
+[dependencies]
+client = { path = "../client" }
+collections = { path = "../collections" }
+gpui = { path = "../gpui" }
+project = { path = "../project" }
+util = { path = "../util" }
+
+anyhow = "1.0.38"
+futures = "0.3"
+postage = { version = "0.4.1", features = ["futures-traits"] }
+
+[dev-dependencies]
+client = { path = "../client", features = ["test-support"] }
+collections = { path = "../collections", features = ["test-support"] }
+gpui = { path = "../gpui", features = ["test-support"] }
+project = { path = "../project", features = ["test-support"] }
+util = { path = "../util", features = ["test-support"] }

crates/call/src/call.rs 🔗

@@ -0,0 +1,261 @@
+mod participant;
+pub mod room;
+
+use anyhow::{anyhow, Result};
+use client::{proto, Client, TypedEnvelope, User, UserStore};
+use gpui::{
+    AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext,
+    Subscription, Task,
+};
+pub use participant::ParticipantLocation;
+use postage::watch;
+use project::Project;
+pub use room::Room;
+use std::sync::Arc;
+
+pub fn init(client: Arc<Client>, user_store: ModelHandle<UserStore>, cx: &mut MutableAppContext) {
+    let active_call = cx.add_model(|cx| ActiveCall::new(client, user_store, cx));
+    cx.set_global(active_call);
+}
+
+#[derive(Clone)]
+pub struct IncomingCall {
+    pub room_id: u64,
+    pub caller: Arc<User>,
+    pub participants: Vec<Arc<User>>,
+    pub initial_project: Option<proto::ParticipantProject>,
+}
+
+pub struct ActiveCall {
+    room: Option<(ModelHandle<Room>, Vec<Subscription>)>,
+    incoming_call: (
+        watch::Sender<Option<IncomingCall>>,
+        watch::Receiver<Option<IncomingCall>>,
+    ),
+    client: Arc<Client>,
+    user_store: ModelHandle<UserStore>,
+    _subscriptions: Vec<client::Subscription>,
+}
+
+impl Entity for ActiveCall {
+    type Event = room::Event;
+}
+
+impl ActiveCall {
+    fn new(
+        client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut ModelContext<Self>,
+    ) -> Self {
+        Self {
+            room: None,
+            incoming_call: watch::channel(),
+            _subscriptions: vec![
+                client.add_request_handler(cx.handle(), Self::handle_incoming_call),
+                client.add_message_handler(cx.handle(), Self::handle_call_canceled),
+            ],
+            client,
+            user_store,
+        }
+    }
+
+    async fn handle_incoming_call(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::IncomingCall>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<proto::Ack> {
+        let user_store = this.read_with(&cx, |this, _| this.user_store.clone());
+        let call = IncomingCall {
+            room_id: envelope.payload.room_id,
+            participants: user_store
+                .update(&mut cx, |user_store, cx| {
+                    user_store.get_users(envelope.payload.participant_user_ids, cx)
+                })
+                .await?,
+            caller: user_store
+                .update(&mut cx, |user_store, cx| {
+                    user_store.get_user(envelope.payload.caller_user_id, cx)
+                })
+                .await?,
+            initial_project: envelope.payload.initial_project,
+        };
+        this.update(&mut cx, |this, _| {
+            *this.incoming_call.0.borrow_mut() = Some(call);
+        });
+
+        Ok(proto::Ack {})
+    }
+
+    async fn handle_call_canceled(
+        this: ModelHandle<Self>,
+        _: TypedEnvelope<proto::CallCanceled>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<()> {
+        this.update(&mut cx, |this, _| {
+            *this.incoming_call.0.borrow_mut() = None;
+        });
+        Ok(())
+    }
+
+    pub fn global(cx: &AppContext) -> ModelHandle<Self> {
+        cx.global::<ModelHandle<Self>>().clone()
+    }
+
+    pub fn invite(
+        &mut self,
+        recipient_user_id: u64,
+        initial_project: Option<ModelHandle<Project>>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        let client = self.client.clone();
+        let user_store = self.user_store.clone();
+        cx.spawn(|this, mut cx| async move {
+            if let Some(room) = this.read_with(&cx, |this, _| this.room().cloned()) {
+                let initial_project_id = if let Some(initial_project) = initial_project {
+                    Some(
+                        room.update(&mut cx, |room, cx| room.share_project(initial_project, cx))
+                            .await?,
+                    )
+                } else {
+                    None
+                };
+
+                room.update(&mut cx, |room, cx| {
+                    room.call(recipient_user_id, initial_project_id, cx)
+                })
+                .await?;
+            } else {
+                let room = cx
+                    .update(|cx| {
+                        Room::create(recipient_user_id, initial_project, client, user_store, cx)
+                    })
+                    .await?;
+                this.update(&mut cx, |this, cx| this.set_room(Some(room), cx));
+            };
+
+            Ok(())
+        })
+    }
+
+    pub fn cancel_invite(
+        &mut self,
+        recipient_user_id: u64,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        let room_id = if let Some(room) = self.room() {
+            room.read(cx).id()
+        } else {
+            return Task::ready(Err(anyhow!("no active call")));
+        };
+
+        let client = self.client.clone();
+        cx.foreground().spawn(async move {
+            client
+                .request(proto::CancelCall {
+                    room_id,
+                    recipient_user_id,
+                })
+                .await?;
+            anyhow::Ok(())
+        })
+    }
+
+    pub fn incoming(&self) -> watch::Receiver<Option<IncomingCall>> {
+        self.incoming_call.1.clone()
+    }
+
+    pub fn accept_incoming(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        if self.room.is_some() {
+            return Task::ready(Err(anyhow!("cannot join while on another call")));
+        }
+
+        let call = if let Some(call) = self.incoming_call.1.borrow().clone() {
+            call
+        } else {
+            return Task::ready(Err(anyhow!("no incoming call")));
+        };
+
+        let join = Room::join(&call, self.client.clone(), self.user_store.clone(), cx);
+        cx.spawn(|this, mut cx| async move {
+            let room = join.await?;
+            this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx));
+            Ok(())
+        })
+    }
+
+    pub fn decline_incoming(&mut self) -> Result<()> {
+        let call = self
+            .incoming_call
+            .0
+            .borrow_mut()
+            .take()
+            .ok_or_else(|| anyhow!("no incoming call"))?;
+        self.client.send(proto::DeclineCall {
+            room_id: call.room_id,
+        })?;
+        Ok(())
+    }
+
+    pub fn hang_up(&mut self, cx: &mut ModelContext<Self>) -> Result<()> {
+        if let Some((room, _)) = self.room.take() {
+            room.update(cx, |room, cx| room.leave(cx))?;
+            cx.notify();
+        }
+        Ok(())
+    }
+
+    pub fn share_project(
+        &mut self,
+        project: ModelHandle<Project>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<u64>> {
+        if let Some((room, _)) = self.room.as_ref() {
+            room.update(cx, |room, cx| room.share_project(project, cx))
+        } else {
+            Task::ready(Err(anyhow!("no active call")))
+        }
+    }
+
+    pub fn set_location(
+        &mut self,
+        project: Option<&ModelHandle<Project>>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        if let Some((room, _)) = self.room.as_ref() {
+            room.update(cx, |room, cx| room.set_location(project, cx))
+        } else {
+            Task::ready(Err(anyhow!("no active call")))
+        }
+    }
+
+    fn set_room(&mut self, room: Option<ModelHandle<Room>>, cx: &mut ModelContext<Self>) {
+        if room.as_ref() != self.room.as_ref().map(|room| &room.0) {
+            if let Some(room) = room {
+                if room.read(cx).status().is_offline() {
+                    self.room = None;
+                } else {
+                    let subscriptions = vec![
+                        cx.observe(&room, |this, room, cx| {
+                            if room.read(cx).status().is_offline() {
+                                this.set_room(None, cx);
+                            }
+
+                            cx.notify();
+                        }),
+                        cx.subscribe(&room, |_, _, event, cx| cx.emit(event.clone())),
+                    ];
+                    self.room = Some((room, subscriptions));
+                }
+            } else {
+                self.room = None;
+            }
+            cx.notify();
+        }
+    }
+
+    pub fn room(&self) -> Option<&ModelHandle<Room>> {
+        self.room.as_ref().map(|(room, _)| room)
+    }
+}

crates/call/src/participant.rs 🔗

@@ -0,0 +1,42 @@
+use anyhow::{anyhow, Result};
+use client::{proto, User};
+use gpui::WeakModelHandle;
+use project::Project;
+use std::sync::Arc;
+
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+pub enum ParticipantLocation {
+    SharedProject { project_id: u64 },
+    UnsharedProject,
+    External,
+}
+
+impl ParticipantLocation {
+    pub fn from_proto(location: Option<proto::ParticipantLocation>) -> Result<Self> {
+        match location.and_then(|l| l.variant) {
+            Some(proto::participant_location::Variant::SharedProject(project)) => {
+                Ok(Self::SharedProject {
+                    project_id: project.id,
+                })
+            }
+            Some(proto::participant_location::Variant::UnsharedProject(_)) => {
+                Ok(Self::UnsharedProject)
+            }
+            Some(proto::participant_location::Variant::External(_)) => Ok(Self::External),
+            None => Err(anyhow!("participant location was not provided")),
+        }
+    }
+}
+
+#[derive(Clone, Default)]
+pub struct LocalParticipant {
+    pub projects: Vec<proto::ParticipantProject>,
+    pub active_project: Option<WeakModelHandle<Project>>,
+}
+
+#[derive(Clone, Debug)]
+pub struct RemoteParticipant {
+    pub user: Arc<User>,
+    pub projects: Vec<proto::ParticipantProject>,
+    pub location: ParticipantLocation,
+}

crates/call/src/room.rs 🔗

@@ -0,0 +1,474 @@
+use crate::{
+    participant::{LocalParticipant, ParticipantLocation, RemoteParticipant},
+    IncomingCall,
+};
+use anyhow::{anyhow, Result};
+use client::{proto, Client, PeerId, TypedEnvelope, User, UserStore};
+use collections::{BTreeMap, HashSet};
+use futures::StreamExt;
+use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task};
+use project::Project;
+use std::sync::Arc;
+use util::ResultExt;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Event {
+    RemoteProjectShared {
+        owner: Arc<User>,
+        project_id: u64,
+        worktree_root_names: Vec<String>,
+    },
+    RemoteProjectUnshared {
+        project_id: u64,
+    },
+    Left,
+}
+
+pub struct Room {
+    id: u64,
+    status: RoomStatus,
+    local_participant: LocalParticipant,
+    remote_participants: BTreeMap<PeerId, RemoteParticipant>,
+    pending_participants: Vec<Arc<User>>,
+    participant_user_ids: HashSet<u64>,
+    pending_call_count: usize,
+    leave_when_empty: bool,
+    client: Arc<Client>,
+    user_store: ModelHandle<UserStore>,
+    subscriptions: Vec<client::Subscription>,
+    pending_room_update: Option<Task<()>>,
+}
+
+impl Entity for Room {
+    type Event = Event;
+
+    fn release(&mut self, _: &mut MutableAppContext) {
+        self.client.send(proto::LeaveRoom { id: self.id }).log_err();
+    }
+}
+
+impl Room {
+    fn new(
+        id: u64,
+        client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut ModelContext<Self>,
+    ) -> Self {
+        let mut client_status = client.status();
+        cx.spawn_weak(|this, mut cx| async move {
+            let is_connected = client_status
+                .next()
+                .await
+                .map_or(false, |s| s.is_connected());
+            // Even if we're initially connected, any future change of the status means we momentarily disconnected.
+            if !is_connected || client_status.next().await.is_some() {
+                if let Some(this) = this.upgrade(&cx) {
+                    let _ = this.update(&mut cx, |this, cx| this.leave(cx));
+                }
+            }
+        })
+        .detach();
+
+        Self {
+            id,
+            status: RoomStatus::Online,
+            participant_user_ids: Default::default(),
+            local_participant: Default::default(),
+            remote_participants: Default::default(),
+            pending_participants: Default::default(),
+            pending_call_count: 0,
+            subscriptions: vec![client.add_message_handler(cx.handle(), Self::handle_room_updated)],
+            leave_when_empty: false,
+            pending_room_update: None,
+            client,
+            user_store,
+        }
+    }
+
+    pub(crate) fn create(
+        recipient_user_id: u64,
+        initial_project: Option<ModelHandle<Project>>,
+        client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<ModelHandle<Self>>> {
+        cx.spawn(|mut cx| async move {
+            let response = client.request(proto::CreateRoom {}).await?;
+            let room = cx.add_model(|cx| Self::new(response.id, client, user_store, cx));
+
+            let initial_project_id = if let Some(initial_project) = initial_project {
+                let initial_project_id = room
+                    .update(&mut cx, |room, cx| {
+                        room.share_project(initial_project.clone(), cx)
+                    })
+                    .await?;
+                Some(initial_project_id)
+            } else {
+                None
+            };
+
+            match room
+                .update(&mut cx, |room, cx| {
+                    room.leave_when_empty = true;
+                    room.call(recipient_user_id, initial_project_id, cx)
+                })
+                .await
+            {
+                Ok(()) => Ok(room),
+                Err(error) => Err(anyhow!("room creation failed: {:?}", error)),
+            }
+        })
+    }
+
+    pub(crate) fn join(
+        call: &IncomingCall,
+        client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<ModelHandle<Self>>> {
+        let room_id = call.room_id;
+        cx.spawn(|mut cx| async move {
+            let response = client.request(proto::JoinRoom { id: room_id }).await?;
+            let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
+            let room = cx.add_model(|cx| Self::new(room_id, client, user_store, cx));
+            room.update(&mut cx, |room, cx| {
+                room.leave_when_empty = true;
+                room.apply_room_update(room_proto, cx)?;
+                anyhow::Ok(())
+            })?;
+            Ok(room)
+        })
+    }
+
+    fn should_leave(&self) -> bool {
+        self.leave_when_empty
+            && self.pending_room_update.is_none()
+            && self.pending_participants.is_empty()
+            && self.remote_participants.is_empty()
+            && self.pending_call_count == 0
+    }
+
+    pub(crate) fn leave(&mut self, cx: &mut ModelContext<Self>) -> Result<()> {
+        if self.status.is_offline() {
+            return Err(anyhow!("room is offline"));
+        }
+
+        cx.notify();
+        cx.emit(Event::Left);
+        self.status = RoomStatus::Offline;
+        self.remote_participants.clear();
+        self.pending_participants.clear();
+        self.participant_user_ids.clear();
+        self.subscriptions.clear();
+        self.client.send(proto::LeaveRoom { id: self.id })?;
+        Ok(())
+    }
+
+    pub fn id(&self) -> u64 {
+        self.id
+    }
+
+    pub fn status(&self) -> RoomStatus {
+        self.status
+    }
+
+    pub fn local_participant(&self) -> &LocalParticipant {
+        &self.local_participant
+    }
+
+    pub fn remote_participants(&self) -> &BTreeMap<PeerId, RemoteParticipant> {
+        &self.remote_participants
+    }
+
+    pub fn pending_participants(&self) -> &[Arc<User>] {
+        &self.pending_participants
+    }
+
+    pub fn contains_participant(&self, user_id: u64) -> bool {
+        self.participant_user_ids.contains(&user_id)
+    }
+
+    async fn handle_room_updated(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::RoomUpdated>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<()> {
+        let room = envelope
+            .payload
+            .room
+            .ok_or_else(|| anyhow!("invalid room"))?;
+        this.update(&mut cx, |this, cx| this.apply_room_update(room, cx))
+    }
+
+    fn apply_room_update(
+        &mut self,
+        mut room: proto::Room,
+        cx: &mut ModelContext<Self>,
+    ) -> Result<()> {
+        // Filter ourselves out from the room's participants.
+        let local_participant_ix = room
+            .participants
+            .iter()
+            .position(|participant| Some(participant.user_id) == self.client.user_id());
+        let local_participant = local_participant_ix.map(|ix| room.participants.swap_remove(ix));
+
+        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| {
+                (
+                    user_store.get_users(remote_participant_user_ids, cx),
+                    user_store.get_users(room.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);
+
+            this.update(&mut cx, |this, cx| {
+                this.participant_user_ids.clear();
+
+                if let Some(participant) = local_participant {
+                    this.local_participant.projects = participant.projects;
+                } else {
+                    this.local_participant.projects.clear();
+                }
+
+                if let Some(participants) = remote_participants.log_err() {
+                    for (participant, user) in room.participants.into_iter().zip(participants) {
+                        let peer_id = PeerId(participant.peer_id);
+                        this.participant_user_ids.insert(participant.user_id);
+
+                        let old_projects = this
+                            .remote_participants
+                            .get(&peer_id)
+                            .into_iter()
+                            .flat_map(|existing| &existing.projects)
+                            .map(|project| project.id)
+                            .collect::<HashSet<_>>();
+                        let new_projects = participant
+                            .projects
+                            .iter()
+                            .map(|project| project.id)
+                            .collect::<HashSet<_>>();
+
+                        for project in &participant.projects {
+                            if !old_projects.contains(&project.id) {
+                                cx.emit(Event::RemoteProjectShared {
+                                    owner: user.clone(),
+                                    project_id: project.id,
+                                    worktree_root_names: project.worktree_root_names.clone(),
+                                });
+                            }
+                        }
+
+                        for unshared_project_id in old_projects.difference(&new_projects) {
+                            cx.emit(Event::RemoteProjectUnshared {
+                                project_id: *unshared_project_id,
+                            });
+                        }
+
+                        this.remote_participants.insert(
+                            peer_id,
+                            RemoteParticipant {
+                                user: user.clone(),
+                                projects: participant.projects,
+                                location: ParticipantLocation::from_proto(participant.location)
+                                    .unwrap_or(ParticipantLocation::External),
+                            },
+                        );
+                    }
+
+                    this.remote_participants.retain(|_, participant| {
+                        if this.participant_user_ids.contains(&participant.user.id) {
+                            true
+                        } else {
+                            for project in &participant.projects {
+                                cx.emit(Event::RemoteProjectUnshared {
+                                    project_id: project.id,
+                                });
+                            }
+                            false
+                        }
+                    });
+                }
+
+                if let Some(pending_participants) = pending_participants.log_err() {
+                    this.pending_participants = pending_participants;
+                    for participant in &this.pending_participants {
+                        this.participant_user_ids.insert(participant.id);
+                    }
+                }
+
+                this.pending_room_update.take();
+                if this.should_leave() {
+                    let _ = this.leave(cx);
+                }
+
+                this.check_invariants();
+                cx.notify();
+            });
+        }));
+
+        cx.notify();
+        Ok(())
+    }
+
+    fn check_invariants(&self) {
+        #[cfg(any(test, feature = "test-support"))]
+        {
+            for participant in self.remote_participants.values() {
+                assert!(self.participant_user_ids.contains(&participant.user.id));
+            }
+
+            for participant in &self.pending_participants {
+                assert!(self.participant_user_ids.contains(&participant.id));
+            }
+
+            assert_eq!(
+                self.participant_user_ids.len(),
+                self.remote_participants.len() + self.pending_participants.len()
+            );
+        }
+    }
+
+    pub(crate) fn call(
+        &mut self,
+        recipient_user_id: u64,
+        initial_project_id: Option<u64>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        if self.status.is_offline() {
+            return Task::ready(Err(anyhow!("room is offline")));
+        }
+
+        cx.notify();
+        let client = self.client.clone();
+        let room_id = self.id;
+        self.pending_call_count += 1;
+        cx.spawn(|this, mut cx| async move {
+            let result = client
+                .request(proto::Call {
+                    room_id,
+                    recipient_user_id,
+                    initial_project_id,
+                })
+                .await;
+            this.update(&mut cx, |this, cx| {
+                this.pending_call_count -= 1;
+                if this.should_leave() {
+                    this.leave(cx)?;
+                }
+                result
+            })?;
+            Ok(())
+        })
+    }
+
+    pub(crate) fn share_project(
+        &mut self,
+        project: ModelHandle<Project>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<u64>> {
+        if project.read(cx).is_remote() {
+            return Task::ready(Err(anyhow!("can't share remote project")));
+        } else if let Some(project_id) = project.read(cx).remote_id() {
+            return Task::ready(Ok(project_id));
+        }
+
+        let request = self.client.request(proto::ShareProject {
+            room_id: self.id(),
+            worktrees: project
+                .read(cx)
+                .worktrees(cx)
+                .map(|worktree| {
+                    let worktree = worktree.read(cx);
+                    proto::WorktreeMetadata {
+                        id: worktree.id().to_proto(),
+                        root_name: worktree.root_name().into(),
+                        visible: worktree.is_visible(),
+                    }
+                })
+                .collect(),
+        });
+        cx.spawn(|this, mut cx| async move {
+            let response = request.await?;
+
+            project
+                .update(&mut cx, |project, cx| {
+                    project.shared(response.project_id, cx)
+                })
+                .await?;
+
+            // If the user's location is in this project, it changes from UnsharedProject to SharedProject.
+            this.update(&mut cx, |this, cx| {
+                let active_project = this.local_participant.active_project.as_ref();
+                if active_project.map_or(false, |location| *location == project) {
+                    this.set_location(Some(&project), cx)
+                } else {
+                    Task::ready(Ok(()))
+                }
+            })
+            .await?;
+
+            Ok(response.project_id)
+        })
+    }
+
+    pub fn set_location(
+        &mut self,
+        project: Option<&ModelHandle<Project>>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        if self.status.is_offline() {
+            return Task::ready(Err(anyhow!("room is offline")));
+        }
+
+        let client = self.client.clone();
+        let room_id = self.id;
+        let location = if let Some(project) = project {
+            self.local_participant.active_project = Some(project.downgrade());
+            if let Some(project_id) = project.read(cx).remote_id() {
+                proto::participant_location::Variant::SharedProject(
+                    proto::participant_location::SharedProject { id: project_id },
+                )
+            } else {
+                proto::participant_location::Variant::UnsharedProject(
+                    proto::participant_location::UnsharedProject {},
+                )
+            }
+        } else {
+            self.local_participant.active_project = None;
+            proto::participant_location::Variant::External(proto::participant_location::External {})
+        };
+
+        cx.notify();
+        cx.foreground().spawn(async move {
+            client
+                .request(proto::UpdateParticipantLocation {
+                    room_id,
+                    location: Some(proto::ParticipantLocation {
+                        variant: Some(location),
+                    }),
+                })
+                .await?;
+            Ok(())
+        })
+    }
+}
+
+#[derive(Copy, Clone, PartialEq, Eq)]
+pub enum RoomStatus {
+    Online,
+    Offline,
+}
+
+impl RoomStatus {
+    pub fn is_offline(&self) -> bool {
+        matches!(self, RoomStatus::Offline)
+    }
+}

crates/client/src/channel.rs 🔗

@@ -530,7 +530,7 @@ impl ChannelMessage {
     ) -> Result<Self> {
         let sender = user_store
             .update(cx, |user_store, cx| {
-                user_store.fetch_user(message.sender_id, cx)
+                user_store.get_user(message.sender_id, cx)
             })
             .await?;
         Ok(ChannelMessage {

crates/client/src/client.rs 🔗

@@ -434,6 +434,29 @@ impl Client {
         }
     }
 
+    pub fn add_request_handler<M, E, H, F>(
+        self: &Arc<Self>,
+        model: ModelHandle<E>,
+        handler: H,
+    ) -> Subscription
+    where
+        M: RequestMessage,
+        E: Entity,
+        H: 'static
+            + Send
+            + Sync
+            + Fn(ModelHandle<E>, TypedEnvelope<M>, Arc<Self>, AsyncAppContext) -> F,
+        F: 'static + Future<Output = Result<M::Response>>,
+    {
+        self.add_message_handler(model, move |handle, envelope, this, cx| {
+            Self::respond_to_request(
+                envelope.receipt(),
+                handler(handle, envelope, this.clone(), cx),
+                this,
+            )
+        })
+    }
+
     pub fn add_view_message_handler<M, E, H, F>(self: &Arc<Self>, handler: H)
     where
         M: EntityMessage,

crates/client/src/user.rs 🔗

@@ -1,14 +1,14 @@
 use super::{http::HttpClient, proto, Client, Status, TypedEnvelope};
 use anyhow::{anyhow, Context, Result};
-use collections::{hash_map::Entry, BTreeSet, HashMap, HashSet};
+use collections::{hash_map::Entry, HashMap, HashSet};
 use futures::{channel::mpsc, future, AsyncReadExt, Future, StreamExt};
 use gpui::{AsyncAppContext, Entity, ImageData, ModelContext, ModelHandle, Task};
-use postage::{prelude::Stream, sink::Sink, watch};
+use postage::{sink::Sink, watch};
 use rpc::proto::{RequestMessage, UsersResponse};
 use std::sync::{Arc, Weak};
 use util::TryFutureExt as _;
 
-#[derive(Debug)]
+#[derive(Default, Debug)]
 pub struct User {
     pub id: u64,
     pub github_login: String,
@@ -39,14 +39,7 @@ impl Eq for User {}
 pub struct Contact {
     pub user: Arc<User>,
     pub online: bool,
-    pub projects: Vec<ProjectMetadata>,
-}
-
-#[derive(Clone, Debug, PartialEq)]
-pub struct ProjectMetadata {
-    pub id: u64,
-    pub visible_worktree_root_names: Vec<String>,
-    pub guests: BTreeSet<Arc<User>>,
+    pub busy: bool,
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -138,12 +131,12 @@ impl UserStore {
             }),
             _maintain_current_user: cx.spawn_weak(|this, mut cx| async move {
                 let mut status = client.status();
-                while let Some(status) = status.recv().await {
+                while let Some(status) = status.next().await {
                     match status {
                         Status::Connected { .. } => {
                             if let Some((this, user_id)) = this.upgrade(&cx).zip(client.user_id()) {
                                 let fetch_user = this
-                                    .update(&mut cx, |this, cx| this.fetch_user(user_id, cx))
+                                    .update(&mut cx, |this, cx| this.get_user(user_id, cx))
                                     .log_err();
                                 let fetch_metrics_id =
                                     client.request(proto::GetPrivateUserInfo {}).log_err();
@@ -244,7 +237,6 @@ impl UserStore {
                 let mut user_ids = HashSet::default();
                 for contact in &message.contacts {
                     user_ids.insert(contact.user_id);
-                    user_ids.extend(contact.projects.iter().flat_map(|w| &w.guests).copied());
                 }
                 user_ids.extend(message.incoming_requests.iter().map(|req| req.requester_id));
                 user_ids.extend(message.outgoing_requests.iter());
@@ -268,9 +260,7 @@ impl UserStore {
                     for request in message.incoming_requests {
                         incoming_requests.push({
                             let user = this
-                                .update(&mut cx, |this, cx| {
-                                    this.fetch_user(request.requester_id, cx)
-                                })
+                                .update(&mut cx, |this, cx| this.get_user(request.requester_id, cx))
                                 .await?;
                             (user, request.should_notify)
                         });
@@ -279,7 +269,7 @@ impl UserStore {
                     let mut outgoing_requests = Vec::new();
                     for requested_user_id in message.outgoing_requests {
                         outgoing_requests.push(
-                            this.update(&mut cx, |this, cx| this.fetch_user(requested_user_id, cx))
+                            this.update(&mut cx, |this, cx| this.get_user(requested_user_id, cx))
                                 .await?,
                         );
                     }
@@ -504,7 +494,7 @@ impl UserStore {
             .unbounded_send(UpdateContacts::Clear(tx))
             .unwrap();
         async move {
-            rx.recv().await;
+            rx.next().await;
         }
     }
 
@@ -514,25 +504,43 @@ impl UserStore {
             .unbounded_send(UpdateContacts::Wait(tx))
             .unwrap();
         async move {
-            rx.recv().await;
+            rx.next().await;
         }
     }
 
     pub fn get_users(
         &mut self,
-        mut user_ids: Vec<u64>,
+        user_ids: Vec<u64>,
         cx: &mut ModelContext<Self>,
-    ) -> Task<Result<()>> {
-        user_ids.retain(|id| !self.users.contains_key(id));
-        if user_ids.is_empty() {
-            Task::ready(Ok(()))
-        } else {
-            let load = self.load_users(proto::GetUsers { user_ids }, cx);
-            cx.foreground().spawn(async move {
-                load.await?;
-                Ok(())
+    ) -> Task<Result<Vec<Arc<User>>>> {
+        let mut user_ids_to_fetch = user_ids.clone();
+        user_ids_to_fetch.retain(|id| !self.users.contains_key(id));
+
+        cx.spawn(|this, mut cx| async move {
+            if !user_ids_to_fetch.is_empty() {
+                this.update(&mut cx, |this, cx| {
+                    this.load_users(
+                        proto::GetUsers {
+                            user_ids: user_ids_to_fetch,
+                        },
+                        cx,
+                    )
+                })
+                .await?;
+            }
+
+            this.read_with(&cx, |this, _| {
+                user_ids
+                    .iter()
+                    .map(|user_id| {
+                        this.users
+                            .get(user_id)
+                            .cloned()
+                            .ok_or_else(|| anyhow!("user {} not found", user_id))
+                    })
+                    .collect()
             })
-        }
+        })
     }
 
     pub fn fuzzy_search_users(
@@ -543,7 +551,7 @@ impl UserStore {
         self.load_users(proto::FuzzySearchUsers { query }, cx)
     }
 
-    pub fn fetch_user(
+    pub fn get_user(
         &mut self,
         user_id: u64,
         cx: &mut ModelContext<Self>,
@@ -623,39 +631,15 @@ impl Contact {
     ) -> Result<Self> {
         let user = user_store
             .update(cx, |user_store, cx| {
-                user_store.fetch_user(contact.user_id, cx)
+                user_store.get_user(contact.user_id, cx)
             })
             .await?;
-        let mut projects = Vec::new();
-        for project in contact.projects {
-            let mut guests = BTreeSet::new();
-            for participant_id in project.guests {
-                guests.insert(
-                    user_store
-                        .update(cx, |user_store, cx| {
-                            user_store.fetch_user(participant_id, cx)
-                        })
-                        .await?,
-                );
-            }
-            projects.push(ProjectMetadata {
-                id: project.id,
-                visible_worktree_root_names: project.visible_worktree_root_names.clone(),
-                guests,
-            });
-        }
         Ok(Self {
             user,
             online: contact.online,
-            projects,
+            busy: contact.busy,
         })
     }
-
-    pub fn non_empty_projects(&self) -> impl Iterator<Item = &ProjectMetadata> {
-        self.projects
-            .iter()
-            .filter(|project| !project.visible_worktree_root_names.is_empty())
-    }
 }
 
 async fn fetch_avatar(http: &dyn HttpClient, url: &str) -> Result<Arc<ImageData>> {

crates/collab/Cargo.toml 🔗

@@ -56,13 +56,14 @@ features = ["runtime-tokio-rustls", "postgres", "time", "uuid"]
 [dev-dependencies]
 collections = { path = "../collections", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
-rpc = { path = "../rpc", features = ["test-support"] }
+call = { path = "../call", features = ["test-support"] }
 client = { path = "../client", features = ["test-support"] }
 editor = { path = "../editor", features = ["test-support"] }
 language = { path = "../language", features = ["test-support"] }
 log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 lsp = { path = "../lsp", features = ["test-support"] }
 project = { path = "../project", features = ["test-support"] }
+rpc = { path = "../rpc", features = ["test-support"] }
 settings = { path = "../settings", features = ["test-support"] }
 theme = { path = "../theme" }
 workspace = { path = "../workspace", features = ["test-support"] }

crates/collab/src/bin/seed.rs 🔗

@@ -84,7 +84,23 @@ async fn main() {
                     },
                 )
                 .await
-                .expect("failed to insert user"),
+                .expect("failed to insert user")
+                .user_id,
+            );
+        } else if admin {
+            zed_user_ids.push(
+                db.create_user(
+                    &format!("{}@zed.dev", github_user.login),
+                    admin,
+                    db::NewUserParams {
+                        github_login: github_user.login,
+                        github_user_id: github_user.id,
+                        invite_count: 5,
+                    },
+                )
+                .await
+                .expect("failed to insert user")
+                .user_id,
             );
         }
     }

crates/collab/src/db.rs 🔗

@@ -1098,10 +1098,7 @@ impl Db for PostgresDb {
             .bind(user_id)
             .fetch(&self.pool);
 
-        let mut contacts = vec![Contact::Accepted {
-            user_id,
-            should_notify: false,
-        }];
+        let mut contacts = Vec::new();
         while let Some(row) = rows.next().await {
             let (user_id_a, user_id_b, a_to_b, accepted, should_notify) = row?;
 
@@ -2080,10 +2077,7 @@ mod test {
 
         async fn get_contacts(&self, id: UserId) -> Result<Vec<Contact>> {
             self.background.simulate_random_delay().await;
-            let mut contacts = vec![Contact::Accepted {
-                user_id: id,
-                should_notify: false,
-            }];
+            let mut contacts = Vec::new();
 
             for contact in self.contacts.lock().iter() {
                 if contact.requester_id == id {

crates/collab/src/db_tests.rs 🔗

@@ -666,13 +666,7 @@ async fn test_add_contacts() {
         let user_3 = user_ids[2];
 
         // User starts with no contacts
-        assert_eq!(
-            db.get_contacts(user_1).await.unwrap(),
-            vec![Contact::Accepted {
-                user_id: user_1,
-                should_notify: false
-            }],
-        );
+        assert_eq!(db.get_contacts(user_1).await.unwrap(), &[]);
 
         // User requests a contact. Both users see the pending request.
         db.send_contact_request(user_1, user_2).await.unwrap();
@@ -680,26 +674,14 @@ async fn test_add_contacts() {
         assert!(!db.has_contact(user_2, user_1).await.unwrap());
         assert_eq!(
             db.get_contacts(user_1).await.unwrap(),
-            &[
-                Contact::Accepted {
-                    user_id: user_1,
-                    should_notify: false
-                },
-                Contact::Outgoing { user_id: user_2 }
-            ],
+            &[Contact::Outgoing { user_id: user_2 }],
         );
         assert_eq!(
             db.get_contacts(user_2).await.unwrap(),
-            &[
-                Contact::Incoming {
-                    user_id: user_1,
-                    should_notify: true
-                },
-                Contact::Accepted {
-                    user_id: user_2,
-                    should_notify: false
-                },
-            ]
+            &[Contact::Incoming {
+                user_id: user_1,
+                should_notify: true
+            }]
         );
 
         // User 2 dismisses the contact request notification without accepting or rejecting.
@@ -712,16 +694,10 @@ async fn test_add_contacts() {
             .unwrap();
         assert_eq!(
             db.get_contacts(user_2).await.unwrap(),
-            &[
-                Contact::Incoming {
-                    user_id: user_1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user_2,
-                    should_notify: false
-                },
-            ]
+            &[Contact::Incoming {
+                user_id: user_1,
+                should_notify: false
+            }]
         );
 
         // User can't accept their own contact request
@@ -735,31 +711,19 @@ async fn test_add_contacts() {
             .unwrap();
         assert_eq!(
             db.get_contacts(user_1).await.unwrap(),
-            &[
-                Contact::Accepted {
-                    user_id: user_1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user_2,
-                    should_notify: true
-                }
-            ],
+            &[Contact::Accepted {
+                user_id: user_2,
+                should_notify: true
+            }],
         );
         assert!(db.has_contact(user_1, user_2).await.unwrap());
         assert!(db.has_contact(user_2, user_1).await.unwrap());
         assert_eq!(
             db.get_contacts(user_2).await.unwrap(),
-            &[
-                Contact::Accepted {
-                    user_id: user_1,
-                    should_notify: false,
-                },
-                Contact::Accepted {
-                    user_id: user_2,
-                    should_notify: false,
-                },
-            ]
+            &[Contact::Accepted {
+                user_id: user_1,
+                should_notify: false,
+            }]
         );
 
         // Users cannot re-request existing contacts.
@@ -772,16 +736,10 @@ async fn test_add_contacts() {
             .unwrap_err();
         assert_eq!(
             db.get_contacts(user_1).await.unwrap(),
-            &[
-                Contact::Accepted {
-                    user_id: user_1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user_2,
-                    should_notify: true,
-                },
-            ]
+            &[Contact::Accepted {
+                user_id: user_2,
+                should_notify: true,
+            }]
         );
 
         // Users can dismiss notifications of other users accepting their requests.
@@ -790,16 +748,10 @@ async fn test_add_contacts() {
             .unwrap();
         assert_eq!(
             db.get_contacts(user_1).await.unwrap(),
-            &[
-                Contact::Accepted {
-                    user_id: user_1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user_2,
-                    should_notify: false,
-                },
-            ]
+            &[Contact::Accepted {
+                user_id: user_2,
+                should_notify: false,
+            }]
         );
 
         // Users send each other concurrent contact requests and
@@ -809,10 +761,6 @@ async fn test_add_contacts() {
         assert_eq!(
             db.get_contacts(user_1).await.unwrap(),
             &[
-                Contact::Accepted {
-                    user_id: user_1,
-                    should_notify: false
-                },
                 Contact::Accepted {
                     user_id: user_2,
                     should_notify: false,
@@ -820,21 +768,15 @@ async fn test_add_contacts() {
                 Contact::Accepted {
                     user_id: user_3,
                     should_notify: false
-                },
+                }
             ]
         );
         assert_eq!(
             db.get_contacts(user_3).await.unwrap(),
-            &[
-                Contact::Accepted {
-                    user_id: user_1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user_3,
-                    should_notify: false
-                }
-            ],
+            &[Contact::Accepted {
+                user_id: user_1,
+                should_notify: false
+            }],
         );
 
         // User declines a contact request. Both users see that it is gone.
@@ -846,29 +788,17 @@ async fn test_add_contacts() {
         assert!(!db.has_contact(user_3, user_2).await.unwrap());
         assert_eq!(
             db.get_contacts(user_2).await.unwrap(),
-            &[
-                Contact::Accepted {
-                    user_id: user_1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user_2,
-                    should_notify: false
-                }
-            ]
+            &[Contact::Accepted {
+                user_id: user_1,
+                should_notify: false
+            }]
         );
         assert_eq!(
             db.get_contacts(user_3).await.unwrap(),
-            &[
-                Contact::Accepted {
-                    user_id: user_1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user_3,
-                    should_notify: false
-                }
-            ],
+            &[Contact::Accepted {
+                user_id: user_1,
+                should_notify: false
+            }],
         );
     }
 }
@@ -930,29 +860,17 @@ async fn test_invite_codes() {
     assert_eq!(db.get_user_metrics_id(user2).await.unwrap(), metrics_id);
     assert_eq!(
         db.get_contacts(user1).await.unwrap(),
-        [
-            Contact::Accepted {
-                user_id: user1,
-                should_notify: false
-            },
-            Contact::Accepted {
-                user_id: user2,
-                should_notify: true
-            }
-        ]
+        [Contact::Accepted {
+            user_id: user2,
+            should_notify: true
+        }]
     );
     assert_eq!(
         db.get_contacts(user2).await.unwrap(),
-        [
-            Contact::Accepted {
-                user_id: user1,
-                should_notify: false
-            },
-            Contact::Accepted {
-                user_id: user2,
-                should_notify: false
-            }
-        ]
+        [Contact::Accepted {
+            user_id: user1,
+            should_notify: false
+        }]
     );
     assert_eq!(
         db.get_invite_code_for_user(user2).await.unwrap().unwrap().1,
@@ -987,10 +905,6 @@ async fn test_invite_codes() {
     assert_eq!(
         db.get_contacts(user1).await.unwrap(),
         [
-            Contact::Accepted {
-                user_id: user1,
-                should_notify: false
-            },
             Contact::Accepted {
                 user_id: user2,
                 should_notify: true
@@ -1003,16 +917,10 @@ async fn test_invite_codes() {
     );
     assert_eq!(
         db.get_contacts(user3).await.unwrap(),
-        [
-            Contact::Accepted {
-                user_id: user1,
-                should_notify: false
-            },
-            Contact::Accepted {
-                user_id: user3,
-                should_notify: false
-            },
-        ]
+        [Contact::Accepted {
+            user_id: user1,
+            should_notify: false
+        }]
     );
     assert_eq!(
         db.get_invite_code_for_user(user3).await.unwrap().unwrap().1,
@@ -1053,10 +961,6 @@ async fn test_invite_codes() {
     assert_eq!(
         db.get_contacts(user1).await.unwrap(),
         [
-            Contact::Accepted {
-                user_id: user1,
-                should_notify: false
-            },
             Contact::Accepted {
                 user_id: user2,
                 should_notify: true
@@ -1073,16 +977,10 @@ async fn test_invite_codes() {
     );
     assert_eq!(
         db.get_contacts(user4).await.unwrap(),
-        [
-            Contact::Accepted {
-                user_id: user1,
-                should_notify: false
-            },
-            Contact::Accepted {
-                user_id: user4,
-                should_notify: false
-            },
-        ]
+        [Contact::Accepted {
+            user_id: user1,
+            should_notify: false
+        }]
     );
     assert_eq!(
         db.get_invite_code_for_user(user4).await.unwrap().unwrap().1,

crates/collab/src/integration_tests.rs 🔗

@@ -5,9 +5,10 @@ use crate::{
 };
 use ::rpc::Peer;
 use anyhow::anyhow;
+use call::{room, ActiveCall, ParticipantLocation, Room};
 use client::{
-    self, proto, test::FakeHttpClient, Channel, ChannelDetails, ChannelList, Client, Connection,
-    Credentials, EstablishConnectionError, ProjectMetadata, UserStore, RECEIVE_TIMEOUT,
+    self, test::FakeHttpClient, Channel, ChannelDetails, ChannelList, Client, Connection,
+    Credentials, EstablishConnectionError, User, UserStore, RECEIVE_TIMEOUT,
 };
 use collections::{BTreeMap, HashMap, HashSet};
 use editor::{
@@ -39,8 +40,8 @@ use serde_json::json;
 use settings::{Formatter, Settings};
 use sqlx::types::time::OffsetDateTime;
 use std::{
-    cell::RefCell,
-    env,
+    cell::{Cell, RefCell},
+    env, mem,
     ops::Deref,
     path::{Path, PathBuf},
     rc::Rc,
@@ -62,20 +63,487 @@ fn init_logger() {
 }
 
 #[gpui::test(iterations = 10)]
-async fn test_share_project(
+async fn test_basic_calls(
     deterministic: Arc<Deterministic>,
     cx_a: &mut TestAppContext,
     cx_b: &mut TestAppContext,
     cx_b2: &mut TestAppContext,
+    cx_c: &mut TestAppContext,
 ) {
-    cx_a.foreground().forbid_parking();
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).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
+        .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
+        .await;
+
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+    let active_call_c = cx_c.read(ActiveCall::global);
+
+    // Call user B from client A.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+    let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
+    deterministic.run_until_parked();
+    assert_eq!(
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: Default::default(),
+            pending: vec!["user_b".to_string()]
+        }
+    );
+
+    // User B receives the call.
+    let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
+    let call_b = incoming_call_b.next().await.unwrap().unwrap();
+    assert_eq!(call_b.caller.github_login, "user_a");
+
+    // User B connects via another client and also receives a ring on the newly-connected client.
+    let _client_b2 = server.create_client(cx_b2, "user_b").await;
+    let active_call_b2 = cx_b2.read(ActiveCall::global);
+    let mut incoming_call_b2 = active_call_b2.read_with(cx_b2, |call, _| call.incoming());
+    deterministic.run_until_parked();
+    let call_b2 = incoming_call_b2.next().await.unwrap().unwrap();
+    assert_eq!(call_b2.caller.github_login, "user_a");
+
+    // User B joins the room using the first client.
+    active_call_b
+        .update(cx_b, |call, cx| call.accept_incoming(cx))
+        .await
+        .unwrap();
+    let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
+    assert!(incoming_call_b.next().await.unwrap().is_none());
+
+    deterministic.run_until_parked();
+    assert_eq!(
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: vec!["user_b".to_string()],
+            pending: Default::default()
+        }
+    );
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: vec!["user_a".to_string()],
+            pending: Default::default()
+        }
+    );
+
+    // Call user C from client B.
+    let mut incoming_call_c = active_call_c.read_with(cx_c, |call, _| call.incoming());
+    active_call_b
+        .update(cx_b, |call, cx| {
+            call.invite(client_c.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+    assert_eq!(
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: vec!["user_b".to_string()],
+            pending: vec!["user_c".to_string()]
+        }
+    );
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: vec!["user_a".to_string()],
+            pending: vec!["user_c".to_string()]
+        }
+    );
+
+    // User C receives the call, but declines it.
+    let call_c = incoming_call_c.next().await.unwrap().unwrap();
+    assert_eq!(call_c.caller.github_login, "user_b");
+    active_call_c.update(cx_c, |call, _| call.decline_incoming().unwrap());
+    assert!(incoming_call_c.next().await.unwrap().is_none());
+
+    deterministic.run_until_parked();
+    assert_eq!(
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: vec!["user_b".to_string()],
+            pending: Default::default()
+        }
+    );
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: vec!["user_a".to_string()],
+            pending: Default::default()
+        }
+    );
+
+    // User A leaves the room.
+    active_call_a.update(cx_a, |call, cx| {
+        call.hang_up(cx).unwrap();
+        assert!(call.room().is_none());
+    });
+    deterministic.run_until_parked();
+    assert_eq!(
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: Default::default(),
+            pending: Default::default()
+        }
+    );
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: Default::default(),
+            pending: Default::default()
+        }
+    );
+
+    // User B leaves the room.
+    active_call_b.update(cx_b, |call, cx| {
+        call.hang_up(cx).unwrap();
+        assert!(call.room().is_none());
+    });
+    deterministic.run_until_parked();
+    assert_eq!(
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: Default::default(),
+            pending: Default::default()
+        }
+    );
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: Default::default(),
+            pending: Default::default()
+        }
+    );
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_room_uniqueness(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_a2: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+    cx_b2: &mut TestAppContext,
+    cx_c: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let _client_a2 = server.create_client(cx_a2, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    let _client_b2 = server.create_client(cx_b2, "user_b").await;
+    let client_c = server.create_client(cx_c, "user_c").await;
+    server
+        .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
+        .await;
+
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_a2 = cx_a2.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+    let active_call_b2 = cx_b2.read(ActiveCall::global);
+    let active_call_c = cx_c.read(ActiveCall::global);
+
+    // Call user B from client A.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+
+    // Ensure a new room can't be created given user A just created one.
+    active_call_a2
+        .update(cx_a2, |call, cx| {
+            call.invite(client_c.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap_err();
+    active_call_a2.read_with(cx_a2, |call, _| assert!(call.room().is_none()));
+
+    // User B receives the call from user A.
+    let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
+    let call_b1 = incoming_call_b.next().await.unwrap().unwrap();
+    assert_eq!(call_b1.caller.github_login, "user_a");
+
+    // Ensure calling users A and B from client C fails.
+    active_call_c
+        .update(cx_c, |call, cx| {
+            call.invite(client_a.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap_err();
+    active_call_c
+        .update(cx_c, |call, cx| {
+            call.invite(client_b.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap_err();
+
+    // Ensure User B can't create a room while they still have an incoming call.
+    active_call_b2
+        .update(cx_b2, |call, cx| {
+            call.invite(client_c.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap_err();
+    active_call_b2.read_with(cx_b2, |call, _| assert!(call.room().is_none()));
+
+    // User B joins the room and calling them after they've joined still fails.
+    active_call_b
+        .update(cx_b, |call, cx| call.accept_incoming(cx))
+        .await
+        .unwrap();
+    active_call_c
+        .update(cx_c, |call, cx| {
+            call.invite(client_b.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap_err();
+
+    // Ensure User B can't create a room while they belong to another room.
+    active_call_b2
+        .update(cx_b2, |call, cx| {
+            call.invite(client_c.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap_err();
+    active_call_b2.read_with(cx_b2, |call, _| assert!(call.room().is_none()));
+
+    // Client C can successfully call client B after client B leaves the room.
+    active_call_b
+        .update(cx_b, |call, cx| call.hang_up(cx))
+        .unwrap();
+    deterministic.run_until_parked();
+    active_call_c
+        .update(cx_c, |call, cx| {
+            call.invite(client_b.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    let call_b2 = incoming_call_b.next().await.unwrap().unwrap();
+    assert_eq!(call_b2.caller.github_login, "user_c");
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_leaving_room_on_disconnection(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    server
+        .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+
+    // Call user B from client A.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+    let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
+
+    // User B receives the call and joins the room.
+    let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
+    incoming_call_b.next().await.unwrap().unwrap();
+    active_call_b
+        .update(cx_b, |call, cx| call.accept_incoming(cx))
+        .await
+        .unwrap();
+    let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
+    deterministic.run_until_parked();
+    assert_eq!(
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: vec!["user_b".to_string()],
+            pending: Default::default()
+        }
+    );
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: vec!["user_a".to_string()],
+            pending: Default::default()
+        }
+    );
+
+    // When user A disconnects, both client A and B clear their room on the active call.
+    server.disconnect_client(client_a.current_user_id(cx_a));
+    cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
+    active_call_a.read_with(cx_a, |call, _| assert!(call.room().is_none()));
+    active_call_b.read_with(cx_b, |call, _| assert!(call.room().is_none()));
+    assert_eq!(
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: Default::default(),
+            pending: Default::default()
+        }
+    );
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: Default::default(),
+            pending: Default::default()
+        }
+    );
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_calls_on_multiple_connections(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b1: &mut TestAppContext,
+    cx_b2: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b1 = server.create_client(cx_b1, "user_b").await;
+    let _client_b2 = server.create_client(cx_b2, "user_b").await;
+    server
+        .make_contacts(&mut [(&client_a, cx_a), (&client_b1, cx_b1)])
+        .await;
+
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b1 = cx_b1.read(ActiveCall::global);
+    let active_call_b2 = cx_b2.read(ActiveCall::global);
+    let mut incoming_call_b1 = active_call_b1.read_with(cx_b1, |call, _| call.incoming());
+    let mut incoming_call_b2 = active_call_b2.read_with(cx_b2, |call, _| call.incoming());
+    assert!(incoming_call_b1.next().await.unwrap().is_none());
+    assert!(incoming_call_b2.next().await.unwrap().is_none());
+
+    // Call user B from client A, ensuring both clients for user B ring.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b1.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_some());
+    assert!(incoming_call_b2.next().await.unwrap().is_some());
+
+    // User B declines the call on one of the two connections, causing both connections
+    // to stop ringing.
+    active_call_b2.update(cx_b2, |call, _| call.decline_incoming().unwrap());
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_none());
+    assert!(incoming_call_b2.next().await.unwrap().is_none());
+
+    // Call user B again from client A.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b1.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_some());
+    assert!(incoming_call_b2.next().await.unwrap().is_some());
+
+    // User B accepts the call on one of the two connections, causing both connections
+    // to stop ringing.
+    active_call_b2
+        .update(cx_b2, |call, cx| call.accept_incoming(cx))
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_none());
+    assert!(incoming_call_b2.next().await.unwrap().is_none());
+
+    // User B hangs up, and user A calls them again.
+    active_call_b2.update(cx_b2, |call, cx| call.hang_up(cx).unwrap());
+    deterministic.run_until_parked();
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b1.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_some());
+    assert!(incoming_call_b2.next().await.unwrap().is_some());
+
+    // User A cancels the call, causing both connections to stop ringing.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.cancel_invite(client_b1.user_id().unwrap(), cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_none());
+    assert!(incoming_call_b2.next().await.unwrap().is_none());
+
+    // User A calls user B again.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b1.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_some());
+    assert!(incoming_call_b2.next().await.unwrap().is_some());
+
+    // User A hangs up, causing both connections to stop ringing.
+    active_call_a.update(cx_a, |call, cx| call.hang_up(cx).unwrap());
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_none());
+    assert!(incoming_call_b2.next().await.unwrap().is_none());
+
+    // User A calls user B again.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b1.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_some());
+    assert!(incoming_call_b2.next().await.unwrap().is_some());
+
+    // User A disconnects up, causing both connections to stop ringing.
+    server.disconnect_client(client_a.current_user_id(cx_a));
+    cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
+    assert!(incoming_call_b1.next().await.unwrap().is_none());
+    assert!(incoming_call_b2.next().await.unwrap().is_none());
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_share_project(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
     let (_, window_b) = cx_b.add_window(|_| EmptyView);
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
     server
-        .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+        .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)])
         .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
 
     client_a
         .fs
@@ -93,30 +561,35 @@ async fn test_share_project(
         )
         .await;
 
+    // Invite client B to collaborate on a project
     let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
-    let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b.user_id().unwrap(), Some(project_a.clone()), cx)
+        })
+        .await
+        .unwrap();
 
     // Join that project as client B
+    let incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
+    deterministic.run_until_parked();
+    let call = incoming_call_b.borrow().clone().unwrap();
+    assert_eq!(call.caller.github_login, "user_a");
+    let initial_project = call.initial_project.unwrap();
+    active_call_b
+        .update(cx_b, |call, cx| call.accept_incoming(cx))
+        .await
+        .unwrap();
     let client_b_peer_id = client_b.peer_id;
-    let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
-    let replica_id_b = project_b.read_with(cx_b, |project, _| {
-        assert_eq!(
-            project
-                .collaborators()
-                .get(&client_a.peer_id)
-                .unwrap()
-                .user
-                .github_login,
-            "user_a"
-        );
-        project.replica_id()
-    });
+    let project_b = client_b
+        .build_remote_project(initial_project.id, cx_b)
+        .await;
+    let replica_id_b = project_b.read_with(cx_b, |project, _| project.replica_id());
 
     deterministic.run_until_parked();
     project_a.read_with(cx_a, |project, _| {
         let client_b_collaborator = project.collaborators().get(&client_b_peer_id).unwrap();
         assert_eq!(client_b_collaborator.replica_id, replica_id_b);
-        assert_eq!(client_b_collaborator.user.github_login, "user_b");
     });
     project_b.read_with(cx_b, |project, cx| {
         let worktree = project.worktrees(cx).next().unwrap().read(cx);
@@ -167,40 +640,6 @@ async fn test_share_project(
     // buffer_a
     //     .condition(&cx_a, |buffer, _| buffer.selection_sets().count() == 0)
     //     .await;
-
-    // Client B can join again on a different window because they are already a participant.
-    let client_b2 = server.create_client(cx_b2, "user_b").await;
-    let project_b2 = Project::remote(
-        project_id,
-        client_b2.client.clone(),
-        client_b2.user_store.clone(),
-        client_b2.project_store.clone(),
-        client_b2.language_registry.clone(),
-        FakeFs::new(cx_b2.background()),
-        cx_b2.to_async(),
-    )
-    .await
-    .unwrap();
-    deterministic.run_until_parked();
-    project_a.read_with(cx_a, |project, _| {
-        assert_eq!(project.collaborators().len(), 2);
-    });
-    project_b.read_with(cx_b, |project, _| {
-        assert_eq!(project.collaborators().len(), 2);
-    });
-    project_b2.read_with(cx_b2, |project, _| {
-        assert_eq!(project.collaborators().len(), 2);
-    });
-
-    // Dropping client B's first project removes only that from client A's collaborators.
-    cx_b.update(move |_| drop(project_b));
-    deterministic.run_until_parked();
-    project_a.read_with(cx_a, |project, _| {
-        assert_eq!(project.collaborators().len(), 1);
-    });
-    project_b2.read_with(cx_b2, |project, _| {
-        assert_eq!(project.collaborators().len(), 1);
-    });
 }
 
 #[gpui::test(iterations = 10)]
@@ -208,15 +647,20 @@ async fn test_unshare_project(
     deterministic: Arc<Deterministic>,
     cx_a: &mut TestAppContext,
     cx_b: &mut TestAppContext,
+    cx_c: &mut TestAppContext,
 ) {
-    cx_a.foreground().forbid_parking();
+    deterministic.forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).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
-        .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
         .await;
 
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+
     client_a
         .fs
         .insert_tree(
@@ -229,8 +673,12 @@ async fn test_unshare_project(
         .await;
 
     let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
     let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap());
-    let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
+    let project_b = client_b.build_remote_project(project_id, cx_b).await;
     assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
 
     project_b
@@ -238,23 +686,39 @@ async fn test_unshare_project(
         .await
         .unwrap();
 
-    // When client B leaves the project, it gets automatically unshared.
-    cx_b.update(|_| drop(project_b));
+    // When client B leaves the room, the project becomes read-only.
+    active_call_b.update(cx_b, |call, cx| call.hang_up(cx).unwrap());
+    deterministic.run_until_parked();
+    assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
+
+    // Client C opens the project.
+    let project_c = client_c.build_remote_project(project_id, cx_c).await;
+
+    // When client A unshares the project, client C's project becomes read-only.
+    project_a
+        .update(cx_a, |project, cx| project.unshare(cx))
+        .unwrap();
     deterministic.run_until_parked();
     assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared()));
+    assert!(project_c.read_with(cx_c, |project, _| project.is_read_only()));
 
-    // When client B joins again, the project gets re-shared.
-    let project_b2 = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
+    // Client C can open the project again after client A re-shares.
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+    let project_c2 = client_c.build_remote_project(project_id, cx_c).await;
     assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
-    project_b2
-        .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
+    project_c2
+        .update(cx_c, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
         .await
         .unwrap();
 
-    // When client A (the host) leaves, the project gets unshared and guests are notified.
-    cx_a.update(|_| drop(project_a));
+    // When client A (the host) leaves the room, the project gets unshared and guests are notified.
+    active_call_a.update(cx_a, |call, cx| call.hang_up(cx).unwrap());
     deterministic.run_until_parked();
-    project_b2.read_with(cx_b, |project, _| {
+    project_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
+    project_c2.read_with(cx_c, |project, _| {
         assert!(project.is_read_only());
         assert!(project.collaborators().is_empty());
     });
@@ -274,11 +738,7 @@ async fn test_host_disconnect(
     let client_b = server.create_client(cx_b, "user_b").await;
     let client_c = server.create_client(cx_c, "user_c").await;
     server
-        .make_contacts(vec![
-            (&client_a, cx_a),
-            (&client_b, cx_b),
-            (&client_c, cx_c),
-        ])
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
         .await;
 
     client_a
@@ -292,11 +752,15 @@ async fn test_host_disconnect(
         )
         .await;
 
+    let active_call_a = cx_a.read(ActiveCall::global);
     let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
     let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap());
-    let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
 
-    let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
+    let project_b = client_b.build_remote_project(project_id, cx_b).await;
     assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
 
     let (_, workspace_b) =
@@ -318,20 +782,6 @@ async fn test_host_disconnect(
     editor_b.update(cx_b, |editor, cx| editor.insert("X", cx));
     assert!(cx_b.is_window_edited(workspace_b.window_id()));
 
-    // Request to join that project as client C
-    let project_c = cx_c.spawn(|cx| {
-        Project::remote(
-            project_id,
-            client_c.client.clone(),
-            client_c.user_store.clone(),
-            client_c.project_store.clone(),
-            client_c.language_registry.clone(),
-            FakeFs::new(cx.background()),
-            cx,
-        )
-    });
-    deterministic.run_until_parked();
-
     // Drop client A's connection. Collaborators should disappear and the project should not be shown as shared.
     server.disconnect_client(client_a.current_user_id(cx_a));
     cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
@@ -343,10 +793,6 @@ async fn test_host_disconnect(
         .condition(cx_b, |project, _| project.is_read_only())
         .await;
     assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared()));
-    assert!(matches!(
-        project_c.await.unwrap_err(),
-        project::JoinProjectError::HostWentOffline
-    ));
 
     // Ensure client B's edited state is reset and that the whole window is blurred.
     cx_b.read(|cx| {
@@ -355,447 +801,265 @@ async fn test_host_disconnect(
     assert!(!cx_b.is_window_edited(workspace_b.window_id()));
 
     // Ensure client B is not prompted to save edits when closing window after disconnecting.
-    workspace_b
-        .update(cx_b, |workspace, cx| {
-            workspace.close(&Default::default(), cx)
-        })
-        .unwrap()
-        .await
-        .unwrap();
-    assert_eq!(cx_b.window_ids().len(), 0);
-    cx_b.update(|_| {
-        drop(workspace_b);
-        drop(project_b);
-    });
-
-    // Ensure guests can still join.
-    let project_b2 = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
-    assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
-    project_b2
-        .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
+    let can_close = workspace_b
+        .update(cx_b, |workspace, cx| workspace.prepare_to_close(true, cx))
         .await
         .unwrap();
+    assert!(can_close);
 }
 
 #[gpui::test(iterations = 10)]
-async fn test_decline_join_request(
+async fn test_active_call_events(
     deterministic: Arc<Deterministic>,
     cx_a: &mut TestAppContext,
     cx_b: &mut TestAppContext,
 ) {
-    cx_a.foreground().forbid_parking();
+    deterministic.forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
-    server
-        .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
-        .await;
-
     client_a.fs.insert_tree("/a", json!({})).await;
+    client_b.fs.insert_tree("/b", json!({})).await;
 
     let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
-    let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
+    let (project_b, _) = client_b.build_local_project("/b", cx_b).await;
 
-    // Request to join that project as client B
-    let project_b = cx_b.spawn(|cx| {
-        Project::remote(
-            project_id,
-            client_b.client.clone(),
-            client_b.user_store.clone(),
-            client_b.project_store.clone(),
-            client_b.language_registry.clone(),
-            FakeFs::new(cx.background()),
-            cx,
-        )
-    });
-    deterministic.run_until_parked();
-    project_a.update(cx_a, |project, cx| {
-        project.respond_to_join_request(client_b.user_id().unwrap(), false, cx)
-    });
-    assert!(matches!(
-        project_b.await.unwrap_err(),
-        project::JoinProjectError::HostDeclined
-    ));
-
-    // Request to join the project again as client B
-    let project_b = cx_b.spawn(|cx| {
-        Project::remote(
-            project_id,
-            client_b.client.clone(),
-            client_b.user_store.clone(),
-            client_b.project_store.clone(),
-            client_b.language_registry.clone(),
-            FakeFs::new(cx.background()),
-            cx,
-        )
-    });
-
-    // Close the project on the host
-    deterministic.run_until_parked();
-    cx_a.update(|_| drop(project_a));
-    deterministic.run_until_parked();
-    assert!(matches!(
-        project_b.await.unwrap_err(),
-        project::JoinProjectError::HostClosedProject
-    ));
-}
-
-#[gpui::test(iterations = 10)]
-async fn test_cancel_join_request(
-    deterministic: Arc<Deterministic>,
-    cx_a: &mut TestAppContext,
-    cx_b: &mut TestAppContext,
-) {
-    cx_a.foreground().forbid_parking();
-    let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-    let client_a = server.create_client(cx_a, "user_a").await;
-    let client_b = server.create_client(cx_b, "user_b").await;
     server
-        .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
         .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
 
-    client_a.fs.insert_tree("/a", json!({})).await;
-    let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
-    let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
+    let events_a = active_call_events(cx_a);
+    let events_b = active_call_events(cx_b);
 
-    let user_b = client_a
-        .user_store
-        .update(cx_a, |store, cx| {
-            store.fetch_user(client_b.user_id().unwrap(), cx)
-        })
+    let project_a_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
         .await
         .unwrap();
-
-    let project_a_events = Rc::new(RefCell::new(Vec::new()));
-    project_a.update(cx_a, {
-        let project_a_events = project_a_events.clone();
-        move |_, cx| {
-            cx.subscribe(&cx.handle(), move |_, _, event, _| {
-                project_a_events.borrow_mut().push(event.clone());
-            })
-            .detach();
-        }
-    });
-
-    // Request to join that project as client B
-    let project_b = cx_b.spawn(|cx| {
-        Project::remote(
-            project_id,
-            client_b.client.clone(),
-            client_b.user_store.clone(),
-            client_b.project_store.clone(),
-            client_b.language_registry.clone(),
-            FakeFs::new(cx.background()),
-            cx,
-        )
-    });
     deterministic.run_until_parked();
+    assert_eq!(mem::take(&mut *events_a.borrow_mut()), vec![]);
     assert_eq!(
-        &*project_a_events.borrow(),
-        &[project::Event::ContactRequestedJoin(user_b.clone())]
+        mem::take(&mut *events_b.borrow_mut()),
+        vec![room::Event::RemoteProjectShared {
+            owner: Arc::new(User {
+                id: client_a.user_id().unwrap(),
+                github_login: "user_a".to_string(),
+                avatar: None,
+            }),
+            project_id: project_a_id,
+            worktree_root_names: vec!["a".to_string()],
+        }]
     );
-    project_a_events.borrow_mut().clear();
 
-    // Cancel the join request by leaving the project
-    client_b
-        .client
-        .send(proto::LeaveProject { project_id })
+    let project_b_id = active_call_b
+        .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx))
+        .await
         .unwrap();
-    drop(project_b);
+    deterministic.run_until_parked();
+    assert_eq!(
+        mem::take(&mut *events_a.borrow_mut()),
+        vec![room::Event::RemoteProjectShared {
+            owner: Arc::new(User {
+                id: client_b.user_id().unwrap(),
+                github_login: "user_b".to_string(),
+                avatar: None,
+            }),
+            project_id: project_b_id,
+            worktree_root_names: vec!["b".to_string()]
+        }]
+    );
+    assert_eq!(mem::take(&mut *events_b.borrow_mut()), vec![]);
 
+    // Sharing a project twice is idempotent.
+    let project_b_id_2 = active_call_b
+        .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx))
+        .await
+        .unwrap();
+    assert_eq!(project_b_id_2, project_b_id);
     deterministic.run_until_parked();
-    assert_eq!(
-        &*project_a_events.borrow(),
-        &[project::Event::ContactCancelledJoinRequest(user_b)]
-    );
+    assert_eq!(mem::take(&mut *events_a.borrow_mut()), vec![]);
+    assert_eq!(mem::take(&mut *events_b.borrow_mut()), vec![]);
+
+    fn active_call_events(cx: &mut TestAppContext) -> Rc<RefCell<Vec<room::Event>>> {
+        let events = Rc::new(RefCell::new(Vec::new()));
+        let active_call = cx.read(ActiveCall::global);
+        cx.update({
+            let events = events.clone();
+            |cx| {
+                cx.subscribe(&active_call, move |_, event, _| {
+                    events.borrow_mut().push(event.clone())
+                })
+                .detach()
+            }
+        });
+        events
+    }
 }
 
 #[gpui::test(iterations = 10)]
-async fn test_offline_projects(
+async fn test_room_location(
     deterministic: Arc<Deterministic>,
     cx_a: &mut TestAppContext,
     cx_b: &mut TestAppContext,
-    cx_c: &mut TestAppContext,
 ) {
-    cx_a.foreground().forbid_parking();
+    deterministic.forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).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;
-    let user_a = UserId::from_proto(client_a.user_id().unwrap());
-    server
-        .make_contacts(vec![
-            (&client_a, cx_a),
-            (&client_b, cx_b),
-            (&client_c, cx_c),
-        ])
-        .await;
-
-    // Set up observers of the project and user stores. Any time either of
-    // these models update, they should be in a consistent state with each
-    // other. There should not be an observable moment where the current
-    // user's contact entry contains a project that does not match one of
-    // the current open projects. That would cause a duplicate entry to be
-    // shown in the contacts panel.
-    let mut subscriptions = vec![];
-    let (window_id, view) = cx_a.add_window(|cx| {
-        subscriptions.push(cx.observe(&client_a.user_store, {
-            let project_store = client_a.project_store.clone();
-            let user_store = client_a.user_store.clone();
-            move |_, _, cx| check_project_list(project_store.clone(), user_store.clone(), cx)
-        }));
-
-        subscriptions.push(cx.observe(&client_a.project_store, {
-            let project_store = client_a.project_store.clone();
-            let user_store = client_a.user_store.clone();
-            move |_, _, cx| check_project_list(project_store.clone(), user_store.clone(), cx)
-        }));
-
-        fn check_project_list(
-            project_store: ModelHandle<ProjectStore>,
-            user_store: ModelHandle<UserStore>,
-            cx: &mut gpui::MutableAppContext,
-        ) {
-            let user_store = user_store.read(cx);
-            for contact in user_store.contacts() {
-                if contact.user.id == user_store.current_user().unwrap().id {
-                    for project in &contact.projects {
-                        let store_contains_project = project_store
-                            .read(cx)
-                            .projects(cx)
-                            .filter_map(|project| project.read(cx).remote_id())
-                            .any(|x| x == project.id);
-
-                        if !store_contains_project {
-                            panic!(
-                                concat!(
-                                    "current user's contact data has a project",
-                                    "that doesn't match any open project {:?}",
-                                ),
-                                project
-                            );
-                        }
-                    }
-                }
-            }
-        }
+    client_a.fs.insert_tree("/a", json!({})).await;
+    client_b.fs.insert_tree("/b", json!({})).await;
 
-        EmptyView
-    });
+    let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
+    let (project_b, _) = client_b.build_local_project("/b", cx_b).await;
 
-    // Build an offline project with two worktrees.
-    client_a
-        .fs
-        .insert_tree(
-            "/code",
-            json!({
-                "crate1": { "a.rs": "" },
-                "crate2": { "b.rs": "" },
-            }),
-        )
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
         .await;
-    let project = cx_a.update(|cx| {
-        Project::local(
-            false,
-            client_a.client.clone(),
-            client_a.user_store.clone(),
-            client_a.project_store.clone(),
-            client_a.language_registry.clone(),
-            client_a.fs.clone(),
-            cx,
-        )
-    });
-    project
-        .update(cx_a, |p, cx| {
-            p.find_or_create_local_worktree("/code/crate1", true, cx)
-        })
-        .await
-        .unwrap();
-    project
-        .update(cx_a, |p, cx| {
-            p.find_or_create_local_worktree("/code/crate2", true, cx)
-        })
-        .await
-        .unwrap();
-    project
-        .update(cx_a, |p, cx| p.restore_state(cx))
-        .await
-        .unwrap();
-
-    // When a project is offline, we still create it on the server but is invisible
-    // to other users.
-    deterministic.run_until_parked();
-    assert!(server
-        .store
-        .lock()
-        .await
-        .project_metadata_for_user(user_a)
-        .is_empty());
-    project.read_with(cx_a, |project, _| {
-        assert!(project.remote_id().is_some());
-        assert!(!project.is_online());
-    });
-    assert!(client_b
-        .user_store
-        .read_with(cx_b, |store, _| { store.contacts()[0].projects.is_empty() }));
 
-    // When the project is taken online, its metadata is sent to the server
-    // and broadcasted to other users.
-    project.update(cx_a, |p, cx| p.set_online(true, cx));
-    deterministic.run_until_parked();
-    let project_id = project.read_with(cx_a, |p, _| p.remote_id()).unwrap();
-    client_b.user_store.read_with(cx_b, |store, _| {
-        assert_eq!(
-            store.contacts()[0].projects,
-            &[ProjectMetadata {
-                id: project_id,
-                visible_worktree_root_names: vec!["crate1".into(), "crate2".into()],
-                guests: Default::default(),
-            }]
-        );
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
+    let a_notified = Rc::new(Cell::new(false));
+    cx_a.update({
+        let notified = a_notified.clone();
+        |cx| {
+            cx.observe(&active_call_a, move |_, _| notified.set(true))
+                .detach()
+        }
     });
 
-    // The project is registered again when the host loses and regains connection.
-    server.disconnect_client(user_a);
-    server.forbid_connections();
-    cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
-    assert!(server
-        .store
-        .lock()
-        .await
-        .project_metadata_for_user(user_a)
-        .is_empty());
-    assert!(project.read_with(cx_a, |p, _| p.remote_id().is_none()));
-    assert!(client_b
-        .user_store
-        .read_with(cx_b, |store, _| { store.contacts()[0].projects.is_empty() }));
-
-    server.allow_connections();
-    cx_b.foreground().advance_clock(Duration::from_secs(10));
-    let project_id = project.read_with(cx_a, |p, _| p.remote_id()).unwrap();
-    client_b.user_store.read_with(cx_b, |store, _| {
-        assert_eq!(
-            store.contacts()[0].projects,
-            &[ProjectMetadata {
-                id: project_id,
-                visible_worktree_root_names: vec!["crate1".into(), "crate2".into()],
-                guests: Default::default(),
-            }]
-        );
+    let active_call_b = cx_b.read(ActiveCall::global);
+    let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
+    let b_notified = Rc::new(Cell::new(false));
+    cx_b.update({
+        let b_notified = b_notified.clone();
+        |cx| {
+            cx.observe(&active_call_b, move |_, _| b_notified.set(true))
+                .detach()
+        }
     });
 
-    project
-        .update(cx_a, |p, cx| {
-            p.find_or_create_local_worktree("/code/crate3", true, cx)
-        })
+    room_a
+        .update(cx_a, |room, cx| room.set_location(Some(&project_a), cx))
         .await
         .unwrap();
     deterministic.run_until_parked();
-    client_b.user_store.read_with(cx_b, |store, _| {
-        assert_eq!(
-            store.contacts()[0].projects,
-            &[ProjectMetadata {
-                id: project_id,
-                visible_worktree_root_names: vec![
-                    "crate1".into(),
-                    "crate2".into(),
-                    "crate3".into()
-                ],
-                guests: Default::default(),
-            }]
-        );
-    });
+    assert!(a_notified.take());
+    assert_eq!(
+        participant_locations(&room_a, cx_a),
+        vec![("user_b".to_string(), ParticipantLocation::External)]
+    );
+    assert!(b_notified.take());
+    assert_eq!(
+        participant_locations(&room_b, cx_b),
+        vec![("user_a".to_string(), ParticipantLocation::UnsharedProject)]
+    );
 
-    // Build another project using a directory which was previously part of
-    // an online project. Restore the project's state from the host's database.
-    let project2_a = cx_a.update(|cx| {
-        Project::local(
-            false,
-            client_a.client.clone(),
-            client_a.user_store.clone(),
-            client_a.project_store.clone(),
-            client_a.language_registry.clone(),
-            client_a.fs.clone(),
-            cx,
-        )
-    });
-    project2_a
-        .update(cx_a, |p, cx| {
-            p.find_or_create_local_worktree("/code/crate3", true, cx)
-        })
+    let project_a_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
         .await
         .unwrap();
-    project2_a
-        .update(cx_a, |project, cx| project.restore_state(cx))
+    deterministic.run_until_parked();
+    assert!(a_notified.take());
+    assert_eq!(
+        participant_locations(&room_a, cx_a),
+        vec![("user_b".to_string(), ParticipantLocation::External)]
+    );
+    assert!(b_notified.take());
+    assert_eq!(
+        participant_locations(&room_b, cx_b),
+        vec![(
+            "user_a".to_string(),
+            ParticipantLocation::SharedProject {
+                project_id: project_a_id
+            }
+        )]
+    );
+
+    let project_b_id = active_call_b
+        .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx))
         .await
         .unwrap();
-
-    // This project is now online, because its directory was previously online.
-    project2_a.read_with(cx_a, |project, _| assert!(project.is_online()));
     deterministic.run_until_parked();
-    let project2_id = project2_a.read_with(cx_a, |p, _| p.remote_id()).unwrap();
-    client_b.user_store.read_with(cx_b, |store, _| {
-        assert_eq!(
-            store.contacts()[0].projects,
-            &[
-                ProjectMetadata {
-                    id: project_id,
-                    visible_worktree_root_names: vec![
-                        "crate1".into(),
-                        "crate2".into(),
-                        "crate3".into()
-                    ],
-                    guests: Default::default(),
-                },
-                ProjectMetadata {
-                    id: project2_id,
-                    visible_worktree_root_names: vec!["crate3".into()],
-                    guests: Default::default(),
-                }
-            ]
-        );
-    });
+    assert!(a_notified.take());
+    assert_eq!(
+        participant_locations(&room_a, cx_a),
+        vec![("user_b".to_string(), ParticipantLocation::External)]
+    );
+    assert!(b_notified.take());
+    assert_eq!(
+        participant_locations(&room_b, cx_b),
+        vec![(
+            "user_a".to_string(),
+            ParticipantLocation::SharedProject {
+                project_id: project_a_id
+            }
+        )]
+    );
 
-    let project2_b = client_b.build_remote_project(&project2_a, cx_a, cx_b).await;
-    let project2_c = cx_c.foreground().spawn(Project::remote(
-        project2_id,
-        client_c.client.clone(),
-        client_c.user_store.clone(),
-        client_c.project_store.clone(),
-        client_c.language_registry.clone(),
-        FakeFs::new(cx_c.background()),
-        cx_c.to_async(),
-    ));
+    room_b
+        .update(cx_b, |room, cx| room.set_location(Some(&project_b), cx))
+        .await
+        .unwrap();
     deterministic.run_until_parked();
+    assert!(a_notified.take());
+    assert_eq!(
+        participant_locations(&room_a, cx_a),
+        vec![(
+            "user_b".to_string(),
+            ParticipantLocation::SharedProject {
+                project_id: project_b_id
+            }
+        )]
+    );
+    assert!(b_notified.take());
+    assert_eq!(
+        participant_locations(&room_b, cx_b),
+        vec![(
+            "user_a".to_string(),
+            ParticipantLocation::SharedProject {
+                project_id: project_a_id
+            }
+        )]
+    );
 
-    // Taking a project offline unshares the project, rejects any pending join request and
-    // disconnects existing guests.
-    project2_a.update(cx_a, |project, cx| project.set_online(false, cx));
+    room_b
+        .update(cx_b, |room, cx| room.set_location(None, cx))
+        .await
+        .unwrap();
     deterministic.run_until_parked();
-    project2_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
-    project2_b.read_with(cx_b, |project, _| assert!(project.is_read_only()));
-    project2_c.await.unwrap_err();
-
-    client_b.user_store.read_with(cx_b, |store, _| {
-        assert_eq!(
-            store.contacts()[0].projects,
-            &[ProjectMetadata {
-                id: project_id,
-                visible_worktree_root_names: vec![
-                    "crate1".into(),
-                    "crate2".into(),
-                    "crate3".into()
-                ],
-                guests: Default::default(),
-            },]
-        );
-    });
+    assert!(a_notified.take());
+    assert_eq!(
+        participant_locations(&room_a, cx_a),
+        vec![("user_b".to_string(), ParticipantLocation::External)]
+    );
+    assert!(b_notified.take());
+    assert_eq!(
+        participant_locations(&room_b, cx_b),
+        vec![(
+            "user_a".to_string(),
+            ParticipantLocation::SharedProject {
+                project_id: project_a_id
+            }
+        )]
+    );
 
-    cx_a.update(|cx| {
-        drop(subscriptions);
-        drop(view);
-        cx.remove_window(window_id);
-    });
+    fn participant_locations(
+        room: &ModelHandle<Room>,
+        cx: &TestAppContext,
+    ) -> Vec<(String, ParticipantLocation)> {
+        room.read_with(cx, |room, _| {
+            room.remote_participants()
+                .values()
+                .map(|participant| {
+                    (
+                        participant.user.github_login.to_string(),
+                        participant.location,
+                    )
+                })
+                .collect()
+        })
+    }
 }
 
 #[gpui::test(iterations = 10)]

crates/collab/src/rpc.rs 🔗

@@ -22,7 +22,7 @@ use axum::{
     routing::get,
     Extension, Router, TypedHeader,
 };
-use collections::HashMap;
+use collections::{HashMap, HashSet};
 use futures::{
     channel::mpsc,
     future::{self, BoxFuture},
@@ -88,11 +88,6 @@ impl<R: RequestMessage> Response<R> {
         self.server.peer.respond(self.receipt, payload)?;
         Ok(())
     }
-
-    fn into_receipt(self) -> Receipt<R> {
-        self.responded.store(true, SeqCst);
-        self.receipt
-    }
 }
 
 pub struct Server {
@@ -151,11 +146,17 @@ impl Server {
 
         server
             .add_request_handler(Server::ping)
-            .add_request_handler(Server::register_project)
-            .add_request_handler(Server::unregister_project)
+            .add_request_handler(Server::create_room)
+            .add_request_handler(Server::join_room)
+            .add_message_handler(Server::leave_room)
+            .add_request_handler(Server::call)
+            .add_request_handler(Server::cancel_call)
+            .add_message_handler(Server::decline_call)
+            .add_request_handler(Server::update_participant_location)
+            .add_request_handler(Server::share_project)
+            .add_message_handler(Server::unshare_project)
             .add_request_handler(Server::join_project)
             .add_message_handler(Server::leave_project)
-            .add_message_handler(Server::respond_to_join_project_request)
             .add_message_handler(Server::update_project)
             .add_message_handler(Server::register_project_activity)
             .add_request_handler(Server::update_worktree)
@@ -385,7 +386,11 @@ impl Server {
 
             {
                 let mut store = this.store().await;
-                store.add_connection(connection_id, user_id, user.admin);
+                let incoming_call = store.add_connection(connection_id, user_id, user.admin);
+                if let Some(incoming_call) = incoming_call {
+                    this.peer.send(connection_id, incoming_call)?;
+                }
+
                 this.peer.send(connection_id, store.build_initial_contacts_update(contacts))?;
 
                 if let Some((code, count)) = invite_code {
@@ -468,69 +473,58 @@ impl Server {
     async fn sign_out(self: &mut Arc<Self>, connection_id: ConnectionId) -> Result<()> {
         self.peer.disconnect(connection_id);
 
-        let mut projects_to_unregister = Vec::new();
-        let removed_user_id;
+        let mut projects_to_unshare = Vec::new();
+        let mut contacts_to_update = HashSet::default();
         {
             let mut store = self.store().await;
             let removed_connection = store.remove_connection(connection_id)?;
 
-            for (project_id, project) in removed_connection.hosted_projects {
-                projects_to_unregister.push(project_id);
+            for project in removed_connection.hosted_projects {
+                projects_to_unshare.push(project.id);
                 broadcast(connection_id, project.guests.keys().copied(), |conn_id| {
                     self.peer.send(
                         conn_id,
-                        proto::UnregisterProject {
-                            project_id: project_id.to_proto(),
+                        proto::UnshareProject {
+                            project_id: project.id.to_proto(),
                         },
                     )
                 });
+            }
 
-                for (_, receipts) in project.join_requests {
-                    for receipt in receipts {
-                        self.peer.respond(
-                            receipt,
-                            proto::JoinProjectResponse {
-                                variant: Some(proto::join_project_response::Variant::Decline(
-                                    proto::join_project_response::Decline {
-                                        reason: proto::join_project_response::decline::Reason::WentOffline as i32
-                                    },
-                                )),
-                            },
-                        )?;
-                    }
-                }
+            for project in removed_connection.guest_projects {
+                broadcast(connection_id, project.connection_ids, |conn_id| {
+                    self.peer.send(
+                        conn_id,
+                        proto::RemoveProjectCollaborator {
+                            project_id: project.id.to_proto(),
+                            peer_id: connection_id.0,
+                        },
+                    )
+                });
             }
 
-            for project_id in removed_connection.guest_project_ids {
-                if let Some(project) = store.project(project_id).trace_err() {
-                    broadcast(connection_id, project.connection_ids(), |conn_id| {
-                        self.peer.send(
-                            conn_id,
-                            proto::RemoveProjectCollaborator {
-                                project_id: project_id.to_proto(),
-                                peer_id: connection_id.0,
-                            },
-                        )
-                    });
-                    if project.guests.is_empty() {
-                        self.peer
-                            .send(
-                                project.host_connection_id,
-                                proto::ProjectUnshared {
-                                    project_id: project_id.to_proto(),
-                                },
-                            )
-                            .trace_err();
-                    }
-                }
+            for connection_id in removed_connection.canceled_call_connection_ids {
+                self.peer
+                    .send(connection_id, proto::CallCanceled {})
+                    .trace_err();
+                contacts_to_update.extend(store.user_id_for_connection(connection_id).ok());
             }
 
-            removed_user_id = removed_connection.user_id;
+            if let Some(room) = removed_connection
+                .room_id
+                .and_then(|room_id| store.room(room_id))
+            {
+                self.room_updated(room);
+            }
+
+            contacts_to_update.insert(removed_connection.user_id);
         };
 
-        self.update_user_contacts(removed_user_id).await.trace_err();
+        for user_id in contacts_to_update {
+            self.update_user_contacts(user_id).await.trace_err();
+        }
 
-        for project_id in projects_to_unregister {
+        for project_id in projects_to_unshare {
             self.app_state
                 .db
                 .unregister_project(project_id)
@@ -598,76 +592,286 @@ impl Server {
         Ok(())
     }
 
-    async fn register_project(
+    async fn create_room(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::CreateRoom>,
+        response: Response<proto::CreateRoom>,
+    ) -> Result<()> {
+        let user_id;
+        let room_id;
+        {
+            let mut store = self.store().await;
+            user_id = store.user_id_for_connection(request.sender_id)?;
+            room_id = store.create_room(request.sender_id)?;
+        }
+        response.send(proto::CreateRoomResponse { id: room_id })?;
+        self.update_user_contacts(user_id).await?;
+        Ok(())
+    }
+
+    async fn join_room(
         self: Arc<Server>,
-        request: TypedEnvelope<proto::RegisterProject>,
-        response: Response<proto::RegisterProject>,
+        request: TypedEnvelope<proto::JoinRoom>,
+        response: Response<proto::JoinRoom>,
+    ) -> Result<()> {
+        let user_id;
+        {
+            let mut store = self.store().await;
+            user_id = store.user_id_for_connection(request.sender_id)?;
+            let (room, recipient_connection_ids) =
+                store.join_room(request.payload.id, request.sender_id)?;
+            for recipient_id in recipient_connection_ids {
+                self.peer
+                    .send(recipient_id, proto::CallCanceled {})
+                    .trace_err();
+            }
+            response.send(proto::JoinRoomResponse {
+                room: Some(room.clone()),
+            })?;
+            self.room_updated(room);
+        }
+        self.update_user_contacts(user_id).await?;
+        Ok(())
+    }
+
+    async fn leave_room(self: Arc<Server>, message: TypedEnvelope<proto::LeaveRoom>) -> Result<()> {
+        let mut contacts_to_update = HashSet::default();
+        {
+            let mut store = self.store().await;
+            let user_id = store.user_id_for_connection(message.sender_id)?;
+            let left_room = store.leave_room(message.payload.id, message.sender_id)?;
+            contacts_to_update.insert(user_id);
+
+            for project in left_room.unshared_projects {
+                for connection_id in project.connection_ids() {
+                    self.peer.send(
+                        connection_id,
+                        proto::UnshareProject {
+                            project_id: project.id.to_proto(),
+                        },
+                    )?;
+                }
+            }
+
+            for project in left_room.left_projects {
+                if project.remove_collaborator {
+                    for connection_id in project.connection_ids {
+                        self.peer.send(
+                            connection_id,
+                            proto::RemoveProjectCollaborator {
+                                project_id: project.id.to_proto(),
+                                peer_id: message.sender_id.0,
+                            },
+                        )?;
+                    }
+
+                    self.peer.send(
+                        message.sender_id,
+                        proto::UnshareProject {
+                            project_id: project.id.to_proto(),
+                        },
+                    )?;
+                }
+            }
+
+            if let Some(room) = left_room.room {
+                self.room_updated(room);
+            }
+
+            for connection_id in left_room.canceled_call_connection_ids {
+                self.peer
+                    .send(connection_id, proto::CallCanceled {})
+                    .trace_err();
+                contacts_to_update.extend(store.user_id_for_connection(connection_id).ok());
+            }
+        }
+
+        for user_id in contacts_to_update {
+            self.update_user_contacts(user_id).await?;
+        }
+
+        Ok(())
+    }
+
+    async fn call(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::Call>,
+        response: Response<proto::Call>,
+    ) -> Result<()> {
+        let caller_user_id = self
+            .store()
+            .await
+            .user_id_for_connection(request.sender_id)?;
+        let recipient_user_id = UserId::from_proto(request.payload.recipient_user_id);
+        let initial_project_id = request
+            .payload
+            .initial_project_id
+            .map(ProjectId::from_proto);
+        if !self
+            .app_state
+            .db
+            .has_contact(caller_user_id, recipient_user_id)
+            .await?
+        {
+            return Err(anyhow!("cannot call a user who isn't a contact"))?;
+        }
+
+        let room_id = request.payload.room_id;
+        let mut calls = {
+            let mut store = self.store().await;
+            let (room, recipient_connection_ids, incoming_call) = store.call(
+                room_id,
+                recipient_user_id,
+                initial_project_id,
+                request.sender_id,
+            )?;
+            self.room_updated(room);
+            recipient_connection_ids
+                .into_iter()
+                .map(|recipient_connection_id| {
+                    self.peer
+                        .request(recipient_connection_id, incoming_call.clone())
+                })
+                .collect::<FuturesUnordered<_>>()
+        };
+        self.update_user_contacts(recipient_user_id).await?;
+
+        while let Some(call_response) = calls.next().await {
+            match call_response.as_ref() {
+                Ok(_) => {
+                    response.send(proto::Ack {})?;
+                    return Ok(());
+                }
+                Err(_) => {
+                    call_response.trace_err();
+                }
+            }
+        }
+
+        {
+            let mut store = self.store().await;
+            let room = store.call_failed(room_id, recipient_user_id)?;
+            self.room_updated(&room);
+        }
+        self.update_user_contacts(recipient_user_id).await?;
+
+        Err(anyhow!("failed to ring call recipient"))?
+    }
+
+    async fn cancel_call(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::CancelCall>,
+        response: Response<proto::CancelCall>,
+    ) -> Result<()> {
+        let recipient_user_id = UserId::from_proto(request.payload.recipient_user_id);
+        {
+            let mut store = self.store().await;
+            let (room, recipient_connection_ids) = store.cancel_call(
+                request.payload.room_id,
+                recipient_user_id,
+                request.sender_id,
+            )?;
+            for recipient_id in recipient_connection_ids {
+                self.peer
+                    .send(recipient_id, proto::CallCanceled {})
+                    .trace_err();
+            }
+            self.room_updated(room);
+            response.send(proto::Ack {})?;
+        }
+        self.update_user_contacts(recipient_user_id).await?;
+        Ok(())
+    }
+
+    async fn decline_call(
+        self: Arc<Server>,
+        message: TypedEnvelope<proto::DeclineCall>,
+    ) -> Result<()> {
+        let recipient_user_id;
+        {
+            let mut store = self.store().await;
+            recipient_user_id = store.user_id_for_connection(message.sender_id)?;
+            let (room, recipient_connection_ids) =
+                store.decline_call(message.payload.room_id, message.sender_id)?;
+            for recipient_id in recipient_connection_ids {
+                self.peer
+                    .send(recipient_id, proto::CallCanceled {})
+                    .trace_err();
+            }
+            self.room_updated(room);
+        }
+        self.update_user_contacts(recipient_user_id).await?;
+        Ok(())
+    }
+
+    async fn update_participant_location(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::UpdateParticipantLocation>,
+        response: Response<proto::UpdateParticipantLocation>,
+    ) -> Result<()> {
+        let room_id = request.payload.room_id;
+        let location = request
+            .payload
+            .location
+            .ok_or_else(|| anyhow!("invalid location"))?;
+        let mut store = self.store().await;
+        let room = store.update_participant_location(room_id, location, request.sender_id)?;
+        self.room_updated(room);
+        response.send(proto::Ack {})?;
+        Ok(())
+    }
+
+    fn room_updated(&self, room: &proto::Room) {
+        for participant in &room.participants {
+            self.peer
+                .send(
+                    ConnectionId(participant.peer_id),
+                    proto::RoomUpdated {
+                        room: Some(room.clone()),
+                    },
+                )
+                .trace_err();
+        }
+    }
+
+    async fn share_project(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::ShareProject>,
+        response: Response<proto::ShareProject>,
     ) -> Result<()> {
         let user_id = self
             .store()
             .await
             .user_id_for_connection(request.sender_id)?;
         let project_id = self.app_state.db.register_project(user_id).await?;
-        self.store().await.register_project(
-            request.sender_id,
+        let mut store = self.store().await;
+        let room = store.share_project(
+            request.payload.room_id,
             project_id,
-            request.payload.online,
+            request.payload.worktrees,
+            request.sender_id,
         )?;
-
-        response.send(proto::RegisterProjectResponse {
+        response.send(proto::ShareProjectResponse {
             project_id: project_id.to_proto(),
         })?;
+        self.room_updated(room);
 
         Ok(())
     }
 
-    async fn unregister_project(
+    async fn unshare_project(
         self: Arc<Server>,
-        request: TypedEnvelope<proto::UnregisterProject>,
-        response: Response<proto::UnregisterProject>,
+        message: TypedEnvelope<proto::UnshareProject>,
     ) -> Result<()> {
-        let project_id = ProjectId::from_proto(request.payload.project_id);
-        let (user_id, project) = {
-            let mut state = self.store().await;
-            let project = state.unregister_project(project_id, request.sender_id)?;
-            (state.user_id_for_connection(request.sender_id)?, project)
-        };
-        self.app_state.db.unregister_project(project_id).await?;
-
+        let project_id = ProjectId::from_proto(message.payload.project_id);
+        let mut store = self.store().await;
+        let (room, project) = store.unshare_project(project_id, message.sender_id)?;
         broadcast(
-            request.sender_id,
-            project.guests.keys().copied(),
-            |conn_id| {
-                self.peer.send(
-                    conn_id,
-                    proto::UnregisterProject {
-                        project_id: project_id.to_proto(),
-                    },
-                )
-            },
+            message.sender_id,
+            project.guest_connection_ids(),
+            |conn_id| self.peer.send(conn_id, message.payload.clone()),
         );
-        for (_, receipts) in project.join_requests {
-            for receipt in receipts {
-                self.peer.respond(
-                    receipt,
-                    proto::JoinProjectResponse {
-                        variant: Some(proto::join_project_response::Variant::Decline(
-                            proto::join_project_response::Decline {
-                                reason: proto::join_project_response::decline::Reason::Closed
-                                    as i32,
-                            },
-                        )),
-                    },
-                )?;
-            }
-        }
-
-        // Send out the `UpdateContacts` message before responding to the unregister
-        // request. This way, when the project's host can keep track of the project's
-        // remote id until after they've received the `UpdateContacts` message for
-        // themself.
-        self.update_user_contacts(user_id).await?;
-        response.send(proto::Ack {})?;
+        self.room_updated(room);
 
         Ok(())
     }
@@ -721,176 +925,94 @@ impl Server {
         };
 
         tracing::info!(%project_id, %host_user_id, %host_connection_id, "join project");
-        let has_contact = self
-            .app_state
-            .db
-            .has_contact(guest_user_id, host_user_id)
-            .await?;
-        if !has_contact {
-            return Err(anyhow!("no such project"))?;
-        }
-
-        self.store().await.request_join_project(
-            guest_user_id,
-            project_id,
-            response.into_receipt(),
-        )?;
-        self.peer.send(
-            host_connection_id,
-            proto::RequestJoinProject {
-                project_id: project_id.to_proto(),
-                requester_id: guest_user_id.to_proto(),
-            },
-        )?;
-        Ok(())
-    }
 
-    async fn respond_to_join_project_request(
-        self: Arc<Server>,
-        request: TypedEnvelope<proto::RespondToJoinProjectRequest>,
-    ) -> Result<()> {
-        let host_user_id;
+        let mut store = self.store().await;
+        let (project, replica_id) = store.join_project(request.sender_id, project_id)?;
+        let peer_count = project.guests.len();
+        let mut collaborators = Vec::with_capacity(peer_count);
+        collaborators.push(proto::Collaborator {
+            peer_id: project.host_connection_id.0,
+            replica_id: 0,
+            user_id: project.host.user_id.to_proto(),
+        });
+        let worktrees = project
+            .worktrees
+            .iter()
+            .map(|(id, worktree)| proto::WorktreeMetadata {
+                id: *id,
+                root_name: worktree.root_name.clone(),
+                visible: worktree.visible,
+            })
+            .collect::<Vec<_>>();
 
-        {
-            let mut state = self.store().await;
-            let project_id = ProjectId::from_proto(request.payload.project_id);
-            let project = state.project(project_id)?;
-            if project.host_connection_id != request.sender_id {
-                Err(anyhow!("no such connection"))?;
+        // Add all guests other than the requesting user's own connections as collaborators
+        for (guest_conn_id, guest) in &project.guests {
+            if request.sender_id != *guest_conn_id {
+                collaborators.push(proto::Collaborator {
+                    peer_id: guest_conn_id.0,
+                    replica_id: guest.replica_id as u32,
+                    user_id: guest.user_id.to_proto(),
+                });
             }
+        }
 
-            host_user_id = project.host.user_id;
-            let guest_user_id = UserId::from_proto(request.payload.requester_id);
-
-            if !request.payload.allow {
-                let receipts = state
-                    .deny_join_project_request(request.sender_id, guest_user_id, project_id)
-                    .ok_or_else(|| anyhow!("no such request"))?;
-                for receipt in receipts {
-                    self.peer.respond(
-                        receipt,
-                        proto::JoinProjectResponse {
-                            variant: Some(proto::join_project_response::Variant::Decline(
-                                proto::join_project_response::Decline {
-                                    reason: proto::join_project_response::decline::Reason::Declined
-                                        as i32,
-                                },
-                            )),
-                        },
-                    )?;
-                }
-                return Ok(());
+        for conn_id in project.connection_ids() {
+            if conn_id != request.sender_id {
+                self.peer.send(
+                    conn_id,
+                    proto::AddProjectCollaborator {
+                        project_id: project_id.to_proto(),
+                        collaborator: Some(proto::Collaborator {
+                            peer_id: request.sender_id.0,
+                            replica_id: replica_id as u32,
+                            user_id: guest_user_id.to_proto(),
+                        }),
+                    },
+                )?;
             }
+        }
 
-            let (receipts_with_replica_ids, project) = state
-                .accept_join_project_request(request.sender_id, guest_user_id, project_id)
-                .ok_or_else(|| anyhow!("no such request"))?;
+        // First, we send the metadata associated with each worktree.
+        response.send(proto::JoinProjectResponse {
+            worktrees: worktrees.clone(),
+            replica_id: replica_id as u32,
+            collaborators: collaborators.clone(),
+            language_servers: project.language_servers.clone(),
+        })?;
 
-            let peer_count = project.guests.len();
-            let mut collaborators = Vec::with_capacity(peer_count);
-            collaborators.push(proto::Collaborator {
-                peer_id: project.host_connection_id.0,
-                replica_id: 0,
-                user_id: project.host.user_id.to_proto(),
-            });
-            let worktrees = project
-                .worktrees
-                .iter()
-                .map(|(id, worktree)| proto::WorktreeMetadata {
-                    id: *id,
-                    root_name: worktree.root_name.clone(),
-                    visible: worktree.visible,
-                })
-                .collect::<Vec<_>>();
-
-            // Add all guests other than the requesting user's own connections as collaborators
-            for (guest_conn_id, guest) in &project.guests {
-                if receipts_with_replica_ids
-                    .iter()
-                    .all(|(receipt, _)| receipt.sender_id != *guest_conn_id)
-                {
-                    collaborators.push(proto::Collaborator {
-                        peer_id: guest_conn_id.0,
-                        replica_id: guest.replica_id as u32,
-                        user_id: guest.user_id.to_proto(),
-                    });
-                }
-            }
+        for (worktree_id, worktree) in &project.worktrees {
+            #[cfg(any(test, feature = "test-support"))]
+            const MAX_CHUNK_SIZE: usize = 2;
+            #[cfg(not(any(test, feature = "test-support")))]
+            const MAX_CHUNK_SIZE: usize = 256;
 
-            for conn_id in project.connection_ids() {
-                for (receipt, replica_id) in &receipts_with_replica_ids {
-                    if conn_id != receipt.sender_id {
-                        self.peer.send(
-                            conn_id,
-                            proto::AddProjectCollaborator {
-                                project_id: project_id.to_proto(),
-                                collaborator: Some(proto::Collaborator {
-                                    peer_id: receipt.sender_id.0,
-                                    replica_id: *replica_id as u32,
-                                    user_id: guest_user_id.to_proto(),
-                                }),
-                            },
-                        )?;
-                    }
-                }
+            // Stream this worktree's entries.
+            let message = proto::UpdateWorktree {
+                project_id: project_id.to_proto(),
+                worktree_id: *worktree_id,
+                root_name: worktree.root_name.clone(),
+                updated_entries: worktree.entries.values().cloned().collect(),
+                removed_entries: Default::default(),
+                scan_id: worktree.scan_id,
+                is_last_update: worktree.is_complete,
+            };
+            for update in proto::split_worktree_update(message, MAX_CHUNK_SIZE) {
+                self.peer.send(request.sender_id, update.clone())?;
             }
 
-            // First, we send the metadata associated with each worktree.
-            for (receipt, replica_id) in &receipts_with_replica_ids {
-                self.peer.respond(
-                    *receipt,
-                    proto::JoinProjectResponse {
-                        variant: Some(proto::join_project_response::Variant::Accept(
-                            proto::join_project_response::Accept {
-                                worktrees: worktrees.clone(),
-                                replica_id: *replica_id as u32,
-                                collaborators: collaborators.clone(),
-                                language_servers: project.language_servers.clone(),
-                            },
-                        )),
+            // Stream this worktree's diagnostics.
+            for summary in worktree.diagnostic_summaries.values() {
+                self.peer.send(
+                    request.sender_id,
+                    proto::UpdateDiagnosticSummary {
+                        project_id: project_id.to_proto(),
+                        worktree_id: *worktree_id,
+                        summary: Some(summary.clone()),
                     },
                 )?;
             }
-
-            for (worktree_id, worktree) in &project.worktrees {
-                #[cfg(any(test, feature = "test-support"))]
-                const MAX_CHUNK_SIZE: usize = 2;
-                #[cfg(not(any(test, feature = "test-support")))]
-                const MAX_CHUNK_SIZE: usize = 256;
-
-                // Stream this worktree's entries.
-                let message = proto::UpdateWorktree {
-                    project_id: project_id.to_proto(),
-                    worktree_id: *worktree_id,
-                    root_name: worktree.root_name.clone(),
-                    updated_entries: worktree.entries.values().cloned().collect(),
-                    removed_entries: Default::default(),
-                    scan_id: worktree.scan_id,
-                    is_last_update: worktree.is_complete,
-                };
-                for update in proto::split_worktree_update(message, MAX_CHUNK_SIZE) {
-                    for (receipt, _) in &receipts_with_replica_ids {
-                        self.peer.send(receipt.sender_id, update.clone())?;
-                    }
-                }
-
-                // Stream this worktree's diagnostics.
-                for summary in worktree.diagnostic_summaries.values() {
-                    for (receipt, _) in &receipts_with_replica_ids {
-                        self.peer.send(
-                            receipt.sender_id,
-                            proto::UpdateDiagnosticSummary {
-                                project_id: project_id.to_proto(),
-                                worktree_id: *worktree_id,
-                                summary: Some(summary.clone()),
-                            },
-                        )?;
-                    }
-                }
-            }
         }
 
-        self.update_user_contacts(host_user_id).await?;
         Ok(())
     }
 
@@ -903,7 +1025,7 @@ impl Server {
         let project;
         {
             let mut store = self.store().await;
-            project = store.leave_project(sender_id, project_id)?;
+            project = store.leave_project(project_id, sender_id)?;
             tracing::info!(
                 %project_id,
                 host_user_id = %project.host_user_id,
@@ -922,27 +1044,8 @@ impl Server {
                     )
                 });
             }
-
-            if let Some(requester_id) = project.cancel_request {
-                self.peer.send(
-                    project.host_connection_id,
-                    proto::JoinProjectRequestCancelled {
-                        project_id: project_id.to_proto(),
-                        requester_id: requester_id.to_proto(),
-                    },
-                )?;
-            }
-
-            if project.unshare {
-                self.peer.send(
-                    project.host_connection_id,
-                    proto::ProjectUnshared {
-                        project_id: project_id.to_proto(),
-                    },
-                )?;
-            }
         }
-        self.update_user_contacts(project.host_user_id).await?;
+
         Ok(())
     }
 
@@ -951,61 +1054,20 @@ impl Server {
         request: TypedEnvelope<proto::UpdateProject>,
     ) -> Result<()> {
         let project_id = ProjectId::from_proto(request.payload.project_id);
-        let user_id;
         {
             let mut state = self.store().await;
-            user_id = state.user_id_for_connection(request.sender_id)?;
             let guest_connection_ids = state
                 .read_project(project_id, request.sender_id)?
                 .guest_connection_ids();
-            let unshared_project = state.update_project(
-                project_id,
-                &request.payload.worktrees,
-                request.payload.online,
-                request.sender_id,
-            )?;
-
-            if let Some(unshared_project) = unshared_project {
-                broadcast(
-                    request.sender_id,
-                    unshared_project.guests.keys().copied(),
-                    |conn_id| {
-                        self.peer.send(
-                            conn_id,
-                            proto::UnregisterProject {
-                                project_id: project_id.to_proto(),
-                            },
-                        )
-                    },
-                );
-                for (_, receipts) in unshared_project.pending_join_requests {
-                    for receipt in receipts {
-                        self.peer.respond(
-                            receipt,
-                            proto::JoinProjectResponse {
-                                variant: Some(proto::join_project_response::Variant::Decline(
-                                    proto::join_project_response::Decline {
-                                        reason:
-                                            proto::join_project_response::decline::Reason::Closed
-                                                as i32,
-                                    },
-                                )),
-                            },
-                        )?;
-                    }
-                }
-            } else {
-                broadcast(request.sender_id, guest_connection_ids, |connection_id| {
-                    self.peer.forward_send(
-                        request.sender_id,
-                        connection_id,
-                        request.payload.clone(),
-                    )
-                });
-            }
+            let room =
+                state.update_project(project_id, &request.payload.worktrees, request.sender_id)?;
+            broadcast(request.sender_id, guest_connection_ids, |connection_id| {
+                self.peer
+                    .forward_send(request.sender_id, connection_id, request.payload.clone())
+            });
+            self.room_updated(room);
         };
 
-        self.update_user_contacts(user_id).await?;
         Ok(())
     }
 
@@ -1027,32 +1089,21 @@ impl Server {
     ) -> Result<()> {
         let project_id = ProjectId::from_proto(request.payload.project_id);
         let worktree_id = request.payload.worktree_id;
-        let (connection_ids, metadata_changed) = {
-            let mut store = self.store().await;
-            let (connection_ids, metadata_changed) = store.update_worktree(
-                request.sender_id,
-                project_id,
-                worktree_id,
-                &request.payload.root_name,
-                &request.payload.removed_entries,
-                &request.payload.updated_entries,
-                request.payload.scan_id,
-                request.payload.is_last_update,
-            )?;
-            (connection_ids, metadata_changed)
-        };
+        let connection_ids = self.store().await.update_worktree(
+            request.sender_id,
+            project_id,
+            worktree_id,
+            &request.payload.root_name,
+            &request.payload.removed_entries,
+            &request.payload.updated_entries,
+            request.payload.scan_id,
+            request.payload.is_last_update,
+        )?;
 
         broadcast(request.sender_id, connection_ids, |connection_id| {
             self.peer
                 .forward_send(request.sender_id, connection_id, request.payload.clone())
         });
-        if metadata_changed {
-            let user_id = self
-                .store()
-                .await
-                .user_id_for_connection(request.sender_id)?;
-            self.update_user_contacts(user_id).await?;
-        }
         response.send(proto::Ack {})?;
         Ok(())
     }

crates/collab/src/rpc/store.rs 🔗

@@ -1,38 +1,55 @@
 use crate::db::{self, ChannelId, ProjectId, UserId};
 use anyhow::{anyhow, Result};
-use collections::{btree_map, hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet};
-use rpc::{proto, ConnectionId, Receipt};
+use collections::{btree_map, BTreeMap, BTreeSet, HashMap, HashSet};
+use rpc::{proto, ConnectionId};
 use serde::Serialize;
 use std::{mem, path::PathBuf, str, time::Duration};
 use time::OffsetDateTime;
 use tracing::instrument;
+use util::post_inc;
+
+pub type RoomId = u64;
 
 #[derive(Default, Serialize)]
 pub struct Store {
     connections: BTreeMap<ConnectionId, ConnectionState>,
-    connections_by_user_id: BTreeMap<UserId, HashSet<ConnectionId>>,
+    connected_users: BTreeMap<UserId, ConnectedUser>,
+    next_room_id: RoomId,
+    rooms: BTreeMap<RoomId, proto::Room>,
     projects: BTreeMap<ProjectId, Project>,
     #[serde(skip)]
     channels: BTreeMap<ChannelId, Channel>,
 }
 
+#[derive(Default, Serialize)]
+struct ConnectedUser {
+    connection_ids: HashSet<ConnectionId>,
+    active_call: Option<Call>,
+}
+
 #[derive(Serialize)]
 struct ConnectionState {
     user_id: UserId,
     admin: bool,
     projects: BTreeSet<ProjectId>,
-    requested_projects: HashSet<ProjectId>,
     channels: HashSet<ChannelId>,
 }
 
+#[derive(Copy, Clone, Eq, PartialEq, Serialize)]
+pub struct Call {
+    pub caller_user_id: UserId,
+    pub room_id: RoomId,
+    pub connection_id: Option<ConnectionId>,
+    pub initial_project_id: Option<ProjectId>,
+}
+
 #[derive(Serialize)]
 pub struct Project {
-    pub online: bool,
+    pub id: ProjectId,
+    pub room_id: RoomId,
     pub host_connection_id: ConnectionId,
     pub host: Collaborator,
     pub guests: HashMap<ConnectionId, Collaborator>,
-    #[serde(skip)]
-    pub join_requests: HashMap<UserId, Vec<Receipt<proto::JoinProject>>>,
     pub active_replica_ids: HashSet<ReplicaId>,
     pub worktrees: BTreeMap<u64, Worktree>,
     pub language_servers: Vec<proto::LanguageServer>,
@@ -69,23 +86,26 @@ pub type ReplicaId = u16;
 #[derive(Default)]
 pub struct RemovedConnectionState {
     pub user_id: UserId,
-    pub hosted_projects: HashMap<ProjectId, Project>,
-    pub guest_project_ids: HashSet<ProjectId>,
+    pub hosted_projects: Vec<Project>,
+    pub guest_projects: Vec<LeftProject>,
     pub contact_ids: HashSet<UserId>,
+    pub room_id: Option<RoomId>,
+    pub canceled_call_connection_ids: Vec<ConnectionId>,
 }
 
 pub struct LeftProject {
+    pub id: ProjectId,
     pub host_user_id: UserId,
     pub host_connection_id: ConnectionId,
     pub connection_ids: Vec<ConnectionId>,
     pub remove_collaborator: bool,
-    pub cancel_request: Option<UserId>,
-    pub unshare: bool,
 }
 
-pub struct UnsharedProject {
-    pub guests: HashMap<ConnectionId, Collaborator>,
-    pub pending_join_requests: HashMap<UserId, Vec<Receipt<proto::JoinProject>>>,
+pub struct LeftRoom<'a> {
+    pub room: Option<&'a proto::Room>,
+    pub unshared_projects: Vec<Project>,
+    pub left_projects: Vec<LeftProject>,
+    pub canceled_call_connection_ids: Vec<ConnectionId>,
 }
 
 #[derive(Copy, Clone)]
@@ -128,21 +148,44 @@ impl Store {
     }
 
     #[instrument(skip(self))]
-    pub fn add_connection(&mut self, connection_id: ConnectionId, user_id: UserId, admin: bool) {
+    pub fn add_connection(
+        &mut self,
+        connection_id: ConnectionId,
+        user_id: UserId,
+        admin: bool,
+    ) -> Option<proto::IncomingCall> {
         self.connections.insert(
             connection_id,
             ConnectionState {
                 user_id,
                 admin,
                 projects: Default::default(),
-                requested_projects: Default::default(),
                 channels: Default::default(),
             },
         );
-        self.connections_by_user_id
-            .entry(user_id)
-            .or_default()
-            .insert(connection_id);
+        let connected_user = self.connected_users.entry(user_id).or_default();
+        connected_user.connection_ids.insert(connection_id);
+        if let Some(active_call) = connected_user.active_call {
+            if active_call.connection_id.is_some() {
+                None
+            } else {
+                let room = self.room(active_call.room_id)?;
+                Some(proto::IncomingCall {
+                    room_id: active_call.room_id,
+                    caller_user_id: active_call.caller_user_id.to_proto(),
+                    participant_user_ids: room
+                        .participants
+                        .iter()
+                        .map(|participant| participant.user_id)
+                        .collect(),
+                    initial_project: active_call
+                        .initial_project_id
+                        .and_then(|id| Self::build_participant_project(id, &self.projects)),
+                })
+            }
+        } else {
+            None
+        }
     }
 
     #[instrument(skip(self))]
@@ -156,7 +199,6 @@ impl Store {
             .ok_or_else(|| anyhow!("no such connection"))?;
 
         let user_id = connection.user_id;
-        let connection_projects = mem::take(&mut connection.projects);
         let connection_channels = mem::take(&mut connection.channels);
 
         let mut result = RemovedConnectionState {
@@ -169,21 +211,21 @@ impl Store {
             self.leave_channel(connection_id, channel_id);
         }
 
-        // Unregister and leave all projects.
-        for project_id in connection_projects {
-            if let Ok(project) = self.unregister_project(project_id, connection_id) {
-                result.hosted_projects.insert(project_id, project);
-            } else if self.leave_project(connection_id, project_id).is_ok() {
-                result.guest_project_ids.insert(project_id);
-            }
+        let connected_user = self.connected_users.get(&user_id).unwrap();
+        if let Some(active_call) = connected_user.active_call.as_ref() {
+            let room_id = active_call.room_id;
+            let left_room = self.leave_room(room_id, connection_id)?;
+            result.hosted_projects = left_room.unshared_projects;
+            result.guest_projects = left_room.left_projects;
+            result.room_id = Some(room_id);
+            result.canceled_call_connection_ids = left_room.canceled_call_connection_ids;
         }
 
-        let user_connections = self.connections_by_user_id.get_mut(&user_id).unwrap();
-        user_connections.remove(&connection_id);
-        if user_connections.is_empty() {
-            self.connections_by_user_id.remove(&user_id);
+        let connected_user = self.connected_users.get_mut(&user_id).unwrap();
+        connected_user.connection_ids.remove(&connection_id);
+        if connected_user.connection_ids.is_empty() {
+            self.connected_users.remove(&user_id);
         }
-
         self.connections.remove(&connection_id).unwrap();
 
         Ok(result)
@@ -229,21 +271,31 @@ impl Store {
         &self,
         user_id: UserId,
     ) -> impl Iterator<Item = ConnectionId> + '_ {
-        self.connections_by_user_id
+        self.connected_users
             .get(&user_id)
             .into_iter()
+            .map(|state| &state.connection_ids)
             .flatten()
             .copied()
     }
 
     pub fn is_user_online(&self, user_id: UserId) -> bool {
         !self
-            .connections_by_user_id
+            .connected_users
             .get(&user_id)
             .unwrap_or(&Default::default())
+            .connection_ids
             .is_empty()
     }
 
+    fn is_user_busy(&self, user_id: UserId) -> bool {
+        self.connected_users
+            .get(&user_id)
+            .unwrap_or(&Default::default())
+            .active_call
+            .is_some()
+    }
+
     pub fn build_initial_contacts_update(
         &self,
         contacts: Vec<db::Contact>,
@@ -281,61 +333,407 @@ impl Store {
     pub fn contact_for_user(&self, user_id: UserId, should_notify: bool) -> proto::Contact {
         proto::Contact {
             user_id: user_id.to_proto(),
-            projects: self.project_metadata_for_user(user_id),
             online: self.is_user_online(user_id),
+            busy: self.is_user_busy(user_id),
             should_notify,
         }
     }
 
-    pub fn project_metadata_for_user(&self, user_id: UserId) -> Vec<proto::ProjectMetadata> {
-        let connection_ids = self.connections_by_user_id.get(&user_id);
-        let project_ids = connection_ids.iter().flat_map(|connection_ids| {
-            connection_ids
-                .iter()
-                .filter_map(|connection_id| self.connections.get(connection_id))
-                .flat_map(|connection| connection.projects.iter().copied())
+    pub fn create_room(&mut self, creator_connection_id: ConnectionId) -> Result<RoomId> {
+        let connection = self
+            .connections
+            .get_mut(&creator_connection_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        let connected_user = self
+            .connected_users
+            .get_mut(&connection.user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        anyhow::ensure!(
+            connected_user.active_call.is_none(),
+            "can't create a room with an active call"
+        );
+
+        let mut room = proto::Room::default();
+        room.participants.push(proto::Participant {
+            user_id: connection.user_id.to_proto(),
+            peer_id: creator_connection_id.0,
+            projects: Default::default(),
+            location: Some(proto::ParticipantLocation {
+                variant: Some(proto::participant_location::Variant::External(
+                    proto::participant_location::External {},
+                )),
+            }),
         });
 
-        let mut metadata = Vec::new();
-        for project_id in project_ids {
-            if let Some(project) = self.projects.get(&project_id) {
-                if project.host.user_id == user_id && project.online {
-                    metadata.push(proto::ProjectMetadata {
-                        id: project_id.to_proto(),
-                        visible_worktree_root_names: project
-                            .worktrees
-                            .values()
-                            .filter(|worktree| worktree.visible)
-                            .map(|worktree| worktree.root_name.clone())
-                            .collect(),
-                        guests: project
-                            .guests
-                            .values()
-                            .map(|guest| guest.user_id.to_proto())
-                            .collect(),
-                    });
-                }
+        let room_id = post_inc(&mut self.next_room_id);
+        self.rooms.insert(room_id, room);
+        connected_user.active_call = Some(Call {
+            caller_user_id: connection.user_id,
+            room_id,
+            connection_id: Some(creator_connection_id),
+            initial_project_id: None,
+        });
+        Ok(room_id)
+    }
+
+    pub fn join_room(
+        &mut self,
+        room_id: RoomId,
+        connection_id: ConnectionId,
+    ) -> Result<(&proto::Room, Vec<ConnectionId>)> {
+        let connection = self
+            .connections
+            .get_mut(&connection_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        let user_id = connection.user_id;
+        let recipient_connection_ids = self.connection_ids_for_user(user_id).collect::<Vec<_>>();
+
+        let connected_user = self
+            .connected_users
+            .get_mut(&user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        let active_call = connected_user
+            .active_call
+            .as_mut()
+            .ok_or_else(|| anyhow!("not being called"))?;
+        anyhow::ensure!(
+            active_call.room_id == room_id && active_call.connection_id.is_none(),
+            "not being called on this room"
+        );
+
+        let room = self
+            .rooms
+            .get_mut(&room_id)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        anyhow::ensure!(
+            room.pending_participant_user_ids
+                .contains(&user_id.to_proto()),
+            anyhow!("no such room")
+        );
+        room.pending_participant_user_ids
+            .retain(|pending| *pending != user_id.to_proto());
+        room.participants.push(proto::Participant {
+            user_id: user_id.to_proto(),
+            peer_id: connection_id.0,
+            projects: Default::default(),
+            location: Some(proto::ParticipantLocation {
+                variant: Some(proto::participant_location::Variant::External(
+                    proto::participant_location::External {},
+                )),
+            }),
+        });
+        active_call.connection_id = Some(connection_id);
+
+        Ok((room, recipient_connection_ids))
+    }
+
+    pub fn leave_room(&mut self, room_id: RoomId, connection_id: ConnectionId) -> Result<LeftRoom> {
+        let connection = self
+            .connections
+            .get_mut(&connection_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        let user_id = connection.user_id;
+
+        let connected_user = self
+            .connected_users
+            .get(&user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        anyhow::ensure!(
+            connected_user
+                .active_call
+                .map_or(false, |call| call.room_id == room_id
+                    && call.connection_id == Some(connection_id)),
+            "cannot leave a room before joining it"
+        );
+
+        // Given that users can only join one room at a time, we can safely unshare
+        // and leave all projects associated with the connection.
+        let mut unshared_projects = Vec::new();
+        let mut left_projects = Vec::new();
+        for project_id in connection.projects.clone() {
+            if let Ok((_, project)) = self.unshare_project(project_id, connection_id) {
+                unshared_projects.push(project);
+            } else if let Ok(project) = self.leave_project(project_id, connection_id) {
+                left_projects.push(project);
             }
         }
+        self.connected_users.get_mut(&user_id).unwrap().active_call = None;
+
+        let room = self
+            .rooms
+            .get_mut(&room_id)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        room.participants
+            .retain(|participant| participant.peer_id != connection_id.0);
+
+        let mut canceled_call_connection_ids = Vec::new();
+        room.pending_participant_user_ids
+            .retain(|pending_participant_user_id| {
+                if let Some(connected_user) = self
+                    .connected_users
+                    .get_mut(&UserId::from_proto(*pending_participant_user_id))
+                {
+                    if let Some(call) = connected_user.active_call.as_ref() {
+                        if call.caller_user_id == user_id {
+                            connected_user.active_call.take();
+                            canceled_call_connection_ids
+                                .extend(connected_user.connection_ids.iter().copied());
+                            false
+                        } else {
+                            true
+                        }
+                    } else {
+                        true
+                    }
+                } else {
+                    true
+                }
+            });
+
+        if room.participants.is_empty() && room.pending_participant_user_ids.is_empty() {
+            self.rooms.remove(&room_id);
+        }
+
+        Ok(LeftRoom {
+            room: self.rooms.get(&room_id),
+            unshared_projects,
+            left_projects,
+            canceled_call_connection_ids,
+        })
+    }
 
-        metadata
+    pub fn room(&self, room_id: RoomId) -> Option<&proto::Room> {
+        self.rooms.get(&room_id)
     }
 
-    pub fn register_project(
+    pub fn call(
         &mut self,
-        host_connection_id: ConnectionId,
+        room_id: RoomId,
+        recipient_user_id: UserId,
+        initial_project_id: Option<ProjectId>,
+        from_connection_id: ConnectionId,
+    ) -> Result<(&proto::Room, Vec<ConnectionId>, proto::IncomingCall)> {
+        let caller_user_id = self.user_id_for_connection(from_connection_id)?;
+
+        let recipient_connection_ids = self
+            .connection_ids_for_user(recipient_user_id)
+            .collect::<Vec<_>>();
+        let mut recipient = self
+            .connected_users
+            .get_mut(&recipient_user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        anyhow::ensure!(
+            recipient.active_call.is_none(),
+            "recipient is already on another call"
+        );
+
+        let room = self
+            .rooms
+            .get_mut(&room_id)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        anyhow::ensure!(
+            room.participants
+                .iter()
+                .any(|participant| participant.peer_id == from_connection_id.0),
+            "no such room"
+        );
+        anyhow::ensure!(
+            room.pending_participant_user_ids
+                .iter()
+                .all(|user_id| UserId::from_proto(*user_id) != recipient_user_id),
+            "cannot call the same user more than once"
+        );
+        room.pending_participant_user_ids
+            .push(recipient_user_id.to_proto());
+
+        if let Some(initial_project_id) = initial_project_id {
+            let project = self
+                .projects
+                .get(&initial_project_id)
+                .ok_or_else(|| anyhow!("no such project"))?;
+            anyhow::ensure!(project.room_id == room_id, "no such project");
+        }
+
+        recipient.active_call = Some(Call {
+            caller_user_id,
+            room_id,
+            connection_id: None,
+            initial_project_id,
+        });
+
+        Ok((
+            room,
+            recipient_connection_ids,
+            proto::IncomingCall {
+                room_id,
+                caller_user_id: caller_user_id.to_proto(),
+                participant_user_ids: room
+                    .participants
+                    .iter()
+                    .map(|participant| participant.user_id)
+                    .collect(),
+                initial_project: initial_project_id
+                    .and_then(|id| Self::build_participant_project(id, &self.projects)),
+            },
+        ))
+    }
+
+    pub fn call_failed(&mut self, room_id: RoomId, to_user_id: UserId) -> Result<&proto::Room> {
+        let mut recipient = self
+            .connected_users
+            .get_mut(&to_user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        anyhow::ensure!(recipient
+            .active_call
+            .map_or(false, |call| call.room_id == room_id
+                && call.connection_id.is_none()));
+        recipient.active_call = None;
+        let room = self
+            .rooms
+            .get_mut(&room_id)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        room.pending_participant_user_ids
+            .retain(|user_id| UserId::from_proto(*user_id) != to_user_id);
+        Ok(room)
+    }
+
+    pub fn cancel_call(
+        &mut self,
+        room_id: RoomId,
+        recipient_user_id: UserId,
+        canceller_connection_id: ConnectionId,
+    ) -> Result<(&proto::Room, HashSet<ConnectionId>)> {
+        let canceller_user_id = self.user_id_for_connection(canceller_connection_id)?;
+        let canceller = self
+            .connected_users
+            .get(&canceller_user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        let recipient = self
+            .connected_users
+            .get(&recipient_user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        let canceller_active_call = canceller
+            .active_call
+            .as_ref()
+            .ok_or_else(|| anyhow!("no active call"))?;
+        let recipient_active_call = recipient
+            .active_call
+            .as_ref()
+            .ok_or_else(|| anyhow!("no active call for recipient"))?;
+
+        anyhow::ensure!(
+            canceller_active_call.room_id == room_id,
+            "users are on different calls"
+        );
+        anyhow::ensure!(
+            recipient_active_call.room_id == room_id,
+            "users are on different calls"
+        );
+        anyhow::ensure!(
+            recipient_active_call.connection_id.is_none(),
+            "recipient has already answered"
+        );
+        let room_id = recipient_active_call.room_id;
+        let room = self
+            .rooms
+            .get_mut(&room_id)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        room.pending_participant_user_ids
+            .retain(|user_id| UserId::from_proto(*user_id) != recipient_user_id);
+
+        let recipient = self.connected_users.get_mut(&recipient_user_id).unwrap();
+        recipient.active_call.take();
+
+        Ok((room, recipient.connection_ids.clone()))
+    }
+
+    pub fn decline_call(
+        &mut self,
+        room_id: RoomId,
+        recipient_connection_id: ConnectionId,
+    ) -> Result<(&proto::Room, Vec<ConnectionId>)> {
+        let recipient_user_id = self.user_id_for_connection(recipient_connection_id)?;
+        let recipient = self
+            .connected_users
+            .get_mut(&recipient_user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        if let Some(active_call) = recipient.active_call.take() {
+            anyhow::ensure!(active_call.room_id == room_id, "no such room");
+            let recipient_connection_ids = self
+                .connection_ids_for_user(recipient_user_id)
+                .collect::<Vec<_>>();
+            let room = self
+                .rooms
+                .get_mut(&active_call.room_id)
+                .ok_or_else(|| anyhow!("no such room"))?;
+            room.pending_participant_user_ids
+                .retain(|user_id| UserId::from_proto(*user_id) != recipient_user_id);
+            Ok((room, recipient_connection_ids))
+        } else {
+            Err(anyhow!("user is not being called"))
+        }
+    }
+
+    pub fn update_participant_location(
+        &mut self,
+        room_id: RoomId,
+        location: proto::ParticipantLocation,
+        connection_id: ConnectionId,
+    ) -> Result<&proto::Room> {
+        let room = self
+            .rooms
+            .get_mut(&room_id)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        if let Some(proto::participant_location::Variant::SharedProject(project)) =
+            location.variant.as_ref()
+        {
+            anyhow::ensure!(
+                room.participants
+                    .iter()
+                    .flat_map(|participant| &participant.projects)
+                    .any(|participant_project| participant_project.id == project.id),
+                "no such project"
+            );
+        }
+
+        let participant = room
+            .participants
+            .iter_mut()
+            .find(|participant| participant.peer_id == connection_id.0)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        participant.location = Some(location);
+
+        Ok(room)
+    }
+
+    pub fn share_project(
+        &mut self,
+        room_id: RoomId,
         project_id: ProjectId,
-        online: bool,
-    ) -> Result<()> {
+        worktrees: Vec<proto::WorktreeMetadata>,
+        host_connection_id: ConnectionId,
+    ) -> Result<&proto::Room> {
         let connection = self
             .connections
             .get_mut(&host_connection_id)
             .ok_or_else(|| anyhow!("no such connection"))?;
+
+        let room = self
+            .rooms
+            .get_mut(&room_id)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        let participant = room
+            .participants
+            .iter_mut()
+            .find(|participant| participant.peer_id == host_connection_id.0)
+            .ok_or_else(|| anyhow!("no such room"))?;
+
         connection.projects.insert(project_id);
         self.projects.insert(
             project_id,
             Project {
-                online,
+                id: project_id,
+                room_id,
                 host_connection_id,
                 host: Collaborator {
                     user_id: connection.user_id,
@@ -344,22 +742,79 @@ impl Store {
                     admin: connection.admin,
                 },
                 guests: Default::default(),
-                join_requests: Default::default(),
                 active_replica_ids: Default::default(),
-                worktrees: Default::default(),
+                worktrees: worktrees
+                    .into_iter()
+                    .map(|worktree| {
+                        (
+                            worktree.id,
+                            Worktree {
+                                root_name: worktree.root_name,
+                                visible: worktree.visible,
+                                ..Default::default()
+                            },
+                        )
+                    })
+                    .collect(),
                 language_servers: Default::default(),
             },
         );
-        Ok(())
+
+        participant
+            .projects
+            .extend(Self::build_participant_project(project_id, &self.projects));
+
+        Ok(room)
+    }
+
+    pub fn unshare_project(
+        &mut self,
+        project_id: ProjectId,
+        connection_id: ConnectionId,
+    ) -> Result<(&proto::Room, Project)> {
+        match self.projects.entry(project_id) {
+            btree_map::Entry::Occupied(e) => {
+                if e.get().host_connection_id == connection_id {
+                    let project = e.remove();
+
+                    if let Some(host_connection) = self.connections.get_mut(&connection_id) {
+                        host_connection.projects.remove(&project_id);
+                    }
+
+                    for guest_connection in project.guests.keys() {
+                        if let Some(connection) = self.connections.get_mut(guest_connection) {
+                            connection.projects.remove(&project_id);
+                        }
+                    }
+
+                    let room = self
+                        .rooms
+                        .get_mut(&project.room_id)
+                        .ok_or_else(|| anyhow!("no such room"))?;
+                    let participant = room
+                        .participants
+                        .iter_mut()
+                        .find(|participant| participant.peer_id == connection_id.0)
+                        .ok_or_else(|| anyhow!("no such room"))?;
+                    participant
+                        .projects
+                        .retain(|project| project.id != project_id.to_proto());
+
+                    Ok((room, project))
+                } else {
+                    Err(anyhow!("no such project"))?
+                }
+            }
+            btree_map::Entry::Vacant(_) => Err(anyhow!("no such project"))?,
+        }
     }
 
     pub fn update_project(
         &mut self,
         project_id: ProjectId,
         worktrees: &[proto::WorktreeMetadata],
-        online: bool,
         connection_id: ConnectionId,
-    ) -> Result<Option<UnsharedProject>> {
+    ) -> Result<&proto::Room> {
         let project = self
             .projects
             .get_mut(&project_id)
@@ -381,80 +836,28 @@ impl Store {
                 }
             }
 
-            if online != project.online {
-                project.online = online;
-                if project.online {
-                    Ok(None)
-                } else {
-                    for connection_id in project.guest_connection_ids() {
-                        if let Some(connection) = self.connections.get_mut(&connection_id) {
-                            connection.projects.remove(&project_id);
-                        }
-                    }
-
-                    project.active_replica_ids.clear();
-                    project.language_servers.clear();
-                    for worktree in project.worktrees.values_mut() {
-                        worktree.diagnostic_summaries.clear();
-                        worktree.entries.clear();
-                    }
+            let room = self
+                .rooms
+                .get_mut(&project.room_id)
+                .ok_or_else(|| anyhow!("no such room"))?;
+            let participant_project = room
+                .participants
+                .iter_mut()
+                .flat_map(|participant| &mut participant.projects)
+                .find(|project| project.id == project_id.to_proto())
+                .ok_or_else(|| anyhow!("no such project"))?;
+            participant_project.worktree_root_names = worktrees
+                .iter()
+                .filter(|worktree| worktree.visible)
+                .map(|worktree| worktree.root_name.clone())
+                .collect();
 
-                    Ok(Some(UnsharedProject {
-                        guests: mem::take(&mut project.guests),
-                        pending_join_requests: mem::take(&mut project.join_requests),
-                    }))
-                }
-            } else {
-                Ok(None)
-            }
+            Ok(room)
         } else {
             Err(anyhow!("no such project"))?
         }
     }
 
-    pub fn unregister_project(
-        &mut self,
-        project_id: ProjectId,
-        connection_id: ConnectionId,
-    ) -> Result<Project> {
-        match self.projects.entry(project_id) {
-            btree_map::Entry::Occupied(e) => {
-                if e.get().host_connection_id == connection_id {
-                    let project = e.remove();
-
-                    if let Some(host_connection) = self.connections.get_mut(&connection_id) {
-                        host_connection.projects.remove(&project_id);
-                    }
-
-                    for guest_connection in project.guests.keys() {
-                        if let Some(connection) = self.connections.get_mut(guest_connection) {
-                            connection.projects.remove(&project_id);
-                        }
-                    }
-
-                    for requester_user_id in project.join_requests.keys() {
-                        if let Some(requester_connection_ids) =
-                            self.connections_by_user_id.get_mut(requester_user_id)
-                        {
-                            for requester_connection_id in requester_connection_ids.iter() {
-                                if let Some(requester_connection) =
-                                    self.connections.get_mut(requester_connection_id)
-                                {
-                                    requester_connection.requested_projects.remove(&project_id);
-                                }
-                            }
-                        }
-                    }
-
-                    Ok(project)
-                } else {
-                    Err(anyhow!("no such project"))?
-                }
-            }
-            btree_map::Entry::Vacant(_) => Err(anyhow!("no such project"))?,
-        }
-    }
-
     pub fn update_diagnostic_summary(
         &mut self,
         project_id: ProjectId,
@@ -498,99 +901,56 @@ impl Store {
         Err(anyhow!("no such project"))?
     }
 
-    pub fn request_join_project(
+    pub fn join_project(
         &mut self,
-        requester_id: UserId,
+        requester_connection_id: ConnectionId,
         project_id: ProjectId,
-        receipt: Receipt<proto::JoinProject>,
-    ) -> Result<()> {
+    ) -> Result<(&Project, ReplicaId)> {
         let connection = self
             .connections
-            .get_mut(&receipt.sender_id)
+            .get_mut(&requester_connection_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        let user = self
+            .connected_users
+            .get(&connection.user_id)
             .ok_or_else(|| anyhow!("no such connection"))?;
+        let active_call = user.active_call.ok_or_else(|| anyhow!("no such project"))?;
+        anyhow::ensure!(
+            active_call.connection_id == Some(requester_connection_id),
+            "no such project"
+        );
+
         let project = self
             .projects
             .get_mut(&project_id)
             .ok_or_else(|| anyhow!("no such project"))?;
-        if project.online {
-            connection.requested_projects.insert(project_id);
-            project
-                .join_requests
-                .entry(requester_id)
-                .or_default()
-                .push(receipt);
-            Ok(())
-        } else {
-            Err(anyhow!("no such project"))
-        }
-    }
-
-    pub fn deny_join_project_request(
-        &mut self,
-        responder_connection_id: ConnectionId,
-        requester_id: UserId,
-        project_id: ProjectId,
-    ) -> Option<Vec<Receipt<proto::JoinProject>>> {
-        let project = self.projects.get_mut(&project_id)?;
-        if responder_connection_id != project.host_connection_id {
-            return None;
-        }
-
-        let receipts = project.join_requests.remove(&requester_id)?;
-        for receipt in &receipts {
-            let requester_connection = self.connections.get_mut(&receipt.sender_id)?;
-            requester_connection.requested_projects.remove(&project_id);
-        }
-        project.host.last_activity = Some(OffsetDateTime::now_utc());
+        anyhow::ensure!(project.room_id == active_call.room_id, "no such project");
 
-        Some(receipts)
-    }
-
-    #[allow(clippy::type_complexity)]
-    pub fn accept_join_project_request(
-        &mut self,
-        responder_connection_id: ConnectionId,
-        requester_id: UserId,
-        project_id: ProjectId,
-    ) -> Option<(Vec<(Receipt<proto::JoinProject>, ReplicaId)>, &Project)> {
-        let project = self.projects.get_mut(&project_id)?;
-        if responder_connection_id != project.host_connection_id {
-            return None;
-        }
-
-        let receipts = project.join_requests.remove(&requester_id)?;
-        let mut receipts_with_replica_ids = Vec::new();
-        for receipt in receipts {
-            let requester_connection = self.connections.get_mut(&receipt.sender_id)?;
-            requester_connection.requested_projects.remove(&project_id);
-            requester_connection.projects.insert(project_id);
-            let mut replica_id = 1;
-            while project.active_replica_ids.contains(&replica_id) {
-                replica_id += 1;
-            }
-            project.active_replica_ids.insert(replica_id);
-            project.guests.insert(
-                receipt.sender_id,
-                Collaborator {
-                    replica_id,
-                    user_id: requester_id,
-                    last_activity: Some(OffsetDateTime::now_utc()),
-                    admin: requester_connection.admin,
-                },
-            );
-            receipts_with_replica_ids.push((receipt, replica_id));
+        connection.projects.insert(project_id);
+        let mut replica_id = 1;
+        while project.active_replica_ids.contains(&replica_id) {
+            replica_id += 1;
         }
+        project.active_replica_ids.insert(replica_id);
+        project.guests.insert(
+            requester_connection_id,
+            Collaborator {
+                replica_id,
+                user_id: connection.user_id,
+                last_activity: Some(OffsetDateTime::now_utc()),
+                admin: connection.admin,
+            },
+        );
 
         project.host.last_activity = Some(OffsetDateTime::now_utc());
-        Some((receipts_with_replica_ids, project))
+        Ok((project, replica_id))
     }
 
     pub fn leave_project(
         &mut self,
-        connection_id: ConnectionId,
         project_id: ProjectId,
+        connection_id: ConnectionId,
     ) -> Result<LeftProject> {
-        let user_id = self.user_id_for_connection(connection_id)?;
         let project = self
             .projects
             .get_mut(&project_id)
@@ -604,39 +964,15 @@ impl Store {
             false
         };
 
-        // If the connection leaving the project has a pending request, remove it.
-        // If that user has no other pending requests on other connections, indicate that the request should be cancelled.
-        let mut cancel_request = None;
-        if let Entry::Occupied(mut entry) = project.join_requests.entry(user_id) {
-            entry
-                .get_mut()
-                .retain(|receipt| receipt.sender_id != connection_id);
-            if entry.get().is_empty() {
-                entry.remove();
-                cancel_request = Some(user_id);
-            }
-        }
-
         if let Some(connection) = self.connections.get_mut(&connection_id) {
             connection.projects.remove(&project_id);
         }
 
-        let connection_ids = project.connection_ids();
-        let unshare = connection_ids.len() <= 1 && project.join_requests.is_empty();
-        if unshare {
-            project.language_servers.clear();
-            for worktree in project.worktrees.values_mut() {
-                worktree.diagnostic_summaries.clear();
-                worktree.entries.clear();
-            }
-        }
-
         Ok(LeftProject {
+            id: project.id,
             host_connection_id: project.host_connection_id,
             host_user_id: project.host.user_id,
-            connection_ids,
-            cancel_request,
-            unshare,
+            connection_ids: project.connection_ids(),
             remove_collaborator,
         })
     }

crates/collab_ui/Cargo.toml 🔗

@@ -0,0 +1,53 @@
+[package]
+name = "collab_ui"
+version = "0.1.0"
+edition = "2021"
+
+[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]
+call = { path = "../call" }
+client = { path = "../client" }
+clock = { path = "../clock" }
+collections = { path = "../collections" }
+editor = { path = "../editor" }
+fuzzy = { path = "../fuzzy" }
+gpui = { path = "../gpui" }
+menu = { path = "../menu" }
+picker = { path = "../picker" }
+project = { path = "../project" }
+settings = { path = "../settings" }
+theme = { path = "../theme" }
+util = { path = "../util" }
+workspace = { path = "../workspace" }
+anyhow = "1.0"
+futures = "0.3"
+log = "0.4"
+postage = { version = "0.4.1", features = ["futures-traits"] }
+serde = { version = "1.0", features = ["derive", "rc"] }
+
+[dev-dependencies]
+call = { path = "../call", features = ["test-support"] }
+client = { path = "../client", features = ["test-support"] }
+collections = { path = "../collections", features = ["test-support"] }
+editor = { path = "../editor", features = ["test-support"] }
+gpui = { path = "../gpui", features = ["test-support"] }
+project = { path = "../project", features = ["test-support"] }
+settings = { path = "../settings", features = ["test-support"] }
+util = { path = "../util", features = ["test-support"] }
+workspace = { path = "../workspace", features = ["test-support"] }

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -0,0 +1,566 @@
+use crate::{contact_notification::ContactNotification, contacts_popover};
+use call::{ActiveCall, ParticipantLocation};
+use client::{Authenticate, ContactEventKind, PeerId, User, UserStore};
+use clock::ReplicaId;
+use contacts_popover::ContactsPopover;
+use gpui::{
+    actions,
+    color::Color,
+    elements::*,
+    geometry::{rect::RectF, vector::vec2f, PathBuilder},
+    json::{self, ToJson},
+    Border, CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext,
+    Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
+};
+use settings::Settings;
+use std::ops::Range;
+use theme::Theme;
+use workspace::{FollowNextCollaborator, JoinProject, ToggleFollow, Workspace};
+
+actions!(
+    contacts_titlebar_item,
+    [ToggleContactsPopover, ShareProject]
+);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(CollabTitlebarItem::toggle_contacts_popover);
+    cx.add_action(CollabTitlebarItem::share_project);
+}
+
+pub struct CollabTitlebarItem {
+    workspace: WeakViewHandle<Workspace>,
+    user_store: ModelHandle<UserStore>,
+    contacts_popover: Option<ViewHandle<ContactsPopover>>,
+    _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 RenderContext<Self>) -> ElementBox {
+        let workspace = if let Some(workspace) = self.workspace.upgrade(cx) {
+            workspace
+        } else {
+            return Empty::new().boxed();
+        };
+
+        let theme = cx.global::<Settings>().theme.clone();
+        let project = workspace.read(cx).project().read(cx);
+
+        let mut container = Flex::row();
+        if workspace.read(cx).client().status().borrow().is_connected() {
+            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));
+            }
+        }
+        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()
+    }
+}
+
+impl CollabTitlebarItem {
+    pub fn new(
+        workspace: &ViewHandle<Workspace>,
+        user_store: &ModelHandle<UserStore>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        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_window_activation(|this, active, cx| {
+            this.window_activation_changed(active, cx)
+        }));
+        subscriptions.push(cx.observe(user_store, |_, _, cx| cx.notify()));
+        subscriptions.push(
+            cx.subscribe(user_store, move |this, user_store, event, cx| {
+                if let Some(workspace) = this.workspace.upgrade(cx) {
+                    workspace.update(cx, |workspace, cx| {
+                        if let client::Event::Contact { user, kind } = event {
+                            if let ContactEventKind::Requested | ContactEventKind::Accepted = kind {
+                                workspace.show_notification(user.id as usize, cx, |cx| {
+                                    cx.add_view(|cx| {
+                                        ContactNotification::new(
+                                            user.clone(),
+                                            *kind,
+                                            user_store,
+                                            cx,
+                                        )
+                                    })
+                                })
+                            }
+                        }
+                    });
+                }
+            }),
+        );
+
+        Self {
+            workspace: workspace.downgrade(),
+            user_store: user_store.clone(),
+            contacts_popover: None,
+            _subscriptions: subscriptions,
+        }
+    }
+
+    fn window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
+        let workspace = self.workspace.upgrade(cx);
+        let room = ActiveCall::global(cx).read(cx).room().cloned();
+        if let Some((workspace, room)) = workspace.zip(room) {
+            let workspace = workspace.read(cx);
+            let project = if active {
+                Some(workspace.project().clone())
+            } else {
+                None
+            };
+            room.update(cx, |room, cx| {
+                room.set_location(project.as_ref(), cx)
+                    .detach_and_log_err(cx);
+            });
+        }
+    }
+
+    fn share_project(&mut self, _: &ShareProject, 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.share_project(project, cx))
+                .detach_and_log_err(cx);
+        }
+    }
+
+    fn toggle_contacts_popover(&mut self, _: &ToggleContactsPopover, cx: &mut ViewContext<Self>) {
+        match self.contacts_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));
+                    cx.focus(&view);
+                    cx.subscribe(&view, |this, _, event, cx| {
+                        match event {
+                            contacts_popover::Event::Dismissed => {
+                                this.contacts_popover = None;
+                            }
+                        }
+
+                        cx.notify();
+                    })
+                    .detach();
+                    self.contacts_popover = Some(view);
+                }
+            }
+        }
+        cx.notify();
+    }
+
+    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)
+            .incoming_contact_requests()
+            .is_empty()
+        {
+            None
+        } else {
+            Some(
+                Empty::new()
+                    .collapsed()
+                    .contained()
+                    .with_style(titlebar.toggle_contacts_badge)
+                    .contained()
+                    .with_margin_left(titlebar.toggle_contacts_button.default.icon_width)
+                    .with_margin_top(titlebar.toggle_contacts_button.default.icon_width)
+                    .aligned()
+                    .boxed(),
+            )
+        };
+        Stack::new()
+            .with_child(
+                MouseEventHandler::<ToggleContactsPopover>::new(0, cx, |state, _| {
+                    let style = titlebar
+                        .toggle_contacts_button
+                        .style_for(state, self.contacts_popover.is_some());
+                    Svg::new("icons/plus_8.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(ToggleContactsPopover);
+                })
+                .aligned()
+                .boxed(),
+            )
+            .with_children(badge)
+            .with_children(self.contacts_popover.as_ref().map(|popover| {
+                Overlay::new(
+                    ChildView::new(popover)
+                        .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)
+                .boxed()
+            }))
+            .boxed()
+    }
+
+    fn render_share_button(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
+        enum Share {}
+
+        let titlebar = &theme.workspace.titlebar;
+        MouseEventHandler::<Share>::new(0, cx, |state, _| {
+            let style = titlebar.share_button.style_for(state, false);
+            Label::new("Share".into(), style.text.clone())
+                .contained()
+                .with_style(style.container)
+                .boxed()
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(ShareProject))
+        .with_tooltip::<Share, _>(
+            0,
+            "Share project with call participants".into(),
+            None,
+            theme.tooltip.clone(),
+            cx,
+        )
+        .aligned()
+        .boxed()
+    }
+
+    fn render_collaborators(
+        &self,
+        workspace: &ViewHandle<Workspace>,
+        theme: &Theme,
+        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()
+                .iter()
+                .map(|(peer_id, collaborator)| (*peer_id, collaborator.clone()))
+                .collect::<Vec<_>>();
+            participants
+                .sort_by_key(|(peer_id, _)| Some(project.collaborators().get(peer_id)?.replica_id));
+            participants
+                .into_iter()
+                .filter_map(|(peer_id, participant)| {
+                    let project = workspace.read(cx).project().read(cx);
+                    let replica_id = project
+                        .collaborators()
+                        .get(&peer_id)
+                        .map(|collaborator| collaborator.replica_id);
+                    let user = participant.user.clone();
+                    Some(self.render_avatar(
+                        &user,
+                        replica_id,
+                        Some((peer_id, &user.github_login, participant.location)),
+                        workspace,
+                        theme,
+                        cx,
+                    ))
+                })
+                .collect()
+        } else {
+            Default::default()
+        }
+    }
+
+    fn render_current_user(
+        &self,
+        workspace: &ViewHandle<Workspace>,
+        theme: &Theme,
+        cx: &mut RenderContext<Self>,
+    ) -> Option<ElementBox> {
+        let user = workspace.read(cx).user_store().read(cx).current_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".to_string(), 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(),
+            )
+        }
+    }
+
+    fn render_avatar(
+        &self,
+        user: &User,
+        replica_id: Option<ReplicaId>,
+        peer: Option<(PeerId, &str, 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 mut avatar_style;
+        if let Some((_, _, location)) = peer.as_ref() {
+            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;
+                } else {
+                    avatar_style = theme.workspace.titlebar.inactive_avatar;
+                }
+            } else {
+                avatar_style = theme.workspace.titlebar.inactive_avatar;
+            }
+        } else {
+            avatar_style = theme.workspace.titlebar.avatar;
+        }
+
+        let mut replica_color = None;
+        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);
+            }
+        }
+
+        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()
+            }))
+            .constrained()
+            .with_width(theme.workspace.titlebar.avatar_width)
+            .contained()
+            .with_margin_left(theme.workspace.titlebar.avatar_margin)
+            .boxed();
+
+        if let Some((peer_id, peer_github_login, location)) = peer {
+            if let Some(replica_id) = replica_id {
+                MouseEventHandler::<ToggleFollow>::new(replica_id.into(), cx, move |_, _| content)
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .on_click(MouseButton::Left, move |_, cx| {
+                        cx.dispatch_action(ToggleFollow(peer_id))
+                    })
+                    .with_tooltip::<ToggleFollow, _>(
+                        peer_id.0 as usize,
+                        if is_followed {
+                            format!("Unfollow {}", peer_github_login)
+                        } else {
+                            format!("Follow {}", peer_github_login)
+                        },
+                        Some(Box::new(FollowNextCollaborator)),
+                        theme.tooltip.clone(),
+                        cx,
+                    )
+                    .boxed()
+            } else if let ParticipantLocation::SharedProject { project_id } = location {
+                let user_id = user.id;
+                MouseEventHandler::<JoinProject>::new(peer_id.0 as usize, cx, move |_, _| content)
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .on_click(MouseButton::Left, move |_, cx| {
+                        cx.dispatch_action(JoinProject {
+                            project_id,
+                            follow_user_id: user_id,
+                        })
+                    })
+                    .with_tooltip::<JoinProject, _>(
+                        peer_id.0 as usize,
+                        format!("Follow {} into external project", peer_github_login),
+                        Some(Box::new(FollowNextCollaborator)),
+                        theme.tooltip.clone(),
+                        cx,
+                    )
+                    .boxed()
+            } else {
+                content
+            }
+        } else {
+            content
+        }
+    }
+
+    fn render_connection_status(
+        &self,
+        workspace: &ViewHandle<Workspace>,
+        cx: &mut RenderContext<Self>,
+    ) -> Option<ElementBox> {
+        let theme = &cx.global::<Settings>().theme;
+        match &*workspace.read(cx).client().status().borrow() {
+            client::Status::ConnectionError
+            | client::Status::ConnectionLost
+            | client::Status::Reauthenticating { .. }
+            | client::Status::Reconnecting { .. }
+            | client::Status::ReconnectionError { .. } => Some(
+                Container::new(
+                    Align::new(
+                        ConstrainedBox::new(
+                            Svg::new("icons/cloud_slash_12.svg")
+                                .with_color(theme.workspace.titlebar.offline_icon.color)
+                                .boxed(),
+                        )
+                        .with_width(theme.workspace.titlebar.offline_icon.width)
+                        .boxed(),
+                    )
+                    .boxed(),
+                )
+                .with_style(theme.workspace.titlebar.offline_icon.container)
+                .boxed(),
+            ),
+            client::Status::UpgradeRequired => Some(
+                Label::new(
+                    "Please update Zed to collaborate".to_string(),
+                    theme.workspace.titlebar.outdated_warning.text.clone(),
+                )
+                .contained()
+                .with_style(theme.workspace.titlebar.outdated_warning.container)
+                .aligned()
+                .boxed(),
+            ),
+            _ => None,
+        }
+    }
+}
+
+pub struct AvatarRibbon {
+    color: Color,
+}
+
+impl AvatarRibbon {
+    pub fn new(color: Color) -> AvatarRibbon {
+        AvatarRibbon { color }
+    }
+}
+
+impl Element for AvatarRibbon {
+    type LayoutState = ();
+
+    type PaintState = ();
+
+    fn layout(
+        &mut self,
+        constraint: gpui::SizeConstraint,
+        _: &mut gpui::LayoutContext,
+    ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
+        (constraint.max, ())
+    }
+
+    fn paint(
+        &mut self,
+        bounds: gpui::geometry::rect::RectF,
+        _: gpui::geometry::rect::RectF,
+        _: &mut Self::LayoutState,
+        cx: &mut gpui::PaintContext,
+    ) -> 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 dispatch_event(
+        &mut self,
+        _: &gpui::Event,
+        _: RectF,
+        _: RectF,
+        _: &mut Self::LayoutState,
+        _: &mut Self::PaintState,
+        _: &mut gpui::EventContext,
+    ) -> bool {
+        false
+    }
+
+    fn rect_for_text_range(
+        &self,
+        _: Range<usize>,
+        _: RectF,
+        _: RectF,
+        _: &Self::LayoutState,
+        _: &Self::PaintState,
+        _: &gpui::MeasurementContext,
+    ) -> Option<RectF> {
+        None
+    }
+
+    fn debug(
+        &self,
+        bounds: gpui::geometry::rect::RectF,
+        _: &Self::LayoutState,
+        _: &Self::PaintState,
+        _: &gpui::DebugContext,
+    ) -> gpui::json::Value {
+        json::json!({
+            "type": "AvatarRibbon",
+            "bounds": bounds.to_json(),
+            "color": self.color.to_json(),
+        })
+    }
+}

crates/collab_ui/src/collab_ui.rs 🔗

@@ -0,0 +1,97 @@
+mod collab_titlebar_item;
+mod contact_finder;
+mod contact_list;
+mod contact_notification;
+mod contacts_popover;
+mod incoming_call_notification;
+mod notifications;
+mod project_shared_notification;
+
+use call::ActiveCall;
+pub use collab_titlebar_item::CollabTitlebarItem;
+use gpui::MutableAppContext;
+use project::Project;
+use std::sync::Arc;
+use workspace::{AppState, JoinProject, ToggleFollow, Workspace};
+
+pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
+    collab_titlebar_item::init(cx);
+    contact_notification::init(cx);
+    contact_list::init(cx);
+    contact_finder::init(cx);
+    contacts_popover::init(cx);
+    incoming_call_notification::init(cx);
+    project_shared_notification::init(cx);
+
+    cx.add_global_action(move |action: &JoinProject, cx| {
+        let project_id = action.project_id;
+        let follow_user_id = action.follow_user_id;
+        let app_state = app_state.clone();
+        cx.spawn(|mut cx| async move {
+            let existing_workspace = cx.update(|cx| {
+                cx.window_ids()
+                    .filter_map(|window_id| cx.root_view::<Workspace>(window_id))
+                    .find(|workspace| {
+                        workspace.read(cx).project().read(cx).remote_id() == Some(project_id)
+                    })
+            });
+
+            let workspace = if let Some(existing_workspace) = existing_workspace {
+                existing_workspace
+            } else {
+                let project = Project::remote(
+                    project_id,
+                    app_state.client.clone(),
+                    app_state.user_store.clone(),
+                    app_state.project_store.clone(),
+                    app_state.languages.clone(),
+                    app_state.fs.clone(),
+                    cx.clone(),
+                )
+                .await?;
+
+                let (_, workspace) = cx.add_window((app_state.build_window_options)(), |cx| {
+                    let mut workspace = Workspace::new(project, app_state.default_item_factory, cx);
+                    (app_state.initialize_workspace)(&mut workspace, &app_state, cx);
+                    workspace
+                });
+                workspace
+            };
+
+            cx.activate_window(workspace.window_id());
+            cx.platform().activate(true);
+
+            workspace.update(&mut cx, |workspace, cx| {
+                if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
+                    let follow_peer_id = room
+                        .read(cx)
+                        .remote_participants()
+                        .iter()
+                        .find(|(_, participant)| participant.user.id == follow_user_id)
+                        .map(|(peer_id, _)| *peer_id)
+                        .or_else(|| {
+                            // If we couldn't follow the given user, follow the host instead.
+                            let collaborator = workspace
+                                .project()
+                                .read(cx)
+                                .collaborators()
+                                .values()
+                                .find(|collaborator| collaborator.replica_id == 0)?;
+                            Some(collaborator.peer_id)
+                        });
+
+                    if let Some(follow_peer_id) = follow_peer_id {
+                        if !workspace.is_following(follow_peer_id) {
+                            workspace
+                                .toggle_follow(&ToggleFollow(follow_peer_id), cx)
+                                .map(|follow| follow.detach_and_log_err(cx));
+                        }
+                    }
+                }
+            });
+
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
+    });
+}

crates/contacts_panel/src/contact_finder.rs → crates/collab_ui/src/contact_finder.rs 🔗

@@ -1,21 +1,15 @@
 use client::{ContactRequestStatus, User, UserStore};
 use gpui::{
-    actions, elements::*, AnyViewHandle, Entity, ModelHandle, MouseState, MutableAppContext,
-    RenderContext, Task, View, ViewContext, ViewHandle,
+    elements::*, AnyViewHandle, Entity, ModelHandle, MouseState, MutableAppContext, RenderContext,
+    Task, View, ViewContext, ViewHandle,
 };
 use picker::{Picker, PickerDelegate};
 use settings::Settings;
 use std::sync::Arc;
 use util::TryFutureExt;
-use workspace::Workspace;
-
-use crate::render_icon_button;
-
-actions!(contact_finder, [Toggle]);
 
 pub fn init(cx: &mut MutableAppContext) {
     Picker::<ContactFinder>::init(cx);
-    cx.add_action(ContactFinder::toggle);
 }
 
 pub struct ContactFinder {
@@ -117,18 +111,21 @@ impl PickerDelegate for ContactFinder {
 
         let icon_path = match request_status {
             ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
-                "icons/check_8.svg"
-            }
-            ContactRequestStatus::RequestSent | ContactRequestStatus::RequestAccepted => {
-                "icons/x_mark_8.svg"
+                Some("icons/check_8.svg")
             }
+            ContactRequestStatus::RequestSent => Some("icons/x_mark_8.svg"),
+            ContactRequestStatus::RequestAccepted => None,
         };
         let button_style = if self.user_store.read(cx).is_contact_request_pending(user) {
             &theme.contact_finder.disabled_contact_button
         } else {
             &theme.contact_finder.contact_button
         };
-        let style = theme.picker.item.style_for(mouse_state, selected);
+        let style = theme
+            .contact_finder
+            .picker
+            .item
+            .style_for(mouse_state, selected);
         Flex::row()
             .with_children(user.avatar.clone().map(|avatar| {
                 Image::new(avatar)
@@ -145,12 +142,21 @@ impl PickerDelegate for ContactFinder {
                     .left()
                     .boxed(),
             )
-            .with_child(
-                render_icon_button(button_style, icon_path)
+            .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()
-                    .boxed(),
-            )
+                    .boxed()
+            }))
             .contained()
             .with_style(style.container)
             .constrained()
@@ -160,34 +166,16 @@ impl PickerDelegate for ContactFinder {
 }
 
 impl ContactFinder {
-    fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
-        workspace.toggle_modal(cx, |workspace, cx| {
-            let finder = cx.add_view(|cx| Self::new(workspace.user_store().clone(), cx));
-            cx.subscribe(&finder, Self::on_event).detach();
-            finder
-        });
-    }
-
     pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
         let this = cx.weak_handle();
         Self {
-            picker: cx.add_view(|cx| Picker::new(this, cx)),
+            picker: cx.add_view(|cx| {
+                Picker::new(this, cx)
+                    .with_theme(|cx| &cx.global::<Settings>().theme.contact_finder.picker)
+            }),
             potential_contacts: Arc::from([]),
             user_store,
             selected_index: 0,
         }
     }
-
-    fn on_event(
-        workspace: &mut Workspace,
-        _: ViewHandle<Self>,
-        event: &Event,
-        cx: &mut ViewContext<Workspace>,
-    ) {
-        match event {
-            Event::Dismissed => {
-                workspace.dismiss_modal(cx);
-            }
-        }
-    }
 }

crates/collab_ui/src/contact_list.rs 🔗

@@ -0,0 +1,1140 @@
+use std::sync::Arc;
+
+use crate::contacts_popover;
+use call::ActiveCall;
+use client::{Contact, PeerId, User, UserStore};
+use editor::{Cancel, Editor};
+use fuzzy::{match_strings, StringMatchCandidate};
+use gpui::{
+    elements::*,
+    geometry::{rect::RectF, vector::vec2f},
+    impl_actions, impl_internal_actions, keymap, AppContext, CursorStyle, Entity, ModelHandle,
+    MouseButton, MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle,
+};
+use menu::{Confirm, SelectNext, SelectPrev};
+use project::Project;
+use serde::Deserialize;
+use settings::Settings;
+use theme::IconButton;
+use util::ResultExt;
+use workspace::JoinProject;
+
+impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]);
+impl_internal_actions!(contact_list, [ToggleExpanded, Call, LeaveCall]);
+
+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::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)]
+struct ToggleExpanded(Section);
+
+#[derive(Clone, PartialEq)]
+struct Call {
+    recipient_user_id: u64,
+    initial_project: Option<ModelHandle<Project>>,
+}
+
+#[derive(Copy, Clone, PartialEq)]
+struct LeaveCall;
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
+enum Section {
+    ActiveCall,
+    Requests,
+    Online,
+    Offline,
+}
+
+#[derive(Clone)]
+enum ContactEntry {
+    Header(Section),
+    CallParticipant {
+        user: Arc<User>,
+        is_pending: bool,
+    },
+    ParticipantProject {
+        project_id: u64,
+        worktree_root_names: Vec<String>,
+        host_user_id: u64,
+        is_host: bool,
+        is_last: bool,
+    },
+    IncomingRequest(Arc<User>),
+    OutgoingRequest(Arc<User>),
+    Contact(Arc<Contact>),
+}
+
+impl PartialEq for ContactEntry {
+    fn eq(&self, other: &Self) -> bool {
+        match self {
+            ContactEntry::Header(section_1) => {
+                if let ContactEntry::Header(section_2) = other {
+                    return section_1 == section_2;
+                }
+            }
+            ContactEntry::CallParticipant { user: user_1, .. } => {
+                if let ContactEntry::CallParticipant { user: user_2, .. } = other {
+                    return user_1.id == user_2.id;
+                }
+            }
+            ContactEntry::ParticipantProject {
+                project_id: project_id_1,
+                ..
+            } => {
+                if let ContactEntry::ParticipantProject {
+                    project_id: project_id_2,
+                    ..
+                } = other
+                {
+                    return project_id_1 == project_id_2;
+                }
+            }
+            ContactEntry::IncomingRequest(user_1) => {
+                if let ContactEntry::IncomingRequest(user_2) = other {
+                    return user_1.id == user_2.id;
+                }
+            }
+            ContactEntry::OutgoingRequest(user_1) => {
+                if let ContactEntry::OutgoingRequest(user_2) = other {
+                    return user_1.id == user_2.id;
+                }
+            }
+            ContactEntry::Contact(contact_1) => {
+                if let ContactEntry::Contact(contact_2) = other {
+                    return contact_1.user.id == contact_2.user.id;
+                }
+            }
+        }
+        false
+    }
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+pub struct RequestContact(pub u64);
+
+#[derive(Clone, Deserialize, PartialEq)]
+pub struct RemoveContact(pub u64);
+
+#[derive(Clone, Deserialize, PartialEq)]
+pub struct RespondToContactRequest {
+    pub user_id: u64,
+    pub accept: bool,
+}
+
+pub enum Event {
+    Dismissed,
+}
+
+pub struct ContactList {
+    entries: Vec<ContactEntry>,
+    match_candidates: Vec<StringMatchCandidate>,
+    list_state: ListState,
+    project: ModelHandle<Project>,
+    user_store: ModelHandle<UserStore>,
+    filter_editor: ViewHandle<Editor>,
+    collapsed_sections: Vec<Section>,
+    selection: Option<usize>,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl ContactList {
+    pub fn new(
+        project: ModelHandle<Project>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let filter_editor = cx.add_view(|cx| {
+            let mut editor = Editor::single_line(
+                Some(|theme| theme.contact_list.user_query_editor.clone()),
+                cx,
+            );
+            editor.set_placeholder_text("Filter 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(cx);
+                if !query.is_empty() {
+                    this.selection = this
+                        .entries
+                        .iter()
+                        .position(|entry| !matches!(entry, ContactEntry::Header(_)));
+                }
+            }
+        })
+        .detach();
+
+        let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
+            let theme = cx.global::<Settings>().theme.clone();
+            let is_selected = this.selection == Some(ix);
+
+            match &this.entries[ix] {
+                ContactEntry::Header(section) => {
+                    let is_collapsed = this.collapsed_sections.contains(section);
+                    Self::render_header(
+                        *section,
+                        &theme.contact_list,
+                        is_selected,
+                        is_collapsed,
+                        cx,
+                    )
+                }
+                ContactEntry::CallParticipant { user, is_pending } => {
+                    Self::render_call_participant(
+                        user,
+                        *is_pending,
+                        is_selected,
+                        &theme.contact_list,
+                    )
+                }
+                ContactEntry::ParticipantProject {
+                    project_id,
+                    worktree_root_names,
+                    host_user_id,
+                    is_host,
+                    is_last,
+                } => Self::render_participant_project(
+                    *project_id,
+                    worktree_root_names,
+                    *host_user_id,
+                    *is_host,
+                    *is_last,
+                    is_selected,
+                    &theme.contact_list,
+                    cx,
+                ),
+                ContactEntry::IncomingRequest(user) => Self::render_contact_request(
+                    user.clone(),
+                    this.user_store.clone(),
+                    &theme.contact_list,
+                    true,
+                    is_selected,
+                    cx,
+                ),
+                ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
+                    user.clone(),
+                    this.user_store.clone(),
+                    &theme.contact_list,
+                    false,
+                    is_selected,
+                    cx,
+                ),
+                ContactEntry::Contact(contact) => Self::render_contact(
+                    contact,
+                    &this.project,
+                    &theme.contact_list,
+                    is_selected,
+                    cx,
+                ),
+            }
+        });
+
+        let active_call = ActiveCall::global(cx);
+        let mut subscriptions = Vec::new();
+        subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx)));
+        subscriptions.push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
+
+        let mut this = Self {
+            list_state,
+            selection: None,
+            collapsed_sections: Default::default(),
+            entries: Default::default(),
+            match_candidates: Default::default(),
+            filter_editor,
+            _subscriptions: subscriptions,
+            project,
+            user_store,
+        };
+        this.update_entries(cx);
+        this
+    }
+
+    fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
+        self.user_store
+            .update(cx, |store, cx| store.remove_contact(request.0, cx))
+            .detach();
+    }
+
+    fn respond_to_contact_request(
+        &mut self,
+        action: &RespondToContactRequest,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.user_store
+            .update(cx, |store, cx| {
+                store.respond_to_contact_request(action.user_id, action.accept, cx)
+            })
+            .detach();
+    }
+
+    fn clear_filter(&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);
+                true
+            } else {
+                false
+            }
+        });
+        if !did_clear {
+            cx.emit(Event::Dismissed);
+        }
+    }
+
+    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.selection {
+            if self.entries.len() > ix + 1 {
+                self.selection = Some(ix + 1);
+            }
+        } else if !self.entries.is_empty() {
+            self.selection = Some(0);
+        }
+        cx.notify();
+        self.list_state.reset(self.entries.len());
+    }
+
+    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.selection {
+            if ix > 0 {
+                self.selection = Some(ix - 1);
+            } else {
+                self.selection = None;
+            }
+        }
+        cx.notify();
+        self.list_state.reset(self.entries.len());
+    }
+
+    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
+        if let Some(selection) = self.selection {
+            if let Some(entry) = self.entries.get(selection) {
+                match entry {
+                    ContactEntry::Header(section) => {
+                        let section = *section;
+                        self.toggle_expanded(&ToggleExpanded(section), cx);
+                    }
+                    ContactEntry::Contact(contact) => {
+                        if contact.online && !contact.busy {
+                            self.call(
+                                &Call {
+                                    recipient_user_id: contact.user.id,
+                                    initial_project: Some(self.project.clone()),
+                                },
+                                cx,
+                            );
+                        }
+                    }
+                    ContactEntry::ParticipantProject {
+                        project_id,
+                        host_user_id,
+                        is_host,
+                        ..
+                    } => {
+                        if !is_host {
+                            cx.dispatch_global_action(JoinProject {
+                                project_id: *project_id,
+                                follow_user_id: *host_user_id,
+                            });
+                        }
+                    }
+                    _ => {}
+                }
+            }
+        }
+    }
+
+    fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
+        let section = action.0;
+        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(cx);
+    }
+
+    fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
+        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());
+        self.entries.clear();
+
+        if let Some(room) = ActiveCall::global(cx).read(cx).room() {
+            let room = room.read(cx);
+            let mut participant_entries = Vec::new();
+
+            // 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;
+                    participant_entries.push(ContactEntry::CallParticipant {
+                        user,
+                        is_pending: false,
+                    });
+                    let mut projects = room.local_participant().projects.iter().peekable();
+                    while let Some(project) = projects.next() {
+                        participant_entries.push(ContactEntry::ParticipantProject {
+                            project_id: project.id,
+                            worktree_root_names: project.worktree_root_names.clone(),
+                            host_user_id: user_id,
+                            is_host: true,
+                            is_last: projects.peek().is_none(),
+                        });
+                    }
+                }
+            }
+
+            // Populate remote participants.
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    room.remote_participants()
+                        .iter()
+                        .map(|(peer_id, participant)| StringMatchCandidate {
+                            id: peer_id.0 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 participant = &room.remote_participants()[&PeerId(mat.candidate_id as u32)];
+                participant_entries.push(ContactEntry::CallParticipant {
+                    user: room.remote_participants()[&PeerId(mat.candidate_id as u32)]
+                        .user
+                        .clone(),
+                    is_pending: false,
+                });
+                let mut projects = participant.projects.iter().peekable();
+                while let Some(project) = projects.next() {
+                    participant_entries.push(ContactEntry::ParticipantProject {
+                        project_id: project.id,
+                        worktree_root_names: project.worktree_root_names.clone(),
+                        host_user_id: participant.user.id,
+                        is_host: false,
+                        is_last: projects.peek().is_none(),
+                    });
+                }
+            }
+
+            // 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(),
+            ));
+            participant_entries.extend(matches.iter().map(|mat| ContactEntry::CallParticipant {
+                user: room.pending_participants()[mat.candidate_id].clone(),
+                is_pending: true,
+            }));
+
+            if !participant_entries.is_empty() {
+                self.entries.push(ContactEntry::Header(Section::ActiveCall));
+                if !self.collapsed_sections.contains(&Section::ActiveCall) {
+                    self.entries.extend(participant_entries);
+                }
+            }
+        }
+
+        let mut request_entries = Vec::new();
+        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| ContactEntry::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| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
+            );
+        }
+
+        if !request_entries.is_empty() {
+            self.entries.push(ContactEntry::Header(Section::Requests));
+            if !self.collapsed_sections.contains(&Section::Requests) {
+                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 (mut online_contacts, offline_contacts) = matches
+                .iter()
+                .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
+            if let Some(room) = ActiveCall::global(cx).read(cx).room() {
+                let room = room.read(cx);
+                online_contacts.retain(|contact| {
+                    let contact = &contacts[contact.candidate_id];
+                    !room.contains_participant(contact.user.id)
+                });
+            }
+
+            for (matches, section) in [
+                (online_contacts, Section::Online),
+                (offline_contacts, Section::Offline),
+            ] {
+                if !matches.is_empty() {
+                    self.entries.push(ContactEntry::Header(section));
+                    if !self.collapsed_sections.contains(&section) {
+                        for mat in matches {
+                            let contact = &contacts[mat.candidate_id];
+                            self.entries.push(ContactEntry::Contact(contact.clone()));
+                        }
+                    }
+                }
+            }
+        }
+
+        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;
+                }
+            }
+        }
+
+        self.list_state.reset(self.entries.len());
+        cx.notify();
+    }
+
+    fn render_call_participant(
+        user: &User,
+        is_pending: bool,
+        is_selected: bool,
+        theme: &theme::ContactList,
+    ) -> ElementBox {
+        Flex::row()
+            .with_children(user.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.contact_avatar)
+                    .aligned()
+                    .left()
+                    .boxed()
+            }))
+            .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)
+                .boxed(),
+            )
+            .with_children(if is_pending {
+                Some(
+                    Label::new("Calling".to_string(), theme.calling_indicator.text.clone())
+                        .contained()
+                        .with_style(theme.calling_indicator.container)
+                        .aligned()
+                        .boxed(),
+                )
+            } else {
+                None
+            })
+            .constrained()
+            .with_height(theme.row_height)
+            .contained()
+            .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
+            .boxed()
+    }
+
+    fn render_participant_project(
+        project_id: u64,
+        worktree_root_names: &[String],
+        host_user_id: u64,
+        is_host: bool,
+        is_last: bool,
+        is_selected: bool,
+        theme: &theme::ContactList,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        let font_cache = cx.font_cache();
+        let host_avatar_height = theme
+            .contact_avatar
+            .width
+            .or(theme.contact_avatar.height)
+            .unwrap_or(0.);
+        let row = &theme.project_row.default;
+        let tree_branch = theme.tree_branch;
+        let line_height = row.name.text.line_height(font_cache);
+        let cap_height = row.name.text.cap_height(font_cache);
+        let baseline_offset =
+            row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
+        let project_name = if worktree_root_names.is_empty() {
+            "untitled".to_string()
+        } else {
+            worktree_root_names.join(", ")
+        };
+
+        MouseEventHandler::<JoinProject>::new(project_id as usize, cx, |mouse_state, _| {
+            let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
+            let row = theme.project_row.style_for(mouse_state, is_selected);
+
+            Flex::row()
+                .with_child(
+                    Stack::new()
+                        .with_child(
+                            Canvas::new(move |bounds, _, cx| {
+                                let start_x = bounds.min_x() + (bounds.width() / 2.)
+                                    - (tree_branch.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 + tree_branch.width,
+                                            if is_last { end_y } else { bounds.max_y() },
+                                        ),
+                                    ),
+                                    background: Some(tree_branch.color),
+                                    border: gpui::Border::default(),
+                                    corner_radius: 0.,
+                                });
+                                cx.scene.push_quad(gpui::Quad {
+                                    bounds: RectF::from_points(
+                                        vec2f(start_x, end_y),
+                                        vec2f(end_x, end_y + tree_branch.width),
+                                    ),
+                                    background: Some(tree_branch.color),
+                                    border: gpui::Border::default(),
+                                    corner_radius: 0.,
+                                });
+                            })
+                            .boxed(),
+                        )
+                        .constrained()
+                        .with_width(host_avatar_height)
+                        .boxed(),
+                )
+                .with_child(
+                    Label::new(project_name, row.name.text.clone())
+                        .aligned()
+                        .left()
+                        .contained()
+                        .with_style(row.name.container)
+                        .flex(1., false)
+                        .boxed(),
+                )
+                .constrained()
+                .with_height(theme.row_height)
+                .contained()
+                .with_style(row.container)
+                .boxed()
+        })
+        .with_cursor_style(if !is_host {
+            CursorStyle::PointingHand
+        } else {
+            CursorStyle::Arrow
+        })
+        .on_click(MouseButton::Left, move |_, cx| {
+            if !is_host {
+                cx.dispatch_global_action(JoinProject {
+                    project_id,
+                    follow_user_id: host_user_id,
+                });
+            }
+        })
+        .boxed()
+    }
+
+    fn render_header(
+        section: Section,
+        theme: &theme::ContactList,
+        is_selected: bool,
+        is_collapsed: bool,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        enum Header {}
+
+        let header_style = theme.header_row.style_for(Default::default(), is_selected);
+        let text = match section {
+            Section::ActiveCall => "Collaborators",
+            Section::Requests => "Contact Requests",
+            Section::Online => "Online",
+            Section::Offline => "Offline",
+        };
+        let leave_call = if section == Section::ActiveCall {
+            Some(
+                MouseEventHandler::<LeaveCall>::new(0, cx, |state, _| {
+                    let style = theme.leave_call.style_for(state, false);
+                    Label::new("Leave Session".into(), style.text.clone())
+                        .contained()
+                        .with_style(style.container)
+                        .boxed()
+                })
+                .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(LeaveCall))
+                .aligned()
+                .boxed(),
+            )
+        } else {
+            None
+        };
+
+        let icon_size = theme.section_icon_size;
+        MouseEventHandler::<Header>::new(section as usize, cx, |_, _| {
+            Flex::row()
+                .with_child(
+                    Svg::new(if is_collapsed {
+                        "icons/chevron_right_8.svg"
+                    } else {
+                        "icons/chevron_down_8.svg"
+                    })
+                    .with_color(header_style.text.color)
+                    .constrained()
+                    .with_max_width(icon_size)
+                    .with_max_height(icon_size)
+                    .aligned()
+                    .constrained()
+                    .with_width(icon_size)
+                    .boxed(),
+                )
+                .with_child(
+                    Label::new(text.to_string(), header_style.text.clone())
+                        .aligned()
+                        .left()
+                        .contained()
+                        .with_margin_left(theme.contact_username.container.margin.left)
+                        .flex(1., true)
+                        .boxed(),
+                )
+                .with_children(leave_call)
+                .constrained()
+                .with_height(theme.row_height)
+                .contained()
+                .with_style(header_style.container)
+                .boxed()
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, cx| {
+            cx.dispatch_action(ToggleExpanded(section))
+        })
+        .boxed()
+    }
+
+    fn render_contact(
+        contact: &Contact,
+        project: &ModelHandle<Project>,
+        theme: &theme::ContactList,
+        is_selected: bool,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        let online = contact.online;
+        let busy = contact.busy;
+        let user_id = contact.user.id;
+        let initial_project = project.clone();
+        let mut element =
+            MouseEventHandler::<Contact>::new(contact.user.id as usize, 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 contact.busy {
+                                        theme.contact_status_busy
+                                    } else {
+                                        theme.contact_status_free
+                                    })
+                                    .aligned()
+                                    .boxed(),
+                            )
+                        } else {
+                            None
+                        };
+                        Stack::new()
+                            .with_child(
+                                Image::new(avatar)
+                                    .with_style(theme.contact_avatar)
+                                    .aligned()
+                                    .left()
+                                    .boxed(),
+                            )
+                            .with_children(status_badge)
+                            .boxed()
+                    }))
+                    .with_child(
+                        Label::new(
+                            contact.user.github_login.clone(),
+                            theme.contact_username.text.clone(),
+                        )
+                        .contained()
+                        .with_style(theme.contact_username.container)
+                        .aligned()
+                        .left()
+                        .flex(1., true)
+                        .boxed(),
+                    )
+                    .constrained()
+                    .with_height(theme.row_height)
+                    .contained()
+                    .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
+                    .boxed()
+            })
+            .on_click(MouseButton::Left, move |_, cx| {
+                if online && !busy {
+                    cx.dispatch_action(Call {
+                        recipient_user_id: user_id,
+                        initial_project: Some(initial_project.clone()),
+                    });
+                }
+            });
+
+        if online {
+            element = element.with_cursor_style(CursorStyle::PointingHand);
+        }
+
+        element.boxed()
+    }
+
+    fn render_contact_request(
+        user: Arc<User>,
+        user_store: ModelHandle<UserStore>,
+        theme: &theme::ContactList,
+        is_incoming: bool,
+        is_selected: bool,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        enum Decline {}
+        enum Accept {}
+        enum Cancel {}
+
+        let mut row = Flex::row()
+            .with_children(user.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.contact_avatar)
+                    .aligned()
+                    .left()
+                    .boxed()
+            }))
+            .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)
+                .boxed(),
+            );
+
+        let user_id = user.id;
+        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_children([
+                MouseEventHandler::<Decline>::new(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, false)
+                    };
+                    render_icon_button(button_style, "icons/x_mark_8.svg")
+                        .aligned()
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, cx| {
+                    cx.dispatch_action(RespondToContactRequest {
+                        user_id,
+                        accept: false,
+                    })
+                })
+                .contained()
+                .with_margin_right(button_spacing)
+                .boxed(),
+                MouseEventHandler::<Accept>::new(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, false)
+                    };
+                    render_icon_button(button_style, "icons/check_8.svg")
+                        .aligned()
+                        .flex_float()
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, cx| {
+                    cx.dispatch_action(RespondToContactRequest {
+                        user_id,
+                        accept: true,
+                    })
+                })
+                .boxed(),
+            ]);
+        } else {
+            row.add_child(
+                MouseEventHandler::<Cancel>::new(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, false)
+                    };
+                    render_icon_button(button_style, "icons/x_mark_8.svg")
+                        .aligned()
+                        .flex_float()
+                        .boxed()
+                })
+                .with_padding(Padding::uniform(2.))
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, cx| {
+                    cx.dispatch_action(RemoveContact(user_id))
+                })
+                .flex_float()
+                .boxed(),
+            );
+        }
+
+        row.constrained()
+            .with_height(theme.row_height)
+            .contained()
+            .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
+            .boxed()
+    }
+
+    fn call(&mut self, action: &Call, cx: &mut ViewContext<Self>) {
+        let recipient_user_id = action.recipient_user_id;
+        let initial_project = action.initial_project.clone();
+        let window_id = cx.window_id();
+
+        let active_call = ActiveCall::global(cx);
+        cx.spawn_weak(|_, mut cx| async move {
+            active_call
+                .update(&mut cx, |active_call, cx| {
+                    active_call.invite(recipient_user_id, initial_project.clone(), cx)
+                })
+                .await?;
+            if cx.update(|cx| cx.window_is_active(window_id)) {
+                active_call
+                    .update(&mut cx, |call, cx| {
+                        call.set_location(initial_project.as_ref(), cx)
+                    })
+                    .await?;
+            }
+            anyhow::Ok(())
+        })
+        .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 {
+    type Event = Event;
+}
+
+impl View for ContactList {
+    fn ui_name() -> &'static str {
+        "ContactList"
+    }
+
+    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
+        let mut cx = Self::default_keymap_context();
+        cx.set.insert("menu".into());
+        cx
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        enum AddContact {}
+        let theme = cx.global::<Settings>().theme.clone();
+
+        Flex::column()
+            .with_child(
+                Flex::row()
+                    .with_child(
+                        ChildView::new(self.filter_editor.clone())
+                            .contained()
+                            .with_style(theme.contact_list.user_query_editor.container)
+                            .flex(1., true)
+                            .boxed(),
+                    )
+                    .with_child(
+                        MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
+                            render_icon_button(
+                                &theme.contact_list.add_contact_button,
+                                "icons/user_plus_16.svg",
+                            )
+                            .boxed()
+                        })
+                        .with_cursor_style(CursorStyle::PointingHand)
+                        .on_click(MouseButton::Left, |_, cx| {
+                            cx.dispatch_action(contacts_popover::ToggleContactFinder)
+                        })
+                        .with_tooltip::<AddContact, _>(
+                            0,
+                            "Add contact".into(),
+                            None,
+                            theme.tooltip.clone(),
+                            cx,
+                        )
+                        .boxed(),
+                    )
+                    .constrained()
+                    .with_height(theme.contact_list.user_query_editor_height)
+                    .boxed(),
+            )
+            .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
+            .boxed()
+    }
+
+    fn on_focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if !self.filter_editor.is_focused(cx) {
+            cx.focus(&self.filter_editor);
+        }
+    }
+
+    fn on_focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if !self.filter_editor.is_focused(cx) {
+            cx.emit(Event::Dismissed);
+        }
+    }
+}
+
+fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
+    Svg::new(svg_path)
+        .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)
+}

crates/contacts_panel/src/contact_notification.rs → crates/collab_ui/src/contact_notification.rs 🔗

@@ -49,10 +49,7 @@ impl View for ContactNotification {
                 self.user.clone(),
                 "wants to add you as a contact",
                 Some("They won't know if you decline."),
-                RespondToContactRequest {
-                    user_id: self.user.id,
-                    accept: false,
-                },
+                Dismiss(self.user.id),
                 vec![
                     (
                         "Decline",

crates/collab_ui/src/contacts_popover.rs 🔗

@@ -0,0 +1,162 @@
+use crate::{contact_finder::ContactFinder, contact_list::ContactList};
+use client::UserStore;
+use gpui::{
+    actions, elements::*, ClipboardItem, CursorStyle, Entity, ModelHandle, MouseButton,
+    MutableAppContext, RenderContext, View, ViewContext, ViewHandle,
+};
+use project::Project;
+use settings::Settings;
+
+actions!(contacts_popover, [ToggleContactFinder]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(ContactsPopover::toggle_contact_finder);
+}
+
+pub enum Event {
+    Dismissed,
+}
+
+enum Child {
+    ContactList(ViewHandle<ContactList>),
+    ContactFinder(ViewHandle<ContactFinder>),
+}
+
+pub struct ContactsPopover {
+    child: Child,
+    project: ModelHandle<Project>,
+    user_store: ModelHandle<UserStore>,
+    _subscription: Option<gpui::Subscription>,
+}
+
+impl ContactsPopover {
+    pub fn new(
+        project: ModelHandle<Project>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let mut this = Self {
+            child: Child::ContactList(
+                cx.add_view(|cx| ContactList::new(project.clone(), user_store.clone(), cx)),
+            ),
+            project,
+            user_store,
+            _subscription: None,
+        };
+        this.show_contact_list(cx);
+        this
+    }
+
+    fn toggle_contact_finder(&mut self, _: &ToggleContactFinder, cx: &mut ViewContext<Self>) {
+        match &self.child {
+            Child::ContactList(_) => self.show_contact_finder(cx),
+            Child::ContactFinder(_) => self.show_contact_list(cx),
+        }
+    }
+
+    fn show_contact_finder(&mut self, cx: &mut ViewContext<ContactsPopover>) {
+        let child = cx.add_view(|cx| ContactFinder::new(self.user_store.clone(), cx));
+        cx.focus(&child);
+        self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event {
+            crate::contact_finder::Event::Dismissed => cx.emit(Event::Dismissed),
+        }));
+        self.child = Child::ContactFinder(child);
+        cx.notify();
+    }
+
+    fn show_contact_list(&mut self, cx: &mut ViewContext<ContactsPopover>) {
+        let child =
+            cx.add_view(|cx| ContactList::new(self.project.clone(), self.user_store.clone(), cx));
+        cx.focus(&child);
+        self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event {
+            crate::contact_list::Event::Dismissed => cx.emit(Event::Dismissed),
+        }));
+        self.child = Child::ContactList(child);
+        cx.notify();
+    }
+}
+
+impl Entity for ContactsPopover {
+    type Event = Event;
+}
+
+impl View for ContactsPopover {
+    fn ui_name() -> &'static str {
+        "ContactsPopover"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        let theme = cx.global::<Settings>().theme.clone();
+        let child = match &self.child {
+            Child::ContactList(child) => ChildView::new(child),
+            Child::ContactFinder(child) => ChildView::new(child),
+        };
+
+        Flex::column()
+            .with_child(child.flex(1., true).boxed())
+            .with_children(
+                self.user_store
+                    .read(cx)
+                    .invite_info()
+                    .cloned()
+                    .and_then(|info| {
+                        enum InviteLink {}
+
+                        if info.count > 0 {
+                            Some(
+                                MouseEventHandler::<InviteLink>::new(0, cx, |state, cx| {
+                                    let style = theme
+                                        .contacts_popover
+                                        .invite_row
+                                        .style_for(state, false)
+                                        .clone();
+
+                                    let copied = cx.read_from_clipboard().map_or(false, |item| {
+                                        item.text().as_str() == info.url.as_ref()
+                                    });
+
+                                    Label::new(
+                                        format!(
+                                            "{} invite link ({} left)",
+                                            if copied { "Copied" } else { "Copy" },
+                                            info.count
+                                        ),
+                                        style.label.clone(),
+                                    )
+                                    .aligned()
+                                    .left()
+                                    .constrained()
+                                    .with_height(theme.contacts_popover.invite_row_height)
+                                    .contained()
+                                    .with_style(style.container)
+                                    .boxed()
+                                })
+                                .with_cursor_style(CursorStyle::PointingHand)
+                                .on_click(MouseButton::Left, move |_, cx| {
+                                    cx.write_to_clipboard(ClipboardItem::new(info.url.to_string()));
+                                    cx.notify();
+                                })
+                                .boxed(),
+                            )
+                        } else {
+                            None
+                        }
+                    }),
+            )
+            .contained()
+            .with_style(theme.contacts_popover.container)
+            .constrained()
+            .with_width(theme.contacts_popover.width)
+            .with_height(theme.contacts_popover.height)
+            .boxed()
+    }
+
+    fn on_focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if cx.is_self_focused() {
+            match &self.child {
+                Child::ContactList(child) => cx.focus(child),
+                Child::ContactFinder(child) => cx.focus(child),
+            }
+        }
+    }
+}

crates/collab_ui/src/incoming_call_notification.rs 🔗

@@ -0,0 +1,232 @@
+use call::{ActiveCall, IncomingCall};
+use client::proto;
+use futures::StreamExt;
+use gpui::{
+    elements::*,
+    geometry::{rect::RectF, vector::vec2f},
+    impl_internal_actions, CursorStyle, Entity, MouseButton, MutableAppContext, RenderContext,
+    View, ViewContext, WindowBounds, WindowKind, WindowOptions,
+};
+use settings::Settings;
+use util::ResultExt;
+use workspace::JoinProject;
+
+impl_internal_actions!(incoming_call_notification, [RespondToCall]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(IncomingCallNotification::respond_to_call);
+
+    let mut incoming_call = ActiveCall::global(cx).read(cx).incoming();
+    cx.spawn(|mut cx| async move {
+        let mut notification_window = None;
+        while let Some(incoming_call) = incoming_call.next().await {
+            if let Some(window_id) = notification_window.take() {
+                cx.remove_window(window_id);
+            }
+
+            if let Some(incoming_call) = incoming_call {
+                const PADDING: f32 = 16.;
+                let screen_size = cx.platform().screen_size();
+
+                let window_size = cx.read(|cx| {
+                    let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+                    vec2f(theme.window_width, theme.window_height)
+                });
+                let (window_id, _) = cx.add_window(
+                    WindowOptions {
+                        bounds: WindowBounds::Fixed(RectF::new(
+                            vec2f(screen_size.x() - window_size.x() - PADDING, PADDING),
+                            window_size,
+                        )),
+                        titlebar: None,
+                        center: false,
+                        kind: WindowKind::PopUp,
+                        is_movable: false,
+                    },
+                    |_| IncomingCallNotification::new(incoming_call),
+                );
+                notification_window = Some(window_id);
+            }
+        }
+    })
+    .detach();
+}
+
+#[derive(Clone, PartialEq)]
+struct RespondToCall {
+    accept: bool,
+}
+
+pub struct IncomingCallNotification {
+    call: IncomingCall,
+}
+
+impl IncomingCallNotification {
+    pub fn new(call: IncomingCall) -> Self {
+        Self { call }
+    }
+
+    fn respond_to_call(&mut self, action: &RespondToCall, cx: &mut ViewContext<Self>) {
+        let active_call = ActiveCall::global(cx);
+        if action.accept {
+            let join = active_call.update(cx, |active_call, cx| active_call.accept_incoming(cx));
+            let caller_user_id = self.call.caller.id;
+            let initial_project_id = self.call.initial_project.as_ref().map(|project| project.id);
+            cx.spawn_weak(|_, mut cx| async move {
+                join.await?;
+                if let Some(project_id) = initial_project_id {
+                    cx.update(|cx| {
+                        cx.dispatch_global_action(JoinProject {
+                            project_id,
+                            follow_user_id: caller_user_id,
+                        })
+                    });
+                }
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
+        } else {
+            active_call.update(cx, |active_call, _| {
+                active_call.decline_incoming().log_err();
+            });
+        }
+    }
+
+    fn render_caller(&self, cx: &mut RenderContext<Self>) -> ElementBox {
+        let theme = &cx.global::<Settings>().theme.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.caller.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.caller_avatar)
+                    .aligned()
+                    .boxed()
+            }))
+            .with_child(
+                Flex::column()
+                    .with_child(
+                        Label::new(
+                            self.call.caller.github_login.clone(),
+                            theme.caller_username.text.clone(),
+                        )
+                        .contained()
+                        .with_style(theme.caller_username.container)
+                        .boxed(),
+                    )
+                    .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)
+                        .boxed(),
+                    )
+                    .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)
+                            .boxed(),
+                        )
+                    })
+                    .contained()
+                    .with_style(theme.caller_metadata)
+                    .aligned()
+                    .boxed(),
+            )
+            .contained()
+            .with_style(theme.caller_container)
+            .flex(1., true)
+            .boxed()
+    }
+
+    fn render_buttons(&self, cx: &mut RenderContext<Self>) -> ElementBox {
+        enum Accept {}
+        enum Decline {}
+
+        Flex::column()
+            .with_child(
+                MouseEventHandler::<Accept>::new(0, cx, |_, cx| {
+                    let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+                    Label::new("Accept".to_string(), theme.accept_button.text.clone())
+                        .aligned()
+                        .contained()
+                        .with_style(theme.accept_button.container)
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, |_, cx| {
+                    cx.dispatch_action(RespondToCall { accept: true });
+                })
+                .flex(1., true)
+                .boxed(),
+            )
+            .with_child(
+                MouseEventHandler::<Decline>::new(0, cx, |_, cx| {
+                    let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+                    Label::new("Decline".to_string(), theme.decline_button.text.clone())
+                        .aligned()
+                        .contained()
+                        .with_style(theme.decline_button.container)
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, |_, cx| {
+                    cx.dispatch_action(RespondToCall { accept: false });
+                })
+                .flex(1., true)
+                .boxed(),
+            )
+            .constrained()
+            .with_width(
+                cx.global::<Settings>()
+                    .theme
+                    .incoming_call_notification
+                    .button_width,
+            )
+            .boxed()
+    }
+}
+
+impl Entity for IncomingCallNotification {
+    type Event = ();
+}
+
+impl View for IncomingCallNotification {
+    fn ui_name() -> &'static str {
+        "IncomingCallNotification"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
+        let background = cx
+            .global::<Settings>()
+            .theme
+            .incoming_call_notification
+            .background;
+        Flex::row()
+            .with_child(self.render_caller(cx))
+            .with_child(self.render_buttons(cx))
+            .contained()
+            .with_background_color(background)
+            .expanded()
+            .boxed()
+    }
+}

crates/contacts_panel/src/notifications.rs → crates/collab_ui/src/notifications.rs 🔗

@@ -1,9 +1,7 @@
-use crate::render_icon_button;
 use client::User;
 use gpui::{
-    elements::{Flex, Image, Label, MouseEventHandler, Padding, ParentElement, Text},
-    platform::CursorStyle,
-    Action, Element, ElementBox, MouseButton, RenderContext, View,
+    elements::*, platform::CursorStyle, Action, Element, ElementBox, MouseButton, RenderContext,
+    View,
 };
 use settings::Settings;
 use std::sync::Arc;
@@ -53,11 +51,18 @@ pub fn render_user_notification<V: View, A: Action + Clone>(
                 )
                 .with_child(
                     MouseEventHandler::<Dismiss>::new(user.id as usize, cx, |state, _| {
-                        render_icon_button(
-                            theme.dismiss_button.style_for(state, false),
-                            "icons/x_mark_thin_8.svg",
-                        )
-                        .boxed()
+                        let style = theme.dismiss_button.style_for(state, false);
+                        Svg::new("icons/x_mark_thin_8.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)
+                            .boxed()
                     })
                     .with_cursor_style(CursorStyle::PointingHand)
                     .with_padding(Padding::uniform(5.))

crates/collab_ui/src/project_shared_notification.rs 🔗

@@ -0,0 +1,232 @@
+use call::{room, ActiveCall};
+use client::User;
+use collections::HashMap;
+use gpui::{
+    actions,
+    elements::*,
+    geometry::{rect::RectF, vector::vec2f},
+    CursorStyle, Entity, MouseButton, MutableAppContext, RenderContext, View, ViewContext,
+    WindowBounds, WindowKind, WindowOptions,
+};
+use settings::Settings;
+use std::sync::Arc;
+use workspace::JoinProject;
+
+actions!(project_shared_notification, [DismissProject]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(ProjectSharedNotification::join);
+    cx.add_action(ProjectSharedNotification::dismiss);
+
+    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,
+        } => {
+            const PADDING: f32 = 16.;
+            let screen_size = cx.platform().screen_size();
+
+            let theme = &cx.global::<Settings>().theme.project_shared_notification;
+            let window_size = vec2f(theme.window_width, theme.window_height);
+            let (window_id, _) = cx.add_window(
+                WindowOptions {
+                    bounds: WindowBounds::Fixed(RectF::new(
+                        vec2f(screen_size.x() - window_size.x() - PADDING, PADDING),
+                        window_size,
+                    )),
+                    titlebar: None,
+                    center: false,
+                    kind: WindowKind::PopUp,
+                    is_movable: false,
+                },
+                |_| {
+                    ProjectSharedNotification::new(
+                        owner.clone(),
+                        *project_id,
+                        worktree_root_names.clone(),
+                    )
+                },
+            );
+            notification_windows.insert(*project_id, window_id);
+        }
+        room::Event::RemoteProjectUnshared { project_id } => {
+            if let Some(window_id) = notification_windows.remove(&project_id) {
+                cx.remove_window(window_id);
+            }
+        }
+        room::Event::Left => {
+            for (_, window_id) in notification_windows.drain() {
+                cx.remove_window(window_id);
+            }
+        }
+    })
+    .detach();
+}
+
+pub struct ProjectSharedNotification {
+    project_id: u64,
+    worktree_root_names: Vec<String>,
+    owner: Arc<User>,
+}
+
+impl ProjectSharedNotification {
+    fn new(owner: Arc<User>, project_id: u64, worktree_root_names: Vec<String>) -> Self {
+        Self {
+            project_id,
+            worktree_root_names,
+            owner,
+        }
+    }
+
+    fn join(&mut self, _: &JoinProject, cx: &mut ViewContext<Self>) {
+        let window_id = cx.window_id();
+        cx.remove_window(window_id);
+        cx.propagate_action();
+    }
+
+    fn dismiss(&mut self, _: &DismissProject, cx: &mut ViewContext<Self>) {
+        let window_id = cx.window_id();
+        cx.remove_window(window_id);
+    }
+
+    fn render_owner(&self, cx: &mut RenderContext<Self>) -> ElementBox {
+        let theme = &cx.global::<Settings>().theme.project_shared_notification;
+        Flex::row()
+            .with_children(self.owner.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.owner_avatar)
+                    .aligned()
+                    .boxed()
+            }))
+            .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)
+                        .boxed(),
+                    )
+                    .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)
+                        .boxed(),
+                    )
+                    .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)
+                            .boxed(),
+                        )
+                    })
+                    .contained()
+                    .with_style(theme.owner_metadata)
+                    .aligned()
+                    .boxed(),
+            )
+            .contained()
+            .with_style(theme.owner_container)
+            .flex(1., true)
+            .boxed()
+    }
+
+    fn render_buttons(&self, cx: &mut RenderContext<Self>) -> ElementBox {
+        enum Open {}
+        enum Dismiss {}
+
+        let project_id = self.project_id;
+        let owner_user_id = self.owner.id;
+
+        Flex::column()
+            .with_child(
+                MouseEventHandler::<Open>::new(0, cx, |_, cx| {
+                    let theme = &cx.global::<Settings>().theme.project_shared_notification;
+                    Label::new("Open".to_string(), theme.open_button.text.clone())
+                        .aligned()
+                        .contained()
+                        .with_style(theme.open_button.container)
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, cx| {
+                    cx.dispatch_action(JoinProject {
+                        project_id,
+                        follow_user_id: owner_user_id,
+                    });
+                })
+                .flex(1., true)
+                .boxed(),
+            )
+            .with_child(
+                MouseEventHandler::<Dismiss>::new(0, cx, |_, cx| {
+                    let theme = &cx.global::<Settings>().theme.project_shared_notification;
+                    Label::new("Dismiss".to_string(), theme.dismiss_button.text.clone())
+                        .aligned()
+                        .contained()
+                        .with_style(theme.dismiss_button.container)
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, |_, cx| {
+                    cx.dispatch_action(DismissProject);
+                })
+                .flex(1., true)
+                .boxed(),
+            )
+            .constrained()
+            .with_width(
+                cx.global::<Settings>()
+                    .theme
+                    .project_shared_notification
+                    .button_width,
+            )
+            .boxed()
+    }
+}
+
+impl Entity for ProjectSharedNotification {
+    type Event = ();
+}
+
+impl View for ProjectSharedNotification {
+    fn ui_name() -> &'static str {
+        "ProjectSharedNotification"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
+        let background = cx
+            .global::<Settings>()
+            .theme
+            .project_shared_notification
+            .background;
+        Flex::row()
+            .with_child(self.render_owner(cx))
+            .with_child(self.render_buttons(cx))
+            .contained()
+            .with_background_color(background)
+            .expanded()
+            .boxed()
+    }
+}

crates/contacts_panel/Cargo.toml 🔗

@@ -1,32 +0,0 @@
-[package]
-name = "contacts_panel"
-version = "0.1.0"
-edition = "2021"
-
-[lib]
-path = "src/contacts_panel.rs"
-doctest = false
-
-[dependencies]
-client = { path = "../client" }
-collections = { path = "../collections" }
-editor = { path = "../editor" }
-fuzzy = { path = "../fuzzy" }
-gpui = { path = "../gpui" }
-menu = { path = "../menu" }
-picker = { path = "../picker" }
-project = { path = "../project" }
-settings = { path = "../settings" }
-theme = { path = "../theme" }
-util = { path = "../util" }
-workspace = { path = "../workspace" }
-anyhow = "1.0"
-futures = "0.3"
-log = "0.4"
-postage = { version = "0.4.1", features = ["futures-traits"] }
-serde = { version = "1.0", features = ["derive", "rc"] }
-
-[dev-dependencies]
-language = { path = "../language", features = ["test-support"] }
-project = { path = "../project", features = ["test-support"] }
-workspace = { path = "../workspace", features = ["test-support"] }

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -1,1653 +0,0 @@
-mod contact_finder;
-mod contact_notification;
-mod join_project_notification;
-mod notifications;
-
-use client::{Contact, ContactEventKind, User, UserStore};
-use contact_notification::ContactNotification;
-use editor::{Cancel, Editor};
-use fuzzy::{match_strings, StringMatchCandidate};
-use gpui::{
-    actions,
-    elements::*,
-    geometry::{rect::RectF, vector::vec2f},
-    impl_actions, impl_internal_actions,
-    platform::CursorStyle,
-    AnyViewHandle, AppContext, ClipboardItem, Element, ElementBox, Entity, ModelHandle,
-    MouseButton, MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle,
-    WeakModelHandle, WeakViewHandle,
-};
-use join_project_notification::JoinProjectNotification;
-use menu::{Confirm, SelectNext, SelectPrev};
-use project::{Project, ProjectStore};
-use serde::Deserialize;
-use settings::Settings;
-use std::{ops::DerefMut, sync::Arc};
-use theme::IconButton;
-use workspace::{sidebar::SidebarItem, JoinProject, ToggleProjectOnline, Workspace};
-
-actions!(contacts_panel, [ToggleFocus]);
-
-impl_actions!(
-    contacts_panel,
-    [RequestContact, RemoveContact, RespondToContactRequest]
-);
-
-impl_internal_actions!(contacts_panel, [ToggleExpanded]);
-
-#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
-enum Section {
-    Requests,
-    Online,
-    Offline,
-}
-
-#[derive(Clone)]
-enum ContactEntry {
-    Header(Section),
-    IncomingRequest(Arc<User>),
-    OutgoingRequest(Arc<User>),
-    Contact(Arc<Contact>),
-    ContactProject(Arc<Contact>, usize, Option<WeakModelHandle<Project>>),
-    OfflineProject(WeakModelHandle<Project>),
-}
-
-#[derive(Clone, PartialEq)]
-struct ToggleExpanded(Section);
-
-pub struct ContactsPanel {
-    entries: Vec<ContactEntry>,
-    match_candidates: Vec<StringMatchCandidate>,
-    list_state: ListState,
-    user_store: ModelHandle<UserStore>,
-    project_store: ModelHandle<ProjectStore>,
-    filter_editor: ViewHandle<Editor>,
-    collapsed_sections: Vec<Section>,
-    selection: Option<usize>,
-    _maintain_contacts: Subscription,
-}
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RequestContact(pub u64);
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RemoveContact(pub u64);
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RespondToContactRequest {
-    pub user_id: u64,
-    pub accept: bool,
-}
-
-pub fn init(cx: &mut MutableAppContext) {
-    contact_finder::init(cx);
-    contact_notification::init(cx);
-    join_project_notification::init(cx);
-    cx.add_action(ContactsPanel::request_contact);
-    cx.add_action(ContactsPanel::remove_contact);
-    cx.add_action(ContactsPanel::respond_to_contact_request);
-    cx.add_action(ContactsPanel::clear_filter);
-    cx.add_action(ContactsPanel::select_next);
-    cx.add_action(ContactsPanel::select_prev);
-    cx.add_action(ContactsPanel::confirm);
-    cx.add_action(ContactsPanel::toggle_expanded);
-}
-
-impl ContactsPanel {
-    pub fn new(
-        user_store: ModelHandle<UserStore>,
-        project_store: ModelHandle<ProjectStore>,
-        workspace: WeakViewHandle<Workspace>,
-        cx: &mut ViewContext<Self>,
-    ) -> Self {
-        let filter_editor = cx.add_view(|cx| {
-            let mut editor = Editor::single_line(
-                Some(|theme| theme.contacts_panel.user_query_editor.clone()),
-                cx,
-            );
-            editor.set_placeholder_text("Filter 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(cx);
-                if !query.is_empty() {
-                    this.selection = this
-                        .entries
-                        .iter()
-                        .position(|entry| !matches!(entry, ContactEntry::Header(_)));
-                }
-            }
-        })
-        .detach();
-
-        cx.defer({
-            let workspace = workspace.clone();
-            move |_, cx| {
-                if let Some(workspace_handle) = workspace.upgrade(cx) {
-                    cx.subscribe(&workspace_handle.read(cx).project().clone(), {
-                        let workspace = workspace;
-                        move |_, project, event, cx| {
-                            if let project::Event::ContactRequestedJoin(user) = event {
-                                if let Some(workspace) = workspace.upgrade(cx) {
-                                    workspace.update(cx, |workspace, cx| {
-                                        workspace.show_notification(user.id as usize, cx, |cx| {
-                                            cx.add_view(|cx| {
-                                                JoinProjectNotification::new(
-                                                    project,
-                                                    user.clone(),
-                                                    cx,
-                                                )
-                                            })
-                                        })
-                                    });
-                                }
-                            }
-                        }
-                    })
-                    .detach();
-                }
-            }
-        });
-
-        cx.observe(&project_store, |this, _, cx| this.update_entries(cx))
-            .detach();
-
-        cx.subscribe(&user_store, move |_, user_store, event, cx| {
-            if let Some(workspace) = workspace.upgrade(cx) {
-                workspace.update(cx, |workspace, cx| {
-                    if let client::Event::Contact { user, kind } = event {
-                        if let ContactEventKind::Requested | ContactEventKind::Accepted = kind {
-                            workspace.show_notification(user.id as usize, cx, |cx| {
-                                cx.add_view(|cx| {
-                                    ContactNotification::new(user.clone(), *kind, user_store, cx)
-                                })
-                            })
-                        }
-                    }
-                });
-            }
-
-            if let client::Event::ShowContacts = event {
-                cx.emit(Event::Activate);
-            }
-        })
-        .detach();
-
-        let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
-            let theme = cx.global::<Settings>().theme.clone();
-            let current_user_id = this.user_store.read(cx).current_user().map(|user| user.id);
-            let is_selected = this.selection == Some(ix);
-
-            match &this.entries[ix] {
-                ContactEntry::Header(section) => {
-                    let is_collapsed = this.collapsed_sections.contains(section);
-                    Self::render_header(
-                        *section,
-                        &theme.contacts_panel,
-                        is_selected,
-                        is_collapsed,
-                        cx,
-                    )
-                }
-                ContactEntry::IncomingRequest(user) => Self::render_contact_request(
-                    user.clone(),
-                    this.user_store.clone(),
-                    &theme.contacts_panel,
-                    true,
-                    is_selected,
-                    cx,
-                ),
-                ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
-                    user.clone(),
-                    this.user_store.clone(),
-                    &theme.contacts_panel,
-                    false,
-                    is_selected,
-                    cx,
-                ),
-                ContactEntry::Contact(contact) => {
-                    Self::render_contact(&contact.user, &theme.contacts_panel, is_selected)
-                }
-                ContactEntry::ContactProject(contact, project_ix, open_project) => {
-                    let is_last_project_for_contact =
-                        this.entries.get(ix + 1).map_or(true, |next| {
-                            if let ContactEntry::ContactProject(next_contact, _, _) = next {
-                                next_contact.user.id != contact.user.id
-                            } else {
-                                true
-                            }
-                        });
-                    Self::render_project(
-                        contact.clone(),
-                        current_user_id,
-                        *project_ix,
-                        *open_project,
-                        &theme.contacts_panel,
-                        &theme.tooltip,
-                        is_last_project_for_contact,
-                        is_selected,
-                        cx,
-                    )
-                }
-                ContactEntry::OfflineProject(project) => Self::render_offline_project(
-                    *project,
-                    &theme.contacts_panel,
-                    &theme.tooltip,
-                    is_selected,
-                    cx,
-                ),
-            }
-        });
-
-        let mut this = Self {
-            list_state,
-            selection: None,
-            collapsed_sections: Default::default(),
-            entries: Default::default(),
-            match_candidates: Default::default(),
-            filter_editor,
-            _maintain_contacts: cx.observe(&user_store, |this, _, cx| this.update_entries(cx)),
-            user_store,
-            project_store,
-        };
-        this.update_entries(cx);
-        this
-    }
-
-    fn render_header(
-        section: Section,
-        theme: &theme::ContactsPanel,
-        is_selected: bool,
-        is_collapsed: bool,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox {
-        enum Header {}
-
-        let header_style = theme.header_row.style_for(Default::default(), is_selected);
-        let text = match section {
-            Section::Requests => "Requests",
-            Section::Online => "Online",
-            Section::Offline => "Offline",
-        };
-        let icon_size = theme.section_icon_size;
-        MouseEventHandler::<Header>::new(section as usize, cx, |_, _| {
-            Flex::row()
-                .with_child(
-                    Svg::new(if is_collapsed {
-                        "icons/chevron_right_8.svg"
-                    } else {
-                        "icons/chevron_down_8.svg"
-                    })
-                    .with_color(header_style.text.color)
-                    .constrained()
-                    .with_max_width(icon_size)
-                    .with_max_height(icon_size)
-                    .aligned()
-                    .constrained()
-                    .with_width(icon_size)
-                    .boxed(),
-                )
-                .with_child(
-                    Label::new(text.to_string(), header_style.text.clone())
-                        .aligned()
-                        .left()
-                        .contained()
-                        .with_margin_left(theme.contact_username.container.margin.left)
-                        .flex(1., true)
-                        .boxed(),
-                )
-                .constrained()
-                .with_height(theme.row_height)
-                .contained()
-                .with_style(header_style.container)
-                .boxed()
-        })
-        .with_cursor_style(CursorStyle::PointingHand)
-        .on_click(MouseButton::Left, move |_, cx| {
-            cx.dispatch_action(ToggleExpanded(section))
-        })
-        .boxed()
-    }
-
-    fn render_contact(user: &User, theme: &theme::ContactsPanel, is_selected: bool) -> ElementBox {
-        Flex::row()
-            .with_children(user.avatar.clone().map(|avatar| {
-                Image::new(avatar)
-                    .with_style(theme.contact_avatar)
-                    .aligned()
-                    .left()
-                    .boxed()
-            }))
-            .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)
-                .boxed(),
-            )
-            .constrained()
-            .with_height(theme.row_height)
-            .contained()
-            .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
-            .boxed()
-    }
-
-    #[allow(clippy::too_many_arguments)]
-    fn render_project(
-        contact: Arc<Contact>,
-        current_user_id: Option<u64>,
-        project_index: usize,
-        open_project: Option<WeakModelHandle<Project>>,
-        theme: &theme::ContactsPanel,
-        tooltip_style: &TooltipStyle,
-        is_last_project: bool,
-        is_selected: bool,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox {
-        enum ToggleOnline {}
-
-        let project = &contact.projects[project_index];
-        let project_id = project.id;
-        let is_host = Some(contact.user.id) == current_user_id;
-        let open_project = open_project.and_then(|p| p.upgrade(cx.deref_mut()));
-
-        let font_cache = cx.font_cache();
-        let host_avatar_height = theme
-            .contact_avatar
-            .width
-            .or(theme.contact_avatar.height)
-            .unwrap_or(0.);
-        let row = &theme.project_row.default;
-        let tree_branch = theme.tree_branch;
-        let line_height = row.name.text.line_height(font_cache);
-        let cap_height = row.name.text.cap_height(font_cache);
-        let baseline_offset =
-            row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
-
-        MouseEventHandler::<JoinProject>::new(project_id as usize, cx, |mouse_state, cx| {
-            let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
-            let row = theme.project_row.style_for(mouse_state, is_selected);
-
-            Flex::row()
-                .with_child(
-                    Stack::new()
-                        .with_child(
-                            Canvas::new(move |bounds, _, cx| {
-                                let start_x = bounds.min_x() + (bounds.width() / 2.)
-                                    - (tree_branch.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 + tree_branch.width,
-                                            if is_last_project {
-                                                end_y
-                                            } else {
-                                                bounds.max_y()
-                                            },
-                                        ),
-                                    ),
-                                    background: Some(tree_branch.color),
-                                    border: gpui::Border::default(),
-                                    corner_radius: 0.,
-                                });
-                                cx.scene.push_quad(gpui::Quad {
-                                    bounds: RectF::from_points(
-                                        vec2f(start_x, end_y),
-                                        vec2f(end_x, end_y + tree_branch.width),
-                                    ),
-                                    background: Some(tree_branch.color),
-                                    border: gpui::Border::default(),
-                                    corner_radius: 0.,
-                                });
-                            })
-                            .boxed(),
-                        )
-                        .with_children(open_project.and_then(|open_project| {
-                            let is_going_offline = !open_project.read(cx).is_online();
-                            if !mouse_state.hovered && !is_going_offline {
-                                return None;
-                            }
-
-                            let button = MouseEventHandler::<ToggleProjectOnline>::new(
-                                project_id as usize,
-                                cx,
-                                |state, _| {
-                                    let mut icon_style =
-                                        *theme.private_button.style_for(state, false);
-                                    icon_style.container.background_color =
-                                        row.container.background_color;
-                                    if is_going_offline {
-                                        icon_style.color = theme.disabled_button.color;
-                                    }
-                                    render_icon_button(&icon_style, "icons/lock_8.svg")
-                                        .aligned()
-                                        .boxed()
-                                },
-                            );
-
-                            if is_going_offline {
-                                Some(button.boxed())
-                            } else {
-                                Some(
-                                    button
-                                        .with_cursor_style(CursorStyle::PointingHand)
-                                        .on_click(MouseButton::Left, move |_, cx| {
-                                            cx.dispatch_action(ToggleProjectOnline {
-                                                project: Some(open_project.clone()),
-                                            })
-                                        })
-                                        .with_tooltip::<ToggleOnline, _>(
-                                            project_id as usize,
-                                            "Take project offline".to_string(),
-                                            None,
-                                            tooltip_style.clone(),
-                                            cx,
-                                        )
-                                        .boxed(),
-                                )
-                            }
-                        }))
-                        .constrained()
-                        .with_width(host_avatar_height)
-                        .boxed(),
-                )
-                .with_child(
-                    Label::new(
-                        project.visible_worktree_root_names.join(", "),
-                        row.name.text.clone(),
-                    )
-                    .aligned()
-                    .left()
-                    .contained()
-                    .with_style(row.name.container)
-                    .flex(1., false)
-                    .boxed(),
-                )
-                .with_children(project.guests.iter().filter_map(|participant| {
-                    participant.avatar.clone().map(|avatar| {
-                        Image::new(avatar)
-                            .with_style(row.guest_avatar)
-                            .aligned()
-                            .left()
-                            .contained()
-                            .with_margin_right(row.guest_avatar_spacing)
-                            .boxed()
-                    })
-                }))
-                .constrained()
-                .with_height(theme.row_height)
-                .contained()
-                .with_style(row.container)
-                .boxed()
-        })
-        .with_cursor_style(if !is_host {
-            CursorStyle::PointingHand
-        } else {
-            CursorStyle::Arrow
-        })
-        .on_click(MouseButton::Left, move |_, cx| {
-            if !is_host {
-                cx.dispatch_global_action(JoinProject {
-                    contact: contact.clone(),
-                    project_index,
-                });
-            }
-        })
-        .boxed()
-    }
-
-    fn render_offline_project(
-        project_handle: WeakModelHandle<Project>,
-        theme: &theme::ContactsPanel,
-        tooltip_style: &TooltipStyle,
-        is_selected: bool,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox {
-        let host_avatar_height = theme
-            .contact_avatar
-            .width
-            .or(theme.contact_avatar.height)
-            .unwrap_or(0.);
-
-        enum LocalProject {}
-        enum ToggleOnline {}
-
-        let project_id = project_handle.id();
-        MouseEventHandler::<LocalProject>::new(project_id, cx, |state, cx| {
-            let row = theme.project_row.style_for(state, is_selected);
-            let mut worktree_root_names = String::new();
-            let project = if let Some(project) = project_handle.upgrade(cx.deref_mut()) {
-                project.read(cx)
-            } else {
-                return Empty::new().boxed();
-            };
-            let is_going_online = project.is_online();
-            for tree in project.visible_worktrees(cx) {
-                if !worktree_root_names.is_empty() {
-                    worktree_root_names.push_str(", ");
-                }
-                worktree_root_names.push_str(tree.read(cx).root_name());
-            }
-
-            Flex::row()
-                .with_child({
-                    let button =
-                        MouseEventHandler::<ToggleOnline>::new(project_id, cx, |state, _| {
-                            let mut style = *theme.private_button.style_for(state, false);
-                            if is_going_online {
-                                style.color = theme.disabled_button.color;
-                            }
-                            render_icon_button(&style, "icons/lock_8.svg")
-                                .aligned()
-                                .constrained()
-                                .with_width(host_avatar_height)
-                                .boxed()
-                        });
-
-                    if is_going_online {
-                        button.boxed()
-                    } else {
-                        button
-                            .with_cursor_style(CursorStyle::PointingHand)
-                            .on_click(MouseButton::Left, move |_, cx| {
-                                let project = project_handle.upgrade(cx.app);
-                                cx.dispatch_action(ToggleProjectOnline { project })
-                            })
-                            .with_tooltip::<ToggleOnline, _>(
-                                project_id,
-                                "Take project online".to_string(),
-                                None,
-                                tooltip_style.clone(),
-                                cx,
-                            )
-                            .boxed()
-                    }
-                })
-                .with_child(
-                    Label::new(worktree_root_names, row.name.text.clone())
-                        .aligned()
-                        .left()
-                        .contained()
-                        .with_style(row.name.container)
-                        .flex(1., false)
-                        .boxed(),
-                )
-                .constrained()
-                .with_height(theme.row_height)
-                .contained()
-                .with_style(row.container)
-                .boxed()
-        })
-        .boxed()
-    }
-
-    fn render_contact_request(
-        user: Arc<User>,
-        user_store: ModelHandle<UserStore>,
-        theme: &theme::ContactsPanel,
-        is_incoming: bool,
-        is_selected: bool,
-        cx: &mut RenderContext<ContactsPanel>,
-    ) -> ElementBox {
-        enum Decline {}
-        enum Accept {}
-        enum Cancel {}
-
-        let mut row = Flex::row()
-            .with_children(user.avatar.clone().map(|avatar| {
-                Image::new(avatar)
-                    .with_style(theme.contact_avatar)
-                    .aligned()
-                    .left()
-                    .boxed()
-            }))
-            .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)
-                .boxed(),
-            );
-
-        let user_id = user.id;
-        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_children([
-                MouseEventHandler::<Decline>::new(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, false)
-                    };
-                    render_icon_button(button_style, "icons/x_mark_8.svg")
-                        .aligned()
-                        // .flex_float()
-                        .boxed()
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, cx| {
-                    cx.dispatch_action(RespondToContactRequest {
-                        user_id,
-                        accept: false,
-                    })
-                })
-                // .flex_float()
-                .contained()
-                .with_margin_right(button_spacing)
-                .boxed(),
-                MouseEventHandler::<Accept>::new(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, false)
-                    };
-                    render_icon_button(button_style, "icons/check_8.svg")
-                        .aligned()
-                        .flex_float()
-                        .boxed()
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, cx| {
-                    cx.dispatch_action(RespondToContactRequest {
-                        user_id,
-                        accept: true,
-                    })
-                })
-                .boxed(),
-            ]);
-        } else {
-            row.add_child(
-                MouseEventHandler::<Cancel>::new(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, false)
-                    };
-                    render_icon_button(button_style, "icons/x_mark_8.svg")
-                        .aligned()
-                        .flex_float()
-                        .boxed()
-                })
-                .with_padding(Padding::uniform(2.))
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, cx| {
-                    cx.dispatch_action(RemoveContact(user_id))
-                })
-                .flex_float()
-                .boxed(),
-            );
-        }
-
-        row.constrained()
-            .with_height(theme.row_height)
-            .contained()
-            .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
-            .boxed()
-    }
-
-    fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
-        let user_store = self.user_store.read(cx);
-        let project_store = self.project_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());
-        self.entries.clear();
-
-        let mut request_entries = Vec::new();
-        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| ContactEntry::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| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
-            );
-        }
-
-        if !request_entries.is_empty() {
-            self.entries.push(ContactEntry::Header(Section::Requests));
-            if !self.collapsed_sections.contains(&Section::Requests) {
-                self.entries.append(&mut request_entries);
-            }
-        }
-
-        let current_user = user_store.current_user();
-
-        let contacts = user_store.contacts();
-        if !contacts.is_empty() {
-            // Always put the current user first.
-            self.match_candidates.clear();
-            self.match_candidates.reserve(contacts.len());
-            self.match_candidates.push(StringMatchCandidate {
-                id: 0,
-                string: Default::default(),
-                char_bag: Default::default(),
-            });
-            for (ix, contact) in contacts.iter().enumerate() {
-                let candidate = StringMatchCandidate {
-                    id: ix,
-                    string: contact.user.github_login.clone(),
-                    char_bag: contact.user.github_login.chars().collect(),
-                };
-                if current_user
-                    .as_ref()
-                    .map_or(false, |current_user| current_user.id == contact.user.id)
-                {
-                    self.match_candidates[0] = candidate;
-                } else {
-                    self.match_candidates.push(candidate);
-                }
-            }
-            if self.match_candidates[0].string.is_empty() {
-                self.match_candidates.remove(0);
-            }
-
-            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(ContactEntry::Header(section));
-                    if !self.collapsed_sections.contains(&section) {
-                        for mat in matches {
-                            let contact = &contacts[mat.candidate_id];
-                            self.entries.push(ContactEntry::Contact(contact.clone()));
-
-                            let is_current_user = current_user
-                                .as_ref()
-                                .map_or(false, |user| user.id == contact.user.id);
-                            if is_current_user {
-                                let mut open_projects =
-                                    project_store.projects(cx).collect::<Vec<_>>();
-                                self.entries.extend(
-                                    contact.projects.iter().enumerate().filter_map(
-                                        |(ix, project)| {
-                                            let open_project = open_projects
-                                                .iter()
-                                                .position(|p| {
-                                                    p.read(cx).remote_id() == Some(project.id)
-                                                })
-                                                .map(|ix| open_projects.remove(ix).downgrade());
-                                            if project.visible_worktree_root_names.is_empty() {
-                                                None
-                                            } else {
-                                                Some(ContactEntry::ContactProject(
-                                                    contact.clone(),
-                                                    ix,
-                                                    open_project,
-                                                ))
-                                            }
-                                        },
-                                    ),
-                                );
-                                self.entries.extend(open_projects.into_iter().filter_map(
-                                    |project| {
-                                        if project.read(cx).visible_worktrees(cx).next().is_none() {
-                                            None
-                                        } else {
-                                            Some(ContactEntry::OfflineProject(project.downgrade()))
-                                        }
-                                    },
-                                ));
-                            } else {
-                                self.entries.extend(
-                                    contact.projects.iter().enumerate().filter_map(
-                                        |(ix, project)| {
-                                            if project.visible_worktree_root_names.is_empty() {
-                                                None
-                                            } else {
-                                                Some(ContactEntry::ContactProject(
-                                                    contact.clone(),
-                                                    ix,
-                                                    None,
-                                                ))
-                                            }
-                                        },
-                                    ),
-                                );
-                            }
-                        }
-                    }
-                }
-            }
-        }
-
-        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;
-                }
-            }
-        }
-
-        self.list_state.reset(self.entries.len());
-        cx.notify();
-    }
-
-    fn request_contact(&mut self, request: &RequestContact, cx: &mut ViewContext<Self>) {
-        self.user_store
-            .update(cx, |store, cx| store.request_contact(request.0, cx))
-            .detach();
-    }
-
-    fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
-        self.user_store
-            .update(cx, |store, cx| store.remove_contact(request.0, cx))
-            .detach();
-    }
-
-    fn respond_to_contact_request(
-        &mut self,
-        action: &RespondToContactRequest,
-        cx: &mut ViewContext<Self>,
-    ) {
-        self.user_store
-            .update(cx, |store, cx| {
-                store.respond_to_contact_request(action.user_id, action.accept, cx)
-            })
-            .detach();
-    }
-
-    fn clear_filter(&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);
-                true
-            } else {
-                false
-            }
-        });
-        if !did_clear {
-            cx.propagate_action();
-        }
-    }
-
-    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
-        if let Some(ix) = self.selection {
-            if self.entries.len() > ix + 1 {
-                self.selection = Some(ix + 1);
-            }
-        } else if !self.entries.is_empty() {
-            self.selection = Some(0);
-        }
-        cx.notify();
-        self.list_state.reset(self.entries.len());
-    }
-
-    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
-        if let Some(ix) = self.selection {
-            if ix > 0 {
-                self.selection = Some(ix - 1);
-            } else {
-                self.selection = None;
-            }
-        }
-        cx.notify();
-        self.list_state.reset(self.entries.len());
-    }
-
-    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
-        if let Some(selection) = self.selection {
-            if let Some(entry) = self.entries.get(selection) {
-                match entry {
-                    ContactEntry::Header(section) => {
-                        let section = *section;
-                        self.toggle_expanded(&ToggleExpanded(section), cx);
-                    }
-                    ContactEntry::ContactProject(contact, project_index, open_project) => {
-                        if let Some(open_project) = open_project {
-                            workspace::activate_workspace_for_project(cx, |_, cx| {
-                                cx.model_id() == open_project.id()
-                            });
-                        } else {
-                            cx.dispatch_global_action(JoinProject {
-                                contact: contact.clone(),
-                                project_index: *project_index,
-                            })
-                        }
-                    }
-                    _ => {}
-                }
-            }
-        }
-    }
-
-    fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
-        let section = action.0;
-        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(cx);
-    }
-}
-
-impl SidebarItem for ContactsPanel {
-    fn should_show_badge(&self, cx: &AppContext) -> bool {
-        !self
-            .user_store
-            .read(cx)
-            .incoming_contact_requests()
-            .is_empty()
-    }
-
-    fn contains_focused_view(&self, cx: &AppContext) -> bool {
-        self.filter_editor.is_focused(cx)
-    }
-
-    fn should_activate_item_on_event(&self, event: &Event, _: &AppContext) -> bool {
-        matches!(event, Event::Activate)
-    }
-}
-
-fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
-    Svg::new(svg_path)
-        .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)
-}
-
-pub enum Event {
-    Activate,
-}
-
-impl Entity for ContactsPanel {
-    type Event = Event;
-}
-
-impl View for ContactsPanel {
-    fn ui_name() -> &'static str {
-        "ContactsPanel"
-    }
-
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        enum AddContact {}
-
-        let theme = cx.global::<Settings>().theme.clone();
-        let theme = &theme.contacts_panel;
-        Container::new(
-            Flex::column()
-                .with_child(
-                    Flex::row()
-                        .with_child(
-                            ChildView::new(self.filter_editor.clone())
-                                .contained()
-                                .with_style(theme.user_query_editor.container)
-                                .flex(1., true)
-                                .boxed(),
-                        )
-                        .with_child(
-                            MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
-                                Svg::new("icons/user_plus_16.svg")
-                                    .with_color(theme.add_contact_button.color)
-                                    .constrained()
-                                    .with_height(16.)
-                                    .contained()
-                                    .with_style(theme.add_contact_button.container)
-                                    .aligned()
-                                    .boxed()
-                            })
-                            .with_cursor_style(CursorStyle::PointingHand)
-                            .on_click(MouseButton::Left, |_, cx| {
-                                cx.dispatch_action(contact_finder::Toggle)
-                            })
-                            .boxed(),
-                        )
-                        .constrained()
-                        .with_height(theme.user_query_editor_height)
-                        .boxed(),
-                )
-                .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
-                .with_children(
-                    self.user_store
-                        .read(cx)
-                        .invite_info()
-                        .cloned()
-                        .and_then(|info| {
-                            enum InviteLink {}
-
-                            if info.count > 0 {
-                                Some(
-                                    MouseEventHandler::<InviteLink>::new(0, cx, |state, cx| {
-                                        let style =
-                                            theme.invite_row.style_for(state, false).clone();
-
-                                        let copied =
-                                            cx.read_from_clipboard().map_or(false, |item| {
-                                                item.text().as_str() == info.url.as_ref()
-                                            });
-
-                                        Label::new(
-                                            format!(
-                                                "{} invite link ({} left)",
-                                                if copied { "Copied" } else { "Copy" },
-                                                info.count
-                                            ),
-                                            style.label.clone(),
-                                        )
-                                        .aligned()
-                                        .left()
-                                        .constrained()
-                                        .with_height(theme.row_height)
-                                        .contained()
-                                        .with_style(style.container)
-                                        .boxed()
-                                    })
-                                    .with_cursor_style(CursorStyle::PointingHand)
-                                    .on_click(MouseButton::Left, move |_, cx| {
-                                        cx.write_to_clipboard(ClipboardItem::new(
-                                            info.url.to_string(),
-                                        ));
-                                        cx.notify();
-                                    })
-                                    .boxed(),
-                                )
-                            } else {
-                                None
-                            }
-                        }),
-                )
-                .boxed(),
-        )
-        .with_style(theme.container)
-        .boxed()
-    }
-
-    fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
-        cx.focus(&self.filter_editor);
-    }
-
-    fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
-        let mut cx = Self::default_keymap_context();
-        cx.set.insert("menu".into());
-        cx
-    }
-}
-
-impl PartialEq for ContactEntry {
-    fn eq(&self, other: &Self) -> bool {
-        match self {
-            ContactEntry::Header(section_1) => {
-                if let ContactEntry::Header(section_2) = other {
-                    return section_1 == section_2;
-                }
-            }
-            ContactEntry::IncomingRequest(user_1) => {
-                if let ContactEntry::IncomingRequest(user_2) = other {
-                    return user_1.id == user_2.id;
-                }
-            }
-            ContactEntry::OutgoingRequest(user_1) => {
-                if let ContactEntry::OutgoingRequest(user_2) = other {
-                    return user_1.id == user_2.id;
-                }
-            }
-            ContactEntry::Contact(contact_1) => {
-                if let ContactEntry::Contact(contact_2) = other {
-                    return contact_1.user.id == contact_2.user.id;
-                }
-            }
-            ContactEntry::ContactProject(contact_1, ix_1, _) => {
-                if let ContactEntry::ContactProject(contact_2, ix_2, _) = other {
-                    return contact_1.user.id == contact_2.user.id && ix_1 == ix_2;
-                }
-            }
-            ContactEntry::OfflineProject(project_1) => {
-                if let ContactEntry::OfflineProject(project_2) = other {
-                    return project_1.id() == project_2.id();
-                }
-            }
-        }
-        false
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use client::{
-        proto,
-        test::{FakeHttpClient, FakeServer},
-        Client,
-    };
-    use collections::HashSet;
-    use gpui::{serde_json::json, TestAppContext};
-    use language::LanguageRegistry;
-    use project::{FakeFs, Project};
-
-    #[gpui::test]
-    async fn test_contact_panel(cx: &mut TestAppContext) {
-        Settings::test_async(cx);
-        let current_user_id = 100;
-
-        let languages = Arc::new(LanguageRegistry::test());
-        let http_client = FakeHttpClient::with_404_response();
-        let client = cx.read(|cx| Client::new(http_client.clone(), cx));
-        let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
-        let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
-        let server = FakeServer::for_client(current_user_id, &client, cx).await;
-
-        let fs = FakeFs::new(cx.background());
-        fs.insert_tree("/private_dir", json!({ "one.rs": "" }))
-            .await;
-        let project = cx.update(|cx| {
-            Project::local(
-                false,
-                client.clone(),
-                user_store.clone(),
-                project_store.clone(),
-                languages,
-                fs,
-                cx,
-            )
-        });
-        let worktree_id = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/private_dir", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |worktree, _| worktree.id().to_proto());
-
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
-        let panel = cx.add_view(&workspace, |cx| {
-            ContactsPanel::new(
-                user_store.clone(),
-                project_store.clone(),
-                workspace.downgrade(),
-                cx,
-            )
-        });
-
-        workspace.update(cx, |_, cx| {
-            cx.observe(&panel, |_, panel, cx| {
-                let entries = render_to_strings(&panel, cx);
-                assert!(
-                    entries.iter().collect::<HashSet<_>>().len() == entries.len(),
-                    "Duplicate contact panel entries {:?}",
-                    entries
-                )
-            })
-            .detach();
-        });
-
-        let get_users_request = server.receive::<proto::GetUsers>().await.unwrap();
-        server
-            .respond(
-                get_users_request.receipt(),
-                proto::UsersResponse {
-                    users: [
-                        "user_zero",
-                        "user_one",
-                        "user_two",
-                        "user_three",
-                        "user_four",
-                        "user_five",
-                    ]
-                    .into_iter()
-                    .enumerate()
-                    .map(|(id, name)| proto::User {
-                        id: id as u64,
-                        github_login: name.to_string(),
-                        ..Default::default()
-                    })
-                    .chain([proto::User {
-                        id: current_user_id,
-                        github_login: "the_current_user".to_string(),
-                        ..Default::default()
-                    }])
-                    .collect(),
-                },
-            )
-            .await;
-
-        let request = server.receive::<proto::RegisterProject>().await.unwrap();
-        server
-            .respond(
-                request.receipt(),
-                proto::RegisterProjectResponse { project_id: 200 },
-            )
-            .await;
-
-        server.send(proto::UpdateContacts {
-            incoming_requests: vec![proto::IncomingContactRequest {
-                requester_id: 1,
-                should_notify: false,
-            }],
-            outgoing_requests: vec![2],
-            contacts: vec![
-                proto::Contact {
-                    user_id: 3,
-                    online: true,
-                    should_notify: false,
-                    projects: vec![proto::ProjectMetadata {
-                        id: 101,
-                        visible_worktree_root_names: vec!["dir1".to_string()],
-                        guests: vec![2],
-                    }],
-                },
-                proto::Contact {
-                    user_id: 4,
-                    online: true,
-                    should_notify: false,
-                    projects: vec![proto::ProjectMetadata {
-                        id: 102,
-                        visible_worktree_root_names: vec!["dir2".to_string()],
-                        guests: vec![2],
-                    }],
-                },
-                proto::Contact {
-                    user_id: 5,
-                    online: false,
-                    should_notify: false,
-                    projects: vec![],
-                },
-                proto::Contact {
-                    user_id: current_user_id,
-                    online: true,
-                    should_notify: false,
-                    projects: vec![proto::ProjectMetadata {
-                        id: 103,
-                        visible_worktree_root_names: vec!["dir3".to_string()],
-                        guests: vec![3],
-                    }],
-                },
-            ],
-            ..Default::default()
-        });
-
-        assert_eq!(
-            server
-                .receive::<proto::UpdateProject>()
-                .await
-                .unwrap()
-                .payload,
-            proto::UpdateProject {
-                project_id: 200,
-                online: false,
-                worktrees: vec![]
-            },
-        );
-
-        cx.foreground().run_until_parked();
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Requests",
-                "  incoming user_one",
-                "  outgoing user_two",
-                "v Online",
-                "  the_current_user",
-                "    dir3",
-                "    🔒 private_dir",
-                "  user_four",
-                "    dir2",
-                "  user_three",
-                "    dir1",
-                "v Offline",
-                "  user_five",
-            ]
-        );
-
-        // Take a project online. It appears as loading, since the project
-        // isn't yet visible to other contacts.
-        project.update(cx, |project, cx| project.set_online(true, cx));
-        cx.foreground().run_until_parked();
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Requests",
-                "  incoming user_one",
-                "  outgoing user_two",
-                "v Online",
-                "  the_current_user",
-                "    dir3",
-                "    🔒 private_dir (going online...)",
-                "  user_four",
-                "    dir2",
-                "  user_three",
-                "    dir1",
-                "v Offline",
-                "  user_five",
-            ]
-        );
-
-        // The server receives the project's metadata and updates the contact metadata
-        // for the current user. Now the project appears as online.
-        assert_eq!(
-            server
-                .receive::<proto::UpdateProject>()
-                .await
-                .unwrap()
-                .payload,
-            proto::UpdateProject {
-                project_id: 200,
-                online: true,
-                worktrees: vec![proto::WorktreeMetadata {
-                    id: worktree_id,
-                    root_name: "private_dir".to_string(),
-                    visible: true,
-                }]
-            },
-        );
-        server
-            .receive::<proto::UpdateWorktreeExtensions>()
-            .await
-            .unwrap();
-
-        server.send(proto::UpdateContacts {
-            contacts: vec![proto::Contact {
-                user_id: current_user_id,
-                online: true,
-                should_notify: false,
-                projects: vec![
-                    proto::ProjectMetadata {
-                        id: 103,
-                        visible_worktree_root_names: vec!["dir3".to_string()],
-                        guests: vec![3],
-                    },
-                    proto::ProjectMetadata {
-                        id: 200,
-                        visible_worktree_root_names: vec!["private_dir".to_string()],
-                        guests: vec![3],
-                    },
-                ],
-            }],
-            ..Default::default()
-        });
-        cx.foreground().run_until_parked();
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Requests",
-                "  incoming user_one",
-                "  outgoing user_two",
-                "v Online",
-                "  the_current_user",
-                "    dir3",
-                "    private_dir",
-                "  user_four",
-                "    dir2",
-                "  user_three",
-                "    dir1",
-                "v Offline",
-                "  user_five",
-            ]
-        );
-
-        // Take the project offline. It appears as loading.
-        project.update(cx, |project, cx| project.set_online(false, cx));
-        cx.foreground().run_until_parked();
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Requests",
-                "  incoming user_one",
-                "  outgoing user_two",
-                "v Online",
-                "  the_current_user",
-                "    dir3",
-                "    private_dir (going offline...)",
-                "  user_four",
-                "    dir2",
-                "  user_three",
-                "    dir1",
-                "v Offline",
-                "  user_five",
-            ]
-        );
-
-        // The server receives the unregister request and updates the contact
-        // metadata for the current user. The project is now offline.
-        assert_eq!(
-            server
-                .receive::<proto::UpdateProject>()
-                .await
-                .unwrap()
-                .payload,
-            proto::UpdateProject {
-                project_id: 200,
-                online: false,
-                worktrees: vec![]
-            },
-        );
-
-        server.send(proto::UpdateContacts {
-            contacts: vec![proto::Contact {
-                user_id: current_user_id,
-                online: true,
-                should_notify: false,
-                projects: vec![proto::ProjectMetadata {
-                    id: 103,
-                    visible_worktree_root_names: vec!["dir3".to_string()],
-                    guests: vec![3],
-                }],
-            }],
-            ..Default::default()
-        });
-        cx.foreground().run_until_parked();
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Requests",
-                "  incoming user_one",
-                "  outgoing user_two",
-                "v Online",
-                "  the_current_user",
-                "    dir3",
-                "    🔒 private_dir",
-                "  user_four",
-                "    dir2",
-                "  user_three",
-                "    dir1",
-                "v Offline",
-                "  user_five",
-            ]
-        );
-
-        panel.update(cx, |panel, cx| {
-            panel
-                .filter_editor
-                .update(cx, |editor, cx| editor.set_text("f", cx))
-        });
-        cx.foreground().run_until_parked();
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Online",
-                "  user_four  <=== selected",
-                "    dir2",
-                "v Offline",
-                "  user_five",
-            ]
-        );
-
-        panel.update(cx, |panel, cx| {
-            panel.select_next(&Default::default(), cx);
-        });
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Online",
-                "  user_four",
-                "    dir2  <=== selected",
-                "v Offline",
-                "  user_five",
-            ]
-        );
-
-        panel.update(cx, |panel, cx| {
-            panel.select_next(&Default::default(), cx);
-        });
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Online",
-                "  user_four",
-                "    dir2",
-                "v Offline  <=== selected",
-                "  user_five",
-            ]
-        );
-    }
-
-    fn render_to_strings(panel: &ViewHandle<ContactsPanel>, cx: &AppContext) -> Vec<String> {
-        let panel = panel.read(cx);
-        let mut entries = Vec::new();
-        entries.extend(panel.entries.iter().enumerate().map(|(ix, entry)| {
-            let mut string = match entry {
-                ContactEntry::Header(name) => {
-                    let icon = if panel.collapsed_sections.contains(name) {
-                        ">"
-                    } else {
-                        "v"
-                    };
-                    format!("{} {:?}", icon, name)
-                }
-                ContactEntry::IncomingRequest(user) => {
-                    format!("  incoming {}", user.github_login)
-                }
-                ContactEntry::OutgoingRequest(user) => {
-                    format!("  outgoing {}", user.github_login)
-                }
-                ContactEntry::Contact(contact) => {
-                    format!("  {}", contact.user.github_login)
-                }
-                ContactEntry::ContactProject(contact, project_ix, project) => {
-                    let project = project
-                        .and_then(|p| p.upgrade(cx))
-                        .map(|project| project.read(cx));
-                    format!(
-                        "    {}{}",
-                        contact.projects[*project_ix]
-                            .visible_worktree_root_names
-                            .join(", "),
-                        if project.map_or(true, |project| project.is_online()) {
-                            ""
-                        } else {
-                            " (going offline...)"
-                        },
-                    )
-                }
-                ContactEntry::OfflineProject(project) => {
-                    let project = project.upgrade(cx).unwrap().read(cx);
-                    format!(
-                        "    🔒 {}{}",
-                        project
-                            .worktree_root_names(cx)
-                            .collect::<Vec<_>>()
-                            .join(", "),
-                        if project.is_online() {
-                            " (going online...)"
-                        } else {
-                            ""
-                        },
-                    )
-                }
-            };
-
-            if panel.selection == Some(ix) {
-                string.push_str("  <=== selected");
-            }
-
-            string
-        }));
-        entries
-    }
-}

crates/contacts_panel/src/join_project_notification.rs 🔗

@@ -1,80 +0,0 @@
-use client::User;
-use gpui::{
-    actions, ElementBox, Entity, ModelHandle, MutableAppContext, RenderContext, View, ViewContext,
-};
-use project::Project;
-use std::sync::Arc;
-use workspace::Notification;
-
-use crate::notifications::render_user_notification;
-
-pub fn init(cx: &mut MutableAppContext) {
-    cx.add_action(JoinProjectNotification::decline);
-    cx.add_action(JoinProjectNotification::accept);
-}
-
-pub enum Event {
-    Dismiss,
-}
-
-actions!(contacts_panel, [Accept, Decline]);
-
-pub struct JoinProjectNotification {
-    project: ModelHandle<Project>,
-    user: Arc<User>,
-}
-
-impl JoinProjectNotification {
-    pub fn new(project: ModelHandle<Project>, user: Arc<User>, cx: &mut ViewContext<Self>) -> Self {
-        cx.subscribe(&project, |this, _, event, cx| {
-            if let project::Event::ContactCancelledJoinRequest(user) = event {
-                if *user == this.user {
-                    cx.emit(Event::Dismiss);
-                }
-            }
-        })
-        .detach();
-        Self { project, user }
-    }
-
-    fn decline(&mut self, _: &Decline, cx: &mut ViewContext<Self>) {
-        self.project.update(cx, |project, cx| {
-            project.respond_to_join_request(self.user.id, false, cx)
-        });
-        cx.emit(Event::Dismiss)
-    }
-
-    fn accept(&mut self, _: &Accept, cx: &mut ViewContext<Self>) {
-        self.project.update(cx, |project, cx| {
-            project.respond_to_join_request(self.user.id, true, cx)
-        });
-        cx.emit(Event::Dismiss)
-    }
-}
-
-impl Entity for JoinProjectNotification {
-    type Event = Event;
-}
-
-impl View for JoinProjectNotification {
-    fn ui_name() -> &'static str {
-        "JoinProjectNotification"
-    }
-
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        render_user_notification(
-            self.user.clone(),
-            "wants to join your project",
-            None,
-            Decline,
-            vec![("Decline", Box::new(Decline)), ("Accept", Box::new(Accept))],
-            cx,
-        )
-    }
-}
-
-impl Notification for JoinProjectNotification {
-    fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
-        matches!(event, Event::Dismiss)
-    }
-}

crates/contacts_status_item/Cargo.toml 🔗

@@ -1,32 +0,0 @@
-[package]
-name = "contacts_status_item"
-version = "0.1.0"
-edition = "2021"
-
-[lib]
-path = "src/contacts_status_item.rs"
-doctest = false
-
-[dependencies]
-client = { path = "../client" }
-collections = { path = "../collections" }
-editor = { path = "../editor" }
-fuzzy = { path = "../fuzzy" }
-gpui = { path = "../gpui" }
-menu = { path = "../menu" }
-picker = { path = "../picker" }
-project = { path = "../project" }
-settings = { path = "../settings" }
-theme = { path = "../theme" }
-util = { path = "../util" }
-workspace = { path = "../workspace" }
-anyhow = "1.0"
-futures = "0.3"
-log = "0.4"
-postage = { version = "0.4.1", features = ["futures-traits"] }
-serde = { version = "1.0", features = ["derive", "rc"] }
-
-[dev-dependencies]
-language = { path = "../language", features = ["test-support"] }
-project = { path = "../project", features = ["test-support"] }
-workspace = { path = "../workspace", features = ["test-support"] }

crates/contacts_status_item/src/contacts_popover.rs 🔗

@@ -1,94 +0,0 @@
-use editor::Editor;
-use gpui::{elements::*, Entity, RenderContext, View, ViewContext, ViewHandle};
-use settings::Settings;
-
-pub enum Event {
-    Deactivated,
-}
-
-pub struct ContactsPopover {
-    filter_editor: ViewHandle<Editor>,
-}
-
-impl Entity for ContactsPopover {
-    type Event = Event;
-}
-
-impl View for ContactsPopover {
-    fn ui_name() -> &'static str {
-        "ContactsPopover"
-    }
-
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let theme = &cx.global::<Settings>().theme.contacts_popover;
-
-        Flex::row()
-            .with_child(
-                ChildView::new(self.filter_editor.clone())
-                    .contained()
-                    .with_style(
-                        cx.global::<Settings>()
-                            .theme
-                            .contacts_panel
-                            .user_query_editor
-                            .container,
-                    )
-                    .flex(1., true)
-                    .boxed(),
-            )
-            // .with_child(
-            //     MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
-            //         Svg::new("icons/user_plus_16.svg")
-            //             .with_color(theme.add_contact_button.color)
-            //             .constrained()
-            //             .with_height(16.)
-            //             .contained()
-            //             .with_style(theme.add_contact_button.container)
-            //             .aligned()
-            //             .boxed()
-            //     })
-            //     .with_cursor_style(CursorStyle::PointingHand)
-            //     .on_click(MouseButton::Left, |_, cx| {
-            //         cx.dispatch_action(contact_finder::Toggle)
-            //     })
-            //     .boxed(),
-            // )
-            .constrained()
-            .with_height(
-                cx.global::<Settings>()
-                    .theme
-                    .contacts_panel
-                    .user_query_editor_height,
-            )
-            .aligned()
-            .top()
-            .contained()
-            .with_background_color(theme.background)
-            .with_uniform_padding(4.)
-            .boxed()
-    }
-}
-
-impl ContactsPopover {
-    pub fn new(cx: &mut ViewContext<Self>) -> Self {
-        cx.observe_window_activation(Self::window_activation_changed)
-            .detach();
-
-        let filter_editor = cx.add_view(|cx| {
-            let mut editor = Editor::single_line(
-                Some(|theme| theme.contacts_panel.user_query_editor.clone()),
-                cx,
-            );
-            editor.set_placeholder_text("Filter contacts", cx);
-            editor
-        });
-
-        Self { filter_editor }
-    }
-
-    fn window_activation_changed(&mut self, is_active: bool, cx: &mut ViewContext<Self>) {
-        if !is_active {
-            cx.emit(Event::Deactivated);
-        }
-    }
-}

crates/contacts_status_item/src/contacts_status_item.rs 🔗

@@ -1,94 +0,0 @@
-mod contacts_popover;
-
-use contacts_popover::ContactsPopover;
-use gpui::{
-    actions,
-    color::Color,
-    elements::*,
-    geometry::{rect::RectF, vector::vec2f},
-    Appearance, Entity, MouseButton, MutableAppContext, RenderContext, View, ViewContext,
-    ViewHandle, WindowKind,
-};
-
-actions!(contacts_status_item, [ToggleContactsPopover]);
-
-pub fn init(cx: &mut MutableAppContext) {
-    cx.add_action(ContactsStatusItem::toggle_contacts_popover);
-}
-
-pub struct ContactsStatusItem {
-    popover: Option<ViewHandle<ContactsPopover>>,
-}
-
-impl Entity for ContactsStatusItem {
-    type Event = ();
-}
-
-impl View for ContactsStatusItem {
-    fn ui_name() -> &'static str {
-        "ContactsStatusItem"
-    }
-
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let color = match cx.appearance {
-            Appearance::Light | Appearance::VibrantLight => Color::black(),
-            Appearance::Dark | Appearance::VibrantDark => Color::white(),
-        };
-        MouseEventHandler::<Self>::new(0, cx, |_, _| {
-            Svg::new("icons/zed_22.svg")
-                .with_color(color)
-                .aligned()
-                .boxed()
-        })
-        .on_click(MouseButton::Left, |_, cx| {
-            cx.dispatch_action(ToggleContactsPopover);
-        })
-        .boxed()
-    }
-}
-
-impl ContactsStatusItem {
-    pub fn new() -> Self {
-        Self { popover: None }
-    }
-
-    fn toggle_contacts_popover(&mut self, _: &ToggleContactsPopover, cx: &mut ViewContext<Self>) {
-        match self.popover.take() {
-            Some(popover) => {
-                cx.remove_window(popover.window_id());
-            }
-            None => {
-                let window_bounds = cx.window_bounds();
-                let size = vec2f(360., 460.);
-                let origin = window_bounds.lower_left()
-                    + vec2f(window_bounds.width() / 2. - size.x() / 2., 0.);
-                let (_, popover) = cx.add_window(
-                    gpui::WindowOptions {
-                        bounds: gpui::WindowBounds::Fixed(RectF::new(origin, size)),
-                        titlebar: None,
-                        center: false,
-                        kind: WindowKind::PopUp,
-                        is_movable: false,
-                    },
-                    |cx| ContactsPopover::new(cx),
-                );
-                cx.subscribe(&popover, Self::on_popover_event).detach();
-                self.popover = Some(popover);
-            }
-        }
-    }
-
-    fn on_popover_event(
-        &mut self,
-        popover: ViewHandle<ContactsPopover>,
-        event: &contacts_popover::Event,
-        cx: &mut ViewContext<Self>,
-    ) {
-        match event {
-            contacts_popover::Event::Deactivated => {
-                self.popover.take();
-                cx.remove_window(popover.window_id());
-            }
-        }
-    }
-}

crates/editor/src/element.rs 🔗

@@ -1731,7 +1731,8 @@ impl Element for EditorElement {
         layout: &mut Self::LayoutState,
         cx: &mut PaintContext,
     ) -> Self::PaintState {
-        cx.scene.push_layer(Some(bounds));
+        let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
+        cx.scene.push_layer(Some(visible_bounds));
 
         let gutter_bounds = RectF::new(bounds.origin(), layout.gutter_size);
         let text_bounds = RectF::new(

crates/gpui/src/app.rs 🔗

@@ -786,6 +786,24 @@ impl AsyncAppContext {
         self.update(|cx| cx.add_window(window_options, build_root_view))
     }
 
+    pub fn remove_window(&mut self, window_id: usize) {
+        self.update(|cx| cx.remove_window(window_id))
+    }
+
+    pub fn activate_window(&mut self, window_id: usize) {
+        self.update(|cx| cx.activate_window(window_id))
+    }
+
+    pub fn prompt(
+        &mut self,
+        window_id: usize,
+        level: PromptLevel,
+        msg: &str,
+        answers: &[&str],
+    ) -> oneshot::Receiver<usize> {
+        self.update(|cx| cx.prompt(window_id, level, msg, answers))
+    }
+
     pub fn platform(&self) -> Arc<dyn Platform> {
         self.0.borrow().platform()
     }
@@ -1519,6 +1537,17 @@ impl MutableAppContext {
         }
     }
 
+    pub fn observe_default_global<G, F>(&mut self, observe: F) -> Subscription
+    where
+        G: Any + Default,
+        F: 'static + FnMut(&mut MutableAppContext),
+    {
+        if !self.has_global::<G>() {
+            self.set_global(G::default());
+        }
+        self.observe_global::<G, F>(observe)
+    }
+
     pub fn observe_release<E, H, F>(&mut self, handle: &H, callback: F) -> Subscription
     where
         E: Entity,
@@ -1887,6 +1916,10 @@ impl MutableAppContext {
         })
     }
 
+    pub fn clear_globals(&mut self) {
+        self.cx.globals.clear();
+    }
+
     pub fn add_model<T, F>(&mut self, build_model: F) -> ModelHandle<T>
     where
         T: Entity,
@@ -1967,6 +2000,10 @@ impl MutableAppContext {
         })
     }
 
+    pub fn remove_status_bar_item(&mut self, id: usize) {
+        self.remove_window(id);
+    }
+
     fn register_platform_window(
         &mut self,
         window_id: usize,
@@ -4650,6 +4687,12 @@ impl<T> PartialEq for WeakModelHandle<T> {
 
 impl<T> Eq for WeakModelHandle<T> {}
 
+impl<T: Entity> PartialEq<ModelHandle<T>> for WeakModelHandle<T> {
+    fn eq(&self, other: &ModelHandle<T>) -> bool {
+        self.model_id == other.model_id
+    }
+}
+
 impl<T> Clone for WeakModelHandle<T> {
     fn clone(&self) -> Self {
         Self {

crates/gpui/src/elements.rs 🔗

@@ -271,9 +271,6 @@ impl<T: Element> AnyElement for Lifecycle<T> {
                 mut layout,
             } => {
                 let bounds = RectF::new(origin, size);
-                let visible_bounds = visible_bounds
-                    .intersection(bounds)
-                    .unwrap_or_else(|| RectF::new(bounds.origin(), Vector2F::default()));
                 let paint = element.paint(bounds, visible_bounds, &mut layout, cx);
                 Lifecycle::PostPaint {
                     element,
@@ -292,9 +289,6 @@ impl<T: Element> AnyElement for Lifecycle<T> {
                 ..
             } => {
                 let bounds = RectF::new(origin, bounds.size());
-                let visible_bounds = visible_bounds
-                    .intersection(bounds)
-                    .unwrap_or_else(|| RectF::new(bounds.origin(), Vector2F::default()));
                 let paint = element.paint(bounds, visible_bounds, &mut layout, cx);
                 Lifecycle::PostPaint {
                     element,

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

@@ -241,11 +241,12 @@ impl Element for Flex {
         remaining_space: &mut Self::LayoutState,
         cx: &mut PaintContext,
     ) -> Self::PaintState {
-        let mut remaining_space = *remaining_space;
+        let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
 
+        let mut remaining_space = *remaining_space;
         let overflowing = remaining_space < 0.;
         if overflowing {
-            cx.scene.push_layer(Some(bounds));
+            cx.scene.push_layer(Some(visible_bounds));
         }
 
         if let Some(scroll_state) = &self.scroll_state {

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

@@ -27,6 +27,8 @@ pub struct ImageStyle {
     pub height: Option<f32>,
     #[serde(default)]
     pub width: Option<f32>,
+    #[serde(default)]
+    pub grayscale: bool,
 }
 
 impl Image {
@@ -74,6 +76,7 @@ impl Element for Image {
             bounds,
             border: self.style.border,
             corner_radius: self.style.corner_radius,
+            grayscale: self.style.grayscale,
             data: self.data.clone(),
         });
     }

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

@@ -261,7 +261,8 @@ impl Element for List {
         scroll_top: &mut ListOffset,
         cx: &mut PaintContext,
     ) {
-        cx.scene.push_layer(Some(bounds));
+        let visible_bounds = visible_bounds.intersection(bounds).unwrap_or_default();
+        cx.scene.push_layer(Some(visible_bounds));
 
         cx.scene
             .push_mouse_region(MouseRegion::new::<Self>(10, 0, bounds).on_scroll({

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

@@ -169,6 +169,7 @@ impl<Tag> Element for MouseEventHandler<Tag> {
         _: &mut Self::LayoutState,
         cx: &mut PaintContext,
     ) -> Self::PaintState {
+        let visible_bounds = visible_bounds.intersection(bounds).unwrap_or_default();
         let hit_bounds = self.hit_bounds(visible_bounds);
         if let Some(style) = self.cursor_style {
             cx.scene.push_cursor_region(CursorRegion {

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

@@ -217,7 +217,11 @@ impl Element for Overlay {
                 ));
         }
 
-        self.child.paint(bounds.origin(), bounds, cx);
+        self.child.paint(
+            bounds.origin(),
+            RectF::new(Vector2F::zero(), cx.window_size),
+            cx,
+        );
         cx.scene.pop_stacking_context();
     }
 

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

@@ -284,7 +284,9 @@ impl Element for UniformList {
         layout: &mut Self::LayoutState,
         cx: &mut PaintContext,
     ) -> Self::PaintState {
-        cx.scene.push_layer(Some(bounds));
+        let visible_bounds = visible_bounds.intersection(bounds).unwrap_or_default();
+
+        cx.scene.push_layer(Some(visible_bounds));
 
         cx.scene.push_mouse_region(
             MouseRegion::new::<Self>(self.view_id, 0, visible_bounds).on_scroll({

crates/gpui/src/platform.rs 🔗

@@ -44,6 +44,8 @@ pub trait Platform: Send + Sync {
     fn unhide_other_apps(&self);
     fn quit(&self);
 
+    fn screen_size(&self) -> Vector2F;
+
     fn open_window(
         &self,
         id: usize,

crates/gpui/src/platform/mac/platform.rs 🔗

@@ -2,7 +2,9 @@ use super::{
     event::key_to_native, status_item::StatusItem, BoolExt as _, Dispatcher, FontSystem, Window,
 };
 use crate::{
-    executor, keymap,
+    executor,
+    geometry::vector::{vec2f, Vector2F},
+    keymap,
     platform::{self, CursorStyle},
     Action, AppVersion, ClipboardItem, Event, Menu, MenuItem,
 };
@@ -12,7 +14,7 @@ use cocoa::{
     appkit::{
         NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular,
         NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSPasteboard,
-        NSPasteboardTypeString, NSSavePanel, NSWindow,
+        NSPasteboardTypeString, NSSavePanel, NSScreen, NSWindow,
     },
     base::{id, nil, selector, YES},
     foundation::{
@@ -486,6 +488,14 @@ impl platform::Platform for MacPlatform {
         }
     }
 
+    fn screen_size(&self) -> Vector2F {
+        unsafe {
+            let screen = NSScreen::mainScreen(nil);
+            let frame = NSScreen::frame(screen);
+            vec2f(frame.size.width as f32, frame.size.height as f32)
+        }
+    }
+
     fn open_window(
         &self,
         id: usize,

crates/gpui/src/platform/mac/renderer.rs 🔗

@@ -747,6 +747,7 @@ impl Renderer {
                     border_left: border_width * (image.border.left as usize as f32),
                     border_color: image.border.color.to_uchar4(),
                     corner_radius,
+                    grayscale: image.grayscale as u8,
                 });
         }
 
@@ -769,6 +770,7 @@ impl Renderer {
                         border_left: 0.,
                         border_color: Default::default(),
                         corner_radius: 0.,
+                        grayscale: false as u8,
                     });
             } else {
                 log::warn!("could not render glyph with id {}", image_glyph.id);

crates/gpui/src/platform/mac/shaders/shaders.metal 🔗

@@ -44,6 +44,7 @@ struct QuadFragmentInput {
     float border_left;
     float4 border_color;
     float corner_radius;
+    uchar grayscale; // only used in image shader
 };
 
 float4 quad_sdf(QuadFragmentInput input) {
@@ -110,6 +111,7 @@ vertex QuadFragmentInput quad_vertex(
         quad.border_left,
         coloru_to_colorf(quad.border_color),
         quad.corner_radius,
+        0,
     };
 }
 
@@ -251,6 +253,7 @@ vertex QuadFragmentInput image_vertex(
         image.border_left,
         coloru_to_colorf(image.border_color),
         image.corner_radius,
+        image.grayscale,
     };
 }
 
@@ -260,6 +263,13 @@ fragment float4 image_fragment(
 ) {
     constexpr sampler atlas_sampler(mag_filter::linear, min_filter::linear);
     input.background_color = atlas.sample(atlas_sampler, input.atlas_position);
+    if (input.grayscale) {
+        float grayscale =
+            0.2126 * input.background_color.r +
+            0.7152 * input.background_color.g + 
+            0.0722 * input.background_color.b;
+        input.background_color = float4(grayscale, grayscale, grayscale, input.background_color.a);
+    }
     return quad_sdf(input);
 }
 
@@ -289,6 +299,7 @@ vertex QuadFragmentInput surface_vertex(
         0.,
         float4(0.),
         0.,
+        0,
     };
 }
 

crates/gpui/src/platform/test.rs 🔗

@@ -131,6 +131,10 @@ impl super::Platform for Platform {
 
     fn quit(&self) {}
 
+    fn screen_size(&self) -> Vector2F {
+        vec2f(1024., 768.)
+    }
+
     fn open_window(
         &self,
         _: usize,

crates/gpui/src/scene.rs 🔗

@@ -172,6 +172,7 @@ pub struct Image {
     pub bounds: RectF,
     pub border: Border,
     pub corner_radius: f32,
+    pub grayscale: bool,
     pub data: Arc<ImageData>,
 }
 

crates/gpui/src/test.rs 🔗

@@ -91,7 +91,7 @@ pub fn run_test(
 
                 cx.update(|cx| cx.remove_all_windows());
                 deterministic.run_until_parked();
-                cx.update(|_| {}); // flush effects
+                cx.update(|cx| cx.clear_globals());
 
                 leak_detector.lock().detect();
                 if is_last_iteration {

crates/gpui_macros/src/gpui_macros.rs 🔗

@@ -122,7 +122,7 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
                             cx_teardowns.extend(quote!(
                                 #cx_varname.update(|cx| cx.remove_all_windows());
                                 deterministic.run_until_parked();
-                                #cx_varname.update(|_| {}); // flush effects
+                                #cx_varname.update(|cx| cx.clear_globals());
                             ));
                             inner_fn_args.extend(quote!(&mut #cx_varname,));
                             continue;

crates/picker/src/picker.rs 🔗

@@ -19,6 +19,7 @@ pub struct Picker<D: PickerDelegate> {
     query_editor: ViewHandle<Editor>,
     list_state: UniformListState,
     max_size: Vector2F,
+    theme: Box<dyn FnMut(&AppContext) -> &theme::Picker>,
     confirmed: bool,
 }
 
@@ -51,8 +52,8 @@ impl<D: PickerDelegate> View for Picker<D> {
     }
 
     fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
-        let settings = cx.global::<Settings>();
-        let container_style = settings.theme.picker.container;
+        let theme = (self.theme)(cx);
+        let container_style = theme.container;
         let delegate = self.delegate.clone();
         let match_count = if let Some(delegate) = delegate.upgrade(cx.app) {
             delegate.read(cx).match_count()
@@ -64,17 +65,14 @@ impl<D: PickerDelegate> View for Picker<D> {
             .with_child(
                 ChildView::new(&self.query_editor)
                     .contained()
-                    .with_style(settings.theme.picker.input_editor.container)
+                    .with_style(theme.input_editor.container)
                     .boxed(),
             )
             .with_child(
                 if match_count == 0 {
-                    Label::new(
-                        "No matches".into(),
-                        settings.theme.picker.empty.label.clone(),
-                    )
-                    .contained()
-                    .with_style(settings.theme.picker.empty.container)
+                    Label::new("No matches".into(), theme.empty.label.clone())
+                        .contained()
+                        .with_style(theme.empty.container)
                 } else {
                     UniformList::new(
                         self.list_state.clone(),
@@ -147,6 +145,7 @@ impl<D: PickerDelegate> Picker<D> {
             list_state: Default::default(),
             delegate,
             max_size: vec2f(540., 420.),
+            theme: Box::new(|cx| &cx.global::<Settings>().theme.picker),
             confirmed: false,
         };
         cx.defer(|this, cx| {
@@ -163,6 +162,14 @@ impl<D: PickerDelegate> Picker<D> {
         self
     }
 
+    pub fn with_theme<F>(mut self, theme: F) -> Self
+    where
+        F: 'static + FnMut(&AppContext) -> &theme::Picker,
+    {
+        self.theme = Box::new(theme);
+        self
+    }
+
     pub fn query(&self, cx: &AppContext) -> String {
         self.query_editor.read(cx).text(cx)
     }

crates/project/src/project.rs 🔗

@@ -8,7 +8,7 @@ pub mod worktree;
 mod project_tests;
 
 use anyhow::{anyhow, Context, Result};
-use client::{proto, Client, PeerId, TypedEnvelope, User, UserStore};
+use client::{proto, Client, PeerId, TypedEnvelope, UserStore};
 use clock::ReplicaId;
 use collections::{hash_map, BTreeMap, HashMap, HashSet};
 use futures::{future::Shared, AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt};
@@ -35,7 +35,6 @@ use lsp::{
 };
 use lsp_command::*;
 use parking_lot::Mutex;
-use postage::stream::Stream;
 use postage::watch;
 use rand::prelude::*;
 use search::SearchQuery;
@@ -74,7 +73,6 @@ pub trait Item: Entity {
 }
 
 pub struct ProjectStore {
-    db: Arc<Db>,
     projects: Vec<WeakModelHandle<Project>>,
 }
 
@@ -127,7 +125,6 @@ pub struct Project {
     buffer_snapshots: HashMap<u64, Vec<(i32, TextBufferSnapshot)>>,
     buffers_being_formatted: HashSet<usize>,
     nonce: u128,
-    initialized_persistent_state: bool,
     _maintain_buffer_languages: Task<()>,
 }
 
@@ -156,13 +153,8 @@ enum WorktreeHandle {
 
 enum ProjectClientState {
     Local {
-        is_shared: bool,
-        remote_id_tx: watch::Sender<Option<u64>>,
-        remote_id_rx: watch::Receiver<Option<u64>>,
-        online_tx: watch::Sender<bool>,
-        online_rx: watch::Receiver<bool>,
-        _maintain_remote_id: Task<Option<()>>,
-        _maintain_online_status: Task<Option<()>>,
+        remote_id: Option<u64>,
+        _detect_unshare: Task<Option<()>>,
     },
     Remote {
         sharing_has_stopped: bool,
@@ -174,7 +166,6 @@ enum ProjectClientState {
 
 #[derive(Clone, Debug)]
 pub struct Collaborator {
-    pub user: Arc<User>,
     pub peer_id: PeerId,
     pub replica_id: ReplicaId,
 }
@@ -197,8 +188,6 @@ pub enum Event {
     RemoteIdChanged(Option<u64>),
     DisconnectedFromHost,
     CollaboratorLeft(PeerId),
-    ContactRequestedJoin(Arc<User>),
-    ContactCancelledJoinRequest(Arc<User>),
 }
 
 pub enum LanguageServerState {
@@ -383,17 +372,14 @@ impl FormatTrigger {
 
 impl Project {
     pub fn init(client: &Arc<Client>) {
-        client.add_model_message_handler(Self::handle_request_join_project);
         client.add_model_message_handler(Self::handle_add_collaborator);
         client.add_model_message_handler(Self::handle_buffer_reloaded);
         client.add_model_message_handler(Self::handle_buffer_saved);
         client.add_model_message_handler(Self::handle_start_language_server);
         client.add_model_message_handler(Self::handle_update_language_server);
         client.add_model_message_handler(Self::handle_remove_collaborator);
-        client.add_model_message_handler(Self::handle_join_project_request_cancelled);
         client.add_model_message_handler(Self::handle_update_project);
-        client.add_model_message_handler(Self::handle_unregister_project);
-        client.add_model_message_handler(Self::handle_project_unshared);
+        client.add_model_message_handler(Self::handle_unshare_project);
         client.add_model_message_handler(Self::handle_create_buffer_for_peer);
         client.add_model_message_handler(Self::handle_update_buffer_file);
         client.add_model_message_handler(Self::handle_update_buffer);
@@ -426,7 +412,6 @@ impl Project {
     }
 
     pub fn local(
-        online: bool,
         client: Arc<Client>,
         user_store: ModelHandle<UserStore>,
         project_store: ModelHandle<ProjectStore>,
@@ -435,41 +420,19 @@ impl Project {
         cx: &mut MutableAppContext,
     ) -> ModelHandle<Self> {
         cx.add_model(|cx: &mut ModelContext<Self>| {
-            let (remote_id_tx, remote_id_rx) = watch::channel();
-            let _maintain_remote_id = cx.spawn_weak({
-                let mut status_rx = client.clone().status();
-                move |this, mut cx| async move {
-                    while let Some(status) = status_rx.recv().await {
-                        let this = this.upgrade(&cx)?;
-                        if status.is_connected() {
-                            this.update(&mut cx, |this, cx| this.register(cx))
-                                .await
-                                .log_err()?;
-                        } else {
-                            this.update(&mut cx, |this, cx| this.unregister(cx))
-                                .await
-                                .log_err();
+            let mut status = client.status();
+            let _detect_unshare = cx.spawn_weak(move |this, mut cx| {
+                async move {
+                    let is_connected = status.next().await.map_or(false, |s| s.is_connected());
+                    // Even if we're initially connected, any future change of the status means we momentarily disconnected.
+                    if !is_connected || status.next().await.is_some() {
+                        if let Some(this) = this.upgrade(&cx) {
+                            let _ = this.update(&mut cx, |this, cx| this.unshare(cx));
                         }
                     }
-                    None
-                }
-            });
-
-            let (online_tx, online_rx) = watch::channel_with(online);
-            let _maintain_online_status = cx.spawn_weak({
-                let mut online_rx = online_rx.clone();
-                move |this, mut cx| async move {
-                    while let Some(online) = online_rx.recv().await {
-                        let this = this.upgrade(&cx)?;
-                        this.update(&mut cx, |this, cx| {
-                            if !online {
-                                this.unshared(cx);
-                            }
-                            this.metadata_changed(false, cx)
-                        });
-                    }
-                    None
+                    Ok(())
                 }
+                .log_err()
             });
 
             let handle = cx.weak_handle();
@@ -485,13 +448,8 @@ impl Project {
                 loading_local_worktrees: Default::default(),
                 buffer_snapshots: Default::default(),
                 client_state: ProjectClientState::Local {
-                    is_shared: false,
-                    remote_id_tx,
-                    remote_id_rx,
-                    online_tx,
-                    online_rx,
-                    _maintain_remote_id,
-                    _maintain_online_status,
+                    remote_id: None,
+                    _detect_unshare,
                 },
                 opened_buffer: watch::channel(),
                 client_subscriptions: Vec::new(),
@@ -513,7 +471,6 @@ impl Project {
                 buffers_being_formatted: Default::default(),
                 next_language_server_id: 0,
                 nonce: StdRng::from_entropy().gen(),
-                initialized_persistent_state: false,
             }
         })
     }
@@ -535,24 +492,6 @@ impl Project {
             })
             .await?;
 
-        let response = match response.variant.ok_or_else(|| anyhow!("missing variant"))? {
-            proto::join_project_response::Variant::Accept(response) => response,
-            proto::join_project_response::Variant::Decline(decline) => {
-                match proto::join_project_response::decline::Reason::from_i32(decline.reason) {
-                    Some(proto::join_project_response::decline::Reason::Declined) => {
-                        Err(JoinProjectError::HostDeclined)?
-                    }
-                    Some(proto::join_project_response::decline::Reason::Closed) => {
-                        Err(JoinProjectError::HostClosedProject)?
-                    }
-                    Some(proto::join_project_response::decline::Reason::WentOffline) => {
-                        Err(JoinProjectError::HostWentOffline)?
-                    }
-                    None => Err(anyhow!("missing decline reason"))?,
-                }
-            }
-        };
-
         let replica_id = response.replica_id as ReplicaId;
 
         let mut worktrees = Vec::new();
@@ -629,7 +568,6 @@ impl Project {
                 buffers_being_formatted: Default::default(),
                 buffer_snapshots: Default::default(),
                 nonce: StdRng::from_entropy().gen(),
-                initialized_persistent_state: false,
             };
             for worktree in worktrees {
                 this.add_worktree(&worktree, cx);
@@ -647,7 +585,7 @@ impl Project {
             .await?;
         let mut collaborators = HashMap::default();
         for message in response.collaborators {
-            let collaborator = Collaborator::from_proto(message, &user_store, &mut cx).await?;
+            let collaborator = Collaborator::from_proto(message);
             collaborators.insert(collaborator.peer_id, collaborator);
         }
 
@@ -672,10 +610,9 @@ impl Project {
         let http_client = client::test::FakeHttpClient::with_404_response();
         let client = cx.update(|cx| client::Client::new(http_client.clone(), cx));
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
-        let project_store = cx.add_model(|_| ProjectStore::new(Db::open_fake()));
-        let project = cx.update(|cx| {
-            Project::local(true, client, user_store, project_store, languages, fs, cx)
-        });
+        let project_store = cx.add_model(|_| ProjectStore::new());
+        let project =
+            cx.update(|cx| Project::local(client, user_store, project_store, languages, fs, cx));
         for path in root_paths {
             let (tree, _) = project
                 .update(cx, |project, cx| {
@@ -689,53 +626,6 @@ impl Project {
         project
     }
 
-    pub fn restore_state(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
-        if self.is_remote() {
-            return Task::ready(Ok(()));
-        }
-
-        let db = self.project_store.read(cx).db.clone();
-        let keys = self.db_keys_for_online_state(cx);
-        let online_by_default = cx.global::<Settings>().projects_online_by_default;
-        let read_online = cx.background().spawn(async move {
-            let values = db.read(keys)?;
-            anyhow::Ok(
-                values
-                    .into_iter()
-                    .all(|e| e.map_or(online_by_default, |e| e == [true as u8])),
-            )
-        });
-        cx.spawn(|this, mut cx| async move {
-            let online = read_online.await.log_err().unwrap_or(false);
-            this.update(&mut cx, |this, cx| {
-                this.initialized_persistent_state = true;
-                if let ProjectClientState::Local { online_tx, .. } = &mut this.client_state {
-                    let mut online_tx = online_tx.borrow_mut();
-                    if *online_tx != online {
-                        *online_tx = online;
-                        drop(online_tx);
-                        this.metadata_changed(false, cx);
-                    }
-                }
-            });
-            Ok(())
-        })
-    }
-
-    fn persist_state(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
-        if self.is_remote() || !self.initialized_persistent_state {
-            return Task::ready(Ok(()));
-        }
-
-        let db = self.project_store.read(cx).db.clone();
-        let keys = self.db_keys_for_online_state(cx);
-        let is_online = self.is_online();
-        cx.background().spawn(async move {
-            let value = &[is_online as u8];
-            db.write(keys.into_iter().map(|key| (key, value)))
-        })
-    }
-
     fn on_settings_changed(&mut self, cx: &mut ModelContext<Self>) {
         let settings = cx.global::<Settings>();
 
@@ -864,136 +754,9 @@ impl Project {
         &self.fs
     }
 
-    pub fn set_online(&mut self, online: bool, _: &mut ModelContext<Self>) {
-        if let ProjectClientState::Local { online_tx, .. } = &mut self.client_state {
-            let mut online_tx = online_tx.borrow_mut();
-            if *online_tx != online {
-                *online_tx = online;
-            }
-        }
-    }
-
-    pub fn is_online(&self) -> bool {
-        match &self.client_state {
-            ProjectClientState::Local { online_rx, .. } => *online_rx.borrow(),
-            ProjectClientState::Remote { .. } => true,
-        }
-    }
-
-    fn unregister(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
-        self.unshared(cx);
-        if let ProjectClientState::Local { remote_id_rx, .. } = &mut self.client_state {
-            if let Some(remote_id) = *remote_id_rx.borrow() {
-                let request = self.client.request(proto::UnregisterProject {
-                    project_id: remote_id,
-                });
-                return cx.spawn(|this, mut cx| async move {
-                    let response = request.await;
-
-                    // Unregistering the project causes the server to send out a
-                    // contact update removing this project from the host's list
-                    // of online projects. Wait until this contact update has been
-                    // processed before clearing out this project's remote id, so
-                    // that there is no moment where this project appears in the
-                    // contact metadata and *also* has no remote id.
-                    this.update(&mut cx, |this, cx| {
-                        this.user_store()
-                            .update(cx, |store, _| store.contact_updates_done())
-                    })
-                    .await;
-
-                    this.update(&mut cx, |this, cx| {
-                        if let ProjectClientState::Local { remote_id_tx, .. } =
-                            &mut this.client_state
-                        {
-                            *remote_id_tx.borrow_mut() = None;
-                        }
-                        this.client_subscriptions.clear();
-                        this.metadata_changed(false, cx);
-                    });
-                    response.map(drop)
-                });
-            }
-        }
-        Task::ready(Ok(()))
-    }
-
-    fn register(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
-        if let ProjectClientState::Local {
-            remote_id_rx,
-            online_rx,
-            ..
-        } = &self.client_state
-        {
-            if remote_id_rx.borrow().is_some() {
-                return Task::ready(Ok(()));
-            }
-
-            let response = self.client.request(proto::RegisterProject {
-                online: *online_rx.borrow(),
-            });
-            cx.spawn(|this, mut cx| async move {
-                let remote_id = response.await?.project_id;
-                this.update(&mut cx, |this, cx| {
-                    if let ProjectClientState::Local { remote_id_tx, .. } = &mut this.client_state {
-                        *remote_id_tx.borrow_mut() = Some(remote_id);
-                    }
-
-                    this.metadata_changed(false, cx);
-                    cx.emit(Event::RemoteIdChanged(Some(remote_id)));
-                    this.client_subscriptions
-                        .push(this.client.add_model_for_remote_entity(remote_id, cx));
-                    Ok(())
-                })
-            })
-        } else {
-            Task::ready(Err(anyhow!("can't register a remote project")))
-        }
-    }
-
     pub fn remote_id(&self) -> Option<u64> {
         match &self.client_state {
-            ProjectClientState::Local { remote_id_rx, .. } => *remote_id_rx.borrow(),
-            ProjectClientState::Remote { remote_id, .. } => Some(*remote_id),
-        }
-    }
-
-    pub fn next_remote_id(&self) -> impl Future<Output = u64> {
-        let mut id = None;
-        let mut watch = None;
-        match &self.client_state {
-            ProjectClientState::Local { remote_id_rx, .. } => watch = Some(remote_id_rx.clone()),
-            ProjectClientState::Remote { remote_id, .. } => id = Some(*remote_id),
-        }
-
-        async move {
-            if let Some(id) = id {
-                return id;
-            }
-            let mut watch = watch.unwrap();
-            loop {
-                let id = *watch.borrow();
-                if let Some(id) = id {
-                    return id;
-                }
-                watch.next().await;
-            }
-        }
-    }
-
-    pub fn shared_remote_id(&self) -> Option<u64> {
-        match &self.client_state {
-            ProjectClientState::Local {
-                remote_id_rx,
-                is_shared,
-                ..
-            } => {
-                if *is_shared {
-                    *remote_id_rx.borrow()
-                } else {
-                    None
-                }
-            }
+            ProjectClientState::Local { remote_id, .. } => *remote_id,
             ProjectClientState::Remote { remote_id, .. } => Some(*remote_id),
         }
     }
@@ -1005,65 +768,50 @@ impl Project {
         }
     }
 
-    fn metadata_changed(&mut self, persist: bool, cx: &mut ModelContext<Self>) {
-        if let ProjectClientState::Local {
-            remote_id_rx,
-            online_rx,
-            ..
-        } = &self.client_state
-        {
+    fn metadata_changed(&mut self, cx: &mut ModelContext<Self>) {
+        if let ProjectClientState::Local { remote_id, .. } = &self.client_state {
             // Broadcast worktrees only if the project is online.
-            let worktrees = if *online_rx.borrow() {
-                self.worktrees
-                    .iter()
-                    .filter_map(|worktree| {
-                        worktree
-                            .upgrade(cx)
-                            .map(|worktree| worktree.read(cx).as_local().unwrap().metadata_proto())
-                    })
-                    .collect()
-            } else {
-                Default::default()
-            };
-            if let Some(project_id) = *remote_id_rx.borrow() {
-                let online = *online_rx.borrow();
+            let worktrees = self
+                .worktrees
+                .iter()
+                .filter_map(|worktree| {
+                    worktree
+                        .upgrade(cx)
+                        .map(|worktree| worktree.read(cx).as_local().unwrap().metadata_proto())
+                })
+                .collect();
+            if let Some(project_id) = *remote_id {
                 self.client
                     .send(proto::UpdateProject {
                         project_id,
                         worktrees,
-                        online,
                     })
                     .log_err();
 
-                if online {
-                    let worktrees = self.visible_worktrees(cx).collect::<Vec<_>>();
-                    let scans_complete =
-                        futures::future::join_all(worktrees.iter().filter_map(|worktree| {
-                            Some(worktree.read(cx).as_local()?.scan_complete())
-                        }));
+                let worktrees = self.visible_worktrees(cx).collect::<Vec<_>>();
+                let scans_complete =
+                    futures::future::join_all(worktrees.iter().filter_map(|worktree| {
+                        Some(worktree.read(cx).as_local()?.scan_complete())
+                    }));
 
-                    let worktrees = worktrees.into_iter().map(|handle| handle.downgrade());
-                    cx.spawn_weak(move |_, cx| async move {
-                        scans_complete.await;
-                        cx.read(|cx| {
-                            for worktree in worktrees {
-                                if let Some(worktree) = worktree
-                                    .upgrade(cx)
-                                    .and_then(|worktree| worktree.read(cx).as_local())
-                                {
-                                    worktree.send_extension_counts(project_id);
-                                }
+                let worktrees = worktrees.into_iter().map(|handle| handle.downgrade());
+                cx.spawn_weak(move |_, cx| async move {
+                    scans_complete.await;
+                    cx.read(|cx| {
+                        for worktree in worktrees {
+                            if let Some(worktree) = worktree
+                                .upgrade(cx)
+                                .and_then(|worktree| worktree.read(cx).as_local())
+                            {
+                                worktree.send_extension_counts(project_id);
                             }
-                        })
+                        }
                     })
-                    .detach();
-                }
+                })
+                .detach();
             }
 
             self.project_store.update(cx, |_, cx| cx.notify());
-            if persist {
-                self.persist_state(cx).detach_and_log_err(cx);
-            }
             cx.notify();
         }
     }
@@ -1101,23 +849,6 @@ impl Project {
             .map(|tree| tree.read(cx).root_name())
     }
 
-    fn db_keys_for_online_state(&self, cx: &AppContext) -> Vec<String> {
-        self.worktrees
-            .iter()
-            .filter_map(|worktree| {
-                let worktree = worktree.upgrade(cx)?.read(cx);
-                if worktree.is_visible() {
-                    Some(format!(
-                        "project-path-online:{}",
-                        worktree.as_local().unwrap().abs_path().to_string_lossy()
-                    ))
-                } else {
-                    None
-                }
-            })
-            .collect::<Vec<_>>()
-    }
-
     pub fn worktree_for_id(
         &self,
         id: WorktreeId,
@@ -1321,142 +1052,106 @@ impl Project {
         }
     }
 
-    fn share(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
-        if !self.is_online() {
-            return Task::ready(Err(anyhow!("can't share an offline project")));
-        }
-
-        let project_id;
-        if let ProjectClientState::Local {
-            remote_id_rx,
-            is_shared,
-            ..
-        } = &mut self.client_state
-        {
-            if *is_shared {
-                return Task::ready(Ok(()));
-            }
-            *is_shared = true;
-            if let Some(id) = *remote_id_rx.borrow() {
-                project_id = id;
-            } else {
-                return Task::ready(Err(anyhow!("project hasn't been registered")));
+    pub fn shared(&mut self, project_id: u64, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        if let ProjectClientState::Local { remote_id, .. } = &mut self.client_state {
+            if remote_id.is_some() {
+                return Task::ready(Err(anyhow!("project was already shared")));
             }
-        } else {
-            return Task::ready(Err(anyhow!("can't share a remote project")));
-        };
 
-        for open_buffer in self.opened_buffers.values_mut() {
-            match open_buffer {
-                OpenBuffer::Strong(_) => {}
-                OpenBuffer::Weak(buffer) => {
-                    if let Some(buffer) = buffer.upgrade(cx) {
-                        *open_buffer = OpenBuffer::Strong(buffer);
+            *remote_id = Some(project_id);
+
+            let mut worktree_share_tasks = Vec::new();
+
+            for open_buffer in self.opened_buffers.values_mut() {
+                match open_buffer {
+                    OpenBuffer::Strong(_) => {}
+                    OpenBuffer::Weak(buffer) => {
+                        if let Some(buffer) = buffer.upgrade(cx) {
+                            *open_buffer = OpenBuffer::Strong(buffer);
+                        }
                     }
+                    OpenBuffer::Operations(_) => unreachable!(),
                 }
-                OpenBuffer::Operations(_) => unreachable!(),
             }
-        }
 
-        for worktree_handle in self.worktrees.iter_mut() {
-            match worktree_handle {
-                WorktreeHandle::Strong(_) => {}
-                WorktreeHandle::Weak(worktree) => {
-                    if let Some(worktree) = worktree.upgrade(cx) {
-                        *worktree_handle = WorktreeHandle::Strong(worktree);
+            for worktree_handle in self.worktrees.iter_mut() {
+                match worktree_handle {
+                    WorktreeHandle::Strong(_) => {}
+                    WorktreeHandle::Weak(worktree) => {
+                        if let Some(worktree) = worktree.upgrade(cx) {
+                            *worktree_handle = WorktreeHandle::Strong(worktree);
+                        }
                     }
                 }
             }
-        }
-
-        let mut tasks = Vec::new();
-        for worktree in self.worktrees(cx).collect::<Vec<_>>() {
-            worktree.update(cx, |worktree, cx| {
-                let worktree = worktree.as_local_mut().unwrap();
-                tasks.push(worktree.share(project_id, cx));
-            });
-        }
 
-        for (server_id, status) in &self.language_server_statuses {
-            self.client
-                .send(proto::StartLanguageServer {
-                    project_id,
-                    server: Some(proto::LanguageServer {
-                        id: *server_id as u64,
-                        name: status.name.clone(),
-                    }),
-                })
-                .log_err();
-        }
+            for worktree in self.worktrees(cx).collect::<Vec<_>>() {
+                worktree.update(cx, |worktree, cx| {
+                    let worktree = worktree.as_local_mut().unwrap();
+                    worktree_share_tasks.push(worktree.share(project_id, cx));
+                });
+            }
 
-        cx.spawn(|this, mut cx| async move {
-            for task in tasks {
-                task.await?;
+            for (server_id, status) in &self.language_server_statuses {
+                self.client
+                    .send(proto::StartLanguageServer {
+                        project_id,
+                        server: Some(proto::LanguageServer {
+                            id: *server_id as u64,
+                            name: status.name.clone(),
+                        }),
+                    })
+                    .log_err();
             }
-            this.update(&mut cx, |_, cx| cx.notify());
-            Ok(())
-        })
+
+            self.client_subscriptions
+                .push(self.client.add_model_for_remote_entity(project_id, cx));
+            self.metadata_changed(cx);
+            cx.emit(Event::RemoteIdChanged(Some(project_id)));
+            cx.notify();
+
+            cx.foreground().spawn(async move {
+                futures::future::try_join_all(worktree_share_tasks).await?;
+                Ok(())
+            })
+        } else {
+            Task::ready(Err(anyhow!("can't share a remote project")))
+        }
     }
 
-    fn unshared(&mut self, cx: &mut ModelContext<Self>) {
-        if let ProjectClientState::Local { is_shared, .. } = &mut self.client_state {
-            if !*is_shared {
-                return;
-            }
+    pub fn unshare(&mut self, cx: &mut ModelContext<Self>) -> Result<()> {
+        if let ProjectClientState::Local { remote_id, .. } = &mut self.client_state {
+            if let Some(project_id) = remote_id.take() {
+                self.collaborators.clear();
+                self.shared_buffers.clear();
+                self.client_subscriptions.clear();
 
-            *is_shared = false;
-            self.collaborators.clear();
-            self.shared_buffers.clear();
-            for worktree_handle in self.worktrees.iter_mut() {
-                if let WorktreeHandle::Strong(worktree) = worktree_handle {
-                    let is_visible = worktree.update(cx, |worktree, _| {
-                        worktree.as_local_mut().unwrap().unshare();
-                        worktree.is_visible()
-                    });
-                    if !is_visible {
-                        *worktree_handle = WorktreeHandle::Weak(worktree.downgrade());
+                for worktree_handle in self.worktrees.iter_mut() {
+                    if let WorktreeHandle::Strong(worktree) = worktree_handle {
+                        let is_visible = worktree.update(cx, |worktree, _| {
+                            worktree.as_local_mut().unwrap().unshare();
+                            worktree.is_visible()
+                        });
+                        if !is_visible {
+                            *worktree_handle = WorktreeHandle::Weak(worktree.downgrade());
+                        }
                     }
                 }
-            }
 
-            for open_buffer in self.opened_buffers.values_mut() {
-                if let OpenBuffer::Strong(buffer) = open_buffer {
-                    *open_buffer = OpenBuffer::Weak(buffer.downgrade());
+                for open_buffer in self.opened_buffers.values_mut() {
+                    if let OpenBuffer::Strong(buffer) = open_buffer {
+                        *open_buffer = OpenBuffer::Weak(buffer.downgrade());
+                    }
                 }
+
+                self.metadata_changed(cx);
+                cx.notify();
+                self.client.send(proto::UnshareProject { project_id })?;
             }
 
-            cx.notify();
+            Ok(())
         } else {
-            log::error!("attempted to unshare a remote project");
-        }
-    }
-
-    pub fn respond_to_join_request(
-        &mut self,
-        requester_id: u64,
-        allow: bool,
-        cx: &mut ModelContext<Self>,
-    ) {
-        if let Some(project_id) = self.remote_id() {
-            let share = if self.is_online() && allow {
-                Some(self.share(cx))
-            } else {
-                None
-            };
-            let client = self.client.clone();
-            cx.foreground()
-                .spawn(async move {
-                    client.send(proto::RespondToJoinProjectRequest {
-                        requester_id,
-                        project_id,
-                        allow,
-                    })?;
-                    if let Some(share) = share {
-                        share.await?;
-                    }
-                    anyhow::Ok(())
-                })
-                .detach_and_log_err(cx);
+            Err(anyhow!("attempted to unshare a remote project"))
         }
     }
 
@@ -1930,7 +1625,7 @@ impl Project {
     ) -> Option<()> {
         match event {
             BufferEvent::Operation(operation) => {
-                if let Some(project_id) = self.shared_remote_id() {
+                if let Some(project_id) = self.remote_id() {
                     let request = self.client.request(proto::UpdateBuffer {
                         project_id,
                         buffer_id: buffer.read(cx).remote_id(),
@@ -2335,7 +2030,7 @@ impl Project {
                                 )
                                 .ok();
 
-                            if let Some(project_id) = this.shared_remote_id() {
+                            if let Some(project_id) = this.remote_id() {
                                 this.client
                                     .send(proto::StartLanguageServer {
                                         project_id,
@@ -2742,7 +2437,7 @@ impl Project {
         language_server_id: usize,
         event: proto::update_language_server::Variant,
     ) {
-        if let Some(project_id) = self.shared_remote_id() {
+        if let Some(project_id) = self.remote_id() {
             self.client
                 .send(proto::UpdateLanguageServer {
                     project_id,
@@ -4472,7 +4167,7 @@ impl Project {
 
     pub fn is_shared(&self) -> bool {
         match &self.client_state {
-            ProjectClientState::Local { is_shared, .. } => *is_shared,
+            ProjectClientState::Local { remote_id, .. } => remote_id.is_some(),
             ProjectClientState::Remote { .. } => false,
         }
     }
@@ -4509,7 +4204,7 @@ impl Project {
 
                         let project_id = project.update(&mut cx, |project, cx| {
                             project.add_worktree(&worktree, cx);
-                            project.shared_remote_id()
+                            project.remote_id()
                         });
 
                         if let Some(project_id) = project_id {
@@ -4550,7 +4245,7 @@ impl Project {
                 false
             }
         });
-        self.metadata_changed(true, cx);
+        self.metadata_changed(cx);
         cx.notify();
     }
 
@@ -4578,7 +4273,7 @@ impl Project {
                 .push(WorktreeHandle::Weak(worktree.downgrade()));
         }
 
-        self.metadata_changed(true, cx);
+        self.metadata_changed(cx);
         cx.observe_release(worktree, |this, worktree, cx| {
             this.remove_worktree(worktree.id(), cx);
             cx.notify();
@@ -4641,7 +4336,7 @@ impl Project {
                             renamed_buffers.push((cx.handle(), old_path));
                         }
 
-                        if let Some(project_id) = self.shared_remote_id() {
+                        if let Some(project_id) = self.remote_id() {
                             self.client
                                 .send(proto::UpdateBufferFile {
                                     project_id,
@@ -4697,7 +4392,7 @@ impl Project {
                     Err(_) => return,
                 };
 
-                let shared_remote_id = self.shared_remote_id();
+                let remote_id = self.remote_id();
                 let client = self.client.clone();
 
                 cx.spawn(|_, mut cx| async move {
@@ -4711,7 +4406,7 @@ impl Project {
                         buffer.remote_id()
                     });
 
-                    if let Some(project_id) = shared_remote_id {
+                    if let Some(project_id) = remote_id {
                         client
                             .send(proto::UpdateDiffBase {
                                 project_id,
@@ -4811,47 +4506,20 @@ impl Project {
 
     // RPC message handlers
 
-    async fn handle_request_join_project(
-        this: ModelHandle<Self>,
-        message: TypedEnvelope<proto::RequestJoinProject>,
-        _: Arc<Client>,
-        mut cx: AsyncAppContext,
-    ) -> Result<()> {
-        let user_id = message.payload.requester_id;
-        if this.read_with(&cx, |project, _| {
-            project.collaborators.values().any(|c| c.user.id == user_id)
-        }) {
-            this.update(&mut cx, |this, cx| {
-                this.respond_to_join_request(user_id, true, cx)
-            });
-        } else {
-            let user_store = this.read_with(&cx, |this, _| this.user_store.clone());
-            let user = user_store
-                .update(&mut cx, |store, cx| store.fetch_user(user_id, cx))
-                .await?;
-            this.update(&mut cx, |_, cx| cx.emit(Event::ContactRequestedJoin(user)));
-        }
-        Ok(())
-    }
-
-    async fn handle_unregister_project(
+    async fn handle_unshare_project(
         this: ModelHandle<Self>,
-        _: TypedEnvelope<proto::UnregisterProject>,
+        _: TypedEnvelope<proto::UnshareProject>,
         _: Arc<Client>,
         mut cx: AsyncAppContext,
     ) -> Result<()> {
-        this.update(&mut cx, |this, cx| this.disconnected_from_host(cx));
-        Ok(())
-    }
-
-    async fn handle_project_unshared(
-        this: ModelHandle<Self>,
-        _: TypedEnvelope<proto::ProjectUnshared>,
-        _: Arc<Client>,
-        mut cx: AsyncAppContext,
-    ) -> Result<()> {
-        this.update(&mut cx, |this, cx| this.unshared(cx));
-        Ok(())
+        this.update(&mut cx, |this, cx| {
+            if this.is_local() {
+                this.unshare(cx)?;
+            } else {
+                this.disconnected_from_host(cx);
+            }
+            Ok(())
+        })
     }
 
     async fn handle_add_collaborator(
@@ -4860,14 +4528,13 @@ impl Project {
         _: Arc<Client>,
         mut cx: AsyncAppContext,
     ) -> Result<()> {
-        let user_store = this.read_with(&cx, |this, _| this.user_store.clone());
         let collaborator = envelope
             .payload
             .collaborator
             .take()
             .ok_or_else(|| anyhow!("empty collaborator"))?;
 
-        let collaborator = Collaborator::from_proto(collaborator, &user_store, &mut cx).await?;
+        let collaborator = Collaborator::from_proto(collaborator);
         this.update(&mut cx, |this, cx| {
             this.collaborators
                 .insert(collaborator.peer_id, collaborator);
@@ -4902,27 +4569,6 @@ impl Project {
         })
     }
 
-    async fn handle_join_project_request_cancelled(
-        this: ModelHandle<Self>,
-        envelope: TypedEnvelope<proto::JoinProjectRequestCancelled>,
-        _: Arc<Client>,
-        mut cx: AsyncAppContext,
-    ) -> Result<()> {
-        let user = this
-            .update(&mut cx, |this, cx| {
-                this.user_store.update(cx, |user_store, cx| {
-                    user_store.fetch_user(envelope.payload.requester_id, cx)
-                })
-            })
-            .await?;
-
-        this.update(&mut cx, |_, cx| {
-            cx.emit(Event::ContactCancelledJoinRequest(user));
-        });
-
-        Ok(())
-    }
-
     async fn handle_update_project(
         this: ModelHandle<Self>,
         envelope: TypedEnvelope<proto::UpdateProject>,
@@ -4954,7 +4600,7 @@ impl Project {
                 }
             }
 
-            this.metadata_changed(true, cx);
+            this.metadata_changed(cx);
             for (id, _) in old_worktrees_by_id {
                 cx.emit(Event::WorktreeRemoved(id));
             }
@@ -6182,9 +5828,8 @@ impl Project {
 }
 
 impl ProjectStore {
-    pub fn new(db: Arc<Db>) -> Self {
+    pub fn new() -> Self {
         Self {
-            db,
             projects: Default::default(),
         }
     }
@@ -6313,10 +5958,10 @@ impl Entity for Project {
         self.project_store.update(cx, ProjectStore::prune_projects);
 
         match &self.client_state {
-            ProjectClientState::Local { remote_id_rx, .. } => {
-                if let Some(project_id) = *remote_id_rx.borrow() {
+            ProjectClientState::Local { remote_id, .. } => {
+                if let Some(project_id) = *remote_id {
                     self.client
-                        .send(proto::UnregisterProject { project_id })
+                        .send(proto::UnshareProject { project_id })
                         .log_err();
                 }
             }
@@ -6357,21 +6002,10 @@ impl Entity for Project {
 }
 
 impl Collaborator {
-    fn from_proto(
-        message: proto::Collaborator,
-        user_store: &ModelHandle<UserStore>,
-        cx: &mut AsyncAppContext,
-    ) -> impl Future<Output = Result<Self>> {
-        let user = user_store.update(cx, |user_store, cx| {
-            user_store.fetch_user(message.user_id, cx)
-        });
-
-        async move {
-            Ok(Self {
-                peer_id: PeerId(message.peer_id),
-                user: user.await?,
-                replica_id: message.replica_id as ReplicaId,
-            })
+    fn from_proto(message: proto::Collaborator) -> Self {
+        Self {
+            peer_id: PeerId(message.peer_id),
+            replica_id: message.replica_id as ReplicaId,
         }
     }
 }

crates/rpc/proto/zed.proto 🔗

@@ -10,107 +10,116 @@ message Envelope {
         Error error = 5;
         Ping ping = 6;
         Test test = 7;
-
-        RegisterProject register_project = 8;
-        RegisterProjectResponse register_project_response = 9;
-        UnregisterProject unregister_project = 10;
-        RequestJoinProject request_join_project = 11;
-        RespondToJoinProjectRequest respond_to_join_project_request = 12;
-        JoinProjectRequestCancelled join_project_request_cancelled = 13;
-        JoinProject join_project = 14;
-        JoinProjectResponse join_project_response = 15;
-        LeaveProject leave_project = 16;
-        AddProjectCollaborator add_project_collaborator = 17;
-        RemoveProjectCollaborator remove_project_collaborator = 18;
-        ProjectUnshared project_unshared = 19;
-
-        GetDefinition get_definition = 20;
-        GetDefinitionResponse get_definition_response = 21;
-        GetTypeDefinition get_type_definition = 22;
-        GetTypeDefinitionResponse get_type_definition_response = 23;
-        GetReferences get_references = 24;
-        GetReferencesResponse get_references_response = 25;
-        GetDocumentHighlights get_document_highlights = 26;
-        GetDocumentHighlightsResponse get_document_highlights_response = 27;
-        GetProjectSymbols get_project_symbols = 28;
-        GetProjectSymbolsResponse get_project_symbols_response = 29;
-        OpenBufferForSymbol open_buffer_for_symbol = 30;
-        OpenBufferForSymbolResponse open_buffer_for_symbol_response = 31;
-
-        UpdateProject update_project = 32;
-        RegisterProjectActivity register_project_activity = 33;
-        UpdateWorktree update_worktree = 34;
-        UpdateWorktreeExtensions update_worktree_extensions = 35;
-
-        CreateProjectEntry create_project_entry = 36;
-        RenameProjectEntry rename_project_entry = 37;
-        CopyProjectEntry copy_project_entry = 38;
-        DeleteProjectEntry delete_project_entry = 39;
-        ProjectEntryResponse project_entry_response = 40;
-
-        UpdateDiagnosticSummary update_diagnostic_summary = 41;
-        StartLanguageServer start_language_server = 42;
-        UpdateLanguageServer update_language_server = 43;
-
-        OpenBufferById open_buffer_by_id = 44;
-        OpenBufferByPath open_buffer_by_path = 45;
-        OpenBufferResponse open_buffer_response = 46;
-        CreateBufferForPeer create_buffer_for_peer = 47;
-        UpdateBuffer update_buffer = 48;
-        UpdateBufferFile update_buffer_file = 49;
-        SaveBuffer save_buffer = 50;
-        BufferSaved buffer_saved = 51;
-        BufferReloaded buffer_reloaded = 52;
-        ReloadBuffers reload_buffers = 53;
-        ReloadBuffersResponse reload_buffers_response = 54;
-        FormatBuffers format_buffers = 55;
-        FormatBuffersResponse format_buffers_response = 56;
-        GetCompletions get_completions = 57;
-        GetCompletionsResponse get_completions_response = 58;
-        ApplyCompletionAdditionalEdits apply_completion_additional_edits = 59;
-        ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 60;
-        GetCodeActions get_code_actions = 61;
-        GetCodeActionsResponse get_code_actions_response = 62;
-        GetHover get_hover = 63;
-        GetHoverResponse get_hover_response = 64;
-        ApplyCodeAction apply_code_action = 65;
-        ApplyCodeActionResponse apply_code_action_response = 66;
-        PrepareRename prepare_rename = 67;
-        PrepareRenameResponse prepare_rename_response = 68;
-        PerformRename perform_rename = 69;
-        PerformRenameResponse perform_rename_response = 70;
-        SearchProject search_project = 71;
-        SearchProjectResponse search_project_response = 72;
-
-        GetChannels get_channels = 73;
-        GetChannelsResponse get_channels_response = 74;
-        JoinChannel join_channel = 75;
-        JoinChannelResponse join_channel_response = 76;
-        LeaveChannel leave_channel = 77;
-        SendChannelMessage send_channel_message = 78;
-        SendChannelMessageResponse send_channel_message_response = 79;
-        ChannelMessageSent channel_message_sent = 80;
-        GetChannelMessages get_channel_messages = 81;
-        GetChannelMessagesResponse get_channel_messages_response = 82;
-
-        UpdateContacts update_contacts = 83;
-        UpdateInviteInfo update_invite_info = 84;
-        ShowContacts show_contacts = 85;
-
-        GetUsers get_users = 86;
-        FuzzySearchUsers fuzzy_search_users = 87;
-        UsersResponse users_response = 88;
-        RequestContact request_contact = 89;
-        RespondToContactRequest respond_to_contact_request = 90;
-        RemoveContact remove_contact = 91;
-
-        Follow follow = 92;
-        FollowResponse follow_response = 93;
-        UpdateFollowers update_followers = 94;
-        Unfollow unfollow = 95;
-        GetPrivateUserInfo get_private_user_info = 96;
-        GetPrivateUserInfoResponse get_private_user_info_response = 97;
-        UpdateDiffBase update_diff_base = 98;
+        
+        CreateRoom create_room = 8;
+        CreateRoomResponse create_room_response = 9;
+        JoinRoom join_room = 10;
+        JoinRoomResponse join_room_response = 11;
+        LeaveRoom leave_room = 12;
+        Call call = 13;
+        IncomingCall incoming_call = 14;
+        CallCanceled call_canceled = 15;
+        CancelCall cancel_call = 16;
+        DeclineCall decline_call = 17;
+        UpdateParticipantLocation update_participant_location = 18;
+        RoomUpdated room_updated = 19;
+
+        ShareProject share_project = 20;
+        ShareProjectResponse share_project_response = 21;
+        UnshareProject unshare_project = 22;
+        JoinProject join_project = 23;
+        JoinProjectResponse join_project_response = 24;
+        LeaveProject leave_project = 25;
+        AddProjectCollaborator add_project_collaborator = 26;
+        RemoveProjectCollaborator remove_project_collaborator = 27;
+
+        GetDefinition get_definition = 28;
+        GetDefinitionResponse get_definition_response = 29;
+        GetTypeDefinition get_type_definition = 30;
+        GetTypeDefinitionResponse get_type_definition_response = 31;
+        GetReferences get_references = 32;
+        GetReferencesResponse get_references_response = 33;
+        GetDocumentHighlights get_document_highlights = 34;
+        GetDocumentHighlightsResponse get_document_highlights_response = 35;
+        GetProjectSymbols get_project_symbols = 36;
+        GetProjectSymbolsResponse get_project_symbols_response = 37;
+        OpenBufferForSymbol open_buffer_for_symbol = 38;
+        OpenBufferForSymbolResponse open_buffer_for_symbol_response = 39;
+
+        UpdateProject update_project = 40;
+        RegisterProjectActivity register_project_activity = 41;
+        UpdateWorktree update_worktree = 42;
+        UpdateWorktreeExtensions update_worktree_extensions = 43;
+
+        CreateProjectEntry create_project_entry = 44;
+        RenameProjectEntry rename_project_entry = 45;
+        CopyProjectEntry copy_project_entry = 46;
+        DeleteProjectEntry delete_project_entry = 47;
+        ProjectEntryResponse project_entry_response = 48;
+
+        UpdateDiagnosticSummary update_diagnostic_summary = 49;
+        StartLanguageServer start_language_server = 50;
+        UpdateLanguageServer update_language_server = 51;
+
+        OpenBufferById open_buffer_by_id = 52;
+        OpenBufferByPath open_buffer_by_path = 53;
+        OpenBufferResponse open_buffer_response = 54;
+        CreateBufferForPeer create_buffer_for_peer = 55;
+        UpdateBuffer update_buffer = 56;
+        UpdateBufferFile update_buffer_file = 57;
+        SaveBuffer save_buffer = 58;
+        BufferSaved buffer_saved = 59;
+        BufferReloaded buffer_reloaded = 60;
+        ReloadBuffers reload_buffers = 61;
+        ReloadBuffersResponse reload_buffers_response = 62;
+        FormatBuffers format_buffers = 63;
+        FormatBuffersResponse format_buffers_response = 64;
+        GetCompletions get_completions = 65;
+        GetCompletionsResponse get_completions_response = 66;
+        ApplyCompletionAdditionalEdits apply_completion_additional_edits = 67;
+        ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 68;
+        GetCodeActions get_code_actions = 69;
+        GetCodeActionsResponse get_code_actions_response = 70;
+        GetHover get_hover = 71;
+        GetHoverResponse get_hover_response = 72;
+        ApplyCodeAction apply_code_action = 73;
+        ApplyCodeActionResponse apply_code_action_response = 74;
+        PrepareRename prepare_rename = 75;
+        PrepareRenameResponse prepare_rename_response = 76;
+        PerformRename perform_rename = 77;
+        PerformRenameResponse perform_rename_response = 78;
+        SearchProject search_project = 79;
+        SearchProjectResponse search_project_response = 80;
+
+        GetChannels get_channels = 81;
+        GetChannelsResponse get_channels_response = 82;
+        JoinChannel join_channel = 83;
+        JoinChannelResponse join_channel_response = 84;
+        LeaveChannel leave_channel = 85;
+        SendChannelMessage send_channel_message = 86;
+        SendChannelMessageResponse send_channel_message_response = 87;
+        ChannelMessageSent channel_message_sent = 88;
+        GetChannelMessages get_channel_messages = 89;
+        GetChannelMessagesResponse get_channel_messages_response = 90;
+
+        UpdateContacts update_contacts = 91;
+        UpdateInviteInfo update_invite_info = 92;
+        ShowContacts show_contacts = 93;
+
+        GetUsers get_users = 94;
+        FuzzySearchUsers fuzzy_search_users = 95;
+        UsersResponse users_response = 96;
+        RequestContact request_contact = 97;
+        RespondToContactRequest respond_to_contact_request = 98;
+        RemoveContact remove_contact = 99;
+
+        Follow follow = 100;
+        FollowResponse follow_response = 101;
+        UpdateFollowers update_followers = 102;
+        Unfollow unfollow = 103;
+        GetPrivateUserInfo get_private_user_info = 104;
+        GetPrivateUserInfoResponse get_private_user_info_response = 105;
+        UpdateDiffBase update_diff_base = 106;
     }
 }
 
@@ -128,42 +137,110 @@ message Test {
     uint64 id = 1;
 }
 
-message RegisterProject {
-    bool online = 1;
+message CreateRoom {}
+
+message CreateRoomResponse {
+    uint64 id = 1;
 }
 
-message RegisterProjectResponse {
-    uint64 project_id = 1;
+message JoinRoom {
+    uint64 id = 1;
 }
 
-message UnregisterProject {
-    uint64 project_id = 1;
+message JoinRoomResponse {
+    Room room = 1;
 }
 
-message UpdateProject {
-    uint64 project_id = 1;
+message LeaveRoom {
+    uint64 id = 1;
+}
+
+message Room {
+    repeated Participant participants = 1;
+    repeated uint64 pending_participant_user_ids = 2;
+}
+
+message Participant {
+    uint64 user_id = 1;
+    uint32 peer_id = 2;
+    repeated ParticipantProject projects = 3;
+    ParticipantLocation location = 4;
+}
+
+message ParticipantProject {
+    uint64 id = 1;
+    repeated string worktree_root_names = 2;
+}
+
+message ParticipantLocation {
+    oneof variant {
+        SharedProject shared_project = 1;
+        UnsharedProject unshared_project = 2;
+        External external = 3;
+    }
+    
+    message SharedProject {
+        uint64 id = 1;
+    }
+    
+    message UnsharedProject {}
+    
+    message External {}
+}
+
+message Call {
+    uint64 room_id = 1;
+    uint64 recipient_user_id = 2;
+    optional uint64 initial_project_id = 3;
+}
+
+message IncomingCall {
+    uint64 room_id = 1;
+    uint64 caller_user_id = 2;
+    repeated uint64 participant_user_ids = 3;
+    optional ParticipantProject initial_project = 4;
+}
+
+message CallCanceled {}
+
+message CancelCall {
+    uint64 room_id = 1;
+    uint64 recipient_user_id = 2;
+}
+
+message DeclineCall {
+    uint64 room_id = 1;
+}
+
+message UpdateParticipantLocation {
+    uint64 room_id = 1;
+    ParticipantLocation location = 2;
+}
+
+message RoomUpdated {
+    Room room = 1;
+}
+
+message ShareProject {
+    uint64 room_id = 1;
     repeated WorktreeMetadata worktrees = 2;
-    bool online = 3;
 }
 
-message RegisterProjectActivity {
+message ShareProjectResponse {
     uint64 project_id = 1;
 }
 
-message RequestJoinProject {
-    uint64 requester_id = 1;
-    uint64 project_id = 2;
+message UnshareProject {
+    uint64 project_id = 1;
 }
 
-message RespondToJoinProjectRequest {
-    uint64 requester_id = 1;
-    uint64 project_id = 2;
-    bool allow = 3;
+message UpdateProject {
+    uint64 project_id = 1;
+    repeated WorktreeMetadata worktrees = 2;
 }
 
-message JoinProjectRequestCancelled {
-    uint64 requester_id = 1;
-    uint64 project_id = 2;
+message RegisterProjectActivity {
+    uint64 project_id = 1;
 }
 
 message JoinProject {
@@ -171,27 +248,10 @@ message JoinProject {
 }
 
 message JoinProjectResponse {
-    oneof variant {
-        Accept accept = 1;
-        Decline decline = 2;
-    }
-
-    message Accept {
-        uint32 replica_id = 1;
-        repeated WorktreeMetadata worktrees = 2;
-        repeated Collaborator collaborators = 3;
-        repeated LanguageServer language_servers = 4;        
-    }
-    
-    message Decline {
-        Reason reason = 1;
-
-        enum Reason {
-            Declined = 0;
-            Closed = 1;
-            WentOffline = 2;
-        }
-    }
+    uint32 replica_id = 1;
+    repeated WorktreeMetadata worktrees = 2;
+    repeated Collaborator collaborators = 3;
+    repeated LanguageServer language_servers = 4;
 }
 
 message LeaveProject {
@@ -254,10 +314,6 @@ message RemoveProjectCollaborator {
     uint32 peer_id = 2;
 }
 
-message ProjectUnshared {
-    uint64 project_id = 1;
-}
-
 message GetDefinition {
      uint64 project_id = 1;
      uint64 buffer_id = 2;
@@ -986,17 +1042,11 @@ message ChannelMessage {
 
 message Contact {
     uint64 user_id = 1;
-    repeated ProjectMetadata projects = 2;
-    bool online = 3;
+    bool online = 2;
+    bool busy = 3;
     bool should_notify = 4;
 }
 
-message ProjectMetadata {
-    uint64 id = 1;
-    repeated string visible_worktree_root_names = 3;
-    repeated uint64 guests = 4;
-}
-
 message WorktreeMetadata {
     uint64 id = 1;
     string root_name = 2;

crates/rpc/src/peer.rs 🔗

@@ -33,7 +33,7 @@ impl fmt::Display for ConnectionId {
     }
 }
 
-#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
 pub struct PeerId(pub u32);
 
 impl fmt::Display for PeerId {
@@ -394,7 +394,11 @@ impl Peer {
             send?;
             let (response, _barrier) = rx.await.map_err(|_| anyhow!("connection was closed"))?;
             if let Some(proto::envelope::Payload::Error(error)) = &response.payload {
-                Err(anyhow!("RPC request failed - {}", error.message))
+                Err(anyhow!(
+                    "RPC request {} failed - {}",
+                    T::NAME,
+                    error.message
+                ))
             } else {
                 T::Response::from_envelope(response)
                     .ok_or_else(|| anyhow!("received response of the wrong type"))

crates/rpc/src/proto.rs 🔗

@@ -83,11 +83,16 @@ messages!(
     (ApplyCompletionAdditionalEditsResponse, Background),
     (BufferReloaded, Foreground),
     (BufferSaved, Foreground),
-    (RemoveContact, Foreground),
+    (Call, Foreground),
+    (CallCanceled, Foreground),
+    (CancelCall, Foreground),
     (ChannelMessageSent, Foreground),
     (CopyProjectEntry, Foreground),
     (CreateBufferForPeer, Foreground),
     (CreateProjectEntry, Foreground),
+    (CreateRoom, Foreground),
+    (CreateRoomResponse, Foreground),
+    (DeclineCall, Foreground),
     (DeleteProjectEntry, Foreground),
     (Error, Foreground),
     (Follow, Foreground),
@@ -116,14 +121,17 @@ messages!(
     (GetProjectSymbols, Background),
     (GetProjectSymbolsResponse, Background),
     (GetUsers, Foreground),
+    (IncomingCall, Foreground),
     (UsersResponse, Foreground),
     (JoinChannel, Foreground),
     (JoinChannelResponse, Foreground),
     (JoinProject, Foreground),
     (JoinProjectResponse, Foreground),
-    (JoinProjectRequestCancelled, Foreground),
+    (JoinRoom, Foreground),
+    (JoinRoomResponse, Foreground),
     (LeaveChannel, Foreground),
     (LeaveProject, Foreground),
+    (LeaveRoom, Foreground),
     (OpenBufferById, Background),
     (OpenBufferByPath, Background),
     (OpenBufferForSymbol, Background),
@@ -134,29 +142,28 @@ messages!(
     (PrepareRename, Background),
     (PrepareRenameResponse, Background),
     (ProjectEntryResponse, Foreground),
-    (ProjectUnshared, Foreground),
-    (RegisterProjectResponse, Foreground),
+    (RemoveContact, Foreground),
     (Ping, Foreground),
-    (RegisterProject, Foreground),
     (RegisterProjectActivity, Foreground),
     (ReloadBuffers, Foreground),
     (ReloadBuffersResponse, Foreground),
     (RemoveProjectCollaborator, Foreground),
     (RenameProjectEntry, Foreground),
     (RequestContact, Foreground),
-    (RequestJoinProject, Foreground),
     (RespondToContactRequest, Foreground),
-    (RespondToJoinProjectRequest, Foreground),
+    (RoomUpdated, Foreground),
     (SaveBuffer, Foreground),
     (SearchProject, Background),
     (SearchProjectResponse, Background),
     (SendChannelMessage, Foreground),
     (SendChannelMessageResponse, Foreground),
+    (ShareProject, Foreground),
+    (ShareProjectResponse, Foreground),
     (ShowContacts, Foreground),
     (StartLanguageServer, Foreground),
     (Test, Foreground),
     (Unfollow, Foreground),
-    (UnregisterProject, Foreground),
+    (UnshareProject, Foreground),
     (UpdateBuffer, Foreground),
     (UpdateBufferFile, Foreground),
     (UpdateContacts, Foreground),
@@ -164,6 +171,7 @@ messages!(
     (UpdateFollowers, Foreground),
     (UpdateInviteInfo, Foreground),
     (UpdateLanguageServer, Foreground),
+    (UpdateParticipantLocation, Foreground),
     (UpdateProject, Foreground),
     (UpdateWorktree, Foreground),
     (UpdateWorktreeExtensions, Background),
@@ -178,8 +186,12 @@ request_messages!(
         ApplyCompletionAdditionalEdits,
         ApplyCompletionAdditionalEditsResponse
     ),
+    (Call, Ack),
+    (CancelCall, Ack),
     (CopyProjectEntry, ProjectEntryResponse),
     (CreateProjectEntry, ProjectEntryResponse),
+    (CreateRoom, CreateRoomResponse),
+    (DeclineCall, Ack),
     (DeleteProjectEntry, ProjectEntryResponse),
     (Follow, FollowResponse),
     (FormatBuffers, FormatBuffersResponse),
@@ -198,13 +210,14 @@ request_messages!(
     (GetUsers, UsersResponse),
     (JoinChannel, JoinChannelResponse),
     (JoinProject, JoinProjectResponse),
+    (JoinRoom, JoinRoomResponse),
+    (IncomingCall, Ack),
     (OpenBufferById, OpenBufferResponse),
     (OpenBufferByPath, OpenBufferResponse),
     (OpenBufferForSymbol, OpenBufferForSymbolResponse),
     (Ping, Ack),
     (PerformRename, PerformRenameResponse),
     (PrepareRename, PrepareRenameResponse),
-    (RegisterProject, RegisterProjectResponse),
     (ReloadBuffers, ReloadBuffersResponse),
     (RequestContact, Ack),
     (RemoveContact, Ack),
@@ -213,9 +226,10 @@ request_messages!(
     (SaveBuffer, BufferSaved),
     (SearchProject, SearchProjectResponse),
     (SendChannelMessage, SendChannelMessageResponse),
+    (ShareProject, ShareProjectResponse),
     (Test, Test),
-    (UnregisterProject, Ack),
     (UpdateBuffer, Ack),
+    (UpdateParticipantLocation, Ack),
     (UpdateWorktree, Ack),
 );
 
@@ -241,24 +255,21 @@ entity_messages!(
     GetReferences,
     GetProjectSymbols,
     JoinProject,
-    JoinProjectRequestCancelled,
     LeaveProject,
     OpenBufferById,
     OpenBufferByPath,
     OpenBufferForSymbol,
     PerformRename,
     PrepareRename,
-    ProjectUnshared,
     RegisterProjectActivity,
     ReloadBuffers,
     RemoveProjectCollaborator,
     RenameProjectEntry,
-    RequestJoinProject,
     SaveBuffer,
     SearchProject,
     StartLanguageServer,
     Unfollow,
-    UnregisterProject,
+    UnshareProject,
     UpdateBuffer,
     UpdateBufferFile,
     UpdateDiagnosticSummary,

crates/rpc/src/rpc.rs 🔗

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

crates/terminal/src/terminal_element.rs 🔗

@@ -726,6 +726,8 @@ impl Element for TerminalElement {
         layout: &mut Self::LayoutState,
         cx: &mut gpui::PaintContext,
     ) -> Self::PaintState {
+        let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
+
         //Setup element stuff
         let clip_bounds = Some(visible_bounds);
 

crates/theme/src/theme.rs 🔗

@@ -20,7 +20,7 @@ pub struct Theme {
     pub context_menu: ContextMenu,
     pub chat_panel: ChatPanel,
     pub contacts_popover: ContactsPopover,
-    pub contacts_panel: ContactsPanel,
+    pub contact_list: ContactList,
     pub contact_finder: ContactFinder,
     pub project_panel: ProjectPanel,
     pub command_palette: CommandPalette,
@@ -31,6 +31,8 @@ pub struct Theme {
     pub breadcrumbs: ContainedText,
     pub contact_notification: ContactNotification,
     pub update_notification: UpdateNotification,
+    pub project_shared_notification: ProjectSharedNotification,
+    pub incoming_call_notification: IncomingCallNotification,
     pub tooltip: TooltipStyle,
     pub terminal: TerminalStyle,
 }
@@ -58,6 +60,7 @@ pub struct Workspace {
     pub notifications: Notifications,
     pub joining_project_avatar: ImageStyle,
     pub joining_project_message: ContainedText,
+    pub external_location_message: ContainedText,
     pub dock: Dock,
 }
 
@@ -72,8 +75,67 @@ pub struct Titlebar {
     pub avatar_ribbon: AvatarRibbon,
     pub offline_icon: OfflineIcon,
     pub avatar: ImageStyle,
+    pub inactive_avatar: ImageStyle,
     pub sign_in_prompt: Interactive<ContainedText>,
     pub outdated_warning: ContainedText,
+    pub share_button: Interactive<ContainedText>,
+    pub toggle_contacts_button: Interactive<IconButton>,
+    pub toggle_contacts_badge: ContainerStyle,
+}
+
+#[derive(Deserialize, Default)]
+pub struct ContactsPopover {
+    #[serde(flatten)]
+    pub container: ContainerStyle,
+    pub height: f32,
+    pub width: f32,
+    pub invite_row_height: f32,
+    pub invite_row: Interactive<ContainedLabel>,
+}
+
+#[derive(Deserialize, Default)]
+pub struct ContactList {
+    pub user_query_editor: FieldEditor,
+    pub user_query_editor_height: f32,
+    pub add_contact_button: IconButton,
+    pub header_row: Interactive<ContainedText>,
+    pub leave_call: Interactive<ContainedText>,
+    pub contact_row: Interactive<ContainerStyle>,
+    pub row_height: f32,
+    pub project_row: Interactive<ProjectRow>,
+    pub tree_branch: Interactive<TreeBranch>,
+    pub contact_avatar: ImageStyle,
+    pub contact_status_free: ContainerStyle,
+    pub contact_status_busy: ContainerStyle,
+    pub contact_username: ContainedText,
+    pub contact_button: Interactive<IconButton>,
+    pub contact_button_spacing: f32,
+    pub disabled_button: IconButton,
+    pub section_icon_size: f32,
+    pub calling_indicator: ContainedText,
+}
+
+#[derive(Deserialize, Default)]
+pub struct ProjectRow {
+    #[serde(flatten)]
+    pub container: ContainerStyle,
+    pub name: ContainedText,
+}
+
+#[derive(Deserialize, Default, Clone, Copy)]
+pub struct TreeBranch {
+    pub width: f32,
+    pub color: Color,
+}
+
+#[derive(Deserialize, Default)]
+pub struct ContactFinder {
+    pub picker: Picker,
+    pub row_height: f32,
+    pub contact_avatar: ImageStyle,
+    pub contact_username: ContainerStyle,
+    pub contact_button: IconButton,
+    pub disabled_contact_button: IconButton,
 }
 
 #[derive(Clone, Deserialize, Default)]
@@ -315,33 +377,6 @@ pub struct CommandPalette {
     pub keystroke_spacing: f32,
 }
 
-#[derive(Deserialize, Default)]
-pub struct ContactsPopover {
-    pub background: Color,
-}
-
-#[derive(Deserialize, Default)]
-pub struct ContactsPanel {
-    #[serde(flatten)]
-    pub container: ContainerStyle,
-    pub user_query_editor: FieldEditor,
-    pub user_query_editor_height: f32,
-    pub add_contact_button: IconButton,
-    pub header_row: Interactive<ContainedText>,
-    pub contact_row: Interactive<ContainerStyle>,
-    pub project_row: Interactive<ProjectRow>,
-    pub row_height: f32,
-    pub contact_avatar: ImageStyle,
-    pub contact_username: ContainedText,
-    pub contact_button: Interactive<IconButton>,
-    pub contact_button_spacing: f32,
-    pub disabled_button: IconButton,
-    pub tree_branch: Interactive<TreeBranch>,
-    pub private_button: Interactive<IconButton>,
-    pub section_icon_size: f32,
-    pub invite_row: Interactive<ContainedLabel>,
-}
-
 #[derive(Deserialize, Default)]
 pub struct InviteLink {
     #[serde(flatten)]
@@ -351,21 +386,6 @@ pub struct InviteLink {
     pub icon: Icon,
 }
 
-#[derive(Deserialize, Default, Clone, Copy)]
-pub struct TreeBranch {
-    pub width: f32,
-    pub color: Color,
-}
-
-#[derive(Deserialize, Default)]
-pub struct ContactFinder {
-    pub row_height: f32,
-    pub contact_avatar: ImageStyle,
-    pub contact_username: ContainerStyle,
-    pub contact_button: IconButton,
-    pub disabled_contact_button: IconButton,
-}
-
 #[derive(Deserialize, Default)]
 pub struct Icon {
     #[serde(flatten)]
@@ -384,16 +404,6 @@ pub struct IconButton {
     pub button_width: f32,
 }
 
-#[derive(Deserialize, Default)]
-pub struct ProjectRow {
-    #[serde(flatten)]
-    pub container: ContainerStyle,
-    pub name: ContainedText,
-    pub guests: ContainerStyle,
-    pub guest_avatar: ImageStyle,
-    pub guest_avatar_spacing: f32,
-}
-
 #[derive(Deserialize, Default)]
 pub struct ChatMessage {
     #[serde(flatten)]
@@ -475,6 +485,40 @@ pub struct UpdateNotification {
     pub dismiss_button: Interactive<IconButton>,
 }
 
+#[derive(Deserialize, Default)]
+pub struct ProjectSharedNotification {
+    pub window_height: f32,
+    pub window_width: f32,
+    #[serde(default)]
+    pub background: Color,
+    pub owner_container: ContainerStyle,
+    pub owner_avatar: ImageStyle,
+    pub owner_metadata: ContainerStyle,
+    pub owner_username: ContainedText,
+    pub message: ContainedText,
+    pub worktree_roots: ContainedText,
+    pub button_width: f32,
+    pub open_button: ContainedText,
+    pub dismiss_button: ContainedText,
+}
+
+#[derive(Deserialize, Default)]
+pub struct IncomingCallNotification {
+    pub window_height: f32,
+    pub window_width: f32,
+    #[serde(default)]
+    pub background: Color,
+    pub caller_container: ContainerStyle,
+    pub caller_avatar: ImageStyle,
+    pub caller_metadata: ContainerStyle,
+    pub caller_username: ContainedText,
+    pub caller_message: ContainedText,
+    pub worktree_roots: ContainedText,
+    pub button_width: f32,
+    pub accept_button: ContainedText,
+    pub decline_button: ContainedText,
+}
+
 #[derive(Clone, Deserialize, Default)]
 pub struct Editor {
     pub text_color: Color,

crates/workspace/Cargo.toml 🔗

@@ -8,11 +8,16 @@ path = "src/workspace.rs"
 doctest = false
 
 [features]
-test-support = ["client/test-support", "project/test-support", "settings/test-support"]
+test-support = [
+    "call/test-support",
+    "client/test-support",
+    "project/test-support",
+    "settings/test-support"
+]
 
 [dependencies]
+call = { path = "../call" }
 client = { path = "../client" }
-clock = { path = "../clock" }
 collections = { path = "../collections" }
 context_menu = { path = "../context_menu" }
 drag_and_drop = { path = "../drag_and_drop" }
@@ -33,6 +38,7 @@ serde_json = { version = "1.0", features = ["preserve_order"] }
 smallvec = { version = "1.6", features = ["union"] }
 
 [dev-dependencies]
+call = { path = "../call", features = ["test-support"] }
 client = { path = "../client", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 project = { path = "../project", features = ["test-support"] }

crates/workspace/src/pane_group.rs 🔗

@@ -1,9 +1,10 @@
-use crate::{FollowerStatesByLeader, Pane};
+use crate::{FollowerStatesByLeader, JoinProject, Pane, Workspace};
 use anyhow::{anyhow, Result};
-use client::PeerId;
-use collections::HashMap;
-use gpui::{elements::*, Axis, Border, ViewHandle};
-use project::Collaborator;
+use call::ActiveCall;
+use gpui::{
+    elements::*, Axis, Border, CursorStyle, ModelHandle, MouseButton, RenderContext, ViewHandle,
+};
+use project::Project;
 use serde::Deserialize;
 use theme::Theme;
 
@@ -56,11 +57,14 @@ impl PaneGroup {
 
     pub(crate) fn render(
         &self,
+        project: &ModelHandle<Project>,
         theme: &Theme,
         follower_states: &FollowerStatesByLeader,
-        collaborators: &HashMap<PeerId, Collaborator>,
+        active_call: Option<&ModelHandle<ActiveCall>>,
+        cx: &mut RenderContext<Workspace>,
     ) -> ElementBox {
-        self.root.render(theme, follower_states, collaborators)
+        self.root
+            .render(project, theme, follower_states, active_call, cx)
     }
 
     pub(crate) fn panes(&self) -> Vec<&ViewHandle<Pane>> {
@@ -100,13 +104,16 @@ impl Member {
 
     pub fn render(
         &self,
+        project: &ModelHandle<Project>,
         theme: &Theme,
         follower_states: &FollowerStatesByLeader,
-        collaborators: &HashMap<PeerId, Collaborator>,
+        active_call: Option<&ModelHandle<ActiveCall>>,
+        cx: &mut RenderContext<Workspace>,
     ) -> ElementBox {
+        enum FollowIntoExternalProject {}
+
         match self {
             Member::Pane(pane) => {
-                let mut border = Border::default();
                 let leader = follower_states
                     .iter()
                     .find_map(|(leader_id, follower_states)| {
@@ -116,21 +123,110 @@ impl Member {
                             None
                         }
                     })
-                    .and_then(|leader_id| collaborators.get(leader_id));
-                if let Some(leader) = leader {
-                    let leader_color = theme
-                        .editor
-                        .replica_selection_style(leader.replica_id)
-                        .cursor;
+                    .and_then(|leader_id| {
+                        let room = active_call?.read(cx).room()?.read(cx);
+                        let collaborator = project.read(cx).collaborators().get(leader_id)?;
+                        let participant = room.remote_participants().get(&leader_id)?;
+                        Some((collaborator.replica_id, participant))
+                    });
+
+                let mut border = Border::default();
+
+                let prompt = if let Some((replica_id, leader)) = leader {
+                    let leader_color = theme.editor.replica_selection_style(replica_id).cursor;
                     border = Border::all(theme.workspace.leader_border_width, leader_color);
                     border
                         .color
                         .fade_out(1. - theme.workspace.leader_border_opacity);
                     border.overlay = true;
-                }
-                ChildView::new(pane).contained().with_border(border).boxed()
+
+                    match leader.location {
+                        call::ParticipantLocation::SharedProject {
+                            project_id: leader_project_id,
+                        } => {
+                            if Some(leader_project_id) == project.read(cx).remote_id() {
+                                None
+                            } else {
+                                let leader_user = leader.user.clone();
+                                let leader_user_id = leader.user.id;
+                                Some(
+                                    MouseEventHandler::<FollowIntoExternalProject>::new(
+                                        pane.id(),
+                                        cx,
+                                        |_, _| {
+                                            Label::new(
+                                                format!(
+                                                    "Follow {} on their active project",
+                                                    leader_user.github_login,
+                                                ),
+                                                theme
+                                                    .workspace
+                                                    .external_location_message
+                                                    .text
+                                                    .clone(),
+                                            )
+                                            .contained()
+                                            .with_style(
+                                                theme.workspace.external_location_message.container,
+                                            )
+                                            .boxed()
+                                        },
+                                    )
+                                    .with_cursor_style(CursorStyle::PointingHand)
+                                    .on_click(MouseButton::Left, move |_, cx| {
+                                        cx.dispatch_action(JoinProject {
+                                            project_id: leader_project_id,
+                                            follow_user_id: leader_user_id,
+                                        })
+                                    })
+                                    .aligned()
+                                    .bottom()
+                                    .right()
+                                    .boxed(),
+                                )
+                            }
+                        }
+                        call::ParticipantLocation::UnsharedProject => Some(
+                            Label::new(
+                                format!(
+                                    "{} is viewing an unshared Zed project",
+                                    leader.user.github_login
+                                ),
+                                theme.workspace.external_location_message.text.clone(),
+                            )
+                            .contained()
+                            .with_style(theme.workspace.external_location_message.container)
+                            .aligned()
+                            .bottom()
+                            .right()
+                            .boxed(),
+                        ),
+                        call::ParticipantLocation::External => Some(
+                            Label::new(
+                                format!(
+                                    "{} is viewing a window outside of Zed",
+                                    leader.user.github_login
+                                ),
+                                theme.workspace.external_location_message.text.clone(),
+                            )
+                            .contained()
+                            .with_style(theme.workspace.external_location_message.container)
+                            .aligned()
+                            .bottom()
+                            .right()
+                            .boxed(),
+                        ),
+                    }
+                } else {
+                    None
+                };
+
+                Stack::new()
+                    .with_child(ChildView::new(pane).contained().with_border(border).boxed())
+                    .with_children(prompt)
+                    .boxed()
             }
-            Member::Axis(axis) => axis.render(theme, follower_states, collaborators),
+            Member::Axis(axis) => axis.render(project, theme, follower_states, active_call, cx),
         }
     }
 
@@ -232,14 +328,16 @@ impl PaneAxis {
 
     fn render(
         &self,
+        project: &ModelHandle<Project>,
         theme: &Theme,
         follower_state: &FollowerStatesByLeader,
-        collaborators: &HashMap<PeerId, Collaborator>,
+        active_call: Option<&ModelHandle<ActiveCall>>,
+        cx: &mut RenderContext<Workspace>,
     ) -> ElementBox {
         let last_member_ix = self.members.len() - 1;
         Flex::new(self.axis)
             .with_children(self.members.iter().enumerate().map(|(ix, member)| {
-                let mut member = member.render(theme, follower_state, collaborators);
+                let mut member = member.render(project, theme, follower_state, active_call, cx);
                 if ix < last_member_ix {
                     let mut border = theme.workspace.pane_divider;
                     border.left = false;

crates/workspace/src/waiting_room.rs 🔗

@@ -1,185 +0,0 @@
-use crate::{sidebar::SidebarSide, AppState, ToggleFollow, Workspace};
-use anyhow::Result;
-use client::{proto, Client, Contact};
-use gpui::{
-    elements::*, ElementBox, Entity, ImageData, MutableAppContext, RenderContext, Task, View,
-    ViewContext,
-};
-use project::Project;
-use settings::Settings;
-use std::sync::Arc;
-use util::ResultExt;
-
-pub struct WaitingRoom {
-    project_id: u64,
-    avatar: Option<Arc<ImageData>>,
-    message: String,
-    waiting: bool,
-    client: Arc<Client>,
-    _join_task: Task<Result<()>>,
-}
-
-impl Entity for WaitingRoom {
-    type Event = ();
-
-    fn release(&mut self, _: &mut MutableAppContext) {
-        if self.waiting {
-            self.client
-                .send(proto::LeaveProject {
-                    project_id: self.project_id,
-                })
-                .log_err();
-        }
-    }
-}
-
-impl View for WaitingRoom {
-    fn ui_name() -> &'static str {
-        "WaitingRoom"
-    }
-
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let theme = &cx.global::<Settings>().theme.workspace;
-
-        Flex::column()
-            .with_children(self.avatar.clone().map(|avatar| {
-                Image::new(avatar)
-                    .with_style(theme.joining_project_avatar)
-                    .aligned()
-                    .boxed()
-            }))
-            .with_child(
-                Text::new(
-                    self.message.clone(),
-                    theme.joining_project_message.text.clone(),
-                )
-                .contained()
-                .with_style(theme.joining_project_message.container)
-                .aligned()
-                .boxed(),
-            )
-            .aligned()
-            .contained()
-            .with_background_color(theme.background)
-            .boxed()
-    }
-}
-
-impl WaitingRoom {
-    pub fn new(
-        contact: Arc<Contact>,
-        project_index: usize,
-        app_state: Arc<AppState>,
-        cx: &mut ViewContext<Self>,
-    ) -> Self {
-        let project_id = contact.projects[project_index].id;
-        let client = app_state.client.clone();
-        let _join_task = cx.spawn_weak({
-            let contact = contact.clone();
-            |this, mut cx| async move {
-                let project = Project::remote(
-                    project_id,
-                    app_state.client.clone(),
-                    app_state.user_store.clone(),
-                    app_state.project_store.clone(),
-                    app_state.languages.clone(),
-                    app_state.fs.clone(),
-                    cx.clone(),
-                )
-                .await;
-
-                if let Some(this) = this.upgrade(&cx) {
-                    this.update(&mut cx, |this, cx| {
-                        this.waiting = false;
-                        match project {
-                            Ok(project) => {
-                                cx.replace_root_view(|cx| {
-                                    let mut workspace =
-                                        Workspace::new(project, app_state.default_item_factory, cx);
-                                    (app_state.initialize_workspace)(
-                                        &mut workspace,
-                                        &app_state,
-                                        cx,
-                                    );
-                                    workspace.toggle_sidebar(SidebarSide::Left, cx);
-                                    if let Some((host_peer_id, _)) = workspace
-                                        .project
-                                        .read(cx)
-                                        .collaborators()
-                                        .iter()
-                                        .find(|(_, collaborator)| collaborator.replica_id == 0)
-                                    {
-                                        if let Some(follow) = workspace
-                                            .toggle_follow(&ToggleFollow(*host_peer_id), cx)
-                                        {
-                                            follow.detach_and_log_err(cx);
-                                        }
-                                    }
-                                    workspace
-                                });
-                            }
-                            Err(error) => {
-                                let login = &contact.user.github_login;
-                                let message = match error {
-                                    project::JoinProjectError::HostDeclined => {
-                                        format!("@{} declined your request.", login)
-                                    }
-                                    project::JoinProjectError::HostClosedProject => {
-                                        format!(
-                                            "@{} closed their copy of {}.",
-                                            login,
-                                            humanize_list(
-                                                &contact.projects[project_index]
-                                                    .visible_worktree_root_names
-                                            )
-                                        )
-                                    }
-                                    project::JoinProjectError::HostWentOffline => {
-                                        format!("@{} went offline.", login)
-                                    }
-                                    project::JoinProjectError::Other(error) => {
-                                        log::error!("error joining project: {}", error);
-                                        "An error occurred.".to_string()
-                                    }
-                                };
-                                this.message = message;
-                                cx.notify();
-                            }
-                        }
-                    })
-                }
-
-                Ok(())
-            }
-        });
-
-        Self {
-            project_id,
-            avatar: contact.user.avatar.clone(),
-            message: format!(
-                "Asking to join @{}'s copy of {}...",
-                contact.user.github_login,
-                humanize_list(&contact.projects[project_index].visible_worktree_root_names)
-            ),
-            waiting: true,
-            client,
-            _join_task,
-        }
-    }
-}
-
-fn humanize_list<'a>(items: impl IntoIterator<Item = &'a String>) -> String {
-    let mut list = String::new();
-    let mut items = items.into_iter().enumerate().peekable();
-    while let Some((ix, item)) = items.next() {
-        if ix > 0 {
-            list.push_str(", ");
-            if items.peek().is_none() {
-                list.push_str("and ");
-            }
-        }
-
-        list.push_str(item);
-    }
-    list
-}

crates/workspace/src/workspace.rs 🔗

@@ -10,28 +10,22 @@ pub mod searchable;
 pub mod sidebar;
 mod status_bar;
 mod toolbar;
-mod waiting_room;
 
 use anyhow::{anyhow, Context, Result};
-use client::{
-    proto, Authenticate, Client, Contact, PeerId, Subscription, TypedEnvelope, User, UserStore,
-};
-use clock::ReplicaId;
+use call::ActiveCall;
+use client::{proto, Client, PeerId, TypedEnvelope, UserStore};
 use collections::{hash_map, HashMap, HashSet};
 use dock::{DefaultItemFactory, Dock, ToggleDockButton};
 use drag_and_drop::DragAndDrop;
-use futures::{channel::oneshot, FutureExt};
+use futures::{channel::oneshot, FutureExt, StreamExt};
 use gpui::{
     actions,
-    color::Color,
     elements::*,
-    geometry::{rect::RectF, vector::vec2f, PathBuilder},
     impl_actions, impl_internal_actions,
-    json::{self, ToJson},
     platform::{CursorStyle, WindowOptions},
-    AnyModelHandle, AnyViewHandle, AppContext, AsyncAppContext, Border, Entity, ImageData,
-    ModelContext, ModelHandle, MouseButton, MutableAppContext, PathPromptOptions, PromptLevel,
-    RenderContext, Task, View, ViewContext, ViewHandle, WeakViewHandle,
+    AnyModelHandle, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
+    MouseButton, MutableAppContext, PathPromptOptions, PromptLevel, RenderContext, Task, View,
+    ViewContext, ViewHandle, WeakViewHandle,
 };
 use language::LanguageRegistry;
 use log::{error, warn};
@@ -52,7 +46,6 @@ use std::{
     cell::RefCell,
     fmt,
     future::Future,
-    ops::Range,
     path::{Path, PathBuf},
     rc::Rc,
     sync::{
@@ -64,7 +57,6 @@ use std::{
 use theme::{Theme, ThemeRegistry};
 pub use toolbar::{ToolbarItemLocation, ToolbarItemView};
 use util::ResultExt;
-use waiting_room::WaitingRoom;
 
 type ProjectItemBuilders = HashMap<
     TypeId,
@@ -115,12 +107,6 @@ pub struct OpenPaths {
     pub paths: Vec<PathBuf>,
 }
 
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct ToggleProjectOnline {
-    #[serde(skip_deserializing)]
-    pub project: Option<ModelHandle<Project>>,
-}
-
 #[derive(Clone, Deserialize, PartialEq)]
 pub struct ActivatePane(pub usize);
 
@@ -129,8 +115,8 @@ pub struct ToggleFollow(pub PeerId);
 
 #[derive(Clone, PartialEq)]
 pub struct JoinProject {
-    pub contact: Arc<Contact>,
-    pub project_index: usize,
+    pub project_id: u64,
+    pub follow_user_id: u64,
 }
 
 impl_internal_actions!(
@@ -142,7 +128,7 @@ impl_internal_actions!(
         RemoveWorktreeFromProject
     ]
 );
-impl_actions!(workspace, [ToggleProjectOnline, ActivatePane]);
+impl_actions!(workspace, [ActivatePane]);
 
 pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     pane::init(cx);
@@ -173,14 +159,6 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
             }
         }
     });
-    cx.add_global_action({
-        let app_state = Arc::downgrade(&app_state);
-        move |action: &JoinProject, cx: &mut MutableAppContext| {
-            if let Some(app_state) = app_state.upgrade() {
-                join_project(action.contact.clone(), action.project_index, &app_state, cx);
-            }
-        }
-    });
 
     cx.add_async_action(Workspace::toggle_follow);
     cx.add_async_action(Workspace::follow_next_collaborator);
@@ -188,7 +166,6 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     cx.add_async_action(Workspace::save_all);
     cx.add_action(Workspace::add_folder_to_project);
     cx.add_action(Workspace::remove_folder_from_project);
-    cx.add_action(Workspace::toggle_project_online);
     cx.add_action(
         |workspace: &mut Workspace, _: &Unfollow, cx: &mut ViewContext<Workspace>| {
             let pane = workspace.active_pane().clone();
@@ -957,7 +934,7 @@ impl AppState {
         let languages = Arc::new(LanguageRegistry::test());
         let http_client = client::test::FakeHttpClient::with_404_response();
         let client = Client::new(http_client.clone(), cx);
-        let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
+        let project_store = cx.add_model(|_| ProjectStore::new());
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
         let themes = ThemeRegistry::new((), cx.font_cache().clone());
         Arc::new(Self {
@@ -984,7 +961,7 @@ pub struct Workspace {
     weak_self: WeakViewHandle<Self>,
     client: Arc<Client>,
     user_store: ModelHandle<client::UserStore>,
-    remote_entity_subscription: Option<Subscription>,
+    remote_entity_subscription: Option<client::Subscription>,
     fs: Arc<dyn Fs>,
     modal: Option<AnyViewHandle>,
     center: PaneGroup,
@@ -995,6 +972,7 @@ pub struct Workspace {
     active_pane: ViewHandle<Pane>,
     last_active_center_pane: Option<ViewHandle<Pane>>,
     status_bar: ViewHandle<StatusBar>,
+    titlebar_item: Option<AnyViewHandle>,
     dock: Dock,
     notifications: Vec<(TypeId, usize, Box<dyn NotificationHandle>)>,
     project: ModelHandle<Project>,
@@ -1002,7 +980,9 @@ pub struct Workspace {
     follower_states_by_leader: FollowerStatesByLeader,
     last_leaders_by_pane: HashMap<WeakViewHandle<Pane>, PeerId>,
     window_edited: bool,
+    active_call: Option<ModelHandle<ActiveCall>>,
     _observe_current_user: Task<()>,
+    _active_call_observation: Option<gpui::Subscription>,
 }
 
 #[derive(Default)]
@@ -1111,6 +1091,14 @@ impl Workspace {
             drag_and_drop.register_container(weak_handle.clone());
         });
 
+        let mut active_call = None;
+        let mut active_call_observation = None;
+        if cx.has_global::<ModelHandle<ActiveCall>>() {
+            let call = cx.global::<ModelHandle<ActiveCall>>().clone();
+            active_call_observation = Some(cx.observe(&call, |_, _, cx| cx.notify()));
+            active_call = Some(call);
+        }
+
         let mut this = Workspace {
             modal: None,
             weak_self: weak_handle,
@@ -1124,6 +1112,7 @@ impl Workspace {
             active_pane: center_pane.clone(),
             last_active_center_pane: Some(center_pane.clone()),
             status_bar,
+            titlebar_item: None,
             notifications: Default::default(),
             client,
             remote_entity_subscription: None,
@@ -1136,7 +1125,9 @@ impl Workspace {
             follower_states_by_leader: Default::default(),
             last_leaders_by_pane: Default::default(),
             window_edited: false,
+            active_call,
             _observe_current_user,
+            _active_call_observation: active_call_observation,
         };
         this.project_remote_id_changed(this.project.read(cx).remote_id(), cx);
         cx.defer(|this, cx| this.update_window_title(cx));
@@ -1168,6 +1159,19 @@ impl Workspace {
         &self.project
     }
 
+    pub fn client(&self) -> &Arc<Client> {
+        &self.client
+    }
+
+    pub fn set_titlebar_item(
+        &mut self,
+        item: impl Into<AnyViewHandle>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.titlebar_item = Some(item.into());
+        cx.notify();
+    }
+
     /// Call the given callback with a workspace whose project is local.
     ///
     /// If the given workspace has a local project, then it will be passed
@@ -1188,7 +1192,6 @@ impl Workspace {
             let (_, workspace) = cx.add_window((app_state.build_window_options)(), |cx| {
                 let mut workspace = Workspace::new(
                     Project::local(
-                        false,
                         app_state.client.clone(),
                         app_state.user_store.clone(),
                         app_state.project_store.clone(),
@@ -1238,7 +1241,7 @@ impl Workspace {
         _: &CloseWindow,
         cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
-        let prepare = self.prepare_to_close(cx);
+        let prepare = self.prepare_to_close(false, cx);
         Some(cx.spawn(|this, mut cx| async move {
             if prepare.await? {
                 this.update(&mut cx, |_, cx| {
@@ -1250,8 +1253,44 @@ impl Workspace {
         }))
     }
 
-    pub fn prepare_to_close(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<bool>> {
-        self.save_all_internal(true, cx)
+    pub fn prepare_to_close(
+        &mut self,
+        quitting: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Result<bool>> {
+        let active_call = self.active_call.clone();
+        let window_id = cx.window_id();
+        let workspace_count = cx
+            .window_ids()
+            .flat_map(|window_id| cx.root_view::<Workspace>(window_id))
+            .count();
+        cx.spawn(|this, mut cx| async move {
+            if let Some(active_call) = active_call {
+                if !quitting
+                    && workspace_count == 1
+                    && active_call.read_with(&cx, |call, _| call.room().is_some())
+                {
+                    let answer = cx
+                        .prompt(
+                            window_id,
+                            PromptLevel::Warning,
+                            "Do you want to leave the current call?",
+                            &["Close window and hang up", "Cancel"],
+                        )
+                        .next()
+                        .await;
+                    if answer == Some(1) {
+                        return anyhow::Ok(false);
+                    } else {
+                        active_call.update(&mut cx, |call, cx| call.hang_up(cx))?;
+                    }
+                }
+            }
+
+            Ok(this
+                .update(&mut cx, |this, cx| this.save_all_internal(true, cx))
+                .await?)
+        })
     }
 
     fn save_all(&mut self, _: &SaveAll, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
@@ -1393,17 +1432,6 @@ impl Workspace {
             .update(cx, |project, cx| project.remove_worktree(*worktree_id, cx));
     }
 
-    fn toggle_project_online(&mut self, action: &ToggleProjectOnline, cx: &mut ViewContext<Self>) {
-        let project = action
-            .project
-            .clone()
-            .unwrap_or_else(|| self.project.clone());
-        project.update(cx, |project, cx| {
-            let public = !project.is_online();
-            project.set_online(public, cx);
-        });
-    }
-
     fn project_path_for_path(
         &self,
         abs_path: &Path,
@@ -2068,46 +2096,12 @@ impl Workspace {
         None
     }
 
-    fn render_connection_status(&self, cx: &mut RenderContext<Self>) -> Option<ElementBox> {
-        let theme = &cx.global::<Settings>().theme;
-        match &*self.client.status().borrow() {
-            client::Status::ConnectionError
-            | client::Status::ConnectionLost
-            | client::Status::Reauthenticating { .. }
-            | client::Status::Reconnecting { .. }
-            | client::Status::ReconnectionError { .. } => Some(
-                Container::new(
-                    Align::new(
-                        ConstrainedBox::new(
-                            Svg::new("icons/cloud_slash_12.svg")
-                                .with_color(theme.workspace.titlebar.offline_icon.color)
-                                .boxed(),
-                        )
-                        .with_width(theme.workspace.titlebar.offline_icon.width)
-                        .boxed(),
-                    )
-                    .boxed(),
-                )
-                .with_style(theme.workspace.titlebar.offline_icon.container)
-                .boxed(),
-            ),
-            client::Status::UpgradeRequired => Some(
-                Label::new(
-                    "Please update Zed to collaborate".to_string(),
-                    theme.workspace.titlebar.outdated_warning.text.clone(),
-                )
-                .contained()
-                .with_style(theme.workspace.titlebar.outdated_warning.container)
-                .aligned()
-                .boxed(),
-            ),
-            _ => None,
-        }
+    pub fn is_following(&self, peer_id: PeerId) -> bool {
+        self.follower_states_by_leader.contains_key(&peer_id)
     }
 
     fn render_titlebar(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
         let project = &self.project.read(cx);
-        let replica_id = project.replica_id();
         let mut worktree_root_names = String::new();
         for (i, name) in project.worktree_root_names(cx).enumerate() {
             if i > 0 {
@@ -2129,7 +2123,7 @@ impl Workspace {
 
         enum TitleBar {}
         ConstrainedBox::new(
-            MouseEventHandler::<TitleBar>::new(0, cx, |_, cx| {
+            MouseEventHandler::<TitleBar>::new(0, cx, |_, _| {
                 Container::new(
                     Stack::new()
                         .with_child(
@@ -2138,21 +2132,10 @@ impl Workspace {
                                 .left()
                                 .boxed(),
                         )
-                        .with_child(
-                            Align::new(
-                                Flex::row()
-                                    .with_children(self.render_collaborators(theme, cx))
-                                    .with_children(self.render_current_user(
-                                        self.user_store.read(cx).current_user().as_ref(),
-                                        replica_id,
-                                        theme,
-                                        cx,
-                                    ))
-                                    .with_children(self.render_connection_status(cx))
-                                    .boxed(),
-                            )
-                            .right()
-                            .boxed(),
+                        .with_children(
+                            self.titlebar_item
+                                .as_ref()
+                                .map(|item| ChildView::new(item).aligned().right().boxed()),
                         )
                         .boxed(),
                 )
@@ -2221,125 +2204,6 @@ impl Workspace {
         }
     }
 
-    fn render_collaborators(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> Vec<ElementBox> {
-        let mut collaborators = self
-            .project
-            .read(cx)
-            .collaborators()
-            .values()
-            .cloned()
-            .collect::<Vec<_>>();
-        collaborators.sort_unstable_by_key(|collaborator| collaborator.replica_id);
-        collaborators
-            .into_iter()
-            .filter_map(|collaborator| {
-                Some(self.render_avatar(
-                    collaborator.user.avatar.clone()?,
-                    collaborator.replica_id,
-                    Some((collaborator.peer_id, &collaborator.user.github_login)),
-                    theme,
-                    cx,
-                ))
-            })
-            .collect()
-    }
-
-    fn render_current_user(
-        &self,
-        user: Option<&Arc<User>>,
-        replica_id: ReplicaId,
-        theme: &Theme,
-        cx: &mut RenderContext<Self>,
-    ) -> Option<ElementBox> {
-        let status = *self.client.status().borrow();
-        if let Some(avatar) = user.and_then(|user| user.avatar.clone()) {
-            Some(self.render_avatar(avatar, replica_id, None, 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".to_string(), 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(),
-            )
-        }
-    }
-
-    fn render_avatar(
-        &self,
-        avatar: Arc<ImageData>,
-        replica_id: ReplicaId,
-        peer: Option<(PeerId, &str)>,
-        theme: &Theme,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox {
-        let replica_color = theme.editor.replica_selection_style(replica_id).cursor;
-        let is_followed = peer.map_or(false, |(peer_id, _)| {
-            self.follower_states_by_leader.contains_key(&peer_id)
-        });
-        let mut avatar_style = theme.workspace.titlebar.avatar;
-        if is_followed {
-            avatar_style.border = Border::all(1.0, replica_color);
-        }
-        let content = Stack::new()
-            .with_child(
-                Image::new(avatar)
-                    .with_style(avatar_style)
-                    .constrained()
-                    .with_width(theme.workspace.titlebar.avatar_width)
-                    .aligned()
-                    .boxed(),
-            )
-            .with_child(
-                AvatarRibbon::new(replica_color)
-                    .constrained()
-                    .with_width(theme.workspace.titlebar.avatar_ribbon.width)
-                    .with_height(theme.workspace.titlebar.avatar_ribbon.height)
-                    .aligned()
-                    .bottom()
-                    .boxed(),
-            )
-            .constrained()
-            .with_width(theme.workspace.titlebar.avatar_width)
-            .contained()
-            .with_margin_left(theme.workspace.titlebar.avatar_margin)
-            .boxed();
-
-        if let Some((peer_id, peer_github_login)) = peer {
-            MouseEventHandler::<ToggleFollow>::new(replica_id.into(), cx, move |_, _| content)
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, cx| {
-                    cx.dispatch_action(ToggleFollow(peer_id))
-                })
-                .with_tooltip::<ToggleFollow, _>(
-                    peer_id.0 as usize,
-                    if is_followed {
-                        format!("Unfollow {}", peer_github_login)
-                    } else {
-                        format!("Follow {}", peer_github_login)
-                    },
-                    Some(Box::new(FollowNextCollaborator)),
-                    theme.tooltip.clone(),
-                    cx,
-                )
-                .boxed()
-        } else {
-            content
-        }
-    }
-
     fn render_disconnected_overlay(&self, cx: &mut RenderContext<Workspace>) -> Option<ElementBox> {
         if self.project.read(cx).is_read_only() {
             enum DisconnectedOverlay {}
@@ -2698,6 +2562,7 @@ impl View for Workspace {
                     .with_child(
                         Stack::new()
                             .with_child({
+                                let project = self.project.clone();
                                 Flex::row()
                                     .with_children(
                                         if self.left_sidebar.read(cx).active_item().is_some() {
@@ -2715,9 +2580,11 @@ impl View for Workspace {
                                             Flex::column()
                                                 .with_child(
                                                     FlexItem::new(self.center.render(
+                                                        &project,
                                                         &theme,
                                                         &self.follower_states_by_leader,
-                                                        self.project.read(cx).collaborators(),
+                                                        self.active_call.as_ref(),
+                                                        cx,
                                                     ))
                                                     .flex(1., true)
                                                     .boxed(),
@@ -2814,87 +2681,6 @@ impl WorkspaceHandle for ViewHandle<Workspace> {
     }
 }
 
-pub struct AvatarRibbon {
-    color: Color,
-}
-
-impl AvatarRibbon {
-    pub fn new(color: Color) -> AvatarRibbon {
-        AvatarRibbon { color }
-    }
-}
-
-impl Element for AvatarRibbon {
-    type LayoutState = ();
-
-    type PaintState = ();
-
-    fn layout(
-        &mut self,
-        constraint: gpui::SizeConstraint,
-        _: &mut gpui::LayoutContext,
-    ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
-        (constraint.max, ())
-    }
-
-    fn paint(
-        &mut self,
-        bounds: gpui::geometry::rect::RectF,
-        _: gpui::geometry::rect::RectF,
-        _: &mut Self::LayoutState,
-        cx: &mut gpui::PaintContext,
-    ) -> 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 dispatch_event(
-        &mut self,
-        _: &gpui::Event,
-        _: RectF,
-        _: RectF,
-        _: &mut Self::LayoutState,
-        _: &mut Self::PaintState,
-        _: &mut gpui::EventContext,
-    ) -> bool {
-        false
-    }
-
-    fn rect_for_text_range(
-        &self,
-        _: Range<usize>,
-        _: RectF,
-        _: RectF,
-        _: &Self::LayoutState,
-        _: &Self::PaintState,
-        _: &gpui::MeasurementContext,
-    ) -> Option<RectF> {
-        None
-    }
-
-    fn debug(
-        &self,
-        bounds: gpui::geometry::rect::RectF,
-        _: &Self::LayoutState,
-        _: &Self::PaintState,
-        _: &gpui::DebugContext,
-    ) -> gpui::json::Value {
-        json::json!({
-            "type": "AvatarRibbon",
-            "bounds": bounds.to_json(),
-            "color": self.color.to_json(),
-        })
-    }
-}
-
 impl std::fmt::Debug for OpenPaths {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct("OpenPaths")
@@ -2964,7 +2750,6 @@ pub fn open_paths(
 
             cx.add_window((app_state.build_window_options)(), |cx| {
                 let project = Project::local(
-                    false,
                     app_state.client.clone(),
                     app_state.user_store.clone(),
                     app_state.project_store.clone(),
@@ -2989,44 +2774,14 @@ pub fn open_paths(
             })
             .await;
 
-        if let Some(project) = new_project {
-            project
-                .update(&mut cx, |project, cx| project.restore_state(cx))
-                .await
-                .log_err();
-        }
-
         (workspace, items)
     })
 }
 
-pub fn join_project(
-    contact: Arc<Contact>,
-    project_index: usize,
-    app_state: &Arc<AppState>,
-    cx: &mut MutableAppContext,
-) {
-    let project_id = contact.projects[project_index].id;
-
-    for window_id in cx.window_ids().collect::<Vec<_>>() {
-        if let Some(workspace) = cx.root_view::<Workspace>(window_id) {
-            if workspace.read(cx).project().read(cx).remote_id() == Some(project_id) {
-                cx.activate_window(window_id);
-                return;
-            }
-        }
-    }
-
-    cx.add_window((app_state.build_window_options)(), |cx| {
-        WaitingRoom::new(contact, project_index, app_state.clone(), cx)
-    });
-}
-
 fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
     let (window_id, workspace) = cx.add_window((app_state.build_window_options)(), |cx| {
         let mut workspace = Workspace::new(
             Project::local(
-                false,
                 app_state.client.clone(),
                 app_state.user_store.clone(),
                 app_state.project_store.clone(),
@@ -3236,7 +2991,7 @@ mod tests {
         // When there are no dirty items, there's nothing to do.
         let item1 = cx.add_view(&workspace, |_| TestItem::new());
         workspace.update(cx, |w, cx| w.add_item(Box::new(item1.clone()), cx));
-        let task = workspace.update(cx, |w, cx| w.prepare_to_close(cx));
+        let task = workspace.update(cx, |w, cx| w.prepare_to_close(false, cx));
         assert!(task.await.unwrap());
 
         // When there are dirty untitled items, prompt to save each one. If the user
@@ -3256,7 +3011,7 @@ mod tests {
             w.add_item(Box::new(item2.clone()), cx);
             w.add_item(Box::new(item3.clone()), cx);
         });
-        let task = workspace.update(cx, |w, cx| w.prepare_to_close(cx));
+        let task = workspace.update(cx, |w, cx| w.prepare_to_close(false, cx));
         cx.foreground().run_until_parked();
         cx.simulate_prompt_answer(window_id, 2 /* cancel */);
         cx.foreground().run_until_parked();

crates/zed/Cargo.toml 🔗

@@ -19,15 +19,15 @@ activity_indicator = { path = "../activity_indicator" }
 assets = { path = "../assets" }
 auto_update = { path = "../auto_update" }
 breadcrumbs = { path = "../breadcrumbs" }
+call = { path = "../call" }
 chat_panel = { path = "../chat_panel" }
 cli = { path = "../cli" }
+collab_ui = { path = "../collab_ui" }
 collections = { path = "../collections" }
 command_palette = { path = "../command_palette" }
 context_menu = { path = "../context_menu" }
 client = { path = "../client" }
 clock = { path = "../clock" }
-contacts_panel = { path = "../contacts_panel" }
-contacts_status_item = { path = "../contacts_status_item" }
 diagnostics = { path = "../diagnostics" }
 editor = { path = "../editor" }
 file_finder = { path = "../file_finder" }
@@ -105,17 +105,19 @@ tree-sitter-html = "0.19.0"
 url = "2.2"
 
 [dev-dependencies]
-text = { path = "../text", features = ["test-support"] }
+call = { path = "../call", features = ["test-support"] }
+client = { path = "../client", features = ["test-support"] }
 editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 language = { path = "../language", features = ["test-support"] }
 lsp = { path = "../lsp", features = ["test-support"] }
 project = { path = "../project", features = ["test-support"] }
 rpc = { path = "../rpc", features = ["test-support"] }
-client = { path = "../client", features = ["test-support"] }
 settings = { path = "../settings", features = ["test-support"] }
+text = { path = "../text", features = ["test-support"] }
 util = { path = "../util", features = ["test-support"] }
 workspace = { path = "../workspace", features = ["test-support"] }
+
 env_logger = "0.9"
 serde_json = { version = "1.0", features = ["preserve_order"] }
 unindent = "0.1.7"

crates/zed/src/main.rs 🔗

@@ -112,7 +112,6 @@ fn main() {
         go_to_line::init(cx);
         file_finder::init(cx);
         chat_panel::init(cx);
-        contacts_panel::init(cx);
         outline::init(cx);
         project_symbols::init(cx);
         project_panel::init(cx);
@@ -138,11 +137,11 @@ fn main() {
         })
         .detach();
 
+        let project_store = cx.add_model(|_| ProjectStore::new());
         let db = cx.background().block(db);
         client.start_telemetry(db.clone());
         client.report_event("start app", Default::default());
 
-        let project_store = cx.add_model(|_| ProjectStore::new(db.clone()));
         let app_state = Arc::new(AppState {
             languages,
             themes,
@@ -159,6 +158,7 @@ fn main() {
         journal::init(app_state.clone(), cx);
         theme_selector::init(app_state.clone(), cx);
         zed::init(&app_state, cx);
+        collab_ui::init(app_state.clone(), cx);
 
         cx.set_menus(menus::menus());
 

crates/zed/src/menus.rs 🔗

@@ -244,10 +244,6 @@ pub fn menus() -> Vec<Menu<'static>> {
                     name: "Project Panel",
                     action: Box::new(project_panel::ToggleFocus),
                 },
-                MenuItem::Action {
-                    name: "Contacts Panel",
-                    action: Box::new(contacts_panel::ToggleFocus),
-                },
                 MenuItem::Action {
                     name: "Command Palette",
                     action: Box::new(command_palette::Toggle),

crates/zed/src/zed.rs 🔗

@@ -10,9 +10,8 @@ use anyhow::{anyhow, Context, Result};
 use assets::Assets;
 use breadcrumbs::Breadcrumbs;
 pub use client;
+use collab_ui::CollabTitlebarItem;
 use collections::VecDeque;
-pub use contacts_panel;
-use contacts_panel::ContactsPanel;
 pub use editor;
 use editor::{Editor, MultiBuffer};
 use gpui::{
@@ -214,15 +213,9 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
             workspace.toggle_sidebar_item_focus(SidebarSide::Left, 0, cx);
         },
     );
-    cx.add_action(
-        |workspace: &mut Workspace,
-         _: &contacts_panel::ToggleFocus,
-         cx: &mut ViewContext<Workspace>| {
-            workspace.toggle_sidebar_item_focus(SidebarSide::Right, 0, cx);
-        },
-    );
 
     activity_indicator::init(cx);
+    call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
     settings::KeymapFileContent::load_defaults(cx);
 }
 
@@ -231,7 +224,8 @@ pub fn initialize_workspace(
     app_state: &Arc<AppState>,
     cx: &mut ViewContext<Workspace>,
 ) {
-    cx.subscribe(&cx.handle(), {
+    let workspace_handle = cx.handle();
+    cx.subscribe(&workspace_handle, {
         move |_, _, event, cx| {
             if let workspace::Event::PaneAdded(pane) = event {
                 pane.update(cx, |pane, cx| {
@@ -285,16 +279,11 @@ pub fn initialize_workspace(
         }));
     });
 
-    let project_panel = ProjectPanel::new(workspace.project().clone(), cx);
-    let contact_panel = cx.add_view(|cx| {
-        ContactsPanel::new(
-            app_state.user_store.clone(),
-            app_state.project_store.clone(),
-            workspace.weak_handle(),
-            cx,
-        )
-    });
+    let collab_titlebar_item =
+        cx.add_view(|cx| CollabTitlebarItem::new(&workspace_handle, &app_state.user_store, cx));
+    workspace.set_titlebar_item(collab_titlebar_item, cx);
 
+    let project_panel = ProjectPanel::new(workspace.project().clone(), cx);
     workspace.left_sidebar().update(cx, |sidebar, cx| {
         sidebar.add_item(
             "icons/folder_tree_16.svg",
@@ -303,14 +292,6 @@ pub fn initialize_workspace(
             cx,
         )
     });
-    workspace.right_sidebar().update(cx, |sidebar, cx| {
-        sidebar.add_item(
-            "icons/user_group_16.svg",
-            "Contacts Panel".to_string(),
-            contact_panel,
-            cx,
-        )
-    });
 
     let diagnostic_summary =
         cx.add_view(|cx| diagnostics::items::DiagnosticIndicator::new(workspace.project(), cx));
@@ -363,7 +344,9 @@ fn quit(_: &Quit, cx: &mut gpui::MutableAppContext) {
         // If the user cancels any save prompt, then keep the app open.
         for workspace in workspaces {
             if !workspace
-                .update(&mut cx, |workspace, cx| workspace.prepare_to_close(cx))
+                .update(&mut cx, |workspace, cx| {
+                    workspace.prepare_to_close(true, cx)
+                })
                 .await?
             {
                 return Ok(());
@@ -1772,6 +1755,7 @@ mod tests {
             let state = Arc::get_mut(&mut app_state).unwrap();
             state.initialize_workspace = initialize_workspace;
             state.build_window_options = build_window_options;
+            call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
             workspace::init(app_state.clone(), cx);
             editor::init(cx);
             pane::init(cx);

styles/src/styleTree/app.ts 🔗

@@ -2,7 +2,6 @@ import Theme from "../themes/common/theme";
 import chatPanel from "./chatPanel";
 import { text } from "./components";
 import contactFinder from "./contactFinder";
-import contactsPanel from "./contactsPanel";
 import contactsPopover from "./contactsPopover";
 import commandPalette from "./commandPalette";
 import editor from "./editor";
@@ -14,8 +13,11 @@ import contextMenu from "./contextMenu";
 import projectDiagnostics from "./projectDiagnostics";
 import contactNotification from "./contactNotification";
 import updateNotification from "./updateNotification";
+import projectSharedNotification from "./projectSharedNotification";
 import tooltip from "./tooltip";
 import terminal from "./terminal";
+import contactList from "./contactList";
+import incomingCallNotification from "./incomingCallNotification";
 
 export const panel = {
   padding: { top: 12, bottom: 12 },
@@ -36,7 +38,7 @@ export default function app(theme: Theme): Object {
     projectPanel: projectPanel(theme),
     chatPanel: chatPanel(theme),
     contactsPopover: contactsPopover(theme),
-    contactsPanel: contactsPanel(theme),
+    contactList: contactList(theme),
     contactFinder: contactFinder(theme),
     search: search(theme),
     breadcrumbs: {
@@ -47,6 +49,8 @@ export default function app(theme: Theme): Object {
     },
     contactNotification: contactNotification(theme),
     updateNotification: updateNotification(theme),
+    projectSharedNotification: projectSharedNotification(theme),
+    incomingCallNotification: incomingCallNotification(theme),
     tooltip: tooltip(theme),
     terminal: terminal(theme),
   };

styles/src/styleTree/contactFinder.ts 🔗

@@ -1,8 +1,9 @@
 import Theme from "../themes/common/theme";
 import picker from "./picker";
-import { backgroundColor, iconColor } from "./components";
+import { backgroundColor, border, iconColor, player, text } from "./components";
 
 export default function contactFinder(theme: Theme) {
+  const sideMargin = 6;
   const contactButton = {
     background: backgroundColor(theme, 100),
     color: iconColor(theme, "primary"),
@@ -12,7 +13,31 @@ export default function contactFinder(theme: Theme) {
   };
 
   return {
-    ...picker(theme),
+    picker: {
+      item: {
+        ...picker(theme).item,
+        margin: { left: sideMargin, right: sideMargin }
+      },
+      empty: picker(theme).empty,
+      inputEditor: {
+        background: backgroundColor(theme, 500),
+        cornerRadius: 6,
+        text: text(theme, "mono", "primary"),
+        placeholderText: text(theme, "mono", "placeholder", { size: "sm" }),
+        selection: player(theme, 1).selection,
+        border: border(theme, "secondary"),
+        padding: {
+          bottom: 4,
+          left: 8,
+          right: 8,
+          top: 4,
+        },
+        margin: {
+          left: sideMargin,
+          right: sideMargin,
+        }
+      }
+    },
     rowHeight: 28,
     contactAvatar: {
       cornerRadius: 10,

styles/src/styleTree/contactsPanel.ts → styles/src/styleTree/contactList.ts 🔗

@@ -1,18 +1,17 @@
 import Theme from "../themes/common/theme";
-import { panel } from "./app";
-import {
-  backgroundColor,
-  border,
-  borderColor,
-  iconColor,
-  player,
-  text,
-} from "./components";
+import { backgroundColor, border, borderColor, iconColor, player, text } from "./components";
 
-export default function contactsPanel(theme: Theme) {
+export default function contactList(theme: Theme) {
   const nameMargin = 8;
   const sidePadding = 12;
 
+  const contactButton = {
+    background: backgroundColor(theme, 100),
+    color: iconColor(theme, "primary"),
+    iconWidth: 8,
+    buttonWidth: 16,
+    cornerRadius: 8,
+  };
   const projectRow = {
     guestAvatarSpacing: 4,
     height: 24,
@@ -39,17 +38,7 @@ export default function contactsPanel(theme: Theme) {
     },
   };
 
-  const contactButton = {
-    background: backgroundColor(theme, 100),
-    color: iconColor(theme, "primary"),
-    iconWidth: 8,
-    buttonWidth: 16,
-    cornerRadius: 8,
-  };
-
   return {
-    ...panel,
-    padding: { top: panel.padding.top, bottom: 0 },
     userQueryEditor: {
       background: backgroundColor(theme, 500),
       cornerRadius: 6,
@@ -64,28 +53,20 @@ export default function contactsPanel(theme: Theme) {
         top: 4,
       },
       margin: {
-        left: sidePadding,
-        right: sidePadding,
+        left: 6
       },
     },
-    userQueryEditorHeight: 32,
+    userQueryEditorHeight: 33,
     addContactButton: {
-      margin: { left: 6, right: 12 },
       color: iconColor(theme, "primary"),
-      buttonWidth: 16,
+      buttonWidth: 28,
       iconWidth: 16,
     },
-    privateButton: {
-      iconWidth: 12,
-      color: iconColor(theme, "primary"),
-      cornerRadius: 5,
-      buttonWidth: 12,
-    },
     rowHeight: 28,
     sectionIconSize: 8,
     headerRow: {
       ...text(theme, "mono", "secondary", { size: "sm" }),
-      margin: { top: 14 },
+      margin: { top: 6 },
       padding: {
         left: sidePadding,
         right: sidePadding,
@@ -95,6 +76,26 @@ export default function contactsPanel(theme: Theme) {
         background: backgroundColor(theme, 100, "active"),
       },
     },
+    leaveCall: {
+      background: backgroundColor(theme, 100),
+      border: border(theme, "secondary"),
+      cornerRadius: 6,
+      margin: {
+        top: 1,
+      },
+      padding: {
+        top: 1,
+        bottom: 1,
+        left: 7,
+        right: 7,
+      },
+      ...text(theme, "sans", "secondary", { size: "xs" }),
+      hover: {
+        ...text(theme, "sans", "active", { size: "xs" }),
+        background: backgroundColor(theme, "on300", "hovered"),
+        border: border(theme, "primary"),
+      },
+    },
     contactRow: {
       padding: {
         left: sidePadding,
@@ -104,20 +105,22 @@ export default function contactsPanel(theme: Theme) {
         background: backgroundColor(theme, 100, "active"),
       },
     },
-    treeBranch: {
-      color: borderColor(theme, "active"),
-      width: 1,
-      hover: {
-        color: borderColor(theme, "active"),
-      },
-      active: {
-        color: borderColor(theme, "active"),
-      },
-    },
     contactAvatar: {
       cornerRadius: 10,
       width: 18,
     },
+    contactStatusFree: {
+      cornerRadius: 4,
+      padding: 4,
+      margin: { top: 12, left: 12 },
+      background: iconColor(theme, "ok"),
+    },
+    contactStatusBusy: {
+      cornerRadius: 4,
+      padding: 4,
+      margin: { top: 12, left: 12 },
+      background: iconColor(theme, "error"),
+    },
     contactUsername: {
       ...text(theme, "mono", "primary", { size: "sm" }),
       margin: {
@@ -136,6 +139,19 @@ export default function contactsPanel(theme: Theme) {
       background: backgroundColor(theme, 100),
       color: iconColor(theme, "muted"),
     },
+    callingIndicator: {
+      ...text(theme, "mono", "muted", { size: "xs" })
+    },
+    treeBranch: {
+      color: borderColor(theme, "active"),
+      width: 1,
+      hover: {
+        color: borderColor(theme, "active"),
+      },
+      active: {
+        color: borderColor(theme, "active"),
+      },
+    },
     projectRow: {
       ...projectRow,
       background: backgroundColor(theme, 300),
@@ -150,16 +166,5 @@ export default function contactsPanel(theme: Theme) {
         background: backgroundColor(theme, 300, "active"),
       },
     },
-    inviteRow: {
-      padding: {
-        left: sidePadding,
-        right: sidePadding,
-      },
-      border: { top: true, width: 1, color: borderColor(theme, "primary") },
-      text: text(theme, "sans", "secondary", { size: "sm" }),
-      hover: {
-        text: text(theme, "sans", "active", { size: "sm" }),
-      },
-    },
-  };
+  }
 }

styles/src/styleTree/contactsPopover.ts 🔗

@@ -1,8 +1,28 @@
 import Theme from "../themes/common/theme";
-import { backgroundColor } from "./components";
+import { backgroundColor, border, borderColor, popoverShadow, text } from "./components";
 
-export default function workspace(theme: Theme) {
+export default function contactsPopover(theme: Theme) {
+  const sidePadding = 12;
   return {
-    background: backgroundColor(theme, 300),
+    background: backgroundColor(theme, 300, "base"),
+    cornerRadius: 6,
+    padding: { top: 6 },
+    margin: { top: -6 },
+    shadow: popoverShadow(theme),
+    border: border(theme, "primary"),
+    width: 300,
+    height: 400,
+    inviteRowHeight: 28,
+    inviteRow: {
+      padding: {
+        left: sidePadding,
+        right: sidePadding,
+      },
+      border: { top: true, width: 1, color: borderColor(theme, "primary") },
+      text: text(theme, "sans", "secondary", { size: "sm" }),
+      hover: {
+        text: text(theme, "sans", "active", { size: "sm" }),
+      },
+    },
   }
 }

styles/src/styleTree/incomingCallNotification.ts 🔗

@@ -0,0 +1,44 @@
+import Theme from "../themes/common/theme";
+import { backgroundColor, borderColor, text } from "./components";
+
+export default function incomingCallNotification(theme: Theme): Object {
+  const avatarSize = 48;
+  return {
+    windowHeight: 74,
+    windowWidth: 380,
+    background: backgroundColor(theme, 300),
+    callerContainer: {
+      padding: 12,
+    },
+    callerAvatar: {
+      height: avatarSize,
+      width: avatarSize,
+      cornerRadius: avatarSize / 2,
+    },
+    callerMetadata: {
+      margin: { left: 10 },
+    },
+    callerUsername: {
+      ...text(theme, "sans", "active", { size: "sm", weight: "bold" }),
+      margin: { top: -3 },
+    },
+    callerMessage: {
+      ...text(theme, "sans", "secondary", { size: "xs" }),
+      margin: { top: -3 },
+    },
+    worktreeRoots: {
+      ...text(theme, "sans", "secondary", { size: "xs", weight: "bold" }),
+      margin: { top: -3 },
+    },
+    buttonWidth: 96,
+    acceptButton: {
+      background: backgroundColor(theme, "ok", "active"),
+      border: { left: true, bottom: true, width: 1, color: borderColor(theme, "primary") },
+      ...text(theme, "sans", "ok", { size: "xs", weight: "extra_bold" })
+    },
+    declineButton: {
+      border: { left: true, width: 1, color: borderColor(theme, "primary") },
+      ...text(theme, "sans", "error", { size: "xs", weight: "extra_bold" })
+    },
+  };
+}

styles/src/styleTree/projectSharedNotification.ts 🔗

@@ -0,0 +1,44 @@
+import Theme from "../themes/common/theme";
+import { backgroundColor, borderColor, text } from "./components";
+
+export default function projectSharedNotification(theme: Theme): Object {
+  const avatarSize = 48;
+  return {
+    windowHeight: 74,
+    windowWidth: 380,
+    background: backgroundColor(theme, 300),
+    ownerContainer: {
+      padding: 12,
+    },
+    ownerAvatar: {
+      height: avatarSize,
+      width: avatarSize,
+      cornerRadius: avatarSize / 2,
+    },
+    ownerMetadata: {
+      margin: { left: 10 },
+    },
+    ownerUsername: {
+      ...text(theme, "sans", "active", { size: "sm", weight: "bold" }),
+      margin: { top: -3 },
+    },
+    message: {
+      ...text(theme, "sans", "secondary", { size: "xs" }),
+      margin: { top: -3 },
+    },
+    worktreeRoots: {
+      ...text(theme, "sans", "secondary", { size: "xs", weight: "bold" }),
+      margin: { top: -3 },
+    },
+    buttonWidth: 96,
+    openButton: {
+      background: backgroundColor(theme, "info", "active"),
+      border: { left: true, bottom: true, width: 1, color: borderColor(theme, "primary") },
+      ...text(theme, "sans", "info", { size: "xs", weight: "extra_bold" })
+    },
+    dismissButton: {
+      border: { left: true, width: 1, color: borderColor(theme, "primary") },
+      ...text(theme, "sans", "secondary", { size: "xs", weight: "extra_bold" })
+    },
+  };
+}

styles/src/styleTree/workspace.ts 🔗

@@ -16,6 +16,27 @@ export function workspaceBackground(theme: Theme) {
 
 export default function workspace(theme: Theme) {
   const titlebarPadding = 6;
+  const titlebarButton = {
+    background: backgroundColor(theme, 100),
+    border: border(theme, "secondary"),
+    cornerRadius: 6,
+    margin: {
+      top: 1,
+    },
+    padding: {
+      top: 1,
+      bottom: 1,
+      left: 7,
+      right: 7,
+    },
+    ...text(theme, "sans", "secondary", { size: "xs" }),
+    hover: {
+      ...text(theme, "sans", "active", { size: "xs" }),
+      background: backgroundColor(theme, "on300", "hovered"),
+      border: border(theme, "primary"),
+    },
+  };
+  const avatarWidth = 18;
 
   return {
     background: backgroundColor(theme, 300),
@@ -27,6 +48,14 @@ export default function workspace(theme: Theme) {
       padding: 12,
       ...text(theme, "sans", "primary", { size: "lg" }),
     },
+    externalLocationMessage: {
+      background: backgroundColor(theme, "info"),
+      border: border(theme, "secondary"),
+      cornerRadius: 6,
+      padding: 12,
+      margin: { bottom: 8, right: 8 },
+      ...text(theme, "sans", "secondary", { size: "xs" }),
+    },
     leaderBorderOpacity: 0.7,
     leaderBorderWidth: 2.0,
     tabBar: tabBar(theme),
@@ -52,7 +81,7 @@ export default function workspace(theme: Theme) {
     },
     statusBar: statusBar(theme),
     titlebar: {
-      avatarWidth: 18,
+      avatarWidth,
       avatarMargin: 8,
       height: 33,
       background: backgroundColor(theme, 100),
@@ -62,12 +91,20 @@ export default function workspace(theme: Theme) {
       },
       title: text(theme, "sans", "primary"),
       avatar: {
-        cornerRadius: 10,
+        cornerRadius: avatarWidth / 2,
         border: {
           color: "#00000088",
           width: 1,
         },
       },
+      inactiveAvatar: {
+        cornerRadius: avatarWidth / 2,
+        border: {
+          color: "#00000088",
+          width: 1,
+        },
+        grayscale: true,
+      },
       avatarRibbon: {
         height: 3,
         width: 12,
@@ -76,24 +113,7 @@ export default function workspace(theme: Theme) {
       },
       border: border(theme, "primary", { bottom: true, overlay: true }),
       signInPrompt: {
-        background: backgroundColor(theme, 100),
-        border: border(theme, "secondary"),
-        cornerRadius: 6,
-        margin: {
-          top: 1,
-        },
-        padding: {
-          top: 1,
-          bottom: 1,
-          left: 7,
-          right: 7,
-        },
-        ...text(theme, "sans", "secondary", { size: "xs" }),
-        hover: {
-          ...text(theme, "sans", "active", { size: "xs" }),
-          background: backgroundColor(theme, "on300", "hovered"),
-          border: border(theme, "primary"),
-        },
+        ...titlebarButton
       },
       offlineIcon: {
         color: iconColor(theme, "secondary"),
@@ -118,6 +138,30 @@ export default function workspace(theme: Theme) {
         },
         cornerRadius: 6,
       },
+      toggleContactsButton: {
+        cornerRadius: 6,
+        color: iconColor(theme, "secondary"),
+        iconWidth: 8,
+        buttonWidth: 20,
+        active: {
+          background: backgroundColor(theme, "on300", "active"),
+          color: iconColor(theme, "active"),
+        },
+        hover: {
+          background: backgroundColor(theme, "on300", "hovered"),
+          color: iconColor(theme, "active"),
+        },
+      },
+      toggleContactsBadge: {
+        cornerRadius: 3,
+        padding: 2,
+        margin: { top: 3, left: 3 },
+        border: { width: 1, color: workspaceBackground(theme) },
+        background: iconColor(theme, "feature"),
+      },
+      shareButton: {
+        ...titlebarButton
+      }
     },
     toolbar: {
       height: 34,