diff --git a/Cargo.lock b/Cargo.lock index da2362670d421322ac3cf39dcbe488e285a5dad6..0448538c3554e170d53e62f945371b109e4fe6b6 100644 --- a/Cargo.lock +++ b/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", diff --git a/assets/icons/zed_22.svg b/assets/icons/zed_22.svg deleted file mode 100644 index 68e7dc8e57c966d5723920bc027c313161b95af8..0000000000000000000000000000000000000000 --- a/assets/icons/zed_22.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index a0bc8c39e6b2d29089292daa2863705a32e05ea4..e2adfc0f81d09bc6226079dd83713cf1b0dc79b5 100644 --- a/assets/keymaps/default.json +++ b/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" } }, diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..e725c7cfe3b053d36f1b04b81d1d5476e68e7bed --- /dev/null +++ b/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"] } diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs new file mode 100644 index 0000000000000000000000000000000000000000..6b06d04375b8a1fca563ef3be558f823ce45cd1c --- /dev/null +++ b/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, user_store: ModelHandle, 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, + pub participants: Vec>, + pub initial_project: Option, +} + +pub struct ActiveCall { + room: Option<(ModelHandle, Vec)>, + incoming_call: ( + watch::Sender>, + watch::Receiver>, + ), + client: Arc, + user_store: ModelHandle, + _subscriptions: Vec, +} + +impl Entity for ActiveCall { + type Event = room::Event; +} + +impl ActiveCall { + fn new( + client: Arc, + user_store: ModelHandle, + cx: &mut ModelContext, + ) -> 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, + envelope: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result { + 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, + _: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, _| { + *this.incoming_call.0.borrow_mut() = None; + }); + Ok(()) + } + + pub fn global(cx: &AppContext) -> ModelHandle { + cx.global::>().clone() + } + + pub fn invite( + &mut self, + recipient_user_id: u64, + initial_project: Option>, + cx: &mut ModelContext, + ) -> Task> { + 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, + ) -> Task> { + 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> { + self.incoming_call.1.clone() + } + + pub fn accept_incoming(&mut self, cx: &mut ModelContext) -> Task> { + 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) -> 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, + cx: &mut ModelContext, + ) -> Task> { + 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>, + cx: &mut ModelContext, + ) -> Task> { + 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>, cx: &mut ModelContext) { + 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> { + self.room.as_ref().map(|(room, _)| room) + } +} diff --git a/crates/call/src/participant.rs b/crates/call/src/participant.rs new file mode 100644 index 0000000000000000000000000000000000000000..a5be5b4af2779a369f0e0707b8144c873a6e61d5 --- /dev/null +++ b/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) -> Result { + 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, + pub active_project: Option>, +} + +#[derive(Clone, Debug)] +pub struct RemoteParticipant { + pub user: Arc, + pub projects: Vec, + pub location: ParticipantLocation, +} diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs new file mode 100644 index 0000000000000000000000000000000000000000..572f512d1c28841ef41f7eeb49dbbc073495c9b8 --- /dev/null +++ b/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, + project_id: u64, + worktree_root_names: Vec, + }, + RemoteProjectUnshared { + project_id: u64, + }, + Left, +} + +pub struct Room { + id: u64, + status: RoomStatus, + local_participant: LocalParticipant, + remote_participants: BTreeMap, + pending_participants: Vec>, + participant_user_ids: HashSet, + pending_call_count: usize, + leave_when_empty: bool, + client: Arc, + user_store: ModelHandle, + subscriptions: Vec, + pending_room_update: Option>, +} + +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, + user_store: ModelHandle, + cx: &mut ModelContext, + ) -> 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>, + client: Arc, + user_store: ModelHandle, + cx: &mut MutableAppContext, + ) -> Task>> { + 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, + user_store: ModelHandle, + cx: &mut MutableAppContext, + ) -> Task>> { + 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) -> 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 { + &self.remote_participants + } + + pub fn pending_participants(&self) -> &[Arc] { + &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, + envelope: TypedEnvelope, + _: Arc, + 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, + ) -> 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::>(); + 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::>(); + let new_projects = participant + .projects + .iter() + .map(|project| project.id) + .collect::>(); + + 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, + cx: &mut ModelContext, + ) -> Task> { + 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, + cx: &mut ModelContext, + ) -> Task> { + 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>, + cx: &mut ModelContext, + ) -> Task> { + 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) + } +} diff --git a/crates/client/src/channel.rs b/crates/client/src/channel.rs index 3f99d7ccd25692085b43b4c544930d9cd7da97c6..7b4f6073ceb34a653130ba64c571734e563687fd 100644 --- a/crates/client/src/channel.rs +++ b/crates/client/src/channel.rs @@ -530,7 +530,7 @@ impl ChannelMessage { ) -> Result { 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 { diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 73ecf16084d833a7b48806a8f27cb4b63d2d8419..e8f6b801734e888ca41b240f4f301855d99ca5c0 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -434,6 +434,29 @@ impl Client { } } + pub fn add_request_handler( + self: &Arc, + model: ModelHandle, + handler: H, + ) -> Subscription + where + M: RequestMessage, + E: Entity, + H: 'static + + Send + + Sync + + Fn(ModelHandle, TypedEnvelope, Arc, AsyncAppContext) -> F, + F: 'static + Future>, + { + 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(self: &Arc, handler: H) where M: EntityMessage, diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index d52d6367b0bba399d2f50565eca9ab08d61696bd..3c3d7e7fb3e199e16e28c106deda13d473a9c840 100644 --- a/crates/client/src/user.rs +++ b/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, pub online: bool, - pub projects: Vec, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct ProjectMetadata { - pub id: u64, - pub visible_worktree_root_names: Vec, - pub guests: BTreeSet>, + 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, + user_ids: Vec, cx: &mut ModelContext, - ) -> Task> { - 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>>> { + 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, @@ -623,39 +631,15 @@ impl Contact { ) -> Result { 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 { - self.projects - .iter() - .filter(|project| !project.visible_worktree_root_names.is_empty()) - } } async fn fetch_avatar(http: &dyn HttpClient, url: &str) -> Result> { diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 47c86e0fe7604abe48004ce9870354ae9fe512d4..840199c2bbc505d4e885945eeb07d5865bd83288 100644 --- a/crates/collab/Cargo.toml +++ b/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"] } diff --git a/crates/collab/src/bin/seed.rs b/crates/collab/src/bin/seed.rs index b7b3a967101f1cd9de3d29677127e03705113092..cabea7d013776d4f3cb248d1b0c8985a0f3090a2 100644 --- a/crates/collab/src/bin/seed.rs +++ b/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, ); } } diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index a12f6a4f89db7b6ea5998e510f3044bbfbfba1a7..4f49c117fd3dadc985ea5d0fac3b129a131292b7 100644 --- a/crates/collab/src/db.rs +++ b/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> { 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 { diff --git a/crates/collab/src/db_tests.rs b/crates/collab/src/db_tests.rs index e063b97eb6ffed39e6cbe99e814432ba3fe5b1f4..477dcd4ab81269fb64634bcc1b47eba023a813d8 100644 --- a/crates/collab/src/db_tests.rs +++ b/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, diff --git a/crates/collab/src/integration_tests.rs b/crates/collab/src/integration_tests.rs index 7e84c70601b8471121038cb24987b9d114a866a7..bdd240fda8e60c2e4479119f6af36a204d221240 100644 --- a/crates/collab/src/integration_tests.rs +++ b/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, 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, + 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, + 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, + 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, + 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, 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, 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, - 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>> { + 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, 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, - user_store: ModelHandle, - 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, + 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)] @@ -810,12 +1074,9 @@ async fn test_propagate_saves_and_fs_changes( 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; + let active_call_a = cx_a.read(ActiveCall::global); client_a .fs @@ -829,10 +1090,14 @@ async fn test_propagate_saves_and_fs_changes( .await; let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; let worktree_a = project_a.read_with(cx_a, |p, cx| p.worktrees(cx).next().unwrap()); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); // Join that worktree as clients B and C. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - let project_c = client_c.build_remote_project(&project_a, cx_a, cx_c).await; + let project_b = client_b.build_remote_project(project_id, cx_b).await; + let project_c = client_c.build_remote_project(project_id, cx_c).await; let worktree_b = project_b.read_with(cx_b, |p, cx| p.worktrees(cx).next().unwrap()); let worktree_c = project_c.read_with(cx_c, |p, cx| p.worktrees(cx).next().unwrap()); @@ -958,8 +1223,9 @@ async fn test_git_diff_base_change( 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); client_a .fs @@ -985,9 +1251,14 @@ async fn test_git_diff_base_change( .await; let (project_local, worktree_id) = client_a.build_local_project("/dir", cx_a).await; - let project_remote = client_b - .build_remote_project(&project_local, cx_a, cx_b) - .await; + let project_id = active_call_a + .update(cx_a, |call, cx| { + call.share_project(project_local.clone(), cx) + }) + .await + .unwrap(); + + let project_remote = client_b.build_remote_project(project_id, cx_b).await; let diff_base = " one @@ -1204,8 +1475,9 @@ async fn test_fs_operations( 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); client_a .fs @@ -1218,7 +1490,11 @@ async fn test_fs_operations( ) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap()); let worktree_b = project_b.read_with(cx_b, |project, cx| project.worktrees(cx).next().unwrap()); @@ -1465,8 +1741,9 @@ async fn test_buffer_conflict_after_save(cx_a: &mut TestAppContext, cx_b: &mut T 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); client_a .fs @@ -1478,7 +1755,11 @@ async fn test_buffer_conflict_after_save(cx_a: &mut TestAppContext, cx_b: &mut T ) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; // Open a buffer as client B let buffer_b = project_b @@ -1514,8 +1795,9 @@ async fn test_buffer_reloading(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont 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); client_a .fs @@ -1527,7 +1809,11 @@ async fn test_buffer_reloading(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont ) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; // Open a buffer as client B let buffer_b = project_b @@ -1568,15 +1854,20 @@ async fn test_editing_while_guest_opens_buffer( 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); client_a .fs .insert_tree("/dir", json!({ "a.txt": "a-contents" })) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; // Open a buffer as client A let buffer_a = project_a @@ -1610,15 +1901,20 @@ async fn test_leaving_worktree_while_opening_buffer( 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); client_a .fs .insert_tree("/dir", json!({ "a.txt": "a-contents" })) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; // See that a guest has joined as client A. project_a @@ -1650,8 +1946,9 @@ async fn test_canceling_buffer_opening( 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); client_a .fs @@ -1663,7 +1960,11 @@ async fn test_canceling_buffer_opening( ) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; let buffer_a = project_a .update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) @@ -1685,14 +1986,21 @@ async fn test_canceling_buffer_opening( } #[gpui::test(iterations = 10)] -async fn test_leaving_project(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); +async fn test_leaving_project( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &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_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); client_a .fs @@ -1705,34 +2013,67 @@ async fn test_leaving_project(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte ) .await; let (project_a, _) = client_a.build_local_project("/a", cx_a).await; - let _project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; + let project_c = client_c.build_remote_project(project_id, cx_c).await; // Client A sees that a guest has joined. - project_a - .condition(cx_a, |p, _| p.collaborators().len() == 1) - .await; + 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_c.read_with(cx_c, |project, _| { + assert_eq!(project.collaborators().len(), 2); + }); - // Drop client B's connection and ensure client A observes client B leaving the project. + // Drop client B's connection and ensure client A and client C observe client B leaving the project. client_b.disconnect(&cx_b.to_async()).unwrap(); - project_a - .condition(cx_a, |p, _| p.collaborators().is_empty()) - .await; - - // Rejoin the project as client B - let _project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + deterministic.run_until_parked(); + project_a.read_with(cx_a, |project, _| { + assert_eq!(project.collaborators().len(), 1); + }); + project_b.read_with(cx_b, |project, _| { + assert!(project.is_read_only()); + }); + project_c.read_with(cx_c, |project, _| { + assert_eq!(project.collaborators().len(), 1); + }); - // Client A sees that a guest has re-joined. - project_a - .condition(cx_a, |p, _| p.collaborators().len() == 1) - .await; + // Client B can't join the project, unless they re-join the room. + 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, + ) + }) + .await + .unwrap_err(); - // Simulate connection loss for client B and ensure client A observes client B leaving the project. - client_b.wait_for_current_user(cx_b).await; - server.disconnect_client(client_b.current_user_id(cx_b)); + // Simulate connection loss for client C and ensure client A observes client C leaving the project. + client_c.wait_for_current_user(cx_c).await; + server.disconnect_client(client_c.current_user_id(cx_c)); cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT); - project_a - .condition(cx_a, |p, _| p.collaborators().is_empty()) - .await; + deterministic.run_until_parked(); + project_a.read_with(cx_a, |project, _| { + assert_eq!(project.collaborators().len(), 0); + }); + project_b.read_with(cx_b, |project, _| { + assert!(project.is_read_only()); + }); + project_c.read_with(cx_c, |project, _| { + assert!(project.is_read_only()); + }); } #[gpui::test(iterations = 10)] @@ -1748,12 +2089,9 @@ async fn test_collaborating_with_diagnostics( 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; + let active_call_a = cx_a.read(ActiveCall::global); // Set up a fake language server. let mut language = Language::new( @@ -1779,7 +2117,10 @@ async fn test_collaborating_with_diagnostics( ) .await; let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; - let project_id = project_a.update(cx_a, |p, _| p.next_remote_id()).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); // Cause the language server to start. let _buffer = cx_a @@ -1797,7 +2138,7 @@ async fn test_collaborating_with_diagnostics( .unwrap(); // Join the worktree as client B. - 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; // Simulate a language server reporting errors for a file. let mut fake_language_server = fake_language_servers.next().await.unwrap(); @@ -1845,7 +2186,7 @@ async fn test_collaborating_with_diagnostics( }); // Join project as client C and observe the diagnostics. - let project_c = client_c.build_remote_project(&project_a, cx_a, cx_c).await; + let project_c = client_c.build_remote_project(project_id, cx_c).await; deterministic.run_until_parked(); project_c.read_with(cx_c, |project, cx| { assert_eq!( @@ -1986,8 +2327,9 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu 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); // Set up a fake language server. let mut language = Language::new( @@ -2023,7 +2365,11 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu ) .await; let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; // Open a file in an editor as the guest. let buffer_b = project_b @@ -2155,8 +2501,9 @@ async fn test_reloading_buffer_manually(cx_a: &mut TestAppContext, cx_b: &mut Te 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); client_a .fs @@ -2167,8 +2514,12 @@ async fn test_reloading_buffer_manually(cx_a: &mut TestAppContext, cx_b: &mut Te .update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx)) .await .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; let buffer_b = cx_b .background() @@ -2243,8 +2594,9 @@ async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppCon 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); // Set up a fake language server. let mut language = Language::new( @@ -2267,7 +2619,11 @@ async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppCon .insert_tree(&directory, json!({ "a.rs": "let one = \"two\"" })) .await; let (project_a, worktree_id) = client_a.build_local_project(&directory, cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; let buffer_b = cx_b .background() @@ -2339,8 +2695,9 @@ async fn test_definition(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { 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); // Set up a fake language server. let mut language = Language::new( @@ -2370,7 +2727,11 @@ async fn test_definition(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { ) .await; let (project_a, worktree_id) = client_a.build_local_project("/root/dir-1", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; // Open the file on client B. let buffer_b = cx_b @@ -2478,8 +2839,9 @@ async fn test_references(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { 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); // Set up a fake language server. let mut language = Language::new( @@ -2509,7 +2871,11 @@ async fn test_references(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { ) .await; let (project_a, worktree_id) = client_a.build_local_project("/root/dir-1", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; // Open the file on client B. let buffer_b = cx_b @@ -2574,8 +2940,9 @@ async fn test_project_search(cx_a: &mut TestAppContext, cx_b: &mut TestAppContex 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); client_a .fs @@ -2604,8 +2971,12 @@ async fn test_project_search(cx_a: &mut TestAppContext, cx_b: &mut TestAppContex worktree_2 .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) .await; + 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; // Perform a search as the guest. let results = project_b @@ -2648,8 +3019,9 @@ async fn test_document_highlights(cx_a: &mut TestAppContext, cx_b: &mut TestAppC 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); client_a .fs @@ -2674,7 +3046,11 @@ async fn test_document_highlights(cx_a: &mut TestAppContext, cx_b: &mut TestAppC client_a.language_registry.add(Arc::new(language)); let (project_a, worktree_id) = client_a.build_local_project("/root-1", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; // Open the file on client B. let buffer_b = cx_b @@ -2745,8 +3121,9 @@ async fn test_lsp_hover(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { 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); client_a .fs @@ -2771,7 +3148,11 @@ async fn test_lsp_hover(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { client_a.language_registry.add(Arc::new(language)); let (project_a, worktree_id) = client_a.build_local_project("/root-1", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; // Open the file as the guest let buffer_b = cx_b @@ -2843,8 +3224,9 @@ async fn test_project_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte 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); // Set up a fake language server. let mut language = Language::new( @@ -2876,7 +3258,11 @@ async fn test_project_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte ) .await; let (project_a, worktree_id) = client_a.build_local_project("/code/crate-1", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; // Cause the language server to start. let _buffer = cx_b @@ -2946,8 +3332,9 @@ async fn test_open_buffer_while_getting_definition_pointing_to_it( 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); // Set up a fake language server. let mut language = Language::new( @@ -2972,7 +3359,11 @@ async fn test_open_buffer_while_getting_definition_pointing_to_it( ) .await; let (project_a, worktree_id) = client_a.build_local_project("/root", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; let buffer_b1 = cx_b .background() @@ -3017,8 +3408,9 @@ async fn test_collaborating_with_code_actions( 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); // Set up a fake language server. let mut language = Language::new( @@ -3043,9 +3435,13 @@ async fn test_collaborating_with_code_actions( ) .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(); // Join the project as client B. - 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; let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::new(project_b.clone(), |_, _| unimplemented!(), cx)); let editor_b = workspace_b @@ -3223,8 +3619,9 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T 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); // Set up a fake language server. let mut language = Language::new( @@ -3260,7 +3657,11 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T ) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::new(project_b.clone(), |_, _| unimplemented!(), cx)); @@ -3410,8 +3811,9 @@ async fn test_language_server_statuses( 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); // Set up a fake language server. let mut language = Language::new( @@ -3468,7 +3870,11 @@ async fn test_language_server_statuses( ); }); - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; project_b.read_with(cx_b, |project, _| { let status = project.language_server_statuses().next().unwrap(); assert_eq!(status.name, "the-language-server"); @@ -3926,172 +4332,311 @@ async fn test_contacts( 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), - ]) + .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); deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![]), - ("user_b", true, vec![]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "free"), + ("user_b".to_string(), "online", "free") + ] + ); - // Share a project as client A. - client_a.fs.create_dir(Path::new("/a")).await.unwrap(); - let (project_a, _) = client_a.build_local_project("/a", cx_a).await; + server.disconnect_client(client_c.current_user_id(cx_c)); + server.forbid_connections(); + deterministic.advance_clock(rpc::RECEIVE_TIMEOUT); + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "free"), + ("user_c".to_string(), "offline", "free") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "free"), + ("user_c".to_string(), "offline", "free") + ] + ); + assert_eq!(contacts(&client_c, cx_c), []); + + server.allow_connections(); + client_c + .authenticate_and_connect(false, &cx_c.to_async()) + .await + .unwrap(); deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![("a", vec![])]), - ("user_b", true, vec![]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "free"), + ("user_b".to_string(), "online", "free") + ] + ); - let _project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "busy"), + ("user_b".to_string(), "online", "busy") + ] + ); + active_call_b.update(cx_b, |call, _| call.decline_incoming().unwrap()); deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![("a", vec!["user_b"])]), - ("user_b", true, vec![]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "free"), + ("user_b".to_string(), "online", "free") + ] + ); - // Add a local project as client B - client_a.fs.create_dir("/b".as_ref()).await.unwrap(); - let (_project_b, _) = client_b.build_local_project("/b", cx_b).await; + active_call_c + .update(cx_c, |call, cx| { + call.invite(client_a.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "free"), + ("user_c".to_string(), "online", "busy") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "busy") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "busy"), + ("user_b".to_string(), "online", "free") + ] + ); + active_call_a + .update(cx_a, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![("a", vec!["user_b"])]), - ("user_b", true, vec![("b", vec![])]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "free"), + ("user_c".to_string(), "online", "busy") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "busy") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "busy"), + ("user_b".to_string(), "online", "free") + ] + ); - project_a - .condition(cx_a, |project, _| { - project.collaborators().contains_key(&client_b.peer_id) + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b.user_id().unwrap(), None, cx) }) - .await; - - cx_a.update(move |_| drop(project_a)); + .await + .unwrap(); deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![]), - ("user_b", true, vec![("b", vec![])]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "busy") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "busy") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "busy"), + ("user_b".to_string(), "online", "busy") + ] + ); - server.disconnect_client(client_c.current_user_id(cx_c)); - server.forbid_connections(); - deterministic.advance_clock(rpc::RECEIVE_TIMEOUT); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![]), - ("user_b", true, vec![("b", vec![])]), - ("user_c", false, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } - client_c - .user_store - .read_with(cx_c, |store, _| assert_eq!(contacts(store), [])); + active_call_a.update(cx_a, |call, cx| call.hang_up(cx).unwrap()); + deterministic.run_until_parked(); + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "free"), + ("user_b".to_string(), "online", "free") + ] + ); - server.allow_connections(); - client_c - .authenticate_and_connect(false, &cx_c.to_async()) + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b.user_id().unwrap(), None, cx) + }) .await .unwrap(); - deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![]), - ("user_b", true, vec![("b", vec![])]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "busy"), + ("user_b".to_string(), "online", "busy") + ] + ); + + server.forbid_connections(); + server.disconnect_client(client_a.current_user_id(cx_a)); + deterministic.advance_clock(rpc::RECEIVE_TIMEOUT); + assert_eq!(contacts(&client_a, cx_a), []); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "offline", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "offline", "free"), + ("user_b".to_string(), "online", "free") + ] + ); #[allow(clippy::type_complexity)] - fn contacts(user_store: &UserStore) -> Vec<(&str, bool, Vec<(&str, Vec<&str>)>)> { - user_store - .contacts() - .iter() - .map(|contact| { - let projects = contact - .projects - .iter() - .map(|p| { - ( - p.visible_worktree_root_names[0].as_str(), - p.guests.iter().map(|p| p.github_login.as_str()).collect(), - ) - }) - .collect(); - (contact.user.github_login.as_str(), contact.online, projects) - }) - .collect() + fn contacts( + client: &TestClient, + cx: &TestAppContext, + ) -> Vec<(String, &'static str, &'static str)> { + client.user_store.read_with(cx, |store, _| { + store + .contacts() + .iter() + .map(|contact| { + ( + contact.user.github_login.clone(), + if contact.online { "online" } else { "offline" }, + if contact.busy { "busy" } else { "free" }, + ) + }) + .collect() + }) } } @@ -4194,18 +4739,18 @@ async fn test_contact_requests( // User B sees user A as their contact now in all client, and the incoming request from them is removed. let contacts_b = client_b.summarize_contacts(cx_b); - assert_eq!(contacts_b.current, &["user_a", "user_b"]); + assert_eq!(contacts_b.current, &["user_a"]); assert_eq!(contacts_b.incoming_requests, &["user_c"]); let contacts_b2 = client_b2.summarize_contacts(cx_b2); - assert_eq!(contacts_b2.current, &["user_a", "user_b"]); + assert_eq!(contacts_b2.current, &["user_a"]); assert_eq!(contacts_b2.incoming_requests, &["user_c"]); // User A sees user B as their contact now in all clients, and the outgoing request to them is removed. let contacts_a = client_a.summarize_contacts(cx_a); - assert_eq!(contacts_a.current, &["user_a", "user_b"]); + assert_eq!(contacts_a.current, &["user_b"]); assert!(contacts_a.outgoing_requests.is_empty()); let contacts_a2 = client_a2.summarize_contacts(cx_a2); - assert_eq!(contacts_a2.current, &["user_a", "user_b"]); + assert_eq!(contacts_a2.current, &["user_b"]); assert!(contacts_a2.outgoing_requests.is_empty()); // Contacts are present upon connecting (tested here via disconnect/reconnect) @@ -4213,19 +4758,13 @@ async fn test_contact_requests( disconnect_and_reconnect(&client_b, cx_b).await; disconnect_and_reconnect(&client_c, cx_c).await; executor.run_until_parked(); - assert_eq!( - client_a.summarize_contacts(cx_a).current, - &["user_a", "user_b"] - ); - assert_eq!( - client_b.summarize_contacts(cx_b).current, - &["user_a", "user_b"] - ); + assert_eq!(client_a.summarize_contacts(cx_a).current, &["user_b"]); + assert_eq!(client_b.summarize_contacts(cx_b).current, &["user_a"]); assert_eq!( client_b.summarize_contacts(cx_b).incoming_requests, &["user_c"] ); - assert_eq!(client_c.summarize_contacts(cx_c).current, &["user_c"]); + assert!(client_c.summarize_contacts(cx_c).current.is_empty()); assert_eq!( client_c.summarize_contacts(cx_c).outgoing_requests, &["user_b"] @@ -4244,18 +4783,18 @@ async fn test_contact_requests( // User B doesn't see user C as their contact, and the incoming request from them is removed. let contacts_b = client_b.summarize_contacts(cx_b); - assert_eq!(contacts_b.current, &["user_a", "user_b"]); + assert_eq!(contacts_b.current, &["user_a"]); assert!(contacts_b.incoming_requests.is_empty()); let contacts_b2 = client_b2.summarize_contacts(cx_b2); - assert_eq!(contacts_b2.current, &["user_a", "user_b"]); + assert_eq!(contacts_b2.current, &["user_a"]); assert!(contacts_b2.incoming_requests.is_empty()); // User C doesn't see user B as their contact, and the outgoing request to them is removed. let contacts_c = client_c.summarize_contacts(cx_c); - assert_eq!(contacts_c.current, &["user_c"]); + assert!(contacts_c.current.is_empty()); assert!(contacts_c.outgoing_requests.is_empty()); let contacts_c2 = client_c2.summarize_contacts(cx_c2); - assert_eq!(contacts_c2.current, &["user_c"]); + assert!(contacts_c2.current.is_empty()); assert!(contacts_c2.outgoing_requests.is_empty()); // Incoming/outgoing requests are not present upon connecting (tested here via disconnect/reconnect) @@ -4263,19 +4802,13 @@ async fn test_contact_requests( disconnect_and_reconnect(&client_b, cx_b).await; disconnect_and_reconnect(&client_c, cx_c).await; executor.run_until_parked(); - assert_eq!( - client_a.summarize_contacts(cx_a).current, - &["user_a", "user_b"] - ); - assert_eq!( - client_b.summarize_contacts(cx_b).current, - &["user_a", "user_b"] - ); + assert_eq!(client_a.summarize_contacts(cx_a).current, &["user_b"]); + assert_eq!(client_b.summarize_contacts(cx_b).current, &["user_a"]); assert!(client_b .summarize_contacts(cx_b) .incoming_requests .is_empty()); - assert_eq!(client_c.summarize_contacts(cx_c).current, &["user_c"]); + assert!(client_c.summarize_contacts(cx_c).current.is_empty()); assert!(client_c .summarize_contacts(cx_c) .outgoing_requests @@ -4294,14 +4827,16 @@ async fn test_contact_requests( #[gpui::test(iterations = 10)] async fn test_following(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { cx_a.foreground().forbid_parking(); + cx_a.update(editor::init); + cx_b.update(editor::init); + 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; - cx_a.update(editor::init); - cx_b.update(editor::init); + let active_call_a = cx_a.read(ActiveCall::global); client_a .fs @@ -4315,8 +4850,11 @@ async fn test_following(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { ) .await; let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; - - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; // Client A opens some editors. let workspace_a = client_a.build_workspace(&project_a, cx_a); @@ -4500,14 +5038,16 @@ async fn test_following(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { #[gpui::test(iterations = 10)] async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { cx_a.foreground().forbid_parking(); + cx_a.update(editor::init); + cx_b.update(editor::init); + 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; - cx_a.update(editor::init); - cx_b.update(editor::init); + let active_call_a = cx_a.read(ActiveCall::global); // Client A shares a project. client_a @@ -4523,9 +5063,13 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T ) .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(); // Client B joins the project. - 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; // Client A opens some editors. let workspace_a = client_a.build_workspace(&project_a, cx_a); @@ -4662,16 +5206,17 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T #[gpui::test(iterations = 10)] async fn test_auto_unfollowing(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { cx_a.foreground().forbid_parking(); + cx_a.update(editor::init); + cx_b.update(editor::init); // 2 clients connect to a server. 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; - cx_a.update(editor::init); - cx_b.update(editor::init); + let active_call_a = cx_a.read(ActiveCall::global); // Client A shares a project. client_a @@ -4686,7 +5231,11 @@ async fn test_auto_unfollowing(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont ) .await; let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + 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_id, cx_b).await; // Client A opens some editors. let workspace_a = client_a.build_workspace(&project_a, cx_a); @@ -4822,21 +5371,26 @@ async fn test_peers_simultaneously_following_each_other( cx_b: &mut TestAppContext, ) { deterministic.forbid_parking(); + cx_a.update(editor::init); + cx_b.update(editor::init); 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; - cx_a.update(editor::init); - cx_b.update(editor::init); + let active_call_a = cx_a.read(ActiveCall::global); client_a.fs.insert_tree("/a", json!({})).await; let (project_a, _) = client_a.build_local_project("/a", cx_a).await; let workspace_a = client_a.build_workspace(&project_a, cx_a); + 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; let workspace_b = client_b.build_workspace(&project_b, cx_b); deterministic.run_until_parked(); @@ -4899,12 +5453,13 @@ async fn test_random_collaboration( let mut server = TestServer::start(cx.foreground(), cx.background()).await; let db = server.app_state.db.clone(); - let host_user_id = db + + let room_creator_user_id = db .create_user( - "host@example.com", + "room-creator@example.com", false, NewUserParams { - github_login: "host".into(), + github_login: "room-creator".into(), github_user_id: 0, invite_count: 0, }, @@ -4919,35 +5474,41 @@ async fn test_random_collaboration( "guest-4".to_string(), ]; - for (ix, username) in available_guests.iter().enumerate() { - let guest_user_id = db + for (ix, username) in Some(&"host".to_string()) + .into_iter() + .chain(&available_guests) + .enumerate() + { + let user_id = db .create_user( &format!("{username}@example.com"), false, NewUserParams { github_login: username.into(), - github_user_id: ix as i32, + github_user_id: (ix + 1) as i32, invite_count: 0, }, ) .await .unwrap() .user_id; - assert_eq!(*username, format!("guest-{}", guest_user_id)); server .app_state .db - .send_contact_request(guest_user_id, host_user_id) + .send_contact_request(user_id, room_creator_user_id) .await .unwrap(); server .app_state .db - .respond_to_contact_request(host_user_id, guest_user_id, true) + .respond_to_contact_request(room_creator_user_id, user_id, true) .await .unwrap(); } + let _room_creator = server.create_client(cx, "room-creator").await; + let active_call = cx.read(ActiveCall::global); + let mut clients = Vec::new(); let mut user_ids = Vec::new(); let mut op_start_signals = Vec::new(); @@ -4965,7 +5526,6 @@ async fn test_random_collaboration( let host = server.create_client(&mut host_cx, "host").await; let host_project = host_cx.update(|cx| { Project::local( - true, host.client.clone(), host.user_store.clone(), host.project_store.clone(), @@ -4974,9 +5534,6 @@ async fn test_random_collaboration( cx, ) }); - let host_project_id = host_project - .update(&mut host_cx, |p, _| p.next_remote_id()) - .await; let (collab_worktree, _) = host_project .update(&mut host_cx, |project, cx| { @@ -5117,8 +5674,30 @@ async fn test_random_collaboration( .await; host_language_registry.add(Arc::new(language)); + let host_user_id = host.current_user_id(&host_cx); + active_call + .update(cx, |call, cx| { + call.invite(host_user_id.to_proto(), None, cx) + }) + .await + .unwrap(); + active_call.read_with(cx, |call, cx| call.room().unwrap().read(cx).id()); + deterministic.run_until_parked(); + let host_active_call = host_cx.read(ActiveCall::global); + host_active_call + .update(&mut host_cx, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + + let host_project_id = host_active_call + .update(&mut host_cx, |call, cx| { + call.share_project(host_project.clone(), cx) + }) + .await + .unwrap(); + let op_start_signal = futures::channel::mpsc::unbounded(); - user_ids.push(host.current_user_id(&host_cx)); + user_ids.push(host_user_id); op_start_signals.push(op_start_signal.0); clients.push(host_cx.foreground().spawn(host.simulate_host( host_project, @@ -5155,26 +5734,16 @@ async fn test_random_collaboration( log::error!("{} error - {:?}", guest.username, guest_err); } - let contacts = server - .app_state - .db - .get_contacts(guest.current_user_id(&guest_cx)) - .await - .unwrap(); - let contacts = server - .store - .lock() - .await - .build_initial_contacts_update(contacts) - .contacts; - assert!(!contacts - .iter() - .flat_map(|contact| &contact.projects) - .any(|project| project.id == host_project_id)); guest_project.read_with(&guest_cx, |project, _| assert!(project.is_read_only())); - guest_cx.update(|_| drop((guest, guest_project))); + guest_cx.update(|cx| { + cx.clear_globals(); + drop((guest, guest_project)); + }); } - host_cx.update(|_| drop((host, host_project))); + host_cx.update(|cx| { + cx.clear_globals(); + drop((host, host_project)); + }); return; } @@ -5198,6 +5767,21 @@ async fn test_random_collaboration( deterministic.start_waiting(); let guest = server.create_client(&mut guest_cx, &guest_username).await; + let guest_user_id = guest.current_user_id(&guest_cx); + + active_call + .update(cx, |call, cx| { + call.invite(guest_user_id.to_proto(), None, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + guest_cx + .read(ActiveCall::global) + .update(&mut guest_cx, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + let guest_project = Project::remote( host_project_id, guest.client.clone(), @@ -5212,7 +5796,7 @@ async fn test_random_collaboration( deterministic.finish_waiting(); let op_start_signal = futures::channel::mpsc::unbounded(); - user_ids.push(guest.current_user_id(&guest_cx)); + user_ids.push(guest_user_id); op_start_signals.push(op_start_signal.0); clients.push(guest_cx.foreground().spawn(guest.simulate_guest( guest_username.clone(), @@ -5259,20 +5843,15 @@ async fn test_random_collaboration( "removed guest is still a contact of another peer" ); } - for project in contact.projects { - for project_guest_id in project.guests { - assert_ne!( - project_guest_id, removed_guest_id.0 as u64, - "removed guest appears as still participating on a project" - ); - } - } } } log::info!("{} removed", guest.username); available_guests.push(guest.username.clone()); - guest_cx.update(|_| drop((guest, guest_project))); + guest_cx.update(|cx| { + cx.clear_globals(); + drop((guest, guest_project)); + }); operations += 1; } @@ -5387,10 +5966,16 @@ async fn test_random_collaboration( ); } - guest_cx.update(|_| drop((guest_project, guest_client))); + guest_cx.update(|cx| { + cx.clear_globals(); + drop((guest_project, guest_client)); + }); } - host_cx.update(|_| drop((host_client, host_project))); + host_cx.update(|cx| { + cx.clear_globals(); + drop((host_client, host_project)) + }); } struct TestServer { @@ -5513,7 +6098,7 @@ impl TestServer { let fs = FakeFs::new(cx.background()); let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx)); - let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake())); + let project_store = cx.add_model(|_| ProjectStore::new()); let app_state = Arc::new(workspace::AppState { client: client.clone(), user_store: user_store.clone(), @@ -5528,7 +6113,10 @@ impl TestServer { Channel::init(&client); Project::init(&client); - cx.update(|cx| workspace::init(app_state.clone(), cx)); + cx.update(|cx| { + workspace::init(app_state.clone(), cx); + call::init(client.clone(), user_store.clone(), cx); + }); client .authenticate_and_connect(false, &cx.to_async()) @@ -5566,12 +6154,14 @@ impl TestServer { self.forbid_connections.store(false, SeqCst); } - async fn make_contacts(&self, mut clients: Vec<(&TestClient, &mut TestAppContext)>) { - while let Some((client_a, cx_a)) = clients.pop() { - for (client_b, cx_b) in &mut clients { + async fn make_contacts(&self, clients: &mut [(&TestClient, &mut TestAppContext)]) { + for ix in 1..clients.len() { + let (left, right) = clients.split_at_mut(ix); + let (client_a, cx_a) = left.last_mut().unwrap(); + for (client_b, cx_b) in right { client_a .user_store - .update(cx_a, |store, cx| { + .update(*cx_a, |store, cx| { store.request_contact(client_b.user_id().unwrap(), cx) }) .await @@ -5588,6 +6178,29 @@ impl TestServer { } } + async fn create_room(&self, clients: &mut [(&TestClient, &mut TestAppContext)]) { + self.make_contacts(clients).await; + + let (left, right) = clients.split_at_mut(1); + let (_client_a, cx_a) = &mut left[0]; + let active_call_a = cx_a.read(ActiveCall::global); + + for (client_b, cx_b) in right { + let user_id_b = client_b.current_user_id(*cx_b).to_proto(); + active_call_a + .update(*cx_a, |call, cx| call.invite(user_id_b, None, cx)) + .await + .unwrap(); + + cx_b.foreground().run_until_parked(); + let active_call_b = cx_b.read(ActiveCall::global); + active_call_b + .update(*cx_b, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + } + } + async fn build_app_state(test_db: &TestDb) -> Arc { Arc::new(AppState { db: test_db.db().clone(), @@ -5699,7 +6312,6 @@ impl TestClient { ) -> (ModelHandle, WorktreeId) { let project = cx.update(|cx| { Project::local( - true, self.client.clone(), self.user_store.clone(), self.project_store.clone(), @@ -5717,40 +6329,26 @@ impl TestClient { worktree .read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete()) .await; - project - .update(cx, |project, _| project.next_remote_id()) - .await; (project, worktree.read_with(cx, |tree, _| tree.id())) } async fn build_remote_project( &self, - host_project: &ModelHandle, - host_cx: &mut TestAppContext, + host_project_id: u64, guest_cx: &mut TestAppContext, ) -> ModelHandle { - let host_project_id = host_project - .read_with(host_cx, |project, _| project.next_remote_id()) - .await; - let guest_user_id = self.user_id().unwrap(); - let languages = host_project.read_with(host_cx, |project, _| project.languages().clone()); let project_b = guest_cx.spawn(|cx| { Project::remote( host_project_id, self.client.clone(), self.user_store.clone(), self.project_store.clone(), - languages, + self.language_registry.clone(), FakeFs::new(cx.background()), cx, ) }); - host_cx.foreground().run_until_parked(); - host_project.update(host_cx, |project, cx| { - project.respond_to_join_request(guest_user_id, true, cx) - }); - let project = project_b.await.unwrap(); - project + project_b.await.unwrap() } fn build_workspace( @@ -5785,18 +6383,6 @@ impl TestClient { ) -> anyhow::Result<()> { let fs = project.read_with(cx, |project, _| project.fs().clone()); - cx.update(|cx| { - cx.subscribe(&project, move |project, event, cx| { - if let project::Event::ContactRequestedJoin(user) = event { - log::info!("Host: accepting join request from {}", user.github_login); - project.update(cx, |project, cx| { - project.respond_to_join_request(user.id, true, cx) - }); - } - }) - .detach(); - }); - while op_start_signal.next().await.is_some() { let distribution = rng.lock().gen_range::(0..100); let files = fs.as_fake().files().await; @@ -6274,3 +6860,24 @@ fn channel_messages(channel: &Channel) -> Vec<(String, String, bool)> { }) .collect() } + +#[derive(Debug, Eq, PartialEq)] +struct RoomParticipants { + remote: Vec, + pending: Vec, +} + +fn room_participants(room: &ModelHandle, cx: &mut TestAppContext) -> RoomParticipants { + room.read_with(cx, |room, _| RoomParticipants { + remote: room + .remote_participants() + .iter() + .map(|(_, participant)| participant.user.github_login.clone()) + .collect(), + pending: room + .pending_participants() + .iter() + .map(|user| user.github_login.clone()) + .collect(), + }) +} diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index da194eb012cf35e7f9160cdedcab14122f0fe3dd..84449e79d5a2e134469bce2b656fa0446aeaf03c 100644 --- a/crates/collab/src/rpc.rs +++ b/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 Response { self.server.peer.respond(self.receipt, payload)?; Ok(()) } - - fn into_receipt(self) -> Receipt { - 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, 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, + request: TypedEnvelope, + response: Response, + ) -> 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, - request: TypedEnvelope, - response: Response, + request: TypedEnvelope, + response: Response, + ) -> 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, message: TypedEnvelope) -> 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, + request: TypedEnvelope, + response: Response, + ) -> 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::>() + }; + 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, + request: TypedEnvelope, + response: Response, + ) -> 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, + message: TypedEnvelope, + ) -> 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, + request: TypedEnvelope, + response: Response, + ) -> 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, + request: TypedEnvelope, + response: Response, ) -> 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, - request: TypedEnvelope, - response: Response, + message: TypedEnvelope, ) -> 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, - request: TypedEnvelope, - ) -> 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::>(); - { - 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::>(); - - // 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, ) -> 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(()) } diff --git a/crates/collab/src/rpc/store.rs b/crates/collab/src/rpc/store.rs index fe18e0404b214e47e7f2e9d42019e8ae9b86bf0a..cc34094782fe5ecc47074d3cbaee5ad12ebb460e 100644 --- a/crates/collab/src/rpc/store.rs +++ b/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, - connections_by_user_id: BTreeMap>, + connected_users: BTreeMap, + next_room_id: RoomId, + rooms: BTreeMap, projects: BTreeMap, #[serde(skip)] channels: BTreeMap, } +#[derive(Default, Serialize)] +struct ConnectedUser { + connection_ids: HashSet, + active_call: Option, +} + #[derive(Serialize)] struct ConnectionState { user_id: UserId, admin: bool, projects: BTreeSet, - requested_projects: HashSet, channels: HashSet, } +#[derive(Copy, Clone, Eq, PartialEq, Serialize)] +pub struct Call { + pub caller_user_id: UserId, + pub room_id: RoomId, + pub connection_id: Option, + pub initial_project_id: Option, +} + #[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, - #[serde(skip)] - pub join_requests: HashMap>>, pub active_replica_ids: HashSet, pub worktrees: BTreeMap, pub language_servers: Vec, @@ -69,23 +86,26 @@ pub type ReplicaId = u16; #[derive(Default)] pub struct RemovedConnectionState { pub user_id: UserId, - pub hosted_projects: HashMap, - pub guest_project_ids: HashSet, + pub hosted_projects: Vec, + pub guest_projects: Vec, pub contact_ids: HashSet, + pub room_id: Option, + pub canceled_call_connection_ids: Vec, } pub struct LeftProject { + pub id: ProjectId, pub host_user_id: UserId, pub host_connection_id: ConnectionId, pub connection_ids: Vec, pub remove_collaborator: bool, - pub cancel_request: Option, - pub unshare: bool, } -pub struct UnsharedProject { - pub guests: HashMap, - pub pending_join_requests: HashMap>>, +pub struct LeftRoom<'a> { + pub room: Option<&'a proto::Room>, + pub unshared_projects: Vec, + pub left_projects: Vec, + pub canceled_call_connection_ids: Vec, } #[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 { 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 + '_ { - 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, @@ -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 { - 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 { + 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)> { + 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::>(); + + 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 { + 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, + from_connection_id: ConnectionId, + ) -> Result<(&proto::Room, Vec, 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::>(); + 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)> { + 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)> { + 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::>(); + 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, + 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> { + ) -> 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 { - 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, - ) -> 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>> { - 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, 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 { - 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, }) } @@ -652,15 +988,11 @@ impl Store { updated_entries: &[proto::Entry], scan_id: u64, is_last_update: bool, - ) -> Result<(Vec, bool)> { + ) -> Result> { let project = self.write_project(project_id, connection_id)?; - if !project.online { - return Err(anyhow!("project is not online")); - } let connection_ids = project.connection_ids(); let mut worktree = project.worktrees.entry(worktree_id).or_default(); - let metadata_changed = worktree_root_name != worktree.root_name; worktree.root_name = worktree_root_name.to_string(); for entry_id in removed_entries { @@ -673,7 +1005,23 @@ impl Store { worktree.scan_id = scan_id; worktree.is_complete = is_last_update; - Ok((connection_ids, metadata_changed)) + Ok(connection_ids) + } + + fn build_participant_project( + project_id: ProjectId, + projects: &BTreeMap, + ) -> Option { + Some(proto::ParticipantProject { + id: project_id.to_proto(), + worktree_root_names: projects + .get(&project_id)? + .worktrees + .values() + .filter(|worktree| worktree.visible) + .map(|worktree| worktree.root_name.clone()) + .collect(), + }) } pub fn project_connection_ids( @@ -789,19 +1137,64 @@ impl Store { assert!(channel.connection_ids.contains(connection_id)); } assert!(self - .connections_by_user_id + .connected_users .get(&connection.user_id) .unwrap() + .connection_ids .contains(connection_id)); } - for (user_id, connection_ids) in &self.connections_by_user_id { - for connection_id in connection_ids { + for (user_id, state) in &self.connected_users { + for connection_id in &state.connection_ids { assert_eq!( self.connections.get(connection_id).unwrap().user_id, *user_id ); } + + if let Some(active_call) = state.active_call.as_ref() { + if let Some(active_call_connection_id) = active_call.connection_id { + assert!( + state.connection_ids.contains(&active_call_connection_id), + "call is active on a dead connection" + ); + assert!( + state.connection_ids.contains(&active_call_connection_id), + "call is active on a dead connection" + ); + } + } + } + + for (room_id, room) in &self.rooms { + for pending_user_id in &room.pending_participant_user_ids { + assert!( + self.connected_users + .contains_key(&UserId::from_proto(*pending_user_id)), + "call is active on a user that has disconnected" + ); + } + + for participant in &room.participants { + assert!( + self.connections + .contains_key(&ConnectionId(participant.peer_id)), + "room contains participant that has disconnected" + ); + + for participant_project in &participant.projects { + let project = &self.projects[&ProjectId::from_proto(participant_project.id)]; + assert_eq!( + project.room_id, *room_id, + "project was shared on a different room" + ); + } + } + + assert!( + !room.pending_participant_user_ids.is_empty() || !room.participants.is_empty(), + "room can't be empty" + ); } for (project_id, project) in &self.projects { @@ -821,6 +1214,20 @@ impl Store { .map(|guest| guest.replica_id) .collect::>(), ); + + let room = &self.rooms[&project.room_id]; + let room_participant = room + .participants + .iter() + .find(|participant| participant.peer_id == project.host_connection_id.0) + .unwrap(); + assert!( + room_participant + .projects + .iter() + .any(|project| project.id == project_id.to_proto()), + "project was not shared in room" + ); } for (channel_id, channel) in &self.channels { diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..20db066ce72a1c0c514c64cf6983f1ccdde43f6a --- /dev/null +++ b/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"] } diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs new file mode 100644 index 0000000000000000000000000000000000000000..9faea76a107795a81eca61c77e7916ac1d62725f --- /dev/null +++ b/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, + user_store: ModelHandle, + contacts_popover: Option>, + _subscriptions: Vec, +} + +impl Entity for CollabTitlebarItem { + type Event = (); +} + +impl View for CollabTitlebarItem { + fn ui_name() -> &'static str { + "CollabTitlebarItem" + } + + fn render(&mut self, cx: &mut RenderContext) -> ElementBox { + let workspace = if let Some(workspace) = self.workspace.upgrade(cx) { + workspace + } else { + return Empty::new().boxed(); + }; + + let theme = cx.global::().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, + user_store: &ModelHandle, + cx: &mut ViewContext, + ) -> 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) { + 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) { + 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) { + 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, + ) -> 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::::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) -> ElementBox { + enum Share {} + + let titlebar = &theme.workspace.titlebar; + MouseEventHandler::::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::( + 0, + "Share project with call participants".into(), + None, + theme.tooltip.clone(), + cx, + ) + .aligned() + .boxed() + } + + fn render_collaborators( + &self, + workspace: &ViewHandle, + theme: &Theme, + cx: &mut RenderContext, + ) -> Vec { + 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::>(); + 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, + theme: &Theme, + cx: &mut RenderContext, + ) -> Option { + 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::::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, + peer: Option<(PeerId, &str, ParticipantLocation)>, + workspace: &ViewHandle, + theme: &Theme, + cx: &mut RenderContext, + ) -> 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::::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::( + 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::::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::( + 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, + cx: &mut RenderContext, + ) -> Option { + let theme = &cx.global::().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, + _: RectF, + _: RectF, + _: &Self::LayoutState, + _: &Self::PaintState, + _: &gpui::MeasurementContext, + ) -> Option { + 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(), + }) + } +} diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs new file mode 100644 index 0000000000000000000000000000000000000000..4b7e3dae01b7a02e5187386a2ce9c6ed2c051f1f --- /dev/null +++ b/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, 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::(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); + }); +} diff --git a/crates/contacts_panel/src/contact_finder.rs b/crates/collab_ui/src/contact_finder.rs similarity index 77% rename from crates/contacts_panel/src/contact_finder.rs rename to crates/collab_ui/src/contact_finder.rs index 1831c1ba72593f9570e1f87825042a2b2e4f2b78..25726e381e6242496b83df734d75ef1b19b1b243 100644 --- a/crates/contacts_panel/src/contact_finder.rs +++ b/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::::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.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, cx: &mut ViewContext) -> 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::().theme.contact_finder.picker) + }), potential_contacts: Arc::from([]), user_store, selected_index: 0, } } - - fn on_event( - workspace: &mut Workspace, - _: ViewHandle, - event: &Event, - cx: &mut ViewContext, - ) { - match event { - Event::Dismissed => { - workspace.dismiss_modal(cx); - } - } - } } diff --git a/crates/collab_ui/src/contact_list.rs b/crates/collab_ui/src/contact_list.rs new file mode 100644 index 0000000000000000000000000000000000000000..7b773240cf0d6f410823cb164b52d427ffeca6a5 --- /dev/null +++ b/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>, +} + +#[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, + is_pending: bool, + }, + ParticipantProject { + project_id: u64, + worktree_root_names: Vec, + host_user_id: u64, + is_host: bool, + is_last: bool, + }, + IncomingRequest(Arc), + OutgoingRequest(Arc), + Contact(Arc), +} + +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, + match_candidates: Vec, + list_state: ListState, + project: ModelHandle, + user_store: ModelHandle, + filter_editor: ViewHandle, + collapsed_sections: Vec
, + selection: Option, + _subscriptions: Vec, +} + +impl ContactList { + pub fn new( + project: ModelHandle, + user_store: ModelHandle, + cx: &mut ViewContext, + ) -> 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::().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.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.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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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::, _>(|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(§ion) { + 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, + ) -> 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::::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, + ) -> 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::::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::
::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, + theme: &theme::ContactList, + is_selected: bool, + cx: &mut RenderContext, + ) -> ElementBox { + let online = contact.online; + let busy = contact.busy; + let user_id = contact.user.id; + let initial_project = project.clone(); + let mut element = + MouseEventHandler::::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_store: ModelHandle, + theme: &theme::ContactList, + is_incoming: bool, + is_selected: bool, + cx: &mut RenderContext, + ) -> 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::::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::::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::::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) { + 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) { + 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) -> ElementBox { + enum AddContact {} + let theme = cx.global::().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::::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::( + 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) { + if !self.filter_editor.is_focused(cx) { + cx.focus(&self.filter_editor); + } + } + + fn on_focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { + 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) +} diff --git a/crates/contacts_panel/src/contact_notification.rs b/crates/collab_ui/src/contact_notification.rs similarity index 96% rename from crates/contacts_panel/src/contact_notification.rs rename to crates/collab_ui/src/contact_notification.rs index c608346d7991c974ee02647586f29c5a8b28eb28..f543a0144610f5fc1f64d568720a3bb19f70bed0 100644 --- a/crates/contacts_panel/src/contact_notification.rs +++ b/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", diff --git a/crates/collab_ui/src/contacts_popover.rs b/crates/collab_ui/src/contacts_popover.rs new file mode 100644 index 0000000000000000000000000000000000000000..deedac9f9816a4d971251651388478326630a46e --- /dev/null +++ b/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), + ContactFinder(ViewHandle), +} + +pub struct ContactsPopover { + child: Child, + project: ModelHandle, + user_store: ModelHandle, + _subscription: Option, +} + +impl ContactsPopover { + pub fn new( + project: ModelHandle, + user_store: ModelHandle, + cx: &mut ViewContext, + ) -> 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) { + 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) { + 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) { + 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) -> ElementBox { + let theme = cx.global::().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::::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) { + if cx.is_self_focused() { + match &self.child { + Child::ContactList(child) => cx.focus(child), + Child::ContactFinder(child) => cx.focus(child), + } + } + } +} diff --git a/crates/collab_ui/src/incoming_call_notification.rs b/crates/collab_ui/src/incoming_call_notification.rs new file mode 100644 index 0000000000000000000000000000000000000000..ff359b9d9e087219cfabb95fd0c719b67183aa44 --- /dev/null +++ b/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::().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) { + 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) -> ElementBox { + let theme = &cx.global::().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) -> ElementBox { + enum Accept {} + enum Decline {} + + Flex::column() + .with_child( + MouseEventHandler::::new(0, cx, |_, cx| { + let theme = &cx.global::().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::::new(0, cx, |_, cx| { + let theme = &cx.global::().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::() + .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) -> gpui::ElementBox { + let background = cx + .global::() + .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() + } +} diff --git a/crates/contacts_panel/src/notifications.rs b/crates/collab_ui/src/notifications.rs similarity index 83% rename from crates/contacts_panel/src/notifications.rs rename to crates/collab_ui/src/notifications.rs index b9a6dba545b820e38fcaf55ae5767e498a3a52c4..dcb98940065e6611e9ebf39d255f43d3d8646af4 100644 --- a/crates/contacts_panel/src/notifications.rs +++ b/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( ) .with_child( MouseEventHandler::::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.)) diff --git a/crates/collab_ui/src/project_shared_notification.rs b/crates/collab_ui/src/project_shared_notification.rs new file mode 100644 index 0000000000000000000000000000000000000000..a17e11b079f42c2bb863b0dee8cc8f734fcc0aca --- /dev/null +++ b/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::().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, + owner: Arc, +} + +impl ProjectSharedNotification { + fn new(owner: Arc, project_id: u64, worktree_root_names: Vec) -> Self { + Self { + project_id, + worktree_root_names, + owner, + } + } + + fn join(&mut self, _: &JoinProject, cx: &mut ViewContext) { + let window_id = cx.window_id(); + cx.remove_window(window_id); + cx.propagate_action(); + } + + fn dismiss(&mut self, _: &DismissProject, cx: &mut ViewContext) { + let window_id = cx.window_id(); + cx.remove_window(window_id); + } + + fn render_owner(&self, cx: &mut RenderContext) -> ElementBox { + let theme = &cx.global::().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) -> ElementBox { + enum Open {} + enum Dismiss {} + + let project_id = self.project_id; + let owner_user_id = self.owner.id; + + Flex::column() + .with_child( + MouseEventHandler::::new(0, cx, |_, cx| { + let theme = &cx.global::().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::::new(0, cx, |_, cx| { + let theme = &cx.global::().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::() + .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) -> gpui::ElementBox { + let background = cx + .global::() + .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() + } +} diff --git a/crates/contacts_panel/Cargo.toml b/crates/contacts_panel/Cargo.toml deleted file mode 100644 index b68f48bb97ed908de9e454da0524601d8eb4bffe..0000000000000000000000000000000000000000 --- a/crates/contacts_panel/Cargo.toml +++ /dev/null @@ -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"] } diff --git a/crates/contacts_panel/src/contacts_panel.rs b/crates/contacts_panel/src/contacts_panel.rs deleted file mode 100644 index c06b2e17a16720f2cab6d63c12d9833ca800b845..0000000000000000000000000000000000000000 --- a/crates/contacts_panel/src/contacts_panel.rs +++ /dev/null @@ -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), - OutgoingRequest(Arc), - Contact(Arc), - ContactProject(Arc, usize, Option>), - OfflineProject(WeakModelHandle), -} - -#[derive(Clone, PartialEq)] -struct ToggleExpanded(Section); - -pub struct ContactsPanel { - entries: Vec, - match_candidates: Vec, - list_state: ListState, - user_store: ModelHandle, - project_store: ModelHandle, - filter_editor: ViewHandle, - collapsed_sections: Vec
, - selection: Option, - _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, - project_store: ModelHandle, - workspace: WeakViewHandle, - cx: &mut ViewContext, - ) -> 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::().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, - ) -> 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::
::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, - current_user_id: Option, - project_index: usize, - open_project: Option>, - theme: &theme::ContactsPanel, - tooltip_style: &TooltipStyle, - is_last_project: bool, - is_selected: bool, - cx: &mut RenderContext, - ) -> 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::::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::::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::( - 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, - theme: &theme::ContactsPanel, - tooltip_style: &TooltipStyle, - is_selected: bool, - cx: &mut RenderContext, - ) -> 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::::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::::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::( - 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_store: ModelHandle, - theme: &theme::ContactsPanel, - is_incoming: bool, - is_selected: bool, - cx: &mut RenderContext, - ) -> 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::::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::::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::::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) { - 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::, _>(|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(§ion) { - 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::>(); - 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.user_store - .update(cx, |store, cx| store.request_contact(request.0, cx)) - .detach(); - } - - fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext) { - 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.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) { - 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) { - 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) { - 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) { - 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) { - 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) -> ElementBox { - enum AddContact {} - - let theme = cx.global::().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::::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::::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) { - 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::>().len() == entries.len(), - "Duplicate contact panel entries {:?}", - entries - ) - }) - .detach(); - }); - - let get_users_request = server.receive::().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::().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::() - .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::() - .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::() - .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::() - .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, cx: &AppContext) -> Vec { - 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::>() - .join(", "), - if project.is_online() { - " (going online...)" - } else { - "" - }, - ) - } - }; - - if panel.selection == Some(ix) { - string.push_str(" <=== selected"); - } - - string - })); - entries - } -} diff --git a/crates/contacts_panel/src/join_project_notification.rs b/crates/contacts_panel/src/join_project_notification.rs deleted file mode 100644 index d8e8e670cff02da81a5b6c4afc6754d88661f2f2..0000000000000000000000000000000000000000 --- a/crates/contacts_panel/src/join_project_notification.rs +++ /dev/null @@ -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, - user: Arc, -} - -impl JoinProjectNotification { - pub fn new(project: ModelHandle, user: Arc, cx: &mut ViewContext) -> 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.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.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) -> 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: &::Event) -> bool { - matches!(event, Event::Dismiss) - } -} diff --git a/crates/contacts_status_item/Cargo.toml b/crates/contacts_status_item/Cargo.toml deleted file mode 100644 index df115a384220553afd75c37b6276931fe6794fb3..0000000000000000000000000000000000000000 --- a/crates/contacts_status_item/Cargo.toml +++ /dev/null @@ -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"] } diff --git a/crates/contacts_status_item/src/contacts_popover.rs b/crates/contacts_status_item/src/contacts_popover.rs deleted file mode 100644 index 2998d74ed8c3b608210c9d482831c3407a973c7f..0000000000000000000000000000000000000000 --- a/crates/contacts_status_item/src/contacts_popover.rs +++ /dev/null @@ -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, -} - -impl Entity for ContactsPopover { - type Event = Event; -} - -impl View for ContactsPopover { - fn ui_name() -> &'static str { - "ContactsPopover" - } - - fn render(&mut self, cx: &mut RenderContext) -> ElementBox { - let theme = &cx.global::().theme.contacts_popover; - - Flex::row() - .with_child( - ChildView::new(self.filter_editor.clone()) - .contained() - .with_style( - cx.global::() - .theme - .contacts_panel - .user_query_editor - .container, - ) - .flex(1., true) - .boxed(), - ) - // .with_child( - // MouseEventHandler::::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::() - .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 { - 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) { - if !is_active { - cx.emit(Event::Deactivated); - } - } -} diff --git a/crates/contacts_status_item/src/contacts_status_item.rs b/crates/contacts_status_item/src/contacts_status_item.rs deleted file mode 100644 index 5d471abcdf51f2153a5cd98627ef5478fb3ceccb..0000000000000000000000000000000000000000 --- a/crates/contacts_status_item/src/contacts_status_item.rs +++ /dev/null @@ -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>, -} - -impl Entity for ContactsStatusItem { - type Event = (); -} - -impl View for ContactsStatusItem { - fn ui_name() -> &'static str { - "ContactsStatusItem" - } - - fn render(&mut self, cx: &mut RenderContext) -> ElementBox { - let color = match cx.appearance { - Appearance::Light | Appearance::VibrantLight => Color::black(), - Appearance::Dark | Appearance::VibrantDark => Color::white(), - }; - MouseEventHandler::::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) { - 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, - event: &contacts_popover::Event, - cx: &mut ViewContext, - ) { - match event { - contacts_popover::Event::Deactivated => { - self.popover.take(); - cx.remove_window(popover.window_id()); - } - } - } -} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 587133e9dd703b04beef2d8f51e602cfd609f446..a3b5ad43f69cce6fef0f692b5eee0d44de4c02f3 100644 --- a/crates/editor/src/element.rs +++ b/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( diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 308ea6c831757326e799eb5fa7a36d035335d1da..ed351cdefefe165d93d21933f3822c796681bb19 100644 --- a/crates/gpui/src/app.rs +++ b/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 { + self.update(|cx| cx.prompt(window_id, level, msg, answers)) + } + pub fn platform(&self) -> Arc { self.0.borrow().platform() } @@ -1519,6 +1537,17 @@ impl MutableAppContext { } } + pub fn observe_default_global(&mut self, observe: F) -> Subscription + where + G: Any + Default, + F: 'static + FnMut(&mut MutableAppContext), + { + if !self.has_global::() { + self.set_global(G::default()); + } + self.observe_global::(observe) + } + pub fn observe_release(&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(&mut self, build_model: F) -> ModelHandle 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 PartialEq for WeakModelHandle { impl Eq for WeakModelHandle {} +impl PartialEq> for WeakModelHandle { + fn eq(&self, other: &ModelHandle) -> bool { + self.model_id == other.model_id + } +} + impl Clone for WeakModelHandle { fn clone(&self) -> Self { Self { diff --git a/crates/gpui/src/elements.rs b/crates/gpui/src/elements.rs index b22f0b3250d7a94dabaa58bc84fbe22688077d3f..59269f8af68ebc2819da7a63be996663e4379982 100644 --- a/crates/gpui/src/elements.rs +++ b/crates/gpui/src/elements.rs @@ -271,9 +271,6 @@ impl AnyElement for Lifecycle { 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 AnyElement for Lifecycle { .. } => { 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, diff --git a/crates/gpui/src/elements/flex.rs b/crates/gpui/src/elements/flex.rs index 227f946ac6529e9d4e33f8acb5c535cc1805699d..fd37b001feb7dbc5c209a8f369cc9e24caabf040 100644 --- a/crates/gpui/src/elements/flex.rs +++ b/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 { diff --git a/crates/gpui/src/elements/image.rs b/crates/gpui/src/elements/image.rs index e0387e39bb1cadd340635ff0c0259d6486e49bad..7e231e5e293f8660b5dc60b9e8a6ebcf32264429 100644 --- a/crates/gpui/src/elements/image.rs +++ b/crates/gpui/src/elements/image.rs @@ -27,6 +27,8 @@ pub struct ImageStyle { pub height: Option, #[serde(default)] pub width: Option, + #[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(), }); } diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index d752a52a1666110a7fc1cc2160988aac86429212..a6c76cf643302d21819c2368172f755e032de573 100644 --- a/crates/gpui/src/elements/list.rs +++ b/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::(10, 0, bounds).on_scroll({ diff --git a/crates/gpui/src/elements/mouse_event_handler.rs b/crates/gpui/src/elements/mouse_event_handler.rs index 5b3b9b13f6c77ae2f3fe2b517a3136e83729e4e0..e809c0080f878e50bc62821fd6df2385156ac0d5 100644 --- a/crates/gpui/src/elements/mouse_event_handler.rs +++ b/crates/gpui/src/elements/mouse_event_handler.rs @@ -169,6 +169,7 @@ impl Element for MouseEventHandler { _: &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 { diff --git a/crates/gpui/src/elements/overlay.rs b/crates/gpui/src/elements/overlay.rs index d47a39e958b8cc18ac12e5b32fa52c602ad0d714..07442d1140c2be8f992641bf447e0ede2aa157d3 100644 --- a/crates/gpui/src/elements/overlay.rs +++ b/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(); } diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index 6bc35c06922638c8221805d7a945b0c5f747ec65..c9cdbc1b2c90f76f8ac9ba3c6014d2d68e3e06f6 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/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.view_id, 0, visible_bounds).on_scroll({ diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 8997bde5273bc11d9977ce91c7b155a043c85f65..25c1d5ac8e2d9b1394bd4b67b4de06df42011363 100644 --- a/crates/gpui/src/platform.rs +++ b/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, diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 628ddde13c9c3c0d910760ecc838a2500c3db73c..f35d5d6935753053d89e9fa834bc99ccaf552a82 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/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, diff --git a/crates/gpui/src/platform/mac/renderer.rs b/crates/gpui/src/platform/mac/renderer.rs index ea094e998c112b4b875f129b2aef0c5d67fd8002..6a70ff41f0a975cefa0fa2b3f9e28cda3dad61f3 100644 --- a/crates/gpui/src/platform/mac/renderer.rs +++ b/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); diff --git a/crates/gpui/src/platform/mac/shaders/shaders.h b/crates/gpui/src/platform/mac/shaders/shaders.h index 29be2c9e1e789ade67c4c05b53e8300779cdc884..6e0ed1a5f1b5ef89182e5fb46518b0801d5a9cb0 100644 --- a/crates/gpui/src/platform/mac/shaders/shaders.h +++ b/crates/gpui/src/platform/mac/shaders/shaders.h @@ -90,6 +90,7 @@ typedef struct { float border_left; vector_uchar4 border_color; float corner_radius; + uint8_t grayscale; } GPUIImage; typedef enum { diff --git a/crates/gpui/src/platform/mac/shaders/shaders.metal b/crates/gpui/src/platform/mac/shaders/shaders.metal index 795026e747e4ffa1f0083737c7d6b2158f79a1b9..397e7647c47107b5176880a0e0fd1ba2adbaecda 100644 --- a/crates/gpui/src/platform/mac/shaders/shaders.metal +++ b/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, }; } diff --git a/crates/gpui/src/platform/test.rs b/crates/gpui/src/platform/test.rs index 58ef1ffaf2eff2b657d94c55c67a3337ca944c25..c3f037fe8651b0d6245f1d85921fcb51ce1dcce2 100644 --- a/crates/gpui/src/platform/test.rs +++ b/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, diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index e676147fa90e72d0f9f36cd02e2356423f310be5..4ef17a3f8f5d384723ed1d94a13019bb495e4d0e 100644 --- a/crates/gpui/src/scene.rs +++ b/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, } diff --git a/crates/gpui/src/test.rs b/crates/gpui/src/test.rs index 4122ad09b7480adcd647db98821a6bb3c533aa1f..6cfb4cf2b64f6518303599d9e8a3209d7dbcc09c 100644 --- a/crates/gpui/src/test.rs +++ b/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 { diff --git a/crates/gpui_macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs index a60d385e8f5305d398503e2508958d089174fbfc..32a821f4c83f010355600814b2d589b87e7b7f59 100644 --- a/crates/gpui_macros/src/gpui_macros.rs +++ b/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; diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index c2fac6371e3d916c935ebaa158dbe06ae0ffea9c..622dc13309a9b41171d8c45053fe8a835d9270d5 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -19,6 +19,7 @@ pub struct Picker { query_editor: ViewHandle, list_state: UniformListState, max_size: Vector2F, + theme: Box &theme::Picker>, confirmed: bool, } @@ -51,8 +52,8 @@ impl View for Picker { } fn render(&mut self, cx: &mut RenderContext) -> gpui::ElementBox { - let settings = cx.global::(); - 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 View for Picker { .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 Picker { list_state: Default::default(), delegate, max_size: vec2f(540., 420.), + theme: Box::new(|cx| &cx.global::().theme.picker), confirmed: false, }; cx.defer(|this, cx| { @@ -163,6 +162,14 @@ impl Picker { self } + pub fn with_theme(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) } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 8effecc44fe86902f6ce6aa414b22239c5eee3d4..84f45070fd45a4273ae1aab00152d737b5287137 100644 --- a/crates/project/src/project.rs +++ b/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, projects: Vec>, } @@ -127,7 +125,6 @@ pub struct Project { buffer_snapshots: HashMap>, buffers_being_formatted: HashSet, 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>, - remote_id_rx: watch::Receiver>, - online_tx: watch::Sender, - online_rx: watch::Receiver, - _maintain_remote_id: Task>, - _maintain_online_status: Task>, + remote_id: Option, + _detect_unshare: Task>, }, Remote { sharing_has_stopped: bool, @@ -174,7 +166,6 @@ enum ProjectClientState { #[derive(Clone, Debug)] pub struct Collaborator { - pub user: Arc, pub peer_id: PeerId, pub replica_id: ReplicaId, } @@ -197,8 +188,6 @@ pub enum Event { RemoteIdChanged(Option), DisconnectedFromHost, CollaboratorLeft(PeerId), - ContactRequestedJoin(Arc), - ContactCancelledJoinRequest(Arc), } pub enum LanguageServerState { @@ -383,17 +372,14 @@ impl FormatTrigger { impl Project { pub fn init(client: &Arc) { - 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, user_store: ModelHandle, project_store: ModelHandle, @@ -435,41 +420,19 @@ impl Project { cx: &mut MutableAppContext, ) -> ModelHandle { cx.add_model(|cx: &mut ModelContext| { - 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) -> Task> { - 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::().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) -> Task> { - 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) { let settings = cx.global::(); @@ -864,136 +754,9 @@ impl Project { &self.fs } - pub fn set_online(&mut self, online: bool, _: &mut ModelContext) { - 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) -> Task> { - 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) -> Task> { - 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 { 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 { - 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 { - 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) { - if let ProjectClientState::Local { - remote_id_rx, - online_rx, - .. - } = &self.client_state - { + fn metadata_changed(&mut self, cx: &mut ModelContext) { + 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::>(); - 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::>(); + 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 { - 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::>() - } - pub fn worktree_for_id( &self, id: WorktreeId, @@ -1321,142 +1052,106 @@ impl Project { } } - fn share(&mut self, cx: &mut ModelContext) -> Task> { - 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) -> Task> { + 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::>() { - 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::>() { + 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) { - if let ProjectClientState::Local { is_shared, .. } = &mut self.client_state { - if !*is_shared { - return; - } + pub fn unshare(&mut self, cx: &mut ModelContext) -> 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, - ) { - 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, - message: TypedEnvelope, - _: Arc, - 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, - _: TypedEnvelope, + _: TypedEnvelope, _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { - this.update(&mut cx, |this, cx| this.disconnected_from_host(cx)); - Ok(()) - } - - async fn handle_project_unshared( - this: ModelHandle, - _: TypedEnvelope, - _: Arc, - 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, 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, - envelope: TypedEnvelope, - _: Arc, - 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, envelope: TypedEnvelope, @@ -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) -> 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, - cx: &mut AsyncAppContext, - ) -> impl Future> { - 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, } } } diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index e8d363aca01717cf391960c25795e71eefb59e0e..283b11fd788b904d8b98d1ff85dc3d08bc8034de 100644 --- a/crates/rpc/proto/zed.proto +++ b/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; diff --git a/crates/rpc/src/peer.rs b/crates/rpc/src/peer.rs index 6c1c4f01da887e107468757fa12f48603cf74934..5b1ed6c2af19dd389197132a01ad540e5deb57fa 100644 --- a/crates/rpc/src/peer.rs +++ b/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")) diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 8d9d715b6c37739f03530fd69f9a5c2f9f6d4a5e..069fde4e59638d25673b6c517c6e9b7c776d8a38 100644 --- a/crates/rpc/src/proto.rs +++ b/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, diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index 640271d4a2f4e496dd10b87ce460a82946c8aabd..5fb9ca79a2c51e7930eb88bdb96d2c949eb3a5f0 100644 --- a/crates/rpc/src/rpc.rs +++ b/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; diff --git a/crates/terminal/src/terminal_element.rs b/crates/terminal/src/terminal_element.rs index 2c3c9646ad96cf641f497d005394dcdfc79f6d69..edf445a1d326dacfb8a806f5c75974a26998ccb9 100644 --- a/crates/terminal/src/terminal_element.rs +++ b/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); diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index d8c829648156654d51cad54ea5b5d000f0cb3f08..37ec279d021b14d135fe1b09720d606b04e8402f 100644 --- a/crates/theme/src/theme.rs +++ b/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, pub outdated_warning: ContainedText, + pub share_button: Interactive, + pub toggle_contacts_button: Interactive, + 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, +} + +#[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, + pub leave_call: Interactive, + pub contact_row: Interactive, + pub row_height: f32, + pub project_row: Interactive, + pub tree_branch: Interactive, + pub contact_avatar: ImageStyle, + pub contact_status_free: ContainerStyle, + pub contact_status_busy: ContainerStyle, + pub contact_username: ContainedText, + pub contact_button: Interactive, + 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, - pub contact_row: Interactive, - pub project_row: Interactive, - pub row_height: f32, - pub contact_avatar: ImageStyle, - pub contact_username: ContainedText, - pub contact_button: Interactive, - pub contact_button_spacing: f32, - pub disabled_button: IconButton, - pub tree_branch: Interactive, - pub private_button: Interactive, - pub section_icon_size: f32, - pub invite_row: Interactive, -} - #[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, } +#[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, diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index c40ce5638947b63bedf05ecd071592539065009a..2fd43b7bcb68267977e2152876a881dd3024be8e 100644 --- a/crates/workspace/Cargo.toml +++ b/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"] } diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 94acf427e4fe0718c3cbe330d269af93a62567dc..f09c31741ef2a60e5475057d0d0e9394d121d3c4 100644 --- a/crates/workspace/src/pane_group.rs +++ b/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, theme: &Theme, follower_states: &FollowerStatesByLeader, - collaborators: &HashMap, + active_call: Option<&ModelHandle>, + cx: &mut RenderContext, ) -> 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> { @@ -100,13 +104,16 @@ impl Member { pub fn render( &self, + project: &ModelHandle, theme: &Theme, follower_states: &FollowerStatesByLeader, - collaborators: &HashMap, + active_call: Option<&ModelHandle>, + cx: &mut RenderContext, ) -> 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::::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, theme: &Theme, follower_state: &FollowerStatesByLeader, - collaborators: &HashMap, + active_call: Option<&ModelHandle>, + cx: &mut RenderContext, ) -> 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; diff --git a/crates/workspace/src/waiting_room.rs b/crates/workspace/src/waiting_room.rs deleted file mode 100644 index bdced26c8b137a288be97621b22d2281714579f2..0000000000000000000000000000000000000000 --- a/crates/workspace/src/waiting_room.rs +++ /dev/null @@ -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>, - message: String, - waiting: bool, - client: Arc, - _join_task: Task>, -} - -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) -> ElementBox { - let theme = &cx.global::().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, - project_index: usize, - app_state: Arc, - cx: &mut ViewContext, - ) -> 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) -> 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 -} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 2ae498d7015053652497bcfce07b11ae750117fd..705823003f43c737f714253353131b8a92cb3e10 100644 --- a/crates/workspace/src/workspace.rs +++ b/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, } -#[derive(Clone, Deserialize, PartialEq)] -pub struct ToggleProjectOnline { - #[serde(skip_deserializing)] - pub project: Option>, -} - #[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, - 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, cx: &mut MutableAppContext) { pane::init(cx); @@ -173,14 +159,6 @@ pub fn init(app_state: Arc, 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, 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| { 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, client: Arc, user_store: ModelHandle, - remote_entity_subscription: Option, + remote_entity_subscription: Option, fs: Arc, modal: Option, center: PaneGroup, @@ -995,6 +972,7 @@ pub struct Workspace { active_pane: ViewHandle, last_active_center_pane: Option>, status_bar: ViewHandle, + titlebar_item: Option, dock: Dock, notifications: Vec<(TypeId, usize, Box)>, project: ModelHandle, @@ -1002,7 +980,9 @@ pub struct Workspace { follower_states_by_leader: FollowerStatesByLeader, last_leaders_by_pane: HashMap, PeerId>, window_edited: bool, + active_call: Option>, _observe_current_user: Task<()>, + _active_call_observation: Option, } #[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::>() { + let call = cx.global::>().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 { + &self.client + } + + pub fn set_titlebar_item( + &mut self, + item: impl Into, + cx: &mut ViewContext, + ) { + 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, ) -> Option>> { - 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) -> Task> { - self.save_all_internal(true, cx) + pub fn prepare_to_close( + &mut self, + quitting: bool, + cx: &mut ViewContext, + ) -> Task> { + 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::(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) -> Option>> { @@ -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) { - 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) -> Option { - let theme = &cx.global::().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) -> 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::::new(0, cx, |_, cx| { + MouseEventHandler::::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) -> Vec { - let mut collaborators = self - .project - .read(cx) - .collaborators() - .values() - .cloned() - .collect::>(); - 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>, - replica_id: ReplicaId, - theme: &Theme, - cx: &mut RenderContext, - ) -> Option { - 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::::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, - replica_id: ReplicaId, - peer: Option<(PeerId, &str)>, - theme: &Theme, - cx: &mut RenderContext, - ) -> 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::::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::( - 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) -> Option { 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 { } } -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, - _: RectF, - _: RectF, - _: &Self::LayoutState, - _: &Self::PaintState, - _: &gpui::MeasurementContext, - ) -> Option { - 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, - project_index: usize, - app_state: &Arc, - cx: &mut MutableAppContext, -) { - let project_id = contact.projects[project_index].id; - - for window_id in cx.window_ids().collect::>() { - if let Some(workspace) = cx.root_view::(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, 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(); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 9a65fd08166fc4e6233ed51a31f74edc7b3b88c4..d0b41b08f118854ab48c10411e68461986b79563 100644 --- a/crates/zed/Cargo.toml +++ b/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" diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 2dd90eb762f75c24ab377dd20c2c525ab429ae61..f48f8b723e86f346e887a5a104c2cd9fb5a68133 100644 --- a/crates/zed/src/main.rs +++ b/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()); diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index f21845a589a0714ab30ac6ce0288ff69a195c2d1..132bbc44778ec94b9d05df0e5250a23a9cc4dac0 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -244,10 +244,6 @@ pub fn menus() -> Vec> { 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), diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index f86022e39c87bbdc1e8c79546c52325d14fa7cbf..f6f3a34242ac82dc143457aa3946c32d36eee67a 100644 --- a/crates/zed/src/zed.rs +++ b/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, 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.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, cx: &mut ViewContext, ) { - 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); diff --git a/styles/src/styleTree/app.ts b/styles/src/styleTree/app.ts index a3ab4b654c6f6d2835804c834dab6a7435cd75f3..f540074a70c47daaa0e57dbb56e13ed10596a78c 100644 --- a/styles/src/styleTree/app.ts +++ b/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), }; diff --git a/styles/src/styleTree/contactFinder.ts b/styles/src/styleTree/contactFinder.ts index e34fac4b2d734734f6b42d5089a9d7decf852919..103d669df1ea26c7937b9bf9b4a1d3c8fee8fa75 100644 --- a/styles/src/styleTree/contactFinder.ts +++ b/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, diff --git a/styles/src/styleTree/contactsPanel.ts b/styles/src/styleTree/contactList.ts similarity index 73% rename from styles/src/styleTree/contactsPanel.ts rename to styles/src/styleTree/contactList.ts index 20fce729e434ac98d2a9959c1b453f5b2e6f8c37..ecf0eaa0c7dc925ffc0fa0fb1bf63275ab35a77b 100644 --- a/styles/src/styleTree/contactsPanel.ts +++ b/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" }), - }, - }, - }; + } } diff --git a/styles/src/styleTree/contactsPopover.ts b/styles/src/styleTree/contactsPopover.ts index e9de5dddaf627120d12ab452137fbd47508771a5..0f82c7c1759e40b1a42abbfb03f8a2794cc89ba7 100644 --- a/styles/src/styleTree/contactsPopover.ts +++ b/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" }), + }, + }, } } diff --git a/styles/src/styleTree/incomingCallNotification.ts b/styles/src/styleTree/incomingCallNotification.ts new file mode 100644 index 0000000000000000000000000000000000000000..55f5cc80fd6781486fdb38df71a3a7f69603150c --- /dev/null +++ b/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" }) + }, + }; +} diff --git a/styles/src/styleTree/projectSharedNotification.ts b/styles/src/styleTree/projectSharedNotification.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3cb29bbf1ea539b2a26040936ad8f85601da23e --- /dev/null +++ b/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" }) + }, + }; +} diff --git a/styles/src/styleTree/workspace.ts b/styles/src/styleTree/workspace.ts index 9e81a06d3f092f6e0ae716b3975854ead1d80823..cfbda49056da6635b6966bca5722b3a39d4a4e89 100644 --- a/styles/src/styleTree/workspace.ts +++ b/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,