Detailed changes
@@ -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",
@@ -1,4 +0,0 @@
-<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M5 11C5 14.3137 7.68629 17 11 17C14.3137 17 17 14.3137 17 11C17 7.68629 14.3137 5 11 5C7.68629 5 5 7.68629 5 11ZM11 3C6.58172 3 3 6.58172 3 11C3 15.4183 6.58172 19 11 19C15.4183 19 19 15.4183 19 11C19 6.58172 15.4183 3 11 3Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M8.09092 8.09088H14.6364L10.5511 12.4545H12.4546L13.9091 13.9091H7.36365L11.7273 9.54543H9.54547L8.09092 8.09088Z" fill="white"/>
-</svg>
@@ -395,7 +395,6 @@
"context": "Workspace",
"bindings": {
"shift-escape": "dock::FocusDock",
- "cmd-shift-c": "contacts_panel::ToggleFocus",
"cmd-shift-b": "workspace::ToggleRightSidebar"
}
},
@@ -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"] }
@@ -0,0 +1,261 @@
+mod participant;
+pub mod room;
+
+use anyhow::{anyhow, Result};
+use client::{proto, Client, TypedEnvelope, User, UserStore};
+use gpui::{
+ AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext,
+ Subscription, Task,
+};
+pub use participant::ParticipantLocation;
+use postage::watch;
+use project::Project;
+pub use room::Room;
+use std::sync::Arc;
+
+pub fn init(client: Arc<Client>, user_store: ModelHandle<UserStore>, cx: &mut MutableAppContext) {
+ let active_call = cx.add_model(|cx| ActiveCall::new(client, user_store, cx));
+ cx.set_global(active_call);
+}
+
+#[derive(Clone)]
+pub struct IncomingCall {
+ pub room_id: u64,
+ pub caller: Arc<User>,
+ pub participants: Vec<Arc<User>>,
+ pub initial_project: Option<proto::ParticipantProject>,
+}
+
+pub struct ActiveCall {
+ room: Option<(ModelHandle<Room>, Vec<Subscription>)>,
+ incoming_call: (
+ watch::Sender<Option<IncomingCall>>,
+ watch::Receiver<Option<IncomingCall>>,
+ ),
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ _subscriptions: Vec<client::Subscription>,
+}
+
+impl Entity for ActiveCall {
+ type Event = room::Event;
+}
+
+impl ActiveCall {
+ fn new(
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut ModelContext<Self>,
+ ) -> Self {
+ Self {
+ room: None,
+ incoming_call: watch::channel(),
+ _subscriptions: vec![
+ client.add_request_handler(cx.handle(), Self::handle_incoming_call),
+ client.add_message_handler(cx.handle(), Self::handle_call_canceled),
+ ],
+ client,
+ user_store,
+ }
+ }
+
+ async fn handle_incoming_call(
+ this: ModelHandle<Self>,
+ envelope: TypedEnvelope<proto::IncomingCall>,
+ _: Arc<Client>,
+ mut cx: AsyncAppContext,
+ ) -> Result<proto::Ack> {
+ let user_store = this.read_with(&cx, |this, _| this.user_store.clone());
+ let call = IncomingCall {
+ room_id: envelope.payload.room_id,
+ participants: user_store
+ .update(&mut cx, |user_store, cx| {
+ user_store.get_users(envelope.payload.participant_user_ids, cx)
+ })
+ .await?,
+ caller: user_store
+ .update(&mut cx, |user_store, cx| {
+ user_store.get_user(envelope.payload.caller_user_id, cx)
+ })
+ .await?,
+ initial_project: envelope.payload.initial_project,
+ };
+ this.update(&mut cx, |this, _| {
+ *this.incoming_call.0.borrow_mut() = Some(call);
+ });
+
+ Ok(proto::Ack {})
+ }
+
+ async fn handle_call_canceled(
+ this: ModelHandle<Self>,
+ _: TypedEnvelope<proto::CallCanceled>,
+ _: Arc<Client>,
+ mut cx: AsyncAppContext,
+ ) -> Result<()> {
+ this.update(&mut cx, |this, _| {
+ *this.incoming_call.0.borrow_mut() = None;
+ });
+ Ok(())
+ }
+
+ pub fn global(cx: &AppContext) -> ModelHandle<Self> {
+ cx.global::<ModelHandle<Self>>().clone()
+ }
+
+ pub fn invite(
+ &mut self,
+ recipient_user_id: u64,
+ initial_project: Option<ModelHandle<Project>>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ let client = self.client.clone();
+ let user_store = self.user_store.clone();
+ cx.spawn(|this, mut cx| async move {
+ if let Some(room) = this.read_with(&cx, |this, _| this.room().cloned()) {
+ let initial_project_id = if let Some(initial_project) = initial_project {
+ Some(
+ room.update(&mut cx, |room, cx| room.share_project(initial_project, cx))
+ .await?,
+ )
+ } else {
+ None
+ };
+
+ room.update(&mut cx, |room, cx| {
+ room.call(recipient_user_id, initial_project_id, cx)
+ })
+ .await?;
+ } else {
+ let room = cx
+ .update(|cx| {
+ Room::create(recipient_user_id, initial_project, client, user_store, cx)
+ })
+ .await?;
+ this.update(&mut cx, |this, cx| this.set_room(Some(room), cx));
+ };
+
+ Ok(())
+ })
+ }
+
+ pub fn cancel_invite(
+ &mut self,
+ recipient_user_id: u64,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ let room_id = if let Some(room) = self.room() {
+ room.read(cx).id()
+ } else {
+ return Task::ready(Err(anyhow!("no active call")));
+ };
+
+ let client = self.client.clone();
+ cx.foreground().spawn(async move {
+ client
+ .request(proto::CancelCall {
+ room_id,
+ recipient_user_id,
+ })
+ .await?;
+ anyhow::Ok(())
+ })
+ }
+
+ pub fn incoming(&self) -> watch::Receiver<Option<IncomingCall>> {
+ self.incoming_call.1.clone()
+ }
+
+ pub fn accept_incoming(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+ if self.room.is_some() {
+ return Task::ready(Err(anyhow!("cannot join while on another call")));
+ }
+
+ let call = if let Some(call) = self.incoming_call.1.borrow().clone() {
+ call
+ } else {
+ return Task::ready(Err(anyhow!("no incoming call")));
+ };
+
+ let join = Room::join(&call, self.client.clone(), self.user_store.clone(), cx);
+ cx.spawn(|this, mut cx| async move {
+ let room = join.await?;
+ this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx));
+ Ok(())
+ })
+ }
+
+ pub fn decline_incoming(&mut self) -> Result<()> {
+ let call = self
+ .incoming_call
+ .0
+ .borrow_mut()
+ .take()
+ .ok_or_else(|| anyhow!("no incoming call"))?;
+ self.client.send(proto::DeclineCall {
+ room_id: call.room_id,
+ })?;
+ Ok(())
+ }
+
+ pub fn hang_up(&mut self, cx: &mut ModelContext<Self>) -> Result<()> {
+ if let Some((room, _)) = self.room.take() {
+ room.update(cx, |room, cx| room.leave(cx))?;
+ cx.notify();
+ }
+ Ok(())
+ }
+
+ pub fn share_project(
+ &mut self,
+ project: ModelHandle<Project>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<u64>> {
+ if let Some((room, _)) = self.room.as_ref() {
+ room.update(cx, |room, cx| room.share_project(project, cx))
+ } else {
+ Task::ready(Err(anyhow!("no active call")))
+ }
+ }
+
+ pub fn set_location(
+ &mut self,
+ project: Option<&ModelHandle<Project>>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ if let Some((room, _)) = self.room.as_ref() {
+ room.update(cx, |room, cx| room.set_location(project, cx))
+ } else {
+ Task::ready(Err(anyhow!("no active call")))
+ }
+ }
+
+ fn set_room(&mut self, room: Option<ModelHandle<Room>>, cx: &mut ModelContext<Self>) {
+ if room.as_ref() != self.room.as_ref().map(|room| &room.0) {
+ if let Some(room) = room {
+ if room.read(cx).status().is_offline() {
+ self.room = None;
+ } else {
+ let subscriptions = vec![
+ cx.observe(&room, |this, room, cx| {
+ if room.read(cx).status().is_offline() {
+ this.set_room(None, cx);
+ }
+
+ cx.notify();
+ }),
+ cx.subscribe(&room, |_, _, event, cx| cx.emit(event.clone())),
+ ];
+ self.room = Some((room, subscriptions));
+ }
+ } else {
+ self.room = None;
+ }
+ cx.notify();
+ }
+ }
+
+ pub fn room(&self) -> Option<&ModelHandle<Room>> {
+ self.room.as_ref().map(|(room, _)| room)
+ }
+}
@@ -0,0 +1,42 @@
+use anyhow::{anyhow, Result};
+use client::{proto, User};
+use gpui::WeakModelHandle;
+use project::Project;
+use std::sync::Arc;
+
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+pub enum ParticipantLocation {
+ SharedProject { project_id: u64 },
+ UnsharedProject,
+ External,
+}
+
+impl ParticipantLocation {
+ pub fn from_proto(location: Option<proto::ParticipantLocation>) -> Result<Self> {
+ match location.and_then(|l| l.variant) {
+ Some(proto::participant_location::Variant::SharedProject(project)) => {
+ Ok(Self::SharedProject {
+ project_id: project.id,
+ })
+ }
+ Some(proto::participant_location::Variant::UnsharedProject(_)) => {
+ Ok(Self::UnsharedProject)
+ }
+ Some(proto::participant_location::Variant::External(_)) => Ok(Self::External),
+ None => Err(anyhow!("participant location was not provided")),
+ }
+ }
+}
+
+#[derive(Clone, Default)]
+pub struct LocalParticipant {
+ pub projects: Vec<proto::ParticipantProject>,
+ pub active_project: Option<WeakModelHandle<Project>>,
+}
+
+#[derive(Clone, Debug)]
+pub struct RemoteParticipant {
+ pub user: Arc<User>,
+ pub projects: Vec<proto::ParticipantProject>,
+ pub location: ParticipantLocation,
+}
@@ -0,0 +1,474 @@
+use crate::{
+ participant::{LocalParticipant, ParticipantLocation, RemoteParticipant},
+ IncomingCall,
+};
+use anyhow::{anyhow, Result};
+use client::{proto, Client, PeerId, TypedEnvelope, User, UserStore};
+use collections::{BTreeMap, HashSet};
+use futures::StreamExt;
+use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task};
+use project::Project;
+use std::sync::Arc;
+use util::ResultExt;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Event {
+ RemoteProjectShared {
+ owner: Arc<User>,
+ project_id: u64,
+ worktree_root_names: Vec<String>,
+ },
+ RemoteProjectUnshared {
+ project_id: u64,
+ },
+ Left,
+}
+
+pub struct Room {
+ id: u64,
+ status: RoomStatus,
+ local_participant: LocalParticipant,
+ remote_participants: BTreeMap<PeerId, RemoteParticipant>,
+ pending_participants: Vec<Arc<User>>,
+ participant_user_ids: HashSet<u64>,
+ pending_call_count: usize,
+ leave_when_empty: bool,
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ subscriptions: Vec<client::Subscription>,
+ pending_room_update: Option<Task<()>>,
+}
+
+impl Entity for Room {
+ type Event = Event;
+
+ fn release(&mut self, _: &mut MutableAppContext) {
+ self.client.send(proto::LeaveRoom { id: self.id }).log_err();
+ }
+}
+
+impl Room {
+ fn new(
+ id: u64,
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut ModelContext<Self>,
+ ) -> Self {
+ let mut client_status = client.status();
+ cx.spawn_weak(|this, mut cx| async move {
+ let is_connected = client_status
+ .next()
+ .await
+ .map_or(false, |s| s.is_connected());
+ // Even if we're initially connected, any future change of the status means we momentarily disconnected.
+ if !is_connected || client_status.next().await.is_some() {
+ if let Some(this) = this.upgrade(&cx) {
+ let _ = this.update(&mut cx, |this, cx| this.leave(cx));
+ }
+ }
+ })
+ .detach();
+
+ Self {
+ id,
+ status: RoomStatus::Online,
+ participant_user_ids: Default::default(),
+ local_participant: Default::default(),
+ remote_participants: Default::default(),
+ pending_participants: Default::default(),
+ pending_call_count: 0,
+ subscriptions: vec![client.add_message_handler(cx.handle(), Self::handle_room_updated)],
+ leave_when_empty: false,
+ pending_room_update: None,
+ client,
+ user_store,
+ }
+ }
+
+ pub(crate) fn create(
+ recipient_user_id: u64,
+ initial_project: Option<ModelHandle<Project>>,
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut MutableAppContext,
+ ) -> Task<Result<ModelHandle<Self>>> {
+ cx.spawn(|mut cx| async move {
+ let response = client.request(proto::CreateRoom {}).await?;
+ let room = cx.add_model(|cx| Self::new(response.id, client, user_store, cx));
+
+ let initial_project_id = if let Some(initial_project) = initial_project {
+ let initial_project_id = room
+ .update(&mut cx, |room, cx| {
+ room.share_project(initial_project.clone(), cx)
+ })
+ .await?;
+ Some(initial_project_id)
+ } else {
+ None
+ };
+
+ match room
+ .update(&mut cx, |room, cx| {
+ room.leave_when_empty = true;
+ room.call(recipient_user_id, initial_project_id, cx)
+ })
+ .await
+ {
+ Ok(()) => Ok(room),
+ Err(error) => Err(anyhow!("room creation failed: {:?}", error)),
+ }
+ })
+ }
+
+ pub(crate) fn join(
+ call: &IncomingCall,
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut MutableAppContext,
+ ) -> Task<Result<ModelHandle<Self>>> {
+ let room_id = call.room_id;
+ cx.spawn(|mut cx| async move {
+ let response = client.request(proto::JoinRoom { id: room_id }).await?;
+ let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
+ let room = cx.add_model(|cx| Self::new(room_id, client, user_store, cx));
+ room.update(&mut cx, |room, cx| {
+ room.leave_when_empty = true;
+ room.apply_room_update(room_proto, cx)?;
+ anyhow::Ok(())
+ })?;
+ Ok(room)
+ })
+ }
+
+ fn should_leave(&self) -> bool {
+ self.leave_when_empty
+ && self.pending_room_update.is_none()
+ && self.pending_participants.is_empty()
+ && self.remote_participants.is_empty()
+ && self.pending_call_count == 0
+ }
+
+ pub(crate) fn leave(&mut self, cx: &mut ModelContext<Self>) -> Result<()> {
+ if self.status.is_offline() {
+ return Err(anyhow!("room is offline"));
+ }
+
+ cx.notify();
+ cx.emit(Event::Left);
+ self.status = RoomStatus::Offline;
+ self.remote_participants.clear();
+ self.pending_participants.clear();
+ self.participant_user_ids.clear();
+ self.subscriptions.clear();
+ self.client.send(proto::LeaveRoom { id: self.id })?;
+ Ok(())
+ }
+
+ pub fn id(&self) -> u64 {
+ self.id
+ }
+
+ pub fn status(&self) -> RoomStatus {
+ self.status
+ }
+
+ pub fn local_participant(&self) -> &LocalParticipant {
+ &self.local_participant
+ }
+
+ pub fn remote_participants(&self) -> &BTreeMap<PeerId, RemoteParticipant> {
+ &self.remote_participants
+ }
+
+ pub fn pending_participants(&self) -> &[Arc<User>] {
+ &self.pending_participants
+ }
+
+ pub fn contains_participant(&self, user_id: u64) -> bool {
+ self.participant_user_ids.contains(&user_id)
+ }
+
+ async fn handle_room_updated(
+ this: ModelHandle<Self>,
+ envelope: TypedEnvelope<proto::RoomUpdated>,
+ _: Arc<Client>,
+ mut cx: AsyncAppContext,
+ ) -> Result<()> {
+ let room = envelope
+ .payload
+ .room
+ .ok_or_else(|| anyhow!("invalid room"))?;
+ this.update(&mut cx, |this, cx| this.apply_room_update(room, cx))
+ }
+
+ fn apply_room_update(
+ &mut self,
+ mut room: proto::Room,
+ cx: &mut ModelContext<Self>,
+ ) -> Result<()> {
+ // Filter ourselves out from the room's participants.
+ let local_participant_ix = room
+ .participants
+ .iter()
+ .position(|participant| Some(participant.user_id) == self.client.user_id());
+ let local_participant = local_participant_ix.map(|ix| room.participants.swap_remove(ix));
+
+ let remote_participant_user_ids = room
+ .participants
+ .iter()
+ .map(|p| p.user_id)
+ .collect::<Vec<_>>();
+ let (remote_participants, pending_participants) =
+ self.user_store.update(cx, move |user_store, cx| {
+ (
+ user_store.get_users(remote_participant_user_ids, cx),
+ user_store.get_users(room.pending_participant_user_ids, cx),
+ )
+ });
+ self.pending_room_update = Some(cx.spawn(|this, mut cx| async move {
+ let (remote_participants, pending_participants) =
+ futures::join!(remote_participants, pending_participants);
+
+ this.update(&mut cx, |this, cx| {
+ this.participant_user_ids.clear();
+
+ if let Some(participant) = local_participant {
+ this.local_participant.projects = participant.projects;
+ } else {
+ this.local_participant.projects.clear();
+ }
+
+ if let Some(participants) = remote_participants.log_err() {
+ for (participant, user) in room.participants.into_iter().zip(participants) {
+ let peer_id = PeerId(participant.peer_id);
+ this.participant_user_ids.insert(participant.user_id);
+
+ let old_projects = this
+ .remote_participants
+ .get(&peer_id)
+ .into_iter()
+ .flat_map(|existing| &existing.projects)
+ .map(|project| project.id)
+ .collect::<HashSet<_>>();
+ let new_projects = participant
+ .projects
+ .iter()
+ .map(|project| project.id)
+ .collect::<HashSet<_>>();
+
+ for project in &participant.projects {
+ if !old_projects.contains(&project.id) {
+ cx.emit(Event::RemoteProjectShared {
+ owner: user.clone(),
+ project_id: project.id,
+ worktree_root_names: project.worktree_root_names.clone(),
+ });
+ }
+ }
+
+ for unshared_project_id in old_projects.difference(&new_projects) {
+ cx.emit(Event::RemoteProjectUnshared {
+ project_id: *unshared_project_id,
+ });
+ }
+
+ this.remote_participants.insert(
+ peer_id,
+ RemoteParticipant {
+ user: user.clone(),
+ projects: participant.projects,
+ location: ParticipantLocation::from_proto(participant.location)
+ .unwrap_or(ParticipantLocation::External),
+ },
+ );
+ }
+
+ this.remote_participants.retain(|_, participant| {
+ if this.participant_user_ids.contains(&participant.user.id) {
+ true
+ } else {
+ for project in &participant.projects {
+ cx.emit(Event::RemoteProjectUnshared {
+ project_id: project.id,
+ });
+ }
+ false
+ }
+ });
+ }
+
+ if let Some(pending_participants) = pending_participants.log_err() {
+ this.pending_participants = pending_participants;
+ for participant in &this.pending_participants {
+ this.participant_user_ids.insert(participant.id);
+ }
+ }
+
+ this.pending_room_update.take();
+ if this.should_leave() {
+ let _ = this.leave(cx);
+ }
+
+ this.check_invariants();
+ cx.notify();
+ });
+ }));
+
+ cx.notify();
+ Ok(())
+ }
+
+ fn check_invariants(&self) {
+ #[cfg(any(test, feature = "test-support"))]
+ {
+ for participant in self.remote_participants.values() {
+ assert!(self.participant_user_ids.contains(&participant.user.id));
+ }
+
+ for participant in &self.pending_participants {
+ assert!(self.participant_user_ids.contains(&participant.id));
+ }
+
+ assert_eq!(
+ self.participant_user_ids.len(),
+ self.remote_participants.len() + self.pending_participants.len()
+ );
+ }
+ }
+
+ pub(crate) fn call(
+ &mut self,
+ recipient_user_id: u64,
+ initial_project_id: Option<u64>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ if self.status.is_offline() {
+ return Task::ready(Err(anyhow!("room is offline")));
+ }
+
+ cx.notify();
+ let client = self.client.clone();
+ let room_id = self.id;
+ self.pending_call_count += 1;
+ cx.spawn(|this, mut cx| async move {
+ let result = client
+ .request(proto::Call {
+ room_id,
+ recipient_user_id,
+ initial_project_id,
+ })
+ .await;
+ this.update(&mut cx, |this, cx| {
+ this.pending_call_count -= 1;
+ if this.should_leave() {
+ this.leave(cx)?;
+ }
+ result
+ })?;
+ Ok(())
+ })
+ }
+
+ pub(crate) fn share_project(
+ &mut self,
+ project: ModelHandle<Project>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<u64>> {
+ if project.read(cx).is_remote() {
+ return Task::ready(Err(anyhow!("can't share remote project")));
+ } else if let Some(project_id) = project.read(cx).remote_id() {
+ return Task::ready(Ok(project_id));
+ }
+
+ let request = self.client.request(proto::ShareProject {
+ room_id: self.id(),
+ worktrees: project
+ .read(cx)
+ .worktrees(cx)
+ .map(|worktree| {
+ let worktree = worktree.read(cx);
+ proto::WorktreeMetadata {
+ id: worktree.id().to_proto(),
+ root_name: worktree.root_name().into(),
+ visible: worktree.is_visible(),
+ }
+ })
+ .collect(),
+ });
+ cx.spawn(|this, mut cx| async move {
+ let response = request.await?;
+
+ project
+ .update(&mut cx, |project, cx| {
+ project.shared(response.project_id, cx)
+ })
+ .await?;
+
+ // If the user's location is in this project, it changes from UnsharedProject to SharedProject.
+ this.update(&mut cx, |this, cx| {
+ let active_project = this.local_participant.active_project.as_ref();
+ if active_project.map_or(false, |location| *location == project) {
+ this.set_location(Some(&project), cx)
+ } else {
+ Task::ready(Ok(()))
+ }
+ })
+ .await?;
+
+ Ok(response.project_id)
+ })
+ }
+
+ pub fn set_location(
+ &mut self,
+ project: Option<&ModelHandle<Project>>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ if self.status.is_offline() {
+ return Task::ready(Err(anyhow!("room is offline")));
+ }
+
+ let client = self.client.clone();
+ let room_id = self.id;
+ let location = if let Some(project) = project {
+ self.local_participant.active_project = Some(project.downgrade());
+ if let Some(project_id) = project.read(cx).remote_id() {
+ proto::participant_location::Variant::SharedProject(
+ proto::participant_location::SharedProject { id: project_id },
+ )
+ } else {
+ proto::participant_location::Variant::UnsharedProject(
+ proto::participant_location::UnsharedProject {},
+ )
+ }
+ } else {
+ self.local_participant.active_project = None;
+ proto::participant_location::Variant::External(proto::participant_location::External {})
+ };
+
+ cx.notify();
+ cx.foreground().spawn(async move {
+ client
+ .request(proto::UpdateParticipantLocation {
+ room_id,
+ location: Some(proto::ParticipantLocation {
+ variant: Some(location),
+ }),
+ })
+ .await?;
+ Ok(())
+ })
+ }
+}
+
+#[derive(Copy, Clone, PartialEq, Eq)]
+pub enum RoomStatus {
+ Online,
+ Offline,
+}
+
+impl RoomStatus {
+ pub fn is_offline(&self) -> bool {
+ matches!(self, RoomStatus::Offline)
+ }
+}
@@ -530,7 +530,7 @@ impl ChannelMessage {
) -> Result<Self> {
let sender = user_store
.update(cx, |user_store, cx| {
- user_store.fetch_user(message.sender_id, cx)
+ user_store.get_user(message.sender_id, cx)
})
.await?;
Ok(ChannelMessage {
@@ -434,6 +434,29 @@ impl Client {
}
}
+ pub fn add_request_handler<M, E, H, F>(
+ self: &Arc<Self>,
+ model: ModelHandle<E>,
+ handler: H,
+ ) -> Subscription
+ where
+ M: RequestMessage,
+ E: Entity,
+ H: 'static
+ + Send
+ + Sync
+ + Fn(ModelHandle<E>, TypedEnvelope<M>, Arc<Self>, AsyncAppContext) -> F,
+ F: 'static + Future<Output = Result<M::Response>>,
+ {
+ self.add_message_handler(model, move |handle, envelope, this, cx| {
+ Self::respond_to_request(
+ envelope.receipt(),
+ handler(handle, envelope, this.clone(), cx),
+ this,
+ )
+ })
+ }
+
pub fn add_view_message_handler<M, E, H, F>(self: &Arc<Self>, handler: H)
where
M: EntityMessage,
@@ -1,14 +1,14 @@
use super::{http::HttpClient, proto, Client, Status, TypedEnvelope};
use anyhow::{anyhow, Context, Result};
-use collections::{hash_map::Entry, BTreeSet, HashMap, HashSet};
+use collections::{hash_map::Entry, HashMap, HashSet};
use futures::{channel::mpsc, future, AsyncReadExt, Future, StreamExt};
use gpui::{AsyncAppContext, Entity, ImageData, ModelContext, ModelHandle, Task};
-use postage::{prelude::Stream, sink::Sink, watch};
+use postage::{sink::Sink, watch};
use rpc::proto::{RequestMessage, UsersResponse};
use std::sync::{Arc, Weak};
use util::TryFutureExt as _;
-#[derive(Debug)]
+#[derive(Default, Debug)]
pub struct User {
pub id: u64,
pub github_login: String,
@@ -39,14 +39,7 @@ impl Eq for User {}
pub struct Contact {
pub user: Arc<User>,
pub online: bool,
- pub projects: Vec<ProjectMetadata>,
-}
-
-#[derive(Clone, Debug, PartialEq)]
-pub struct ProjectMetadata {
- pub id: u64,
- pub visible_worktree_root_names: Vec<String>,
- pub guests: BTreeSet<Arc<User>>,
+ pub busy: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -138,12 +131,12 @@ impl UserStore {
}),
_maintain_current_user: cx.spawn_weak(|this, mut cx| async move {
let mut status = client.status();
- while let Some(status) = status.recv().await {
+ while let Some(status) = status.next().await {
match status {
Status::Connected { .. } => {
if let Some((this, user_id)) = this.upgrade(&cx).zip(client.user_id()) {
let fetch_user = this
- .update(&mut cx, |this, cx| this.fetch_user(user_id, cx))
+ .update(&mut cx, |this, cx| this.get_user(user_id, cx))
.log_err();
let fetch_metrics_id =
client.request(proto::GetPrivateUserInfo {}).log_err();
@@ -244,7 +237,6 @@ impl UserStore {
let mut user_ids = HashSet::default();
for contact in &message.contacts {
user_ids.insert(contact.user_id);
- user_ids.extend(contact.projects.iter().flat_map(|w| &w.guests).copied());
}
user_ids.extend(message.incoming_requests.iter().map(|req| req.requester_id));
user_ids.extend(message.outgoing_requests.iter());
@@ -268,9 +260,7 @@ impl UserStore {
for request in message.incoming_requests {
incoming_requests.push({
let user = this
- .update(&mut cx, |this, cx| {
- this.fetch_user(request.requester_id, cx)
- })
+ .update(&mut cx, |this, cx| this.get_user(request.requester_id, cx))
.await?;
(user, request.should_notify)
});
@@ -279,7 +269,7 @@ impl UserStore {
let mut outgoing_requests = Vec::new();
for requested_user_id in message.outgoing_requests {
outgoing_requests.push(
- this.update(&mut cx, |this, cx| this.fetch_user(requested_user_id, cx))
+ this.update(&mut cx, |this, cx| this.get_user(requested_user_id, cx))
.await?,
);
}
@@ -504,7 +494,7 @@ impl UserStore {
.unbounded_send(UpdateContacts::Clear(tx))
.unwrap();
async move {
- rx.recv().await;
+ rx.next().await;
}
}
@@ -514,25 +504,43 @@ impl UserStore {
.unbounded_send(UpdateContacts::Wait(tx))
.unwrap();
async move {
- rx.recv().await;
+ rx.next().await;
}
}
pub fn get_users(
&mut self,
- mut user_ids: Vec<u64>,
+ user_ids: Vec<u64>,
cx: &mut ModelContext<Self>,
- ) -> Task<Result<()>> {
- user_ids.retain(|id| !self.users.contains_key(id));
- if user_ids.is_empty() {
- Task::ready(Ok(()))
- } else {
- let load = self.load_users(proto::GetUsers { user_ids }, cx);
- cx.foreground().spawn(async move {
- load.await?;
- Ok(())
+ ) -> Task<Result<Vec<Arc<User>>>> {
+ let mut user_ids_to_fetch = user_ids.clone();
+ user_ids_to_fetch.retain(|id| !self.users.contains_key(id));
+
+ cx.spawn(|this, mut cx| async move {
+ if !user_ids_to_fetch.is_empty() {
+ this.update(&mut cx, |this, cx| {
+ this.load_users(
+ proto::GetUsers {
+ user_ids: user_ids_to_fetch,
+ },
+ cx,
+ )
+ })
+ .await?;
+ }
+
+ this.read_with(&cx, |this, _| {
+ user_ids
+ .iter()
+ .map(|user_id| {
+ this.users
+ .get(user_id)
+ .cloned()
+ .ok_or_else(|| anyhow!("user {} not found", user_id))
+ })
+ .collect()
})
- }
+ })
}
pub fn fuzzy_search_users(
@@ -543,7 +551,7 @@ impl UserStore {
self.load_users(proto::FuzzySearchUsers { query }, cx)
}
- pub fn fetch_user(
+ pub fn get_user(
&mut self,
user_id: u64,
cx: &mut ModelContext<Self>,
@@ -623,39 +631,15 @@ impl Contact {
) -> Result<Self> {
let user = user_store
.update(cx, |user_store, cx| {
- user_store.fetch_user(contact.user_id, cx)
+ user_store.get_user(contact.user_id, cx)
})
.await?;
- let mut projects = Vec::new();
- for project in contact.projects {
- let mut guests = BTreeSet::new();
- for participant_id in project.guests {
- guests.insert(
- user_store
- .update(cx, |user_store, cx| {
- user_store.fetch_user(participant_id, cx)
- })
- .await?,
- );
- }
- projects.push(ProjectMetadata {
- id: project.id,
- visible_worktree_root_names: project.visible_worktree_root_names.clone(),
- guests,
- });
- }
Ok(Self {
user,
online: contact.online,
- projects,
+ busy: contact.busy,
})
}
-
- pub fn non_empty_projects(&self) -> impl Iterator<Item = &ProjectMetadata> {
- self.projects
- .iter()
- .filter(|project| !project.visible_worktree_root_names.is_empty())
- }
}
async fn fetch_avatar(http: &dyn HttpClient, url: &str) -> Result<Arc<ImageData>> {
@@ -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"] }
@@ -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,
);
}
}
@@ -1098,10 +1098,7 @@ impl Db for PostgresDb {
.bind(user_id)
.fetch(&self.pool);
- let mut contacts = vec![Contact::Accepted {
- user_id,
- should_notify: false,
- }];
+ let mut contacts = Vec::new();
while let Some(row) = rows.next().await {
let (user_id_a, user_id_b, a_to_b, accepted, should_notify) = row?;
@@ -2080,10 +2077,7 @@ mod test {
async fn get_contacts(&self, id: UserId) -> Result<Vec<Contact>> {
self.background.simulate_random_delay().await;
- let mut contacts = vec![Contact::Accepted {
- user_id: id,
- should_notify: false,
- }];
+ let mut contacts = Vec::new();
for contact in self.contacts.lock().iter() {
if contact.requester_id == id {
@@ -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,
@@ -5,9 +5,10 @@ use crate::{
};
use ::rpc::Peer;
use anyhow::anyhow;
+use call::{room, ActiveCall, ParticipantLocation, Room};
use client::{
- self, proto, test::FakeHttpClient, Channel, ChannelDetails, ChannelList, Client, Connection,
- Credentials, EstablishConnectionError, ProjectMetadata, UserStore, RECEIVE_TIMEOUT,
+ self, test::FakeHttpClient, Channel, ChannelDetails, ChannelList, Client, Connection,
+ Credentials, EstablishConnectionError, User, UserStore, RECEIVE_TIMEOUT,
};
use collections::{BTreeMap, HashMap, HashSet};
use editor::{
@@ -39,8 +40,8 @@ use serde_json::json;
use settings::{Formatter, Settings};
use sqlx::types::time::OffsetDateTime;
use std::{
- cell::RefCell,
- env,
+ cell::{Cell, RefCell},
+ env, mem,
ops::Deref,
path::{Path, PathBuf},
rc::Rc,
@@ -62,20 +63,487 @@ fn init_logger() {
}
#[gpui::test(iterations = 10)]
-async fn test_share_project(
+async fn test_basic_calls(
deterministic: Arc<Deterministic>,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
cx_b2: &mut TestAppContext,
+ cx_c: &mut TestAppContext,
) {
- cx_a.foreground().forbid_parking();
+ deterministic.forbid_parking();
+ let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+ let client_c = server.create_client(cx_c, "user_c").await;
+ server
+ .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
+ .await;
+
+ let active_call_a = cx_a.read(ActiveCall::global);
+ let active_call_b = cx_b.read(ActiveCall::global);
+ let active_call_c = cx_c.read(ActiveCall::global);
+
+ // Call user B from client A.
+ active_call_a
+ .update(cx_a, |call, cx| {
+ call.invite(client_b.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap();
+ let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
+ deterministic.run_until_parked();
+ assert_eq!(
+ room_participants(&room_a, cx_a),
+ RoomParticipants {
+ remote: Default::default(),
+ pending: vec!["user_b".to_string()]
+ }
+ );
+
+ // User B receives the call.
+ let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
+ let call_b = incoming_call_b.next().await.unwrap().unwrap();
+ assert_eq!(call_b.caller.github_login, "user_a");
+
+ // User B connects via another client and also receives a ring on the newly-connected client.
+ let _client_b2 = server.create_client(cx_b2, "user_b").await;
+ let active_call_b2 = cx_b2.read(ActiveCall::global);
+ let mut incoming_call_b2 = active_call_b2.read_with(cx_b2, |call, _| call.incoming());
+ deterministic.run_until_parked();
+ let call_b2 = incoming_call_b2.next().await.unwrap().unwrap();
+ assert_eq!(call_b2.caller.github_login, "user_a");
+
+ // User B joins the room using the first client.
+ active_call_b
+ .update(cx_b, |call, cx| call.accept_incoming(cx))
+ .await
+ .unwrap();
+ let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
+ assert!(incoming_call_b.next().await.unwrap().is_none());
+
+ deterministic.run_until_parked();
+ assert_eq!(
+ room_participants(&room_a, cx_a),
+ RoomParticipants {
+ remote: vec!["user_b".to_string()],
+ pending: Default::default()
+ }
+ );
+ assert_eq!(
+ room_participants(&room_b, cx_b),
+ RoomParticipants {
+ remote: vec!["user_a".to_string()],
+ pending: Default::default()
+ }
+ );
+
+ // Call user C from client B.
+ let mut incoming_call_c = active_call_c.read_with(cx_c, |call, _| call.incoming());
+ active_call_b
+ .update(cx_b, |call, cx| {
+ call.invite(client_c.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+ assert_eq!(
+ room_participants(&room_a, cx_a),
+ RoomParticipants {
+ remote: vec!["user_b".to_string()],
+ pending: vec!["user_c".to_string()]
+ }
+ );
+ assert_eq!(
+ room_participants(&room_b, cx_b),
+ RoomParticipants {
+ remote: vec!["user_a".to_string()],
+ pending: vec!["user_c".to_string()]
+ }
+ );
+
+ // User C receives the call, but declines it.
+ let call_c = incoming_call_c.next().await.unwrap().unwrap();
+ assert_eq!(call_c.caller.github_login, "user_b");
+ active_call_c.update(cx_c, |call, _| call.decline_incoming().unwrap());
+ assert!(incoming_call_c.next().await.unwrap().is_none());
+
+ deterministic.run_until_parked();
+ assert_eq!(
+ room_participants(&room_a, cx_a),
+ RoomParticipants {
+ remote: vec!["user_b".to_string()],
+ pending: Default::default()
+ }
+ );
+ assert_eq!(
+ room_participants(&room_b, cx_b),
+ RoomParticipants {
+ remote: vec!["user_a".to_string()],
+ pending: Default::default()
+ }
+ );
+
+ // User A leaves the room.
+ active_call_a.update(cx_a, |call, cx| {
+ call.hang_up(cx).unwrap();
+ assert!(call.room().is_none());
+ });
+ deterministic.run_until_parked();
+ assert_eq!(
+ room_participants(&room_a, cx_a),
+ RoomParticipants {
+ remote: Default::default(),
+ pending: Default::default()
+ }
+ );
+ assert_eq!(
+ room_participants(&room_b, cx_b),
+ RoomParticipants {
+ remote: Default::default(),
+ pending: Default::default()
+ }
+ );
+
+ // User B leaves the room.
+ active_call_b.update(cx_b, |call, cx| {
+ call.hang_up(cx).unwrap();
+ assert!(call.room().is_none());
+ });
+ deterministic.run_until_parked();
+ assert_eq!(
+ room_participants(&room_a, cx_a),
+ RoomParticipants {
+ remote: Default::default(),
+ pending: Default::default()
+ }
+ );
+ assert_eq!(
+ room_participants(&room_b, cx_b),
+ RoomParticipants {
+ remote: Default::default(),
+ pending: Default::default()
+ }
+ );
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_room_uniqueness(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_a2: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+ cx_b2: &mut TestAppContext,
+ cx_c: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+ let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let _client_a2 = server.create_client(cx_a2, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+ let _client_b2 = server.create_client(cx_b2, "user_b").await;
+ let client_c = server.create_client(cx_c, "user_c").await;
+ server
+ .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
+ .await;
+
+ let active_call_a = cx_a.read(ActiveCall::global);
+ let active_call_a2 = cx_a2.read(ActiveCall::global);
+ let active_call_b = cx_b.read(ActiveCall::global);
+ let active_call_b2 = cx_b2.read(ActiveCall::global);
+ let active_call_c = cx_c.read(ActiveCall::global);
+
+ // Call user B from client A.
+ active_call_a
+ .update(cx_a, |call, cx| {
+ call.invite(client_b.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap();
+
+ // Ensure a new room can't be created given user A just created one.
+ active_call_a2
+ .update(cx_a2, |call, cx| {
+ call.invite(client_c.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap_err();
+ active_call_a2.read_with(cx_a2, |call, _| assert!(call.room().is_none()));
+
+ // User B receives the call from user A.
+ let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
+ let call_b1 = incoming_call_b.next().await.unwrap().unwrap();
+ assert_eq!(call_b1.caller.github_login, "user_a");
+
+ // Ensure calling users A and B from client C fails.
+ active_call_c
+ .update(cx_c, |call, cx| {
+ call.invite(client_a.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap_err();
+ active_call_c
+ .update(cx_c, |call, cx| {
+ call.invite(client_b.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap_err();
+
+ // Ensure User B can't create a room while they still have an incoming call.
+ active_call_b2
+ .update(cx_b2, |call, cx| {
+ call.invite(client_c.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap_err();
+ active_call_b2.read_with(cx_b2, |call, _| assert!(call.room().is_none()));
+
+ // User B joins the room and calling them after they've joined still fails.
+ active_call_b
+ .update(cx_b, |call, cx| call.accept_incoming(cx))
+ .await
+ .unwrap();
+ active_call_c
+ .update(cx_c, |call, cx| {
+ call.invite(client_b.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap_err();
+
+ // Ensure User B can't create a room while they belong to another room.
+ active_call_b2
+ .update(cx_b2, |call, cx| {
+ call.invite(client_c.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap_err();
+ active_call_b2.read_with(cx_b2, |call, _| assert!(call.room().is_none()));
+
+ // Client C can successfully call client B after client B leaves the room.
+ active_call_b
+ .update(cx_b, |call, cx| call.hang_up(cx))
+ .unwrap();
+ deterministic.run_until_parked();
+ active_call_c
+ .update(cx_c, |call, cx| {
+ call.invite(client_b.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap();
+ deterministic.run_until_parked();
+ let call_b2 = incoming_call_b.next().await.unwrap().unwrap();
+ assert_eq!(call_b2.caller.github_login, "user_c");
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_leaving_room_on_disconnection(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+ let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+ server
+ .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+ .await;
+
+ let active_call_a = cx_a.read(ActiveCall::global);
+ let active_call_b = cx_b.read(ActiveCall::global);
+
+ // Call user B from client A.
+ active_call_a
+ .update(cx_a, |call, cx| {
+ call.invite(client_b.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap();
+ let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
+
+ // User B receives the call and joins the room.
+ let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
+ incoming_call_b.next().await.unwrap().unwrap();
+ active_call_b
+ .update(cx_b, |call, cx| call.accept_incoming(cx))
+ .await
+ .unwrap();
+ let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
+ deterministic.run_until_parked();
+ assert_eq!(
+ room_participants(&room_a, cx_a),
+ RoomParticipants {
+ remote: vec!["user_b".to_string()],
+ pending: Default::default()
+ }
+ );
+ assert_eq!(
+ room_participants(&room_b, cx_b),
+ RoomParticipants {
+ remote: vec!["user_a".to_string()],
+ pending: Default::default()
+ }
+ );
+
+ // When user A disconnects, both client A and B clear their room on the active call.
+ server.disconnect_client(client_a.current_user_id(cx_a));
+ cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
+ active_call_a.read_with(cx_a, |call, _| assert!(call.room().is_none()));
+ active_call_b.read_with(cx_b, |call, _| assert!(call.room().is_none()));
+ assert_eq!(
+ room_participants(&room_a, cx_a),
+ RoomParticipants {
+ remote: Default::default(),
+ pending: Default::default()
+ }
+ );
+ assert_eq!(
+ room_participants(&room_b, cx_b),
+ RoomParticipants {
+ remote: Default::default(),
+ pending: Default::default()
+ }
+ );
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_calls_on_multiple_connections(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b1: &mut TestAppContext,
+ cx_b2: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+ let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b1 = server.create_client(cx_b1, "user_b").await;
+ let _client_b2 = server.create_client(cx_b2, "user_b").await;
+ server
+ .make_contacts(&mut [(&client_a, cx_a), (&client_b1, cx_b1)])
+ .await;
+
+ let active_call_a = cx_a.read(ActiveCall::global);
+ let active_call_b1 = cx_b1.read(ActiveCall::global);
+ let active_call_b2 = cx_b2.read(ActiveCall::global);
+ let mut incoming_call_b1 = active_call_b1.read_with(cx_b1, |call, _| call.incoming());
+ let mut incoming_call_b2 = active_call_b2.read_with(cx_b2, |call, _| call.incoming());
+ assert!(incoming_call_b1.next().await.unwrap().is_none());
+ assert!(incoming_call_b2.next().await.unwrap().is_none());
+
+ // Call user B from client A, ensuring both clients for user B ring.
+ active_call_a
+ .update(cx_a, |call, cx| {
+ call.invite(client_b1.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap();
+ deterministic.run_until_parked();
+ assert!(incoming_call_b1.next().await.unwrap().is_some());
+ assert!(incoming_call_b2.next().await.unwrap().is_some());
+
+ // User B declines the call on one of the two connections, causing both connections
+ // to stop ringing.
+ active_call_b2.update(cx_b2, |call, _| call.decline_incoming().unwrap());
+ deterministic.run_until_parked();
+ assert!(incoming_call_b1.next().await.unwrap().is_none());
+ assert!(incoming_call_b2.next().await.unwrap().is_none());
+
+ // Call user B again from client A.
+ active_call_a
+ .update(cx_a, |call, cx| {
+ call.invite(client_b1.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap();
+ deterministic.run_until_parked();
+ assert!(incoming_call_b1.next().await.unwrap().is_some());
+ assert!(incoming_call_b2.next().await.unwrap().is_some());
+
+ // User B accepts the call on one of the two connections, causing both connections
+ // to stop ringing.
+ active_call_b2
+ .update(cx_b2, |call, cx| call.accept_incoming(cx))
+ .await
+ .unwrap();
+ deterministic.run_until_parked();
+ assert!(incoming_call_b1.next().await.unwrap().is_none());
+ assert!(incoming_call_b2.next().await.unwrap().is_none());
+
+ // User B hangs up, and user A calls them again.
+ active_call_b2.update(cx_b2, |call, cx| call.hang_up(cx).unwrap());
+ deterministic.run_until_parked();
+ active_call_a
+ .update(cx_a, |call, cx| {
+ call.invite(client_b1.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap();
+ deterministic.run_until_parked();
+ assert!(incoming_call_b1.next().await.unwrap().is_some());
+ assert!(incoming_call_b2.next().await.unwrap().is_some());
+
+ // User A cancels the call, causing both connections to stop ringing.
+ active_call_a
+ .update(cx_a, |call, cx| {
+ call.cancel_invite(client_b1.user_id().unwrap(), cx)
+ })
+ .await
+ .unwrap();
+ deterministic.run_until_parked();
+ assert!(incoming_call_b1.next().await.unwrap().is_none());
+ assert!(incoming_call_b2.next().await.unwrap().is_none());
+
+ // User A calls user B again.
+ active_call_a
+ .update(cx_a, |call, cx| {
+ call.invite(client_b1.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap();
+ deterministic.run_until_parked();
+ assert!(incoming_call_b1.next().await.unwrap().is_some());
+ assert!(incoming_call_b2.next().await.unwrap().is_some());
+
+ // User A hangs up, causing both connections to stop ringing.
+ active_call_a.update(cx_a, |call, cx| call.hang_up(cx).unwrap());
+ deterministic.run_until_parked();
+ assert!(incoming_call_b1.next().await.unwrap().is_none());
+ assert!(incoming_call_b2.next().await.unwrap().is_none());
+
+ // User A calls user B again.
+ active_call_a
+ .update(cx_a, |call, cx| {
+ call.invite(client_b1.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap();
+ deterministic.run_until_parked();
+ assert!(incoming_call_b1.next().await.unwrap().is_some());
+ assert!(incoming_call_b2.next().await.unwrap().is_some());
+
+ // User A disconnects up, causing both connections to stop ringing.
+ server.disconnect_client(client_a.current_user_id(cx_a));
+ cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
+ assert!(incoming_call_b1.next().await.unwrap().is_none());
+ assert!(incoming_call_b2.next().await.unwrap().is_none());
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_share_project(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
let (_, window_b) = cx_b.add_window(|_| EmptyView);
let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
- .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+ .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
+ let active_call_a = cx_a.read(ActiveCall::global);
+ let active_call_b = cx_b.read(ActiveCall::global);
client_a
.fs
@@ -93,30 +561,35 @@ async fn test_share_project(
)
.await;
+ // Invite client B to collaborate on a project
let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
- let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
+ active_call_a
+ .update(cx_a, |call, cx| {
+ call.invite(client_b.user_id().unwrap(), Some(project_a.clone()), cx)
+ })
+ .await
+ .unwrap();
// Join that project as client B
+ let incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
+ deterministic.run_until_parked();
+ let call = incoming_call_b.borrow().clone().unwrap();
+ assert_eq!(call.caller.github_login, "user_a");
+ let initial_project = call.initial_project.unwrap();
+ active_call_b
+ .update(cx_b, |call, cx| call.accept_incoming(cx))
+ .await
+ .unwrap();
let client_b_peer_id = client_b.peer_id;
- let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
- let replica_id_b = project_b.read_with(cx_b, |project, _| {
- assert_eq!(
- project
- .collaborators()
- .get(&client_a.peer_id)
- .unwrap()
- .user
- .github_login,
- "user_a"
- );
- project.replica_id()
- });
+ let project_b = client_b
+ .build_remote_project(initial_project.id, cx_b)
+ .await;
+ let replica_id_b = project_b.read_with(cx_b, |project, _| project.replica_id());
deterministic.run_until_parked();
project_a.read_with(cx_a, |project, _| {
let client_b_collaborator = project.collaborators().get(&client_b_peer_id).unwrap();
assert_eq!(client_b_collaborator.replica_id, replica_id_b);
- assert_eq!(client_b_collaborator.user.github_login, "user_b");
});
project_b.read_with(cx_b, |project, cx| {
let worktree = project.worktrees(cx).next().unwrap().read(cx);
@@ -167,40 +640,6 @@ async fn test_share_project(
// buffer_a
// .condition(&cx_a, |buffer, _| buffer.selection_sets().count() == 0)
// .await;
-
- // Client B can join again on a different window because they are already a participant.
- let client_b2 = server.create_client(cx_b2, "user_b").await;
- let project_b2 = Project::remote(
- project_id,
- client_b2.client.clone(),
- client_b2.user_store.clone(),
- client_b2.project_store.clone(),
- client_b2.language_registry.clone(),
- FakeFs::new(cx_b2.background()),
- cx_b2.to_async(),
- )
- .await
- .unwrap();
- deterministic.run_until_parked();
- project_a.read_with(cx_a, |project, _| {
- assert_eq!(project.collaborators().len(), 2);
- });
- project_b.read_with(cx_b, |project, _| {
- assert_eq!(project.collaborators().len(), 2);
- });
- project_b2.read_with(cx_b2, |project, _| {
- assert_eq!(project.collaborators().len(), 2);
- });
-
- // Dropping client B's first project removes only that from client A's collaborators.
- cx_b.update(move |_| drop(project_b));
- deterministic.run_until_parked();
- project_a.read_with(cx_a, |project, _| {
- assert_eq!(project.collaborators().len(), 1);
- });
- project_b2.read_with(cx_b2, |project, _| {
- assert_eq!(project.collaborators().len(), 1);
- });
}
#[gpui::test(iterations = 10)]
@@ -208,15 +647,20 @@ async fn test_unshare_project(
deterministic: Arc<Deterministic>,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
+ cx_c: &mut TestAppContext,
) {
- cx_a.foreground().forbid_parking();
+ deterministic.forbid_parking();
let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
+ let client_c = server.create_client(cx_c, "user_c").await;
server
- .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+ .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
.await;
+ let active_call_a = cx_a.read(ActiveCall::global);
+ let active_call_b = cx_b.read(ActiveCall::global);
+
client_a
.fs
.insert_tree(
@@ -229,8 +673,12 @@ async fn test_unshare_project(
.await;
let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
+ let project_id = active_call_a
+ .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+ .await
+ .unwrap();
let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap());
- let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
+ let project_b = client_b.build_remote_project(project_id, cx_b).await;
assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
project_b
@@ -238,23 +686,39 @@ async fn test_unshare_project(
.await
.unwrap();
- // When client B leaves the project, it gets automatically unshared.
- cx_b.update(|_| drop(project_b));
+ // When client B leaves the room, the project becomes read-only.
+ active_call_b.update(cx_b, |call, cx| call.hang_up(cx).unwrap());
+ deterministic.run_until_parked();
+ assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
+
+ // Client C opens the project.
+ let project_c = client_c.build_remote_project(project_id, cx_c).await;
+
+ // When client A unshares the project, client C's project becomes read-only.
+ project_a
+ .update(cx_a, |project, cx| project.unshare(cx))
+ .unwrap();
deterministic.run_until_parked();
assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared()));
+ assert!(project_c.read_with(cx_c, |project, _| project.is_read_only()));
- // When client B joins again, the project gets re-shared.
- let project_b2 = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
+ // Client C can open the project again after client A re-shares.
+ let project_id = active_call_a
+ .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+ .await
+ .unwrap();
+ let project_c2 = client_c.build_remote_project(project_id, cx_c).await;
assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
- project_b2
- .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
+ project_c2
+ .update(cx_c, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
.await
.unwrap();
- // When client A (the host) leaves, the project gets unshared and guests are notified.
- cx_a.update(|_| drop(project_a));
+ // When client A (the host) leaves the room, the project gets unshared and guests are notified.
+ active_call_a.update(cx_a, |call, cx| call.hang_up(cx).unwrap());
deterministic.run_until_parked();
- project_b2.read_with(cx_b, |project, _| {
+ project_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
+ project_c2.read_with(cx_c, |project, _| {
assert!(project.is_read_only());
assert!(project.collaborators().is_empty());
});
@@ -274,11 +738,7 @@ async fn test_host_disconnect(
let client_b = server.create_client(cx_b, "user_b").await;
let client_c = server.create_client(cx_c, "user_c").await;
server
- .make_contacts(vec![
- (&client_a, cx_a),
- (&client_b, cx_b),
- (&client_c, cx_c),
- ])
+ .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
.await;
client_a
@@ -292,11 +752,15 @@ async fn test_host_disconnect(
)
.await;
+ let active_call_a = cx_a.read(ActiveCall::global);
let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap());
- let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
+ let project_id = active_call_a
+ .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+ .await
+ .unwrap();
- let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
+ let project_b = client_b.build_remote_project(project_id, cx_b).await;
assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
let (_, workspace_b) =
@@ -318,20 +782,6 @@ async fn test_host_disconnect(
editor_b.update(cx_b, |editor, cx| editor.insert("X", cx));
assert!(cx_b.is_window_edited(workspace_b.window_id()));
- // Request to join that project as client C
- let project_c = cx_c.spawn(|cx| {
- Project::remote(
- project_id,
- client_c.client.clone(),
- client_c.user_store.clone(),
- client_c.project_store.clone(),
- client_c.language_registry.clone(),
- FakeFs::new(cx.background()),
- cx,
- )
- });
- deterministic.run_until_parked();
-
// Drop client A's connection. Collaborators should disappear and the project should not be shown as shared.
server.disconnect_client(client_a.current_user_id(cx_a));
cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
@@ -343,10 +793,6 @@ async fn test_host_disconnect(
.condition(cx_b, |project, _| project.is_read_only())
.await;
assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared()));
- assert!(matches!(
- project_c.await.unwrap_err(),
- project::JoinProjectError::HostWentOffline
- ));
// Ensure client B's edited state is reset and that the whole window is blurred.
cx_b.read(|cx| {
@@ -355,447 +801,265 @@ async fn test_host_disconnect(
assert!(!cx_b.is_window_edited(workspace_b.window_id()));
// Ensure client B is not prompted to save edits when closing window after disconnecting.
- workspace_b
- .update(cx_b, |workspace, cx| {
- workspace.close(&Default::default(), cx)
- })
- .unwrap()
- .await
- .unwrap();
- assert_eq!(cx_b.window_ids().len(), 0);
- cx_b.update(|_| {
- drop(workspace_b);
- drop(project_b);
- });
-
- // Ensure guests can still join.
- let project_b2 = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
- assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
- project_b2
- .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
+ let can_close = workspace_b
+ .update(cx_b, |workspace, cx| workspace.prepare_to_close(true, cx))
.await
.unwrap();
+ assert!(can_close);
}
#[gpui::test(iterations = 10)]
-async fn test_decline_join_request(
+async fn test_active_call_events(
deterministic: Arc<Deterministic>,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
- cx_a.foreground().forbid_parking();
+ deterministic.forbid_parking();
let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
- server
- .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
- .await;
-
client_a.fs.insert_tree("/a", json!({})).await;
+ client_b.fs.insert_tree("/b", json!({})).await;
let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
- let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
+ let (project_b, _) = client_b.build_local_project("/b", cx_b).await;
- // Request to join that project as client B
- let project_b = cx_b.spawn(|cx| {
- Project::remote(
- project_id,
- client_b.client.clone(),
- client_b.user_store.clone(),
- client_b.project_store.clone(),
- client_b.language_registry.clone(),
- FakeFs::new(cx.background()),
- cx,
- )
- });
- deterministic.run_until_parked();
- project_a.update(cx_a, |project, cx| {
- project.respond_to_join_request(client_b.user_id().unwrap(), false, cx)
- });
- assert!(matches!(
- project_b.await.unwrap_err(),
- project::JoinProjectError::HostDeclined
- ));
-
- // Request to join the project again as client B
- let project_b = cx_b.spawn(|cx| {
- Project::remote(
- project_id,
- client_b.client.clone(),
- client_b.user_store.clone(),
- client_b.project_store.clone(),
- client_b.language_registry.clone(),
- FakeFs::new(cx.background()),
- cx,
- )
- });
-
- // Close the project on the host
- deterministic.run_until_parked();
- cx_a.update(|_| drop(project_a));
- deterministic.run_until_parked();
- assert!(matches!(
- project_b.await.unwrap_err(),
- project::JoinProjectError::HostClosedProject
- ));
-}
-
-#[gpui::test(iterations = 10)]
-async fn test_cancel_join_request(
- deterministic: Arc<Deterministic>,
- cx_a: &mut TestAppContext,
- cx_b: &mut TestAppContext,
-) {
- cx_a.foreground().forbid_parking();
- let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
- let client_a = server.create_client(cx_a, "user_a").await;
- let client_b = server.create_client(cx_b, "user_b").await;
server
- .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+ .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
+ let active_call_a = cx_a.read(ActiveCall::global);
+ let active_call_b = cx_b.read(ActiveCall::global);
- client_a.fs.insert_tree("/a", json!({})).await;
- let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
- let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
+ let events_a = active_call_events(cx_a);
+ let events_b = active_call_events(cx_b);
- let user_b = client_a
- .user_store
- .update(cx_a, |store, cx| {
- store.fetch_user(client_b.user_id().unwrap(), cx)
- })
+ let project_a_id = active_call_a
+ .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
-
- let project_a_events = Rc::new(RefCell::new(Vec::new()));
- project_a.update(cx_a, {
- let project_a_events = project_a_events.clone();
- move |_, cx| {
- cx.subscribe(&cx.handle(), move |_, _, event, _| {
- project_a_events.borrow_mut().push(event.clone());
- })
- .detach();
- }
- });
-
- // Request to join that project as client B
- let project_b = cx_b.spawn(|cx| {
- Project::remote(
- project_id,
- client_b.client.clone(),
- client_b.user_store.clone(),
- client_b.project_store.clone(),
- client_b.language_registry.clone(),
- FakeFs::new(cx.background()),
- cx,
- )
- });
deterministic.run_until_parked();
+ assert_eq!(mem::take(&mut *events_a.borrow_mut()), vec![]);
assert_eq!(
- &*project_a_events.borrow(),
- &[project::Event::ContactRequestedJoin(user_b.clone())]
+ mem::take(&mut *events_b.borrow_mut()),
+ vec![room::Event::RemoteProjectShared {
+ owner: Arc::new(User {
+ id: client_a.user_id().unwrap(),
+ github_login: "user_a".to_string(),
+ avatar: None,
+ }),
+ project_id: project_a_id,
+ worktree_root_names: vec!["a".to_string()],
+ }]
);
- project_a_events.borrow_mut().clear();
- // Cancel the join request by leaving the project
- client_b
- .client
- .send(proto::LeaveProject { project_id })
+ let project_b_id = active_call_b
+ .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx))
+ .await
.unwrap();
- drop(project_b);
+ deterministic.run_until_parked();
+ assert_eq!(
+ mem::take(&mut *events_a.borrow_mut()),
+ vec![room::Event::RemoteProjectShared {
+ owner: Arc::new(User {
+ id: client_b.user_id().unwrap(),
+ github_login: "user_b".to_string(),
+ avatar: None,
+ }),
+ project_id: project_b_id,
+ worktree_root_names: vec!["b".to_string()]
+ }]
+ );
+ assert_eq!(mem::take(&mut *events_b.borrow_mut()), vec![]);
+ // Sharing a project twice is idempotent.
+ let project_b_id_2 = active_call_b
+ .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx))
+ .await
+ .unwrap();
+ assert_eq!(project_b_id_2, project_b_id);
deterministic.run_until_parked();
- assert_eq!(
- &*project_a_events.borrow(),
- &[project::Event::ContactCancelledJoinRequest(user_b)]
- );
+ assert_eq!(mem::take(&mut *events_a.borrow_mut()), vec![]);
+ assert_eq!(mem::take(&mut *events_b.borrow_mut()), vec![]);
+
+ fn active_call_events(cx: &mut TestAppContext) -> Rc<RefCell<Vec<room::Event>>> {
+ let events = Rc::new(RefCell::new(Vec::new()));
+ let active_call = cx.read(ActiveCall::global);
+ cx.update({
+ let events = events.clone();
+ |cx| {
+ cx.subscribe(&active_call, move |_, event, _| {
+ events.borrow_mut().push(event.clone())
+ })
+ .detach()
+ }
+ });
+ events
+ }
}
#[gpui::test(iterations = 10)]
-async fn test_offline_projects(
+async fn test_room_location(
deterministic: Arc<Deterministic>,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
- cx_c: &mut TestAppContext,
) {
- cx_a.foreground().forbid_parking();
+ deterministic.forbid_parking();
let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
- let client_c = server.create_client(cx_c, "user_c").await;
- let user_a = UserId::from_proto(client_a.user_id().unwrap());
- server
- .make_contacts(vec![
- (&client_a, cx_a),
- (&client_b, cx_b),
- (&client_c, cx_c),
- ])
- .await;
-
- // Set up observers of the project and user stores. Any time either of
- // these models update, they should be in a consistent state with each
- // other. There should not be an observable moment where the current
- // user's contact entry contains a project that does not match one of
- // the current open projects. That would cause a duplicate entry to be
- // shown in the contacts panel.
- let mut subscriptions = vec![];
- let (window_id, view) = cx_a.add_window(|cx| {
- subscriptions.push(cx.observe(&client_a.user_store, {
- let project_store = client_a.project_store.clone();
- let user_store = client_a.user_store.clone();
- move |_, _, cx| check_project_list(project_store.clone(), user_store.clone(), cx)
- }));
-
- subscriptions.push(cx.observe(&client_a.project_store, {
- let project_store = client_a.project_store.clone();
- let user_store = client_a.user_store.clone();
- move |_, _, cx| check_project_list(project_store.clone(), user_store.clone(), cx)
- }));
-
- fn check_project_list(
- project_store: ModelHandle<ProjectStore>,
- user_store: ModelHandle<UserStore>,
- cx: &mut gpui::MutableAppContext,
- ) {
- let user_store = user_store.read(cx);
- for contact in user_store.contacts() {
- if contact.user.id == user_store.current_user().unwrap().id {
- for project in &contact.projects {
- let store_contains_project = project_store
- .read(cx)
- .projects(cx)
- .filter_map(|project| project.read(cx).remote_id())
- .any(|x| x == project.id);
-
- if !store_contains_project {
- panic!(
- concat!(
- "current user's contact data has a project",
- "that doesn't match any open project {:?}",
- ),
- project
- );
- }
- }
- }
- }
- }
+ client_a.fs.insert_tree("/a", json!({})).await;
+ client_b.fs.insert_tree("/b", json!({})).await;
- EmptyView
- });
+ let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
+ let (project_b, _) = client_b.build_local_project("/b", cx_b).await;
- // Build an offline project with two worktrees.
- client_a
- .fs
- .insert_tree(
- "/code",
- json!({
- "crate1": { "a.rs": "" },
- "crate2": { "b.rs": "" },
- }),
- )
+ server
+ .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
- let project = cx_a.update(|cx| {
- Project::local(
- false,
- client_a.client.clone(),
- client_a.user_store.clone(),
- client_a.project_store.clone(),
- client_a.language_registry.clone(),
- client_a.fs.clone(),
- cx,
- )
- });
- project
- .update(cx_a, |p, cx| {
- p.find_or_create_local_worktree("/code/crate1", true, cx)
- })
- .await
- .unwrap();
- project
- .update(cx_a, |p, cx| {
- p.find_or_create_local_worktree("/code/crate2", true, cx)
- })
- .await
- .unwrap();
- project
- .update(cx_a, |p, cx| p.restore_state(cx))
- .await
- .unwrap();
-
- // When a project is offline, we still create it on the server but is invisible
- // to other users.
- deterministic.run_until_parked();
- assert!(server
- .store
- .lock()
- .await
- .project_metadata_for_user(user_a)
- .is_empty());
- project.read_with(cx_a, |project, _| {
- assert!(project.remote_id().is_some());
- assert!(!project.is_online());
- });
- assert!(client_b
- .user_store
- .read_with(cx_b, |store, _| { store.contacts()[0].projects.is_empty() }));
- // When the project is taken online, its metadata is sent to the server
- // and broadcasted to other users.
- project.update(cx_a, |p, cx| p.set_online(true, cx));
- deterministic.run_until_parked();
- let project_id = project.read_with(cx_a, |p, _| p.remote_id()).unwrap();
- client_b.user_store.read_with(cx_b, |store, _| {
- assert_eq!(
- store.contacts()[0].projects,
- &[ProjectMetadata {
- id: project_id,
- visible_worktree_root_names: vec!["crate1".into(), "crate2".into()],
- guests: Default::default(),
- }]
- );
+ let active_call_a = cx_a.read(ActiveCall::global);
+ let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
+ let a_notified = Rc::new(Cell::new(false));
+ cx_a.update({
+ let notified = a_notified.clone();
+ |cx| {
+ cx.observe(&active_call_a, move |_, _| notified.set(true))
+ .detach()
+ }
});
- // The project is registered again when the host loses and regains connection.
- server.disconnect_client(user_a);
- server.forbid_connections();
- cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
- assert!(server
- .store
- .lock()
- .await
- .project_metadata_for_user(user_a)
- .is_empty());
- assert!(project.read_with(cx_a, |p, _| p.remote_id().is_none()));
- assert!(client_b
- .user_store
- .read_with(cx_b, |store, _| { store.contacts()[0].projects.is_empty() }));
-
- server.allow_connections();
- cx_b.foreground().advance_clock(Duration::from_secs(10));
- let project_id = project.read_with(cx_a, |p, _| p.remote_id()).unwrap();
- client_b.user_store.read_with(cx_b, |store, _| {
- assert_eq!(
- store.contacts()[0].projects,
- &[ProjectMetadata {
- id: project_id,
- visible_worktree_root_names: vec!["crate1".into(), "crate2".into()],
- guests: Default::default(),
- }]
- );
+ let active_call_b = cx_b.read(ActiveCall::global);
+ let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
+ let b_notified = Rc::new(Cell::new(false));
+ cx_b.update({
+ let b_notified = b_notified.clone();
+ |cx| {
+ cx.observe(&active_call_b, move |_, _| b_notified.set(true))
+ .detach()
+ }
});
- project
- .update(cx_a, |p, cx| {
- p.find_or_create_local_worktree("/code/crate3", true, cx)
- })
+ room_a
+ .update(cx_a, |room, cx| room.set_location(Some(&project_a), cx))
.await
.unwrap();
deterministic.run_until_parked();
- client_b.user_store.read_with(cx_b, |store, _| {
- assert_eq!(
- store.contacts()[0].projects,
- &[ProjectMetadata {
- id: project_id,
- visible_worktree_root_names: vec![
- "crate1".into(),
- "crate2".into(),
- "crate3".into()
- ],
- guests: Default::default(),
- }]
- );
- });
+ assert!(a_notified.take());
+ assert_eq!(
+ participant_locations(&room_a, cx_a),
+ vec![("user_b".to_string(), ParticipantLocation::External)]
+ );
+ assert!(b_notified.take());
+ assert_eq!(
+ participant_locations(&room_b, cx_b),
+ vec![("user_a".to_string(), ParticipantLocation::UnsharedProject)]
+ );
- // Build another project using a directory which was previously part of
- // an online project. Restore the project's state from the host's database.
- let project2_a = cx_a.update(|cx| {
- Project::local(
- false,
- client_a.client.clone(),
- client_a.user_store.clone(),
- client_a.project_store.clone(),
- client_a.language_registry.clone(),
- client_a.fs.clone(),
- cx,
- )
- });
- project2_a
- .update(cx_a, |p, cx| {
- p.find_or_create_local_worktree("/code/crate3", true, cx)
- })
+ let project_a_id = active_call_a
+ .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
- project2_a
- .update(cx_a, |project, cx| project.restore_state(cx))
+ deterministic.run_until_parked();
+ assert!(a_notified.take());
+ assert_eq!(
+ participant_locations(&room_a, cx_a),
+ vec![("user_b".to_string(), ParticipantLocation::External)]
+ );
+ assert!(b_notified.take());
+ assert_eq!(
+ participant_locations(&room_b, cx_b),
+ vec![(
+ "user_a".to_string(),
+ ParticipantLocation::SharedProject {
+ project_id: project_a_id
+ }
+ )]
+ );
+
+ let project_b_id = active_call_b
+ .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx))
.await
.unwrap();
-
- // This project is now online, because its directory was previously online.
- project2_a.read_with(cx_a, |project, _| assert!(project.is_online()));
deterministic.run_until_parked();
- let project2_id = project2_a.read_with(cx_a, |p, _| p.remote_id()).unwrap();
- client_b.user_store.read_with(cx_b, |store, _| {
- assert_eq!(
- store.contacts()[0].projects,
- &[
- ProjectMetadata {
- id: project_id,
- visible_worktree_root_names: vec![
- "crate1".into(),
- "crate2".into(),
- "crate3".into()
- ],
- guests: Default::default(),
- },
- ProjectMetadata {
- id: project2_id,
- visible_worktree_root_names: vec!["crate3".into()],
- guests: Default::default(),
- }
- ]
- );
- });
+ assert!(a_notified.take());
+ assert_eq!(
+ participant_locations(&room_a, cx_a),
+ vec![("user_b".to_string(), ParticipantLocation::External)]
+ );
+ assert!(b_notified.take());
+ assert_eq!(
+ participant_locations(&room_b, cx_b),
+ vec![(
+ "user_a".to_string(),
+ ParticipantLocation::SharedProject {
+ project_id: project_a_id
+ }
+ )]
+ );
- let project2_b = client_b.build_remote_project(&project2_a, cx_a, cx_b).await;
- let project2_c = cx_c.foreground().spawn(Project::remote(
- project2_id,
- client_c.client.clone(),
- client_c.user_store.clone(),
- client_c.project_store.clone(),
- client_c.language_registry.clone(),
- FakeFs::new(cx_c.background()),
- cx_c.to_async(),
- ));
+ room_b
+ .update(cx_b, |room, cx| room.set_location(Some(&project_b), cx))
+ .await
+ .unwrap();
deterministic.run_until_parked();
+ assert!(a_notified.take());
+ assert_eq!(
+ participant_locations(&room_a, cx_a),
+ vec![(
+ "user_b".to_string(),
+ ParticipantLocation::SharedProject {
+ project_id: project_b_id
+ }
+ )]
+ );
+ assert!(b_notified.take());
+ assert_eq!(
+ participant_locations(&room_b, cx_b),
+ vec![(
+ "user_a".to_string(),
+ ParticipantLocation::SharedProject {
+ project_id: project_a_id
+ }
+ )]
+ );
- // Taking a project offline unshares the project, rejects any pending join request and
- // disconnects existing guests.
- project2_a.update(cx_a, |project, cx| project.set_online(false, cx));
+ room_b
+ .update(cx_b, |room, cx| room.set_location(None, cx))
+ .await
+ .unwrap();
deterministic.run_until_parked();
- project2_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
- project2_b.read_with(cx_b, |project, _| assert!(project.is_read_only()));
- project2_c.await.unwrap_err();
-
- client_b.user_store.read_with(cx_b, |store, _| {
- assert_eq!(
- store.contacts()[0].projects,
- &[ProjectMetadata {
- id: project_id,
- visible_worktree_root_names: vec![
- "crate1".into(),
- "crate2".into(),
- "crate3".into()
- ],
- guests: Default::default(),
- },]
- );
- });
+ assert!(a_notified.take());
+ assert_eq!(
+ participant_locations(&room_a, cx_a),
+ vec![("user_b".to_string(), ParticipantLocation::External)]
+ );
+ assert!(b_notified.take());
+ assert_eq!(
+ participant_locations(&room_b, cx_b),
+ vec![(
+ "user_a".to_string(),
+ ParticipantLocation::SharedProject {
+ project_id: project_a_id
+ }
+ )]
+ );
- cx_a.update(|cx| {
- drop(subscriptions);
- drop(view);
- cx.remove_window(window_id);
- });
+ fn participant_locations(
+ room: &ModelHandle<Room>,
+ cx: &TestAppContext,
+ ) -> Vec<(String, ParticipantLocation)> {
+ room.read_with(cx, |room, _| {
+ room.remote_participants()
+ .values()
+ .map(|participant| {
+ (
+ participant.user.github_login.to_string(),
+ participant.location,
+ )
+ })
+ .collect()
+ })
+ }
}
#[gpui::test(iterations = 10)]
@@ -22,7 +22,7 @@ use axum::{
routing::get,
Extension, Router, TypedHeader,
};
-use collections::HashMap;
+use collections::{HashMap, HashSet};
use futures::{
channel::mpsc,
future::{self, BoxFuture},
@@ -88,11 +88,6 @@ impl<R: RequestMessage> Response<R> {
self.server.peer.respond(self.receipt, payload)?;
Ok(())
}
-
- fn into_receipt(self) -> Receipt<R> {
- self.responded.store(true, SeqCst);
- self.receipt
- }
}
pub struct Server {
@@ -151,11 +146,17 @@ impl Server {
server
.add_request_handler(Server::ping)
- .add_request_handler(Server::register_project)
- .add_request_handler(Server::unregister_project)
+ .add_request_handler(Server::create_room)
+ .add_request_handler(Server::join_room)
+ .add_message_handler(Server::leave_room)
+ .add_request_handler(Server::call)
+ .add_request_handler(Server::cancel_call)
+ .add_message_handler(Server::decline_call)
+ .add_request_handler(Server::update_participant_location)
+ .add_request_handler(Server::share_project)
+ .add_message_handler(Server::unshare_project)
.add_request_handler(Server::join_project)
.add_message_handler(Server::leave_project)
- .add_message_handler(Server::respond_to_join_project_request)
.add_message_handler(Server::update_project)
.add_message_handler(Server::register_project_activity)
.add_request_handler(Server::update_worktree)
@@ -385,7 +386,11 @@ impl Server {
{
let mut store = this.store().await;
- store.add_connection(connection_id, user_id, user.admin);
+ let incoming_call = store.add_connection(connection_id, user_id, user.admin);
+ if let Some(incoming_call) = incoming_call {
+ this.peer.send(connection_id, incoming_call)?;
+ }
+
this.peer.send(connection_id, store.build_initial_contacts_update(contacts))?;
if let Some((code, count)) = invite_code {
@@ -468,69 +473,58 @@ impl Server {
async fn sign_out(self: &mut Arc<Self>, connection_id: ConnectionId) -> Result<()> {
self.peer.disconnect(connection_id);
- let mut projects_to_unregister = Vec::new();
- let removed_user_id;
+ let mut projects_to_unshare = Vec::new();
+ let mut contacts_to_update = HashSet::default();
{
let mut store = self.store().await;
let removed_connection = store.remove_connection(connection_id)?;
- for (project_id, project) in removed_connection.hosted_projects {
- projects_to_unregister.push(project_id);
+ for project in removed_connection.hosted_projects {
+ projects_to_unshare.push(project.id);
broadcast(connection_id, project.guests.keys().copied(), |conn_id| {
self.peer.send(
conn_id,
- proto::UnregisterProject {
- project_id: project_id.to_proto(),
+ proto::UnshareProject {
+ project_id: project.id.to_proto(),
},
)
});
+ }
- for (_, receipts) in project.join_requests {
- for receipt in receipts {
- self.peer.respond(
- receipt,
- proto::JoinProjectResponse {
- variant: Some(proto::join_project_response::Variant::Decline(
- proto::join_project_response::Decline {
- reason: proto::join_project_response::decline::Reason::WentOffline as i32
- },
- )),
- },
- )?;
- }
- }
+ for project in removed_connection.guest_projects {
+ broadcast(connection_id, project.connection_ids, |conn_id| {
+ self.peer.send(
+ conn_id,
+ proto::RemoveProjectCollaborator {
+ project_id: project.id.to_proto(),
+ peer_id: connection_id.0,
+ },
+ )
+ });
}
- for project_id in removed_connection.guest_project_ids {
- if let Some(project) = store.project(project_id).trace_err() {
- broadcast(connection_id, project.connection_ids(), |conn_id| {
- self.peer.send(
- conn_id,
- proto::RemoveProjectCollaborator {
- project_id: project_id.to_proto(),
- peer_id: connection_id.0,
- },
- )
- });
- if project.guests.is_empty() {
- self.peer
- .send(
- project.host_connection_id,
- proto::ProjectUnshared {
- project_id: project_id.to_proto(),
- },
- )
- .trace_err();
- }
- }
+ for connection_id in removed_connection.canceled_call_connection_ids {
+ self.peer
+ .send(connection_id, proto::CallCanceled {})
+ .trace_err();
+ contacts_to_update.extend(store.user_id_for_connection(connection_id).ok());
}
- removed_user_id = removed_connection.user_id;
+ if let Some(room) = removed_connection
+ .room_id
+ .and_then(|room_id| store.room(room_id))
+ {
+ self.room_updated(room);
+ }
+
+ contacts_to_update.insert(removed_connection.user_id);
};
- self.update_user_contacts(removed_user_id).await.trace_err();
+ for user_id in contacts_to_update {
+ self.update_user_contacts(user_id).await.trace_err();
+ }
- for project_id in projects_to_unregister {
+ for project_id in projects_to_unshare {
self.app_state
.db
.unregister_project(project_id)
@@ -598,76 +592,286 @@ impl Server {
Ok(())
}
- async fn register_project(
+ async fn create_room(
+ self: Arc<Server>,
+ request: TypedEnvelope<proto::CreateRoom>,
+ response: Response<proto::CreateRoom>,
+ ) -> Result<()> {
+ let user_id;
+ let room_id;
+ {
+ let mut store = self.store().await;
+ user_id = store.user_id_for_connection(request.sender_id)?;
+ room_id = store.create_room(request.sender_id)?;
+ }
+ response.send(proto::CreateRoomResponse { id: room_id })?;
+ self.update_user_contacts(user_id).await?;
+ Ok(())
+ }
+
+ async fn join_room(
self: Arc<Server>,
- request: TypedEnvelope<proto::RegisterProject>,
- response: Response<proto::RegisterProject>,
+ request: TypedEnvelope<proto::JoinRoom>,
+ response: Response<proto::JoinRoom>,
+ ) -> Result<()> {
+ let user_id;
+ {
+ let mut store = self.store().await;
+ user_id = store.user_id_for_connection(request.sender_id)?;
+ let (room, recipient_connection_ids) =
+ store.join_room(request.payload.id, request.sender_id)?;
+ for recipient_id in recipient_connection_ids {
+ self.peer
+ .send(recipient_id, proto::CallCanceled {})
+ .trace_err();
+ }
+ response.send(proto::JoinRoomResponse {
+ room: Some(room.clone()),
+ })?;
+ self.room_updated(room);
+ }
+ self.update_user_contacts(user_id).await?;
+ Ok(())
+ }
+
+ async fn leave_room(self: Arc<Server>, message: TypedEnvelope<proto::LeaveRoom>) -> Result<()> {
+ let mut contacts_to_update = HashSet::default();
+ {
+ let mut store = self.store().await;
+ let user_id = store.user_id_for_connection(message.sender_id)?;
+ let left_room = store.leave_room(message.payload.id, message.sender_id)?;
+ contacts_to_update.insert(user_id);
+
+ for project in left_room.unshared_projects {
+ for connection_id in project.connection_ids() {
+ self.peer.send(
+ connection_id,
+ proto::UnshareProject {
+ project_id: project.id.to_proto(),
+ },
+ )?;
+ }
+ }
+
+ for project in left_room.left_projects {
+ if project.remove_collaborator {
+ for connection_id in project.connection_ids {
+ self.peer.send(
+ connection_id,
+ proto::RemoveProjectCollaborator {
+ project_id: project.id.to_proto(),
+ peer_id: message.sender_id.0,
+ },
+ )?;
+ }
+
+ self.peer.send(
+ message.sender_id,
+ proto::UnshareProject {
+ project_id: project.id.to_proto(),
+ },
+ )?;
+ }
+ }
+
+ if let Some(room) = left_room.room {
+ self.room_updated(room);
+ }
+
+ for connection_id in left_room.canceled_call_connection_ids {
+ self.peer
+ .send(connection_id, proto::CallCanceled {})
+ .trace_err();
+ contacts_to_update.extend(store.user_id_for_connection(connection_id).ok());
+ }
+ }
+
+ for user_id in contacts_to_update {
+ self.update_user_contacts(user_id).await?;
+ }
+
+ Ok(())
+ }
+
+ async fn call(
+ self: Arc<Server>,
+ request: TypedEnvelope<proto::Call>,
+ response: Response<proto::Call>,
+ ) -> Result<()> {
+ let caller_user_id = self
+ .store()
+ .await
+ .user_id_for_connection(request.sender_id)?;
+ let recipient_user_id = UserId::from_proto(request.payload.recipient_user_id);
+ let initial_project_id = request
+ .payload
+ .initial_project_id
+ .map(ProjectId::from_proto);
+ if !self
+ .app_state
+ .db
+ .has_contact(caller_user_id, recipient_user_id)
+ .await?
+ {
+ return Err(anyhow!("cannot call a user who isn't a contact"))?;
+ }
+
+ let room_id = request.payload.room_id;
+ let mut calls = {
+ let mut store = self.store().await;
+ let (room, recipient_connection_ids, incoming_call) = store.call(
+ room_id,
+ recipient_user_id,
+ initial_project_id,
+ request.sender_id,
+ )?;
+ self.room_updated(room);
+ recipient_connection_ids
+ .into_iter()
+ .map(|recipient_connection_id| {
+ self.peer
+ .request(recipient_connection_id, incoming_call.clone())
+ })
+ .collect::<FuturesUnordered<_>>()
+ };
+ self.update_user_contacts(recipient_user_id).await?;
+
+ while let Some(call_response) = calls.next().await {
+ match call_response.as_ref() {
+ Ok(_) => {
+ response.send(proto::Ack {})?;
+ return Ok(());
+ }
+ Err(_) => {
+ call_response.trace_err();
+ }
+ }
+ }
+
+ {
+ let mut store = self.store().await;
+ let room = store.call_failed(room_id, recipient_user_id)?;
+ self.room_updated(&room);
+ }
+ self.update_user_contacts(recipient_user_id).await?;
+
+ Err(anyhow!("failed to ring call recipient"))?
+ }
+
+ async fn cancel_call(
+ self: Arc<Server>,
+ request: TypedEnvelope<proto::CancelCall>,
+ response: Response<proto::CancelCall>,
+ ) -> Result<()> {
+ let recipient_user_id = UserId::from_proto(request.payload.recipient_user_id);
+ {
+ let mut store = self.store().await;
+ let (room, recipient_connection_ids) = store.cancel_call(
+ request.payload.room_id,
+ recipient_user_id,
+ request.sender_id,
+ )?;
+ for recipient_id in recipient_connection_ids {
+ self.peer
+ .send(recipient_id, proto::CallCanceled {})
+ .trace_err();
+ }
+ self.room_updated(room);
+ response.send(proto::Ack {})?;
+ }
+ self.update_user_contacts(recipient_user_id).await?;
+ Ok(())
+ }
+
+ async fn decline_call(
+ self: Arc<Server>,
+ message: TypedEnvelope<proto::DeclineCall>,
+ ) -> Result<()> {
+ let recipient_user_id;
+ {
+ let mut store = self.store().await;
+ recipient_user_id = store.user_id_for_connection(message.sender_id)?;
+ let (room, recipient_connection_ids) =
+ store.decline_call(message.payload.room_id, message.sender_id)?;
+ for recipient_id in recipient_connection_ids {
+ self.peer
+ .send(recipient_id, proto::CallCanceled {})
+ .trace_err();
+ }
+ self.room_updated(room);
+ }
+ self.update_user_contacts(recipient_user_id).await?;
+ Ok(())
+ }
+
+ async fn update_participant_location(
+ self: Arc<Server>,
+ request: TypedEnvelope<proto::UpdateParticipantLocation>,
+ response: Response<proto::UpdateParticipantLocation>,
+ ) -> Result<()> {
+ let room_id = request.payload.room_id;
+ let location = request
+ .payload
+ .location
+ .ok_or_else(|| anyhow!("invalid location"))?;
+ let mut store = self.store().await;
+ let room = store.update_participant_location(room_id, location, request.sender_id)?;
+ self.room_updated(room);
+ response.send(proto::Ack {})?;
+ Ok(())
+ }
+
+ fn room_updated(&self, room: &proto::Room) {
+ for participant in &room.participants {
+ self.peer
+ .send(
+ ConnectionId(participant.peer_id),
+ proto::RoomUpdated {
+ room: Some(room.clone()),
+ },
+ )
+ .trace_err();
+ }
+ }
+
+ async fn share_project(
+ self: Arc<Server>,
+ request: TypedEnvelope<proto::ShareProject>,
+ response: Response<proto::ShareProject>,
) -> Result<()> {
let user_id = self
.store()
.await
.user_id_for_connection(request.sender_id)?;
let project_id = self.app_state.db.register_project(user_id).await?;
- self.store().await.register_project(
- request.sender_id,
+ let mut store = self.store().await;
+ let room = store.share_project(
+ request.payload.room_id,
project_id,
- request.payload.online,
+ request.payload.worktrees,
+ request.sender_id,
)?;
-
- response.send(proto::RegisterProjectResponse {
+ response.send(proto::ShareProjectResponse {
project_id: project_id.to_proto(),
})?;
+ self.room_updated(room);
Ok(())
}
- async fn unregister_project(
+ async fn unshare_project(
self: Arc<Server>,
- request: TypedEnvelope<proto::UnregisterProject>,
- response: Response<proto::UnregisterProject>,
+ message: TypedEnvelope<proto::UnshareProject>,
) -> Result<()> {
- let project_id = ProjectId::from_proto(request.payload.project_id);
- let (user_id, project) = {
- let mut state = self.store().await;
- let project = state.unregister_project(project_id, request.sender_id)?;
- (state.user_id_for_connection(request.sender_id)?, project)
- };
- self.app_state.db.unregister_project(project_id).await?;
-
+ let project_id = ProjectId::from_proto(message.payload.project_id);
+ let mut store = self.store().await;
+ let (room, project) = store.unshare_project(project_id, message.sender_id)?;
broadcast(
- request.sender_id,
- project.guests.keys().copied(),
- |conn_id| {
- self.peer.send(
- conn_id,
- proto::UnregisterProject {
- project_id: project_id.to_proto(),
- },
- )
- },
+ message.sender_id,
+ project.guest_connection_ids(),
+ |conn_id| self.peer.send(conn_id, message.payload.clone()),
);
- for (_, receipts) in project.join_requests {
- for receipt in receipts {
- self.peer.respond(
- receipt,
- proto::JoinProjectResponse {
- variant: Some(proto::join_project_response::Variant::Decline(
- proto::join_project_response::Decline {
- reason: proto::join_project_response::decline::Reason::Closed
- as i32,
- },
- )),
- },
- )?;
- }
- }
-
- // Send out the `UpdateContacts` message before responding to the unregister
- // request. This way, when the project's host can keep track of the project's
- // remote id until after they've received the `UpdateContacts` message for
- // themself.
- self.update_user_contacts(user_id).await?;
- response.send(proto::Ack {})?;
+ self.room_updated(room);
Ok(())
}
@@ -721,176 +925,94 @@ impl Server {
};
tracing::info!(%project_id, %host_user_id, %host_connection_id, "join project");
- let has_contact = self
- .app_state
- .db
- .has_contact(guest_user_id, host_user_id)
- .await?;
- if !has_contact {
- return Err(anyhow!("no such project"))?;
- }
-
- self.store().await.request_join_project(
- guest_user_id,
- project_id,
- response.into_receipt(),
- )?;
- self.peer.send(
- host_connection_id,
- proto::RequestJoinProject {
- project_id: project_id.to_proto(),
- requester_id: guest_user_id.to_proto(),
- },
- )?;
- Ok(())
- }
- async fn respond_to_join_project_request(
- self: Arc<Server>,
- request: TypedEnvelope<proto::RespondToJoinProjectRequest>,
- ) -> Result<()> {
- let host_user_id;
+ let mut store = self.store().await;
+ let (project, replica_id) = store.join_project(request.sender_id, project_id)?;
+ let peer_count = project.guests.len();
+ let mut collaborators = Vec::with_capacity(peer_count);
+ collaborators.push(proto::Collaborator {
+ peer_id: project.host_connection_id.0,
+ replica_id: 0,
+ user_id: project.host.user_id.to_proto(),
+ });
+ let worktrees = project
+ .worktrees
+ .iter()
+ .map(|(id, worktree)| proto::WorktreeMetadata {
+ id: *id,
+ root_name: worktree.root_name.clone(),
+ visible: worktree.visible,
+ })
+ .collect::<Vec<_>>();
- {
- let mut state = self.store().await;
- let project_id = ProjectId::from_proto(request.payload.project_id);
- let project = state.project(project_id)?;
- if project.host_connection_id != request.sender_id {
- Err(anyhow!("no such connection"))?;
+ // Add all guests other than the requesting user's own connections as collaborators
+ for (guest_conn_id, guest) in &project.guests {
+ if request.sender_id != *guest_conn_id {
+ collaborators.push(proto::Collaborator {
+ peer_id: guest_conn_id.0,
+ replica_id: guest.replica_id as u32,
+ user_id: guest.user_id.to_proto(),
+ });
}
+ }
- host_user_id = project.host.user_id;
- let guest_user_id = UserId::from_proto(request.payload.requester_id);
-
- if !request.payload.allow {
- let receipts = state
- .deny_join_project_request(request.sender_id, guest_user_id, project_id)
- .ok_or_else(|| anyhow!("no such request"))?;
- for receipt in receipts {
- self.peer.respond(
- receipt,
- proto::JoinProjectResponse {
- variant: Some(proto::join_project_response::Variant::Decline(
- proto::join_project_response::Decline {
- reason: proto::join_project_response::decline::Reason::Declined
- as i32,
- },
- )),
- },
- )?;
- }
- return Ok(());
+ for conn_id in project.connection_ids() {
+ if conn_id != request.sender_id {
+ self.peer.send(
+ conn_id,
+ proto::AddProjectCollaborator {
+ project_id: project_id.to_proto(),
+ collaborator: Some(proto::Collaborator {
+ peer_id: request.sender_id.0,
+ replica_id: replica_id as u32,
+ user_id: guest_user_id.to_proto(),
+ }),
+ },
+ )?;
}
+ }
- let (receipts_with_replica_ids, project) = state
- .accept_join_project_request(request.sender_id, guest_user_id, project_id)
- .ok_or_else(|| anyhow!("no such request"))?;
+ // First, we send the metadata associated with each worktree.
+ response.send(proto::JoinProjectResponse {
+ worktrees: worktrees.clone(),
+ replica_id: replica_id as u32,
+ collaborators: collaborators.clone(),
+ language_servers: project.language_servers.clone(),
+ })?;
- let peer_count = project.guests.len();
- let mut collaborators = Vec::with_capacity(peer_count);
- collaborators.push(proto::Collaborator {
- peer_id: project.host_connection_id.0,
- replica_id: 0,
- user_id: project.host.user_id.to_proto(),
- });
- let worktrees = project
- .worktrees
- .iter()
- .map(|(id, worktree)| proto::WorktreeMetadata {
- id: *id,
- root_name: worktree.root_name.clone(),
- visible: worktree.visible,
- })
- .collect::<Vec<_>>();
-
- // Add all guests other than the requesting user's own connections as collaborators
- for (guest_conn_id, guest) in &project.guests {
- if receipts_with_replica_ids
- .iter()
- .all(|(receipt, _)| receipt.sender_id != *guest_conn_id)
- {
- collaborators.push(proto::Collaborator {
- peer_id: guest_conn_id.0,
- replica_id: guest.replica_id as u32,
- user_id: guest.user_id.to_proto(),
- });
- }
- }
+ for (worktree_id, worktree) in &project.worktrees {
+ #[cfg(any(test, feature = "test-support"))]
+ const MAX_CHUNK_SIZE: usize = 2;
+ #[cfg(not(any(test, feature = "test-support")))]
+ const MAX_CHUNK_SIZE: usize = 256;
- for conn_id in project.connection_ids() {
- for (receipt, replica_id) in &receipts_with_replica_ids {
- if conn_id != receipt.sender_id {
- self.peer.send(
- conn_id,
- proto::AddProjectCollaborator {
- project_id: project_id.to_proto(),
- collaborator: Some(proto::Collaborator {
- peer_id: receipt.sender_id.0,
- replica_id: *replica_id as u32,
- user_id: guest_user_id.to_proto(),
- }),
- },
- )?;
- }
- }
+ // Stream this worktree's entries.
+ let message = proto::UpdateWorktree {
+ project_id: project_id.to_proto(),
+ worktree_id: *worktree_id,
+ root_name: worktree.root_name.clone(),
+ updated_entries: worktree.entries.values().cloned().collect(),
+ removed_entries: Default::default(),
+ scan_id: worktree.scan_id,
+ is_last_update: worktree.is_complete,
+ };
+ for update in proto::split_worktree_update(message, MAX_CHUNK_SIZE) {
+ self.peer.send(request.sender_id, update.clone())?;
}
- // First, we send the metadata associated with each worktree.
- for (receipt, replica_id) in &receipts_with_replica_ids {
- self.peer.respond(
- *receipt,
- proto::JoinProjectResponse {
- variant: Some(proto::join_project_response::Variant::Accept(
- proto::join_project_response::Accept {
- worktrees: worktrees.clone(),
- replica_id: *replica_id as u32,
- collaborators: collaborators.clone(),
- language_servers: project.language_servers.clone(),
- },
- )),
+ // Stream this worktree's diagnostics.
+ for summary in worktree.diagnostic_summaries.values() {
+ self.peer.send(
+ request.sender_id,
+ proto::UpdateDiagnosticSummary {
+ project_id: project_id.to_proto(),
+ worktree_id: *worktree_id,
+ summary: Some(summary.clone()),
},
)?;
}
-
- for (worktree_id, worktree) in &project.worktrees {
- #[cfg(any(test, feature = "test-support"))]
- const MAX_CHUNK_SIZE: usize = 2;
- #[cfg(not(any(test, feature = "test-support")))]
- const MAX_CHUNK_SIZE: usize = 256;
-
- // Stream this worktree's entries.
- let message = proto::UpdateWorktree {
- project_id: project_id.to_proto(),
- worktree_id: *worktree_id,
- root_name: worktree.root_name.clone(),
- updated_entries: worktree.entries.values().cloned().collect(),
- removed_entries: Default::default(),
- scan_id: worktree.scan_id,
- is_last_update: worktree.is_complete,
- };
- for update in proto::split_worktree_update(message, MAX_CHUNK_SIZE) {
- for (receipt, _) in &receipts_with_replica_ids {
- self.peer.send(receipt.sender_id, update.clone())?;
- }
- }
-
- // Stream this worktree's diagnostics.
- for summary in worktree.diagnostic_summaries.values() {
- for (receipt, _) in &receipts_with_replica_ids {
- self.peer.send(
- receipt.sender_id,
- proto::UpdateDiagnosticSummary {
- project_id: project_id.to_proto(),
- worktree_id: *worktree_id,
- summary: Some(summary.clone()),
- },
- )?;
- }
- }
- }
}
- self.update_user_contacts(host_user_id).await?;
Ok(())
}
@@ -903,7 +1025,7 @@ impl Server {
let project;
{
let mut store = self.store().await;
- project = store.leave_project(sender_id, project_id)?;
+ project = store.leave_project(project_id, sender_id)?;
tracing::info!(
%project_id,
host_user_id = %project.host_user_id,
@@ -922,27 +1044,8 @@ impl Server {
)
});
}
-
- if let Some(requester_id) = project.cancel_request {
- self.peer.send(
- project.host_connection_id,
- proto::JoinProjectRequestCancelled {
- project_id: project_id.to_proto(),
- requester_id: requester_id.to_proto(),
- },
- )?;
- }
-
- if project.unshare {
- self.peer.send(
- project.host_connection_id,
- proto::ProjectUnshared {
- project_id: project_id.to_proto(),
- },
- )?;
- }
}
- self.update_user_contacts(project.host_user_id).await?;
+
Ok(())
}
@@ -951,61 +1054,20 @@ impl Server {
request: TypedEnvelope<proto::UpdateProject>,
) -> Result<()> {
let project_id = ProjectId::from_proto(request.payload.project_id);
- let user_id;
{
let mut state = self.store().await;
- user_id = state.user_id_for_connection(request.sender_id)?;
let guest_connection_ids = state
.read_project(project_id, request.sender_id)?
.guest_connection_ids();
- let unshared_project = state.update_project(
- project_id,
- &request.payload.worktrees,
- request.payload.online,
- request.sender_id,
- )?;
-
- if let Some(unshared_project) = unshared_project {
- broadcast(
- request.sender_id,
- unshared_project.guests.keys().copied(),
- |conn_id| {
- self.peer.send(
- conn_id,
- proto::UnregisterProject {
- project_id: project_id.to_proto(),
- },
- )
- },
- );
- for (_, receipts) in unshared_project.pending_join_requests {
- for receipt in receipts {
- self.peer.respond(
- receipt,
- proto::JoinProjectResponse {
- variant: Some(proto::join_project_response::Variant::Decline(
- proto::join_project_response::Decline {
- reason:
- proto::join_project_response::decline::Reason::Closed
- as i32,
- },
- )),
- },
- )?;
- }
- }
- } else {
- broadcast(request.sender_id, guest_connection_ids, |connection_id| {
- self.peer.forward_send(
- request.sender_id,
- connection_id,
- request.payload.clone(),
- )
- });
- }
+ let room =
+ state.update_project(project_id, &request.payload.worktrees, request.sender_id)?;
+ broadcast(request.sender_id, guest_connection_ids, |connection_id| {
+ self.peer
+ .forward_send(request.sender_id, connection_id, request.payload.clone())
+ });
+ self.room_updated(room);
};
- self.update_user_contacts(user_id).await?;
Ok(())
}
@@ -1027,32 +1089,21 @@ impl Server {
) -> Result<()> {
let project_id = ProjectId::from_proto(request.payload.project_id);
let worktree_id = request.payload.worktree_id;
- let (connection_ids, metadata_changed) = {
- let mut store = self.store().await;
- let (connection_ids, metadata_changed) = store.update_worktree(
- request.sender_id,
- project_id,
- worktree_id,
- &request.payload.root_name,
- &request.payload.removed_entries,
- &request.payload.updated_entries,
- request.payload.scan_id,
- request.payload.is_last_update,
- )?;
- (connection_ids, metadata_changed)
- };
+ let connection_ids = self.store().await.update_worktree(
+ request.sender_id,
+ project_id,
+ worktree_id,
+ &request.payload.root_name,
+ &request.payload.removed_entries,
+ &request.payload.updated_entries,
+ request.payload.scan_id,
+ request.payload.is_last_update,
+ )?;
broadcast(request.sender_id, connection_ids, |connection_id| {
self.peer
.forward_send(request.sender_id, connection_id, request.payload.clone())
});
- if metadata_changed {
- let user_id = self
- .store()
- .await
- .user_id_for_connection(request.sender_id)?;
- self.update_user_contacts(user_id).await?;
- }
response.send(proto::Ack {})?;
Ok(())
}
@@ -1,38 +1,55 @@
use crate::db::{self, ChannelId, ProjectId, UserId};
use anyhow::{anyhow, Result};
-use collections::{btree_map, hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet};
-use rpc::{proto, ConnectionId, Receipt};
+use collections::{btree_map, BTreeMap, BTreeSet, HashMap, HashSet};
+use rpc::{proto, ConnectionId};
use serde::Serialize;
use std::{mem, path::PathBuf, str, time::Duration};
use time::OffsetDateTime;
use tracing::instrument;
+use util::post_inc;
+
+pub type RoomId = u64;
#[derive(Default, Serialize)]
pub struct Store {
connections: BTreeMap<ConnectionId, ConnectionState>,
- connections_by_user_id: BTreeMap<UserId, HashSet<ConnectionId>>,
+ connected_users: BTreeMap<UserId, ConnectedUser>,
+ next_room_id: RoomId,
+ rooms: BTreeMap<RoomId, proto::Room>,
projects: BTreeMap<ProjectId, Project>,
#[serde(skip)]
channels: BTreeMap<ChannelId, Channel>,
}
+#[derive(Default, Serialize)]
+struct ConnectedUser {
+ connection_ids: HashSet<ConnectionId>,
+ active_call: Option<Call>,
+}
+
#[derive(Serialize)]
struct ConnectionState {
user_id: UserId,
admin: bool,
projects: BTreeSet<ProjectId>,
- requested_projects: HashSet<ProjectId>,
channels: HashSet<ChannelId>,
}
+#[derive(Copy, Clone, Eq, PartialEq, Serialize)]
+pub struct Call {
+ pub caller_user_id: UserId,
+ pub room_id: RoomId,
+ pub connection_id: Option<ConnectionId>,
+ pub initial_project_id: Option<ProjectId>,
+}
+
#[derive(Serialize)]
pub struct Project {
- pub online: bool,
+ pub id: ProjectId,
+ pub room_id: RoomId,
pub host_connection_id: ConnectionId,
pub host: Collaborator,
pub guests: HashMap<ConnectionId, Collaborator>,
- #[serde(skip)]
- pub join_requests: HashMap<UserId, Vec<Receipt<proto::JoinProject>>>,
pub active_replica_ids: HashSet<ReplicaId>,
pub worktrees: BTreeMap<u64, Worktree>,
pub language_servers: Vec<proto::LanguageServer>,
@@ -69,23 +86,26 @@ pub type ReplicaId = u16;
#[derive(Default)]
pub struct RemovedConnectionState {
pub user_id: UserId,
- pub hosted_projects: HashMap<ProjectId, Project>,
- pub guest_project_ids: HashSet<ProjectId>,
+ pub hosted_projects: Vec<Project>,
+ pub guest_projects: Vec<LeftProject>,
pub contact_ids: HashSet<UserId>,
+ pub room_id: Option<RoomId>,
+ pub canceled_call_connection_ids: Vec<ConnectionId>,
}
pub struct LeftProject {
+ pub id: ProjectId,
pub host_user_id: UserId,
pub host_connection_id: ConnectionId,
pub connection_ids: Vec<ConnectionId>,
pub remove_collaborator: bool,
- pub cancel_request: Option<UserId>,
- pub unshare: bool,
}
-pub struct UnsharedProject {
- pub guests: HashMap<ConnectionId, Collaborator>,
- pub pending_join_requests: HashMap<UserId, Vec<Receipt<proto::JoinProject>>>,
+pub struct LeftRoom<'a> {
+ pub room: Option<&'a proto::Room>,
+ pub unshared_projects: Vec<Project>,
+ pub left_projects: Vec<LeftProject>,
+ pub canceled_call_connection_ids: Vec<ConnectionId>,
}
#[derive(Copy, Clone)]
@@ -128,21 +148,44 @@ impl Store {
}
#[instrument(skip(self))]
- pub fn add_connection(&mut self, connection_id: ConnectionId, user_id: UserId, admin: bool) {
+ pub fn add_connection(
+ &mut self,
+ connection_id: ConnectionId,
+ user_id: UserId,
+ admin: bool,
+ ) -> Option<proto::IncomingCall> {
self.connections.insert(
connection_id,
ConnectionState {
user_id,
admin,
projects: Default::default(),
- requested_projects: Default::default(),
channels: Default::default(),
},
);
- self.connections_by_user_id
- .entry(user_id)
- .or_default()
- .insert(connection_id);
+ let connected_user = self.connected_users.entry(user_id).or_default();
+ connected_user.connection_ids.insert(connection_id);
+ if let Some(active_call) = connected_user.active_call {
+ if active_call.connection_id.is_some() {
+ None
+ } else {
+ let room = self.room(active_call.room_id)?;
+ Some(proto::IncomingCall {
+ room_id: active_call.room_id,
+ caller_user_id: active_call.caller_user_id.to_proto(),
+ participant_user_ids: room
+ .participants
+ .iter()
+ .map(|participant| participant.user_id)
+ .collect(),
+ initial_project: active_call
+ .initial_project_id
+ .and_then(|id| Self::build_participant_project(id, &self.projects)),
+ })
+ }
+ } else {
+ None
+ }
}
#[instrument(skip(self))]
@@ -156,7 +199,6 @@ impl Store {
.ok_or_else(|| anyhow!("no such connection"))?;
let user_id = connection.user_id;
- let connection_projects = mem::take(&mut connection.projects);
let connection_channels = mem::take(&mut connection.channels);
let mut result = RemovedConnectionState {
@@ -169,21 +211,21 @@ impl Store {
self.leave_channel(connection_id, channel_id);
}
- // Unregister and leave all projects.
- for project_id in connection_projects {
- if let Ok(project) = self.unregister_project(project_id, connection_id) {
- result.hosted_projects.insert(project_id, project);
- } else if self.leave_project(connection_id, project_id).is_ok() {
- result.guest_project_ids.insert(project_id);
- }
+ let connected_user = self.connected_users.get(&user_id).unwrap();
+ if let Some(active_call) = connected_user.active_call.as_ref() {
+ let room_id = active_call.room_id;
+ let left_room = self.leave_room(room_id, connection_id)?;
+ result.hosted_projects = left_room.unshared_projects;
+ result.guest_projects = left_room.left_projects;
+ result.room_id = Some(room_id);
+ result.canceled_call_connection_ids = left_room.canceled_call_connection_ids;
}
- let user_connections = self.connections_by_user_id.get_mut(&user_id).unwrap();
- user_connections.remove(&connection_id);
- if user_connections.is_empty() {
- self.connections_by_user_id.remove(&user_id);
+ let connected_user = self.connected_users.get_mut(&user_id).unwrap();
+ connected_user.connection_ids.remove(&connection_id);
+ if connected_user.connection_ids.is_empty() {
+ self.connected_users.remove(&user_id);
}
-
self.connections.remove(&connection_id).unwrap();
Ok(result)
@@ -229,21 +271,31 @@ impl Store {
&self,
user_id: UserId,
) -> impl Iterator<Item = ConnectionId> + '_ {
- self.connections_by_user_id
+ self.connected_users
.get(&user_id)
.into_iter()
+ .map(|state| &state.connection_ids)
.flatten()
.copied()
}
pub fn is_user_online(&self, user_id: UserId) -> bool {
!self
- .connections_by_user_id
+ .connected_users
.get(&user_id)
.unwrap_or(&Default::default())
+ .connection_ids
.is_empty()
}
+ fn is_user_busy(&self, user_id: UserId) -> bool {
+ self.connected_users
+ .get(&user_id)
+ .unwrap_or(&Default::default())
+ .active_call
+ .is_some()
+ }
+
pub fn build_initial_contacts_update(
&self,
contacts: Vec<db::Contact>,
@@ -281,61 +333,407 @@ impl Store {
pub fn contact_for_user(&self, user_id: UserId, should_notify: bool) -> proto::Contact {
proto::Contact {
user_id: user_id.to_proto(),
- projects: self.project_metadata_for_user(user_id),
online: self.is_user_online(user_id),
+ busy: self.is_user_busy(user_id),
should_notify,
}
}
- pub fn project_metadata_for_user(&self, user_id: UserId) -> Vec<proto::ProjectMetadata> {
- let connection_ids = self.connections_by_user_id.get(&user_id);
- let project_ids = connection_ids.iter().flat_map(|connection_ids| {
- connection_ids
- .iter()
- .filter_map(|connection_id| self.connections.get(connection_id))
- .flat_map(|connection| connection.projects.iter().copied())
+ pub fn create_room(&mut self, creator_connection_id: ConnectionId) -> Result<RoomId> {
+ let connection = self
+ .connections
+ .get_mut(&creator_connection_id)
+ .ok_or_else(|| anyhow!("no such connection"))?;
+ let connected_user = self
+ .connected_users
+ .get_mut(&connection.user_id)
+ .ok_or_else(|| anyhow!("no such connection"))?;
+ anyhow::ensure!(
+ connected_user.active_call.is_none(),
+ "can't create a room with an active call"
+ );
+
+ let mut room = proto::Room::default();
+ room.participants.push(proto::Participant {
+ user_id: connection.user_id.to_proto(),
+ peer_id: creator_connection_id.0,
+ projects: Default::default(),
+ location: Some(proto::ParticipantLocation {
+ variant: Some(proto::participant_location::Variant::External(
+ proto::participant_location::External {},
+ )),
+ }),
});
- let mut metadata = Vec::new();
- for project_id in project_ids {
- if let Some(project) = self.projects.get(&project_id) {
- if project.host.user_id == user_id && project.online {
- metadata.push(proto::ProjectMetadata {
- id: project_id.to_proto(),
- visible_worktree_root_names: project
- .worktrees
- .values()
- .filter(|worktree| worktree.visible)
- .map(|worktree| worktree.root_name.clone())
- .collect(),
- guests: project
- .guests
- .values()
- .map(|guest| guest.user_id.to_proto())
- .collect(),
- });
- }
+ let room_id = post_inc(&mut self.next_room_id);
+ self.rooms.insert(room_id, room);
+ connected_user.active_call = Some(Call {
+ caller_user_id: connection.user_id,
+ room_id,
+ connection_id: Some(creator_connection_id),
+ initial_project_id: None,
+ });
+ Ok(room_id)
+ }
+
+ pub fn join_room(
+ &mut self,
+ room_id: RoomId,
+ connection_id: ConnectionId,
+ ) -> Result<(&proto::Room, Vec<ConnectionId>)> {
+ let connection = self
+ .connections
+ .get_mut(&connection_id)
+ .ok_or_else(|| anyhow!("no such connection"))?;
+ let user_id = connection.user_id;
+ let recipient_connection_ids = self.connection_ids_for_user(user_id).collect::<Vec<_>>();
+
+ let connected_user = self
+ .connected_users
+ .get_mut(&user_id)
+ .ok_or_else(|| anyhow!("no such connection"))?;
+ let active_call = connected_user
+ .active_call
+ .as_mut()
+ .ok_or_else(|| anyhow!("not being called"))?;
+ anyhow::ensure!(
+ active_call.room_id == room_id && active_call.connection_id.is_none(),
+ "not being called on this room"
+ );
+
+ let room = self
+ .rooms
+ .get_mut(&room_id)
+ .ok_or_else(|| anyhow!("no such room"))?;
+ anyhow::ensure!(
+ room.pending_participant_user_ids
+ .contains(&user_id.to_proto()),
+ anyhow!("no such room")
+ );
+ room.pending_participant_user_ids
+ .retain(|pending| *pending != user_id.to_proto());
+ room.participants.push(proto::Participant {
+ user_id: user_id.to_proto(),
+ peer_id: connection_id.0,
+ projects: Default::default(),
+ location: Some(proto::ParticipantLocation {
+ variant: Some(proto::participant_location::Variant::External(
+ proto::participant_location::External {},
+ )),
+ }),
+ });
+ active_call.connection_id = Some(connection_id);
+
+ Ok((room, recipient_connection_ids))
+ }
+
+ pub fn leave_room(&mut self, room_id: RoomId, connection_id: ConnectionId) -> Result<LeftRoom> {
+ let connection = self
+ .connections
+ .get_mut(&connection_id)
+ .ok_or_else(|| anyhow!("no such connection"))?;
+ let user_id = connection.user_id;
+
+ let connected_user = self
+ .connected_users
+ .get(&user_id)
+ .ok_or_else(|| anyhow!("no such connection"))?;
+ anyhow::ensure!(
+ connected_user
+ .active_call
+ .map_or(false, |call| call.room_id == room_id
+ && call.connection_id == Some(connection_id)),
+ "cannot leave a room before joining it"
+ );
+
+ // Given that users can only join one room at a time, we can safely unshare
+ // and leave all projects associated with the connection.
+ let mut unshared_projects = Vec::new();
+ let mut left_projects = Vec::new();
+ for project_id in connection.projects.clone() {
+ if let Ok((_, project)) = self.unshare_project(project_id, connection_id) {
+ unshared_projects.push(project);
+ } else if let Ok(project) = self.leave_project(project_id, connection_id) {
+ left_projects.push(project);
}
}
+ self.connected_users.get_mut(&user_id).unwrap().active_call = None;
+
+ let room = self
+ .rooms
+ .get_mut(&room_id)
+ .ok_or_else(|| anyhow!("no such room"))?;
+ room.participants
+ .retain(|participant| participant.peer_id != connection_id.0);
+
+ let mut canceled_call_connection_ids = Vec::new();
+ room.pending_participant_user_ids
+ .retain(|pending_participant_user_id| {
+ if let Some(connected_user) = self
+ .connected_users
+ .get_mut(&UserId::from_proto(*pending_participant_user_id))
+ {
+ if let Some(call) = connected_user.active_call.as_ref() {
+ if call.caller_user_id == user_id {
+ connected_user.active_call.take();
+ canceled_call_connection_ids
+ .extend(connected_user.connection_ids.iter().copied());
+ false
+ } else {
+ true
+ }
+ } else {
+ true
+ }
+ } else {
+ true
+ }
+ });
+
+ if room.participants.is_empty() && room.pending_participant_user_ids.is_empty() {
+ self.rooms.remove(&room_id);
+ }
+
+ Ok(LeftRoom {
+ room: self.rooms.get(&room_id),
+ unshared_projects,
+ left_projects,
+ canceled_call_connection_ids,
+ })
+ }
- metadata
+ pub fn room(&self, room_id: RoomId) -> Option<&proto::Room> {
+ self.rooms.get(&room_id)
}
- pub fn register_project(
+ pub fn call(
&mut self,
- host_connection_id: ConnectionId,
+ room_id: RoomId,
+ recipient_user_id: UserId,
+ initial_project_id: Option<ProjectId>,
+ from_connection_id: ConnectionId,
+ ) -> Result<(&proto::Room, Vec<ConnectionId>, proto::IncomingCall)> {
+ let caller_user_id = self.user_id_for_connection(from_connection_id)?;
+
+ let recipient_connection_ids = self
+ .connection_ids_for_user(recipient_user_id)
+ .collect::<Vec<_>>();
+ let mut recipient = self
+ .connected_users
+ .get_mut(&recipient_user_id)
+ .ok_or_else(|| anyhow!("no such connection"))?;
+ anyhow::ensure!(
+ recipient.active_call.is_none(),
+ "recipient is already on another call"
+ );
+
+ let room = self
+ .rooms
+ .get_mut(&room_id)
+ .ok_or_else(|| anyhow!("no such room"))?;
+ anyhow::ensure!(
+ room.participants
+ .iter()
+ .any(|participant| participant.peer_id == from_connection_id.0),
+ "no such room"
+ );
+ anyhow::ensure!(
+ room.pending_participant_user_ids
+ .iter()
+ .all(|user_id| UserId::from_proto(*user_id) != recipient_user_id),
+ "cannot call the same user more than once"
+ );
+ room.pending_participant_user_ids
+ .push(recipient_user_id.to_proto());
+
+ if let Some(initial_project_id) = initial_project_id {
+ let project = self
+ .projects
+ .get(&initial_project_id)
+ .ok_or_else(|| anyhow!("no such project"))?;
+ anyhow::ensure!(project.room_id == room_id, "no such project");
+ }
+
+ recipient.active_call = Some(Call {
+ caller_user_id,
+ room_id,
+ connection_id: None,
+ initial_project_id,
+ });
+
+ Ok((
+ room,
+ recipient_connection_ids,
+ proto::IncomingCall {
+ room_id,
+ caller_user_id: caller_user_id.to_proto(),
+ participant_user_ids: room
+ .participants
+ .iter()
+ .map(|participant| participant.user_id)
+ .collect(),
+ initial_project: initial_project_id
+ .and_then(|id| Self::build_participant_project(id, &self.projects)),
+ },
+ ))
+ }
+
+ pub fn call_failed(&mut self, room_id: RoomId, to_user_id: UserId) -> Result<&proto::Room> {
+ let mut recipient = self
+ .connected_users
+ .get_mut(&to_user_id)
+ .ok_or_else(|| anyhow!("no such connection"))?;
+ anyhow::ensure!(recipient
+ .active_call
+ .map_or(false, |call| call.room_id == room_id
+ && call.connection_id.is_none()));
+ recipient.active_call = None;
+ let room = self
+ .rooms
+ .get_mut(&room_id)
+ .ok_or_else(|| anyhow!("no such room"))?;
+ room.pending_participant_user_ids
+ .retain(|user_id| UserId::from_proto(*user_id) != to_user_id);
+ Ok(room)
+ }
+
+ pub fn cancel_call(
+ &mut self,
+ room_id: RoomId,
+ recipient_user_id: UserId,
+ canceller_connection_id: ConnectionId,
+ ) -> Result<(&proto::Room, HashSet<ConnectionId>)> {
+ let canceller_user_id = self.user_id_for_connection(canceller_connection_id)?;
+ let canceller = self
+ .connected_users
+ .get(&canceller_user_id)
+ .ok_or_else(|| anyhow!("no such connection"))?;
+ let recipient = self
+ .connected_users
+ .get(&recipient_user_id)
+ .ok_or_else(|| anyhow!("no such connection"))?;
+ let canceller_active_call = canceller
+ .active_call
+ .as_ref()
+ .ok_or_else(|| anyhow!("no active call"))?;
+ let recipient_active_call = recipient
+ .active_call
+ .as_ref()
+ .ok_or_else(|| anyhow!("no active call for recipient"))?;
+
+ anyhow::ensure!(
+ canceller_active_call.room_id == room_id,
+ "users are on different calls"
+ );
+ anyhow::ensure!(
+ recipient_active_call.room_id == room_id,
+ "users are on different calls"
+ );
+ anyhow::ensure!(
+ recipient_active_call.connection_id.is_none(),
+ "recipient has already answered"
+ );
+ let room_id = recipient_active_call.room_id;
+ let room = self
+ .rooms
+ .get_mut(&room_id)
+ .ok_or_else(|| anyhow!("no such room"))?;
+ room.pending_participant_user_ids
+ .retain(|user_id| UserId::from_proto(*user_id) != recipient_user_id);
+
+ let recipient = self.connected_users.get_mut(&recipient_user_id).unwrap();
+ recipient.active_call.take();
+
+ Ok((room, recipient.connection_ids.clone()))
+ }
+
+ pub fn decline_call(
+ &mut self,
+ room_id: RoomId,
+ recipient_connection_id: ConnectionId,
+ ) -> Result<(&proto::Room, Vec<ConnectionId>)> {
+ let recipient_user_id = self.user_id_for_connection(recipient_connection_id)?;
+ let recipient = self
+ .connected_users
+ .get_mut(&recipient_user_id)
+ .ok_or_else(|| anyhow!("no such connection"))?;
+ if let Some(active_call) = recipient.active_call.take() {
+ anyhow::ensure!(active_call.room_id == room_id, "no such room");
+ let recipient_connection_ids = self
+ .connection_ids_for_user(recipient_user_id)
+ .collect::<Vec<_>>();
+ let room = self
+ .rooms
+ .get_mut(&active_call.room_id)
+ .ok_or_else(|| anyhow!("no such room"))?;
+ room.pending_participant_user_ids
+ .retain(|user_id| UserId::from_proto(*user_id) != recipient_user_id);
+ Ok((room, recipient_connection_ids))
+ } else {
+ Err(anyhow!("user is not being called"))
+ }
+ }
+
+ pub fn update_participant_location(
+ &mut self,
+ room_id: RoomId,
+ location: proto::ParticipantLocation,
+ connection_id: ConnectionId,
+ ) -> Result<&proto::Room> {
+ let room = self
+ .rooms
+ .get_mut(&room_id)
+ .ok_or_else(|| anyhow!("no such room"))?;
+ if let Some(proto::participant_location::Variant::SharedProject(project)) =
+ location.variant.as_ref()
+ {
+ anyhow::ensure!(
+ room.participants
+ .iter()
+ .flat_map(|participant| &participant.projects)
+ .any(|participant_project| participant_project.id == project.id),
+ "no such project"
+ );
+ }
+
+ let participant = room
+ .participants
+ .iter_mut()
+ .find(|participant| participant.peer_id == connection_id.0)
+ .ok_or_else(|| anyhow!("no such room"))?;
+ participant.location = Some(location);
+
+ Ok(room)
+ }
+
+ pub fn share_project(
+ &mut self,
+ room_id: RoomId,
project_id: ProjectId,
- online: bool,
- ) -> Result<()> {
+ worktrees: Vec<proto::WorktreeMetadata>,
+ host_connection_id: ConnectionId,
+ ) -> Result<&proto::Room> {
let connection = self
.connections
.get_mut(&host_connection_id)
.ok_or_else(|| anyhow!("no such connection"))?;
+
+ let room = self
+ .rooms
+ .get_mut(&room_id)
+ .ok_or_else(|| anyhow!("no such room"))?;
+ let participant = room
+ .participants
+ .iter_mut()
+ .find(|participant| participant.peer_id == host_connection_id.0)
+ .ok_or_else(|| anyhow!("no such room"))?;
+
connection.projects.insert(project_id);
self.projects.insert(
project_id,
Project {
- online,
+ id: project_id,
+ room_id,
host_connection_id,
host: Collaborator {
user_id: connection.user_id,
@@ -344,22 +742,79 @@ impl Store {
admin: connection.admin,
},
guests: Default::default(),
- join_requests: Default::default(),
active_replica_ids: Default::default(),
- worktrees: Default::default(),
+ worktrees: worktrees
+ .into_iter()
+ .map(|worktree| {
+ (
+ worktree.id,
+ Worktree {
+ root_name: worktree.root_name,
+ visible: worktree.visible,
+ ..Default::default()
+ },
+ )
+ })
+ .collect(),
language_servers: Default::default(),
},
);
- Ok(())
+
+ participant
+ .projects
+ .extend(Self::build_participant_project(project_id, &self.projects));
+
+ Ok(room)
+ }
+
+ pub fn unshare_project(
+ &mut self,
+ project_id: ProjectId,
+ connection_id: ConnectionId,
+ ) -> Result<(&proto::Room, Project)> {
+ match self.projects.entry(project_id) {
+ btree_map::Entry::Occupied(e) => {
+ if e.get().host_connection_id == connection_id {
+ let project = e.remove();
+
+ if let Some(host_connection) = self.connections.get_mut(&connection_id) {
+ host_connection.projects.remove(&project_id);
+ }
+
+ for guest_connection in project.guests.keys() {
+ if let Some(connection) = self.connections.get_mut(guest_connection) {
+ connection.projects.remove(&project_id);
+ }
+ }
+
+ let room = self
+ .rooms
+ .get_mut(&project.room_id)
+ .ok_or_else(|| anyhow!("no such room"))?;
+ let participant = room
+ .participants
+ .iter_mut()
+ .find(|participant| participant.peer_id == connection_id.0)
+ .ok_or_else(|| anyhow!("no such room"))?;
+ participant
+ .projects
+ .retain(|project| project.id != project_id.to_proto());
+
+ Ok((room, project))
+ } else {
+ Err(anyhow!("no such project"))?
+ }
+ }
+ btree_map::Entry::Vacant(_) => Err(anyhow!("no such project"))?,
+ }
}
pub fn update_project(
&mut self,
project_id: ProjectId,
worktrees: &[proto::WorktreeMetadata],
- online: bool,
connection_id: ConnectionId,
- ) -> Result<Option<UnsharedProject>> {
+ ) -> Result<&proto::Room> {
let project = self
.projects
.get_mut(&project_id)
@@ -381,80 +836,28 @@ impl Store {
}
}
- if online != project.online {
- project.online = online;
- if project.online {
- Ok(None)
- } else {
- for connection_id in project.guest_connection_ids() {
- if let Some(connection) = self.connections.get_mut(&connection_id) {
- connection.projects.remove(&project_id);
- }
- }
-
- project.active_replica_ids.clear();
- project.language_servers.clear();
- for worktree in project.worktrees.values_mut() {
- worktree.diagnostic_summaries.clear();
- worktree.entries.clear();
- }
+ let room = self
+ .rooms
+ .get_mut(&project.room_id)
+ .ok_or_else(|| anyhow!("no such room"))?;
+ let participant_project = room
+ .participants
+ .iter_mut()
+ .flat_map(|participant| &mut participant.projects)
+ .find(|project| project.id == project_id.to_proto())
+ .ok_or_else(|| anyhow!("no such project"))?;
+ participant_project.worktree_root_names = worktrees
+ .iter()
+ .filter(|worktree| worktree.visible)
+ .map(|worktree| worktree.root_name.clone())
+ .collect();
- Ok(Some(UnsharedProject {
- guests: mem::take(&mut project.guests),
- pending_join_requests: mem::take(&mut project.join_requests),
- }))
- }
- } else {
- Ok(None)
- }
+ Ok(room)
} else {
Err(anyhow!("no such project"))?
}
}
- pub fn unregister_project(
- &mut self,
- project_id: ProjectId,
- connection_id: ConnectionId,
- ) -> Result<Project> {
- match self.projects.entry(project_id) {
- btree_map::Entry::Occupied(e) => {
- if e.get().host_connection_id == connection_id {
- let project = e.remove();
-
- if let Some(host_connection) = self.connections.get_mut(&connection_id) {
- host_connection.projects.remove(&project_id);
- }
-
- for guest_connection in project.guests.keys() {
- if let Some(connection) = self.connections.get_mut(guest_connection) {
- connection.projects.remove(&project_id);
- }
- }
-
- for requester_user_id in project.join_requests.keys() {
- if let Some(requester_connection_ids) =
- self.connections_by_user_id.get_mut(requester_user_id)
- {
- for requester_connection_id in requester_connection_ids.iter() {
- if let Some(requester_connection) =
- self.connections.get_mut(requester_connection_id)
- {
- requester_connection.requested_projects.remove(&project_id);
- }
- }
- }
- }
-
- Ok(project)
- } else {
- Err(anyhow!("no such project"))?
- }
- }
- btree_map::Entry::Vacant(_) => Err(anyhow!("no such project"))?,
- }
- }
-
pub fn update_diagnostic_summary(
&mut self,
project_id: ProjectId,
@@ -498,99 +901,56 @@ impl Store {
Err(anyhow!("no such project"))?
}
- pub fn request_join_project(
+ pub fn join_project(
&mut self,
- requester_id: UserId,
+ requester_connection_id: ConnectionId,
project_id: ProjectId,
- receipt: Receipt<proto::JoinProject>,
- ) -> Result<()> {
+ ) -> Result<(&Project, ReplicaId)> {
let connection = self
.connections
- .get_mut(&receipt.sender_id)
+ .get_mut(&requester_connection_id)
+ .ok_or_else(|| anyhow!("no such connection"))?;
+ let user = self
+ .connected_users
+ .get(&connection.user_id)
.ok_or_else(|| anyhow!("no such connection"))?;
+ let active_call = user.active_call.ok_or_else(|| anyhow!("no such project"))?;
+ anyhow::ensure!(
+ active_call.connection_id == Some(requester_connection_id),
+ "no such project"
+ );
+
let project = self
.projects
.get_mut(&project_id)
.ok_or_else(|| anyhow!("no such project"))?;
- if project.online {
- connection.requested_projects.insert(project_id);
- project
- .join_requests
- .entry(requester_id)
- .or_default()
- .push(receipt);
- Ok(())
- } else {
- Err(anyhow!("no such project"))
- }
- }
-
- pub fn deny_join_project_request(
- &mut self,
- responder_connection_id: ConnectionId,
- requester_id: UserId,
- project_id: ProjectId,
- ) -> Option<Vec<Receipt<proto::JoinProject>>> {
- let project = self.projects.get_mut(&project_id)?;
- if responder_connection_id != project.host_connection_id {
- return None;
- }
-
- let receipts = project.join_requests.remove(&requester_id)?;
- for receipt in &receipts {
- let requester_connection = self.connections.get_mut(&receipt.sender_id)?;
- requester_connection.requested_projects.remove(&project_id);
- }
- project.host.last_activity = Some(OffsetDateTime::now_utc());
+ anyhow::ensure!(project.room_id == active_call.room_id, "no such project");
- Some(receipts)
- }
-
- #[allow(clippy::type_complexity)]
- pub fn accept_join_project_request(
- &mut self,
- responder_connection_id: ConnectionId,
- requester_id: UserId,
- project_id: ProjectId,
- ) -> Option<(Vec<(Receipt<proto::JoinProject>, ReplicaId)>, &Project)> {
- let project = self.projects.get_mut(&project_id)?;
- if responder_connection_id != project.host_connection_id {
- return None;
- }
-
- let receipts = project.join_requests.remove(&requester_id)?;
- let mut receipts_with_replica_ids = Vec::new();
- for receipt in receipts {
- let requester_connection = self.connections.get_mut(&receipt.sender_id)?;
- requester_connection.requested_projects.remove(&project_id);
- requester_connection.projects.insert(project_id);
- let mut replica_id = 1;
- while project.active_replica_ids.contains(&replica_id) {
- replica_id += 1;
- }
- project.active_replica_ids.insert(replica_id);
- project.guests.insert(
- receipt.sender_id,
- Collaborator {
- replica_id,
- user_id: requester_id,
- last_activity: Some(OffsetDateTime::now_utc()),
- admin: requester_connection.admin,
- },
- );
- receipts_with_replica_ids.push((receipt, replica_id));
+ connection.projects.insert(project_id);
+ let mut replica_id = 1;
+ while project.active_replica_ids.contains(&replica_id) {
+ replica_id += 1;
}
+ project.active_replica_ids.insert(replica_id);
+ project.guests.insert(
+ requester_connection_id,
+ Collaborator {
+ replica_id,
+ user_id: connection.user_id,
+ last_activity: Some(OffsetDateTime::now_utc()),
+ admin: connection.admin,
+ },
+ );
project.host.last_activity = Some(OffsetDateTime::now_utc());
- Some((receipts_with_replica_ids, project))
+ Ok((project, replica_id))
}
pub fn leave_project(
&mut self,
- connection_id: ConnectionId,
project_id: ProjectId,
+ connection_id: ConnectionId,
) -> Result<LeftProject> {
- let user_id = self.user_id_for_connection(connection_id)?;
let project = self
.projects
.get_mut(&project_id)
@@ -604,39 +964,15 @@ impl Store {
false
};
- // If the connection leaving the project has a pending request, remove it.
- // If that user has no other pending requests on other connections, indicate that the request should be cancelled.
- let mut cancel_request = None;
- if let Entry::Occupied(mut entry) = project.join_requests.entry(user_id) {
- entry
- .get_mut()
- .retain(|receipt| receipt.sender_id != connection_id);
- if entry.get().is_empty() {
- entry.remove();
- cancel_request = Some(user_id);
- }
- }
-
if let Some(connection) = self.connections.get_mut(&connection_id) {
connection.projects.remove(&project_id);
}
- let connection_ids = project.connection_ids();
- let unshare = connection_ids.len() <= 1 && project.join_requests.is_empty();
- if unshare {
- project.language_servers.clear();
- for worktree in project.worktrees.values_mut() {
- worktree.diagnostic_summaries.clear();
- worktree.entries.clear();
- }
- }
-
Ok(LeftProject {
+ id: project.id,
host_connection_id: project.host_connection_id,
host_user_id: project.host.user_id,
- connection_ids,
- cancel_request,
- unshare,
+ connection_ids: project.connection_ids(),
remove_collaborator,
})
}
@@ -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"] }
@@ -0,0 +1,566 @@
+use crate::{contact_notification::ContactNotification, contacts_popover};
+use call::{ActiveCall, ParticipantLocation};
+use client::{Authenticate, ContactEventKind, PeerId, User, UserStore};
+use clock::ReplicaId;
+use contacts_popover::ContactsPopover;
+use gpui::{
+ actions,
+ color::Color,
+ elements::*,
+ geometry::{rect::RectF, vector::vec2f, PathBuilder},
+ json::{self, ToJson},
+ Border, CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext,
+ Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
+};
+use settings::Settings;
+use std::ops::Range;
+use theme::Theme;
+use workspace::{FollowNextCollaborator, JoinProject, ToggleFollow, Workspace};
+
+actions!(
+ contacts_titlebar_item,
+ [ToggleContactsPopover, ShareProject]
+);
+
+pub fn init(cx: &mut MutableAppContext) {
+ cx.add_action(CollabTitlebarItem::toggle_contacts_popover);
+ cx.add_action(CollabTitlebarItem::share_project);
+}
+
+pub struct CollabTitlebarItem {
+ workspace: WeakViewHandle<Workspace>,
+ user_store: ModelHandle<UserStore>,
+ contacts_popover: Option<ViewHandle<ContactsPopover>>,
+ _subscriptions: Vec<Subscription>,
+}
+
+impl Entity for CollabTitlebarItem {
+ type Event = ();
+}
+
+impl View for CollabTitlebarItem {
+ fn ui_name() -> &'static str {
+ "CollabTitlebarItem"
+ }
+
+ fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+ let workspace = if let Some(workspace) = self.workspace.upgrade(cx) {
+ workspace
+ } else {
+ return Empty::new().boxed();
+ };
+
+ let theme = cx.global::<Settings>().theme.clone();
+ let project = workspace.read(cx).project().read(cx);
+
+ let mut container = Flex::row();
+ if workspace.read(cx).client().status().borrow().is_connected() {
+ if project.is_shared()
+ || project.is_remote()
+ || ActiveCall::global(cx).read(cx).room().is_none()
+ {
+ container.add_child(self.render_toggle_contacts_button(&theme, cx));
+ } else {
+ container.add_child(self.render_share_button(&theme, cx));
+ }
+ }
+ container.add_children(self.render_collaborators(&workspace, &theme, cx));
+ container.add_children(self.render_current_user(&workspace, &theme, cx));
+ container.add_children(self.render_connection_status(&workspace, cx));
+ container.boxed()
+ }
+}
+
+impl CollabTitlebarItem {
+ pub fn new(
+ workspace: &ViewHandle<Workspace>,
+ user_store: &ModelHandle<UserStore>,
+ cx: &mut ViewContext<Self>,
+ ) -> Self {
+ let active_call = ActiveCall::global(cx);
+ let mut subscriptions = Vec::new();
+ subscriptions.push(cx.observe(workspace, |_, _, cx| cx.notify()));
+ subscriptions.push(cx.observe(&active_call, |_, _, cx| cx.notify()));
+ subscriptions.push(cx.observe_window_activation(|this, active, cx| {
+ this.window_activation_changed(active, cx)
+ }));
+ subscriptions.push(cx.observe(user_store, |_, _, cx| cx.notify()));
+ subscriptions.push(
+ cx.subscribe(user_store, move |this, user_store, event, cx| {
+ if let Some(workspace) = this.workspace.upgrade(cx) {
+ workspace.update(cx, |workspace, cx| {
+ if let client::Event::Contact { user, kind } = event {
+ if let ContactEventKind::Requested | ContactEventKind::Accepted = kind {
+ workspace.show_notification(user.id as usize, cx, |cx| {
+ cx.add_view(|cx| {
+ ContactNotification::new(
+ user.clone(),
+ *kind,
+ user_store,
+ cx,
+ )
+ })
+ })
+ }
+ }
+ });
+ }
+ }),
+ );
+
+ Self {
+ workspace: workspace.downgrade(),
+ user_store: user_store.clone(),
+ contacts_popover: None,
+ _subscriptions: subscriptions,
+ }
+ }
+
+ fn window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
+ let workspace = self.workspace.upgrade(cx);
+ let room = ActiveCall::global(cx).read(cx).room().cloned();
+ if let Some((workspace, room)) = workspace.zip(room) {
+ let workspace = workspace.read(cx);
+ let project = if active {
+ Some(workspace.project().clone())
+ } else {
+ None
+ };
+ room.update(cx, |room, cx| {
+ room.set_location(project.as_ref(), cx)
+ .detach_and_log_err(cx);
+ });
+ }
+ }
+
+ fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext<Self>) {
+ if let Some(workspace) = self.workspace.upgrade(cx) {
+ let active_call = ActiveCall::global(cx);
+ let project = workspace.read(cx).project().clone();
+ active_call
+ .update(cx, |call, cx| call.share_project(project, cx))
+ .detach_and_log_err(cx);
+ }
+ }
+
+ fn toggle_contacts_popover(&mut self, _: &ToggleContactsPopover, cx: &mut ViewContext<Self>) {
+ match self.contacts_popover.take() {
+ Some(_) => {}
+ None => {
+ if let Some(workspace) = self.workspace.upgrade(cx) {
+ let project = workspace.read(cx).project().clone();
+ let user_store = workspace.read(cx).user_store().clone();
+ let view = cx.add_view(|cx| ContactsPopover::new(project, user_store, cx));
+ cx.focus(&view);
+ cx.subscribe(&view, |this, _, event, cx| {
+ match event {
+ contacts_popover::Event::Dismissed => {
+ this.contacts_popover = None;
+ }
+ }
+
+ cx.notify();
+ })
+ .detach();
+ self.contacts_popover = Some(view);
+ }
+ }
+ }
+ cx.notify();
+ }
+
+ fn render_toggle_contacts_button(
+ &self,
+ theme: &Theme,
+ cx: &mut RenderContext<Self>,
+ ) -> ElementBox {
+ let titlebar = &theme.workspace.titlebar;
+ let badge = if self
+ .user_store
+ .read(cx)
+ .incoming_contact_requests()
+ .is_empty()
+ {
+ None
+ } else {
+ Some(
+ Empty::new()
+ .collapsed()
+ .contained()
+ .with_style(titlebar.toggle_contacts_badge)
+ .contained()
+ .with_margin_left(titlebar.toggle_contacts_button.default.icon_width)
+ .with_margin_top(titlebar.toggle_contacts_button.default.icon_width)
+ .aligned()
+ .boxed(),
+ )
+ };
+ Stack::new()
+ .with_child(
+ MouseEventHandler::<ToggleContactsPopover>::new(0, cx, |state, _| {
+ let style = titlebar
+ .toggle_contacts_button
+ .style_for(state, self.contacts_popover.is_some());
+ Svg::new("icons/plus_8.svg")
+ .with_color(style.color)
+ .constrained()
+ .with_width(style.icon_width)
+ .aligned()
+ .constrained()
+ .with_width(style.button_width)
+ .with_height(style.button_width)
+ .contained()
+ .with_style(style.container)
+ .boxed()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, cx| {
+ cx.dispatch_action(ToggleContactsPopover);
+ })
+ .aligned()
+ .boxed(),
+ )
+ .with_children(badge)
+ .with_children(self.contacts_popover.as_ref().map(|popover| {
+ Overlay::new(
+ ChildView::new(popover)
+ .contained()
+ .with_margin_top(titlebar.height)
+ .with_margin_left(titlebar.toggle_contacts_button.default.button_width)
+ .with_margin_right(-titlebar.toggle_contacts_button.default.button_width)
+ .boxed(),
+ )
+ .with_fit_mode(OverlayFitMode::SwitchAnchor)
+ .with_anchor_corner(AnchorCorner::BottomLeft)
+ .boxed()
+ }))
+ .boxed()
+ }
+
+ fn render_share_button(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
+ enum Share {}
+
+ let titlebar = &theme.workspace.titlebar;
+ MouseEventHandler::<Share>::new(0, cx, |state, _| {
+ let style = titlebar.share_button.style_for(state, false);
+ Label::new("Share".into(), style.text.clone())
+ .contained()
+ .with_style(style.container)
+ .boxed()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(ShareProject))
+ .with_tooltip::<Share, _>(
+ 0,
+ "Share project with call participants".into(),
+ None,
+ theme.tooltip.clone(),
+ cx,
+ )
+ .aligned()
+ .boxed()
+ }
+
+ fn render_collaborators(
+ &self,
+ workspace: &ViewHandle<Workspace>,
+ theme: &Theme,
+ cx: &mut RenderContext<Self>,
+ ) -> Vec<ElementBox> {
+ let active_call = ActiveCall::global(cx);
+ if let Some(room) = active_call.read(cx).room().cloned() {
+ let project = workspace.read(cx).project().read(cx);
+ let mut participants = room
+ .read(cx)
+ .remote_participants()
+ .iter()
+ .map(|(peer_id, collaborator)| (*peer_id, collaborator.clone()))
+ .collect::<Vec<_>>();
+ participants
+ .sort_by_key(|(peer_id, _)| Some(project.collaborators().get(peer_id)?.replica_id));
+ participants
+ .into_iter()
+ .filter_map(|(peer_id, participant)| {
+ let project = workspace.read(cx).project().read(cx);
+ let replica_id = project
+ .collaborators()
+ .get(&peer_id)
+ .map(|collaborator| collaborator.replica_id);
+ let user = participant.user.clone();
+ Some(self.render_avatar(
+ &user,
+ replica_id,
+ Some((peer_id, &user.github_login, participant.location)),
+ workspace,
+ theme,
+ cx,
+ ))
+ })
+ .collect()
+ } else {
+ Default::default()
+ }
+ }
+
+ fn render_current_user(
+ &self,
+ workspace: &ViewHandle<Workspace>,
+ theme: &Theme,
+ cx: &mut RenderContext<Self>,
+ ) -> Option<ElementBox> {
+ let user = workspace.read(cx).user_store().read(cx).current_user();
+ let replica_id = workspace.read(cx).project().read(cx).replica_id();
+ let status = *workspace.read(cx).client().status().borrow();
+ if let Some(user) = user {
+ Some(self.render_avatar(&user, Some(replica_id), None, workspace, theme, cx))
+ } else if matches!(status, client::Status::UpgradeRequired) {
+ None
+ } else {
+ Some(
+ MouseEventHandler::<Authenticate>::new(0, cx, |state, _| {
+ let style = theme
+ .workspace
+ .titlebar
+ .sign_in_prompt
+ .style_for(state, false);
+ Label::new("Sign in".to_string(), style.text.clone())
+ .contained()
+ .with_style(style.container)
+ .boxed()
+ })
+ .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate))
+ .with_cursor_style(CursorStyle::PointingHand)
+ .aligned()
+ .boxed(),
+ )
+ }
+ }
+
+ fn render_avatar(
+ &self,
+ user: &User,
+ replica_id: Option<ReplicaId>,
+ peer: Option<(PeerId, &str, ParticipantLocation)>,
+ workspace: &ViewHandle<Workspace>,
+ theme: &Theme,
+ cx: &mut RenderContext<Self>,
+ ) -> ElementBox {
+ let is_followed = peer.map_or(false, |(peer_id, _, _)| {
+ workspace.read(cx).is_following(peer_id)
+ });
+
+ let mut avatar_style;
+ if let Some((_, _, location)) = peer.as_ref() {
+ if let ParticipantLocation::SharedProject { project_id } = *location {
+ if Some(project_id) == workspace.read(cx).project().read(cx).remote_id() {
+ avatar_style = theme.workspace.titlebar.avatar;
+ } else {
+ avatar_style = theme.workspace.titlebar.inactive_avatar;
+ }
+ } else {
+ avatar_style = theme.workspace.titlebar.inactive_avatar;
+ }
+ } else {
+ avatar_style = theme.workspace.titlebar.avatar;
+ }
+
+ let mut replica_color = None;
+ if let Some(replica_id) = replica_id {
+ let color = theme.editor.replica_selection_style(replica_id).cursor;
+ replica_color = Some(color);
+ if is_followed {
+ avatar_style.border = Border::all(1.0, color);
+ }
+ }
+
+ let content = Stack::new()
+ .with_children(user.avatar.as_ref().map(|avatar| {
+ Image::new(avatar.clone())
+ .with_style(avatar_style)
+ .constrained()
+ .with_width(theme.workspace.titlebar.avatar_width)
+ .aligned()
+ .boxed()
+ }))
+ .with_children(replica_color.map(|replica_color| {
+ AvatarRibbon::new(replica_color)
+ .constrained()
+ .with_width(theme.workspace.titlebar.avatar_ribbon.width)
+ .with_height(theme.workspace.titlebar.avatar_ribbon.height)
+ .aligned()
+ .bottom()
+ .boxed()
+ }))
+ .constrained()
+ .with_width(theme.workspace.titlebar.avatar_width)
+ .contained()
+ .with_margin_left(theme.workspace.titlebar.avatar_margin)
+ .boxed();
+
+ if let Some((peer_id, peer_github_login, location)) = peer {
+ if let Some(replica_id) = replica_id {
+ MouseEventHandler::<ToggleFollow>::new(replica_id.into(), cx, move |_, _| content)
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, cx| {
+ cx.dispatch_action(ToggleFollow(peer_id))
+ })
+ .with_tooltip::<ToggleFollow, _>(
+ peer_id.0 as usize,
+ if is_followed {
+ format!("Unfollow {}", peer_github_login)
+ } else {
+ format!("Follow {}", peer_github_login)
+ },
+ Some(Box::new(FollowNextCollaborator)),
+ theme.tooltip.clone(),
+ cx,
+ )
+ .boxed()
+ } else if let ParticipantLocation::SharedProject { project_id } = location {
+ let user_id = user.id;
+ MouseEventHandler::<JoinProject>::new(peer_id.0 as usize, cx, move |_, _| content)
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, cx| {
+ cx.dispatch_action(JoinProject {
+ project_id,
+ follow_user_id: user_id,
+ })
+ })
+ .with_tooltip::<JoinProject, _>(
+ peer_id.0 as usize,
+ format!("Follow {} into external project", peer_github_login),
+ Some(Box::new(FollowNextCollaborator)),
+ theme.tooltip.clone(),
+ cx,
+ )
+ .boxed()
+ } else {
+ content
+ }
+ } else {
+ content
+ }
+ }
+
+ fn render_connection_status(
+ &self,
+ workspace: &ViewHandle<Workspace>,
+ cx: &mut RenderContext<Self>,
+ ) -> Option<ElementBox> {
+ let theme = &cx.global::<Settings>().theme;
+ match &*workspace.read(cx).client().status().borrow() {
+ client::Status::ConnectionError
+ | client::Status::ConnectionLost
+ | client::Status::Reauthenticating { .. }
+ | client::Status::Reconnecting { .. }
+ | client::Status::ReconnectionError { .. } => Some(
+ Container::new(
+ Align::new(
+ ConstrainedBox::new(
+ Svg::new("icons/cloud_slash_12.svg")
+ .with_color(theme.workspace.titlebar.offline_icon.color)
+ .boxed(),
+ )
+ .with_width(theme.workspace.titlebar.offline_icon.width)
+ .boxed(),
+ )
+ .boxed(),
+ )
+ .with_style(theme.workspace.titlebar.offline_icon.container)
+ .boxed(),
+ ),
+ client::Status::UpgradeRequired => Some(
+ Label::new(
+ "Please update Zed to collaborate".to_string(),
+ theme.workspace.titlebar.outdated_warning.text.clone(),
+ )
+ .contained()
+ .with_style(theme.workspace.titlebar.outdated_warning.container)
+ .aligned()
+ .boxed(),
+ ),
+ _ => None,
+ }
+ }
+}
+
+pub struct AvatarRibbon {
+ color: Color,
+}
+
+impl AvatarRibbon {
+ pub fn new(color: Color) -> AvatarRibbon {
+ AvatarRibbon { color }
+ }
+}
+
+impl Element for AvatarRibbon {
+ type LayoutState = ();
+
+ type PaintState = ();
+
+ fn layout(
+ &mut self,
+ constraint: gpui::SizeConstraint,
+ _: &mut gpui::LayoutContext,
+ ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
+ (constraint.max, ())
+ }
+
+ fn paint(
+ &mut self,
+ bounds: gpui::geometry::rect::RectF,
+ _: gpui::geometry::rect::RectF,
+ _: &mut Self::LayoutState,
+ cx: &mut gpui::PaintContext,
+ ) -> Self::PaintState {
+ let mut path = PathBuilder::new();
+ path.reset(bounds.lower_left());
+ path.curve_to(
+ bounds.origin() + vec2f(bounds.height(), 0.),
+ bounds.origin(),
+ );
+ path.line_to(bounds.upper_right() - vec2f(bounds.height(), 0.));
+ path.curve_to(bounds.lower_right(), bounds.upper_right());
+ path.line_to(bounds.lower_left());
+ cx.scene.push_path(path.build(self.color, None));
+ }
+
+ fn dispatch_event(
+ &mut self,
+ _: &gpui::Event,
+ _: RectF,
+ _: RectF,
+ _: &mut Self::LayoutState,
+ _: &mut Self::PaintState,
+ _: &mut gpui::EventContext,
+ ) -> bool {
+ false
+ }
+
+ fn rect_for_text_range(
+ &self,
+ _: Range<usize>,
+ _: RectF,
+ _: RectF,
+ _: &Self::LayoutState,
+ _: &Self::PaintState,
+ _: &gpui::MeasurementContext,
+ ) -> Option<RectF> {
+ None
+ }
+
+ fn debug(
+ &self,
+ bounds: gpui::geometry::rect::RectF,
+ _: &Self::LayoutState,
+ _: &Self::PaintState,
+ _: &gpui::DebugContext,
+ ) -> gpui::json::Value {
+ json::json!({
+ "type": "AvatarRibbon",
+ "bounds": bounds.to_json(),
+ "color": self.color.to_json(),
+ })
+ }
+}
@@ -0,0 +1,97 @@
+mod collab_titlebar_item;
+mod contact_finder;
+mod contact_list;
+mod contact_notification;
+mod contacts_popover;
+mod incoming_call_notification;
+mod notifications;
+mod project_shared_notification;
+
+use call::ActiveCall;
+pub use collab_titlebar_item::CollabTitlebarItem;
+use gpui::MutableAppContext;
+use project::Project;
+use std::sync::Arc;
+use workspace::{AppState, JoinProject, ToggleFollow, Workspace};
+
+pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
+ collab_titlebar_item::init(cx);
+ contact_notification::init(cx);
+ contact_list::init(cx);
+ contact_finder::init(cx);
+ contacts_popover::init(cx);
+ incoming_call_notification::init(cx);
+ project_shared_notification::init(cx);
+
+ cx.add_global_action(move |action: &JoinProject, cx| {
+ let project_id = action.project_id;
+ let follow_user_id = action.follow_user_id;
+ let app_state = app_state.clone();
+ cx.spawn(|mut cx| async move {
+ let existing_workspace = cx.update(|cx| {
+ cx.window_ids()
+ .filter_map(|window_id| cx.root_view::<Workspace>(window_id))
+ .find(|workspace| {
+ workspace.read(cx).project().read(cx).remote_id() == Some(project_id)
+ })
+ });
+
+ let workspace = if let Some(existing_workspace) = existing_workspace {
+ existing_workspace
+ } else {
+ let project = Project::remote(
+ project_id,
+ app_state.client.clone(),
+ app_state.user_store.clone(),
+ app_state.project_store.clone(),
+ app_state.languages.clone(),
+ app_state.fs.clone(),
+ cx.clone(),
+ )
+ .await?;
+
+ let (_, workspace) = cx.add_window((app_state.build_window_options)(), |cx| {
+ let mut workspace = Workspace::new(project, app_state.default_item_factory, cx);
+ (app_state.initialize_workspace)(&mut workspace, &app_state, cx);
+ workspace
+ });
+ workspace
+ };
+
+ cx.activate_window(workspace.window_id());
+ cx.platform().activate(true);
+
+ workspace.update(&mut cx, |workspace, cx| {
+ if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
+ let follow_peer_id = room
+ .read(cx)
+ .remote_participants()
+ .iter()
+ .find(|(_, participant)| participant.user.id == follow_user_id)
+ .map(|(peer_id, _)| *peer_id)
+ .or_else(|| {
+ // If we couldn't follow the given user, follow the host instead.
+ let collaborator = workspace
+ .project()
+ .read(cx)
+ .collaborators()
+ .values()
+ .find(|collaborator| collaborator.replica_id == 0)?;
+ Some(collaborator.peer_id)
+ });
+
+ if let Some(follow_peer_id) = follow_peer_id {
+ if !workspace.is_following(follow_peer_id) {
+ workspace
+ .toggle_follow(&ToggleFollow(follow_peer_id), cx)
+ .map(|follow| follow.detach_and_log_err(cx));
+ }
+ }
+ }
+ });
+
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ });
+}
@@ -1,21 +1,15 @@
use client::{ContactRequestStatus, User, UserStore};
use gpui::{
- actions, elements::*, AnyViewHandle, Entity, ModelHandle, MouseState, MutableAppContext,
- RenderContext, Task, View, ViewContext, ViewHandle,
+ elements::*, AnyViewHandle, Entity, ModelHandle, MouseState, MutableAppContext, RenderContext,
+ Task, View, ViewContext, ViewHandle,
};
use picker::{Picker, PickerDelegate};
use settings::Settings;
use std::sync::Arc;
use util::TryFutureExt;
-use workspace::Workspace;
-
-use crate::render_icon_button;
-
-actions!(contact_finder, [Toggle]);
pub fn init(cx: &mut MutableAppContext) {
Picker::<ContactFinder>::init(cx);
- cx.add_action(ContactFinder::toggle);
}
pub struct ContactFinder {
@@ -117,18 +111,21 @@ impl PickerDelegate for ContactFinder {
let icon_path = match request_status {
ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
- "icons/check_8.svg"
- }
- ContactRequestStatus::RequestSent | ContactRequestStatus::RequestAccepted => {
- "icons/x_mark_8.svg"
+ Some("icons/check_8.svg")
}
+ ContactRequestStatus::RequestSent => Some("icons/x_mark_8.svg"),
+ ContactRequestStatus::RequestAccepted => None,
};
let button_style = if self.user_store.read(cx).is_contact_request_pending(user) {
&theme.contact_finder.disabled_contact_button
} else {
&theme.contact_finder.contact_button
};
- let style = theme.picker.item.style_for(mouse_state, selected);
+ let style = theme
+ .contact_finder
+ .picker
+ .item
+ .style_for(mouse_state, selected);
Flex::row()
.with_children(user.avatar.clone().map(|avatar| {
Image::new(avatar)
@@ -145,12 +142,21 @@ impl PickerDelegate for ContactFinder {
.left()
.boxed(),
)
- .with_child(
- render_icon_button(button_style, icon_path)
+ .with_children(icon_path.map(|icon_path| {
+ Svg::new(icon_path)
+ .with_color(button_style.color)
+ .constrained()
+ .with_width(button_style.icon_width)
+ .aligned()
+ .contained()
+ .with_style(button_style.container)
+ .constrained()
+ .with_width(button_style.button_width)
+ .with_height(button_style.button_width)
.aligned()
.flex_float()
- .boxed(),
- )
+ .boxed()
+ }))
.contained()
.with_style(style.container)
.constrained()
@@ -160,34 +166,16 @@ impl PickerDelegate for ContactFinder {
}
impl ContactFinder {
- fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
- workspace.toggle_modal(cx, |workspace, cx| {
- let finder = cx.add_view(|cx| Self::new(workspace.user_store().clone(), cx));
- cx.subscribe(&finder, Self::on_event).detach();
- finder
- });
- }
-
pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
let this = cx.weak_handle();
Self {
- picker: cx.add_view(|cx| Picker::new(this, cx)),
+ picker: cx.add_view(|cx| {
+ Picker::new(this, cx)
+ .with_theme(|cx| &cx.global::<Settings>().theme.contact_finder.picker)
+ }),
potential_contacts: Arc::from([]),
user_store,
selected_index: 0,
}
}
-
- fn on_event(
- workspace: &mut Workspace,
- _: ViewHandle<Self>,
- event: &Event,
- cx: &mut ViewContext<Workspace>,
- ) {
- match event {
- Event::Dismissed => {
- workspace.dismiss_modal(cx);
- }
- }
- }
}
@@ -0,0 +1,1140 @@
+use std::sync::Arc;
+
+use crate::contacts_popover;
+use call::ActiveCall;
+use client::{Contact, PeerId, User, UserStore};
+use editor::{Cancel, Editor};
+use fuzzy::{match_strings, StringMatchCandidate};
+use gpui::{
+ elements::*,
+ geometry::{rect::RectF, vector::vec2f},
+ impl_actions, impl_internal_actions, keymap, AppContext, CursorStyle, Entity, ModelHandle,
+ MouseButton, MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle,
+};
+use menu::{Confirm, SelectNext, SelectPrev};
+use project::Project;
+use serde::Deserialize;
+use settings::Settings;
+use theme::IconButton;
+use util::ResultExt;
+use workspace::JoinProject;
+
+impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]);
+impl_internal_actions!(contact_list, [ToggleExpanded, Call, LeaveCall]);
+
+pub fn init(cx: &mut MutableAppContext) {
+ cx.add_action(ContactList::remove_contact);
+ cx.add_action(ContactList::respond_to_contact_request);
+ cx.add_action(ContactList::clear_filter);
+ cx.add_action(ContactList::select_next);
+ cx.add_action(ContactList::select_prev);
+ cx.add_action(ContactList::confirm);
+ cx.add_action(ContactList::toggle_expanded);
+ cx.add_action(ContactList::call);
+ cx.add_action(ContactList::leave_call);
+}
+
+#[derive(Clone, PartialEq)]
+struct ToggleExpanded(Section);
+
+#[derive(Clone, PartialEq)]
+struct Call {
+ recipient_user_id: u64,
+ initial_project: Option<ModelHandle<Project>>,
+}
+
+#[derive(Copy, Clone, PartialEq)]
+struct LeaveCall;
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
+enum Section {
+ ActiveCall,
+ Requests,
+ Online,
+ Offline,
+}
+
+#[derive(Clone)]
+enum ContactEntry {
+ Header(Section),
+ CallParticipant {
+ user: Arc<User>,
+ is_pending: bool,
+ },
+ ParticipantProject {
+ project_id: u64,
+ worktree_root_names: Vec<String>,
+ host_user_id: u64,
+ is_host: bool,
+ is_last: bool,
+ },
+ IncomingRequest(Arc<User>),
+ OutgoingRequest(Arc<User>),
+ Contact(Arc<Contact>),
+}
+
+impl PartialEq for ContactEntry {
+ fn eq(&self, other: &Self) -> bool {
+ match self {
+ ContactEntry::Header(section_1) => {
+ if let ContactEntry::Header(section_2) = other {
+ return section_1 == section_2;
+ }
+ }
+ ContactEntry::CallParticipant { user: user_1, .. } => {
+ if let ContactEntry::CallParticipant { user: user_2, .. } = other {
+ return user_1.id == user_2.id;
+ }
+ }
+ ContactEntry::ParticipantProject {
+ project_id: project_id_1,
+ ..
+ } => {
+ if let ContactEntry::ParticipantProject {
+ project_id: project_id_2,
+ ..
+ } = other
+ {
+ return project_id_1 == project_id_2;
+ }
+ }
+ ContactEntry::IncomingRequest(user_1) => {
+ if let ContactEntry::IncomingRequest(user_2) = other {
+ return user_1.id == user_2.id;
+ }
+ }
+ ContactEntry::OutgoingRequest(user_1) => {
+ if let ContactEntry::OutgoingRequest(user_2) = other {
+ return user_1.id == user_2.id;
+ }
+ }
+ ContactEntry::Contact(contact_1) => {
+ if let ContactEntry::Contact(contact_2) = other {
+ return contact_1.user.id == contact_2.user.id;
+ }
+ }
+ }
+ false
+ }
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+pub struct RequestContact(pub u64);
+
+#[derive(Clone, Deserialize, PartialEq)]
+pub struct RemoveContact(pub u64);
+
+#[derive(Clone, Deserialize, PartialEq)]
+pub struct RespondToContactRequest {
+ pub user_id: u64,
+ pub accept: bool,
+}
+
+pub enum Event {
+ Dismissed,
+}
+
+pub struct ContactList {
+ entries: Vec<ContactEntry>,
+ match_candidates: Vec<StringMatchCandidate>,
+ list_state: ListState,
+ project: ModelHandle<Project>,
+ user_store: ModelHandle<UserStore>,
+ filter_editor: ViewHandle<Editor>,
+ collapsed_sections: Vec<Section>,
+ selection: Option<usize>,
+ _subscriptions: Vec<Subscription>,
+}
+
+impl ContactList {
+ pub fn new(
+ project: ModelHandle<Project>,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut ViewContext<Self>,
+ ) -> Self {
+ let filter_editor = cx.add_view(|cx| {
+ let mut editor = Editor::single_line(
+ Some(|theme| theme.contact_list.user_query_editor.clone()),
+ cx,
+ );
+ editor.set_placeholder_text("Filter contacts", cx);
+ editor
+ });
+
+ cx.subscribe(&filter_editor, |this, _, event, cx| {
+ if let editor::Event::BufferEdited = event {
+ let query = this.filter_editor.read(cx).text(cx);
+ if !query.is_empty() {
+ this.selection.take();
+ }
+ this.update_entries(cx);
+ if !query.is_empty() {
+ this.selection = this
+ .entries
+ .iter()
+ .position(|entry| !matches!(entry, ContactEntry::Header(_)));
+ }
+ }
+ })
+ .detach();
+
+ let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
+ let theme = cx.global::<Settings>().theme.clone();
+ let is_selected = this.selection == Some(ix);
+
+ match &this.entries[ix] {
+ ContactEntry::Header(section) => {
+ let is_collapsed = this.collapsed_sections.contains(section);
+ Self::render_header(
+ *section,
+ &theme.contact_list,
+ is_selected,
+ is_collapsed,
+ cx,
+ )
+ }
+ ContactEntry::CallParticipant { user, is_pending } => {
+ Self::render_call_participant(
+ user,
+ *is_pending,
+ is_selected,
+ &theme.contact_list,
+ )
+ }
+ ContactEntry::ParticipantProject {
+ project_id,
+ worktree_root_names,
+ host_user_id,
+ is_host,
+ is_last,
+ } => Self::render_participant_project(
+ *project_id,
+ worktree_root_names,
+ *host_user_id,
+ *is_host,
+ *is_last,
+ is_selected,
+ &theme.contact_list,
+ cx,
+ ),
+ ContactEntry::IncomingRequest(user) => Self::render_contact_request(
+ user.clone(),
+ this.user_store.clone(),
+ &theme.contact_list,
+ true,
+ is_selected,
+ cx,
+ ),
+ ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
+ user.clone(),
+ this.user_store.clone(),
+ &theme.contact_list,
+ false,
+ is_selected,
+ cx,
+ ),
+ ContactEntry::Contact(contact) => Self::render_contact(
+ contact,
+ &this.project,
+ &theme.contact_list,
+ is_selected,
+ cx,
+ ),
+ }
+ });
+
+ let active_call = ActiveCall::global(cx);
+ let mut subscriptions = Vec::new();
+ subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx)));
+ subscriptions.push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
+
+ let mut this = Self {
+ list_state,
+ selection: None,
+ collapsed_sections: Default::default(),
+ entries: Default::default(),
+ match_candidates: Default::default(),
+ filter_editor,
+ _subscriptions: subscriptions,
+ project,
+ user_store,
+ };
+ this.update_entries(cx);
+ this
+ }
+
+ fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
+ self.user_store
+ .update(cx, |store, cx| store.remove_contact(request.0, cx))
+ .detach();
+ }
+
+ fn respond_to_contact_request(
+ &mut self,
+ action: &RespondToContactRequest,
+ cx: &mut ViewContext<Self>,
+ ) {
+ self.user_store
+ .update(cx, |store, cx| {
+ store.respond_to_contact_request(action.user_id, action.accept, cx)
+ })
+ .detach();
+ }
+
+ fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
+ let did_clear = self.filter_editor.update(cx, |editor, cx| {
+ if editor.buffer().read(cx).len(cx) > 0 {
+ editor.set_text("", cx);
+ true
+ } else {
+ false
+ }
+ });
+ if !did_clear {
+ cx.emit(Event::Dismissed);
+ }
+ }
+
+ fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
+ if let Some(ix) = self.selection {
+ if self.entries.len() > ix + 1 {
+ self.selection = Some(ix + 1);
+ }
+ } else if !self.entries.is_empty() {
+ self.selection = Some(0);
+ }
+ cx.notify();
+ self.list_state.reset(self.entries.len());
+ }
+
+ fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
+ if let Some(ix) = self.selection {
+ if ix > 0 {
+ self.selection = Some(ix - 1);
+ } else {
+ self.selection = None;
+ }
+ }
+ cx.notify();
+ self.list_state.reset(self.entries.len());
+ }
+
+ fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
+ if let Some(selection) = self.selection {
+ if let Some(entry) = self.entries.get(selection) {
+ match entry {
+ ContactEntry::Header(section) => {
+ let section = *section;
+ self.toggle_expanded(&ToggleExpanded(section), cx);
+ }
+ ContactEntry::Contact(contact) => {
+ if contact.online && !contact.busy {
+ self.call(
+ &Call {
+ recipient_user_id: contact.user.id,
+ initial_project: Some(self.project.clone()),
+ },
+ cx,
+ );
+ }
+ }
+ ContactEntry::ParticipantProject {
+ project_id,
+ host_user_id,
+ is_host,
+ ..
+ } => {
+ if !is_host {
+ cx.dispatch_global_action(JoinProject {
+ project_id: *project_id,
+ follow_user_id: *host_user_id,
+ });
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+ }
+
+ fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
+ let section = action.0;
+ if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
+ self.collapsed_sections.remove(ix);
+ } else {
+ self.collapsed_sections.push(section);
+ }
+ self.update_entries(cx);
+ }
+
+ fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
+ let user_store = self.user_store.read(cx);
+ let query = self.filter_editor.read(cx).text(cx);
+ let executor = cx.background().clone();
+
+ let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
+ self.entries.clear();
+
+ if let Some(room) = ActiveCall::global(cx).read(cx).room() {
+ let room = room.read(cx);
+ let mut participant_entries = Vec::new();
+
+ // Populate the active user.
+ if let Some(user) = user_store.current_user() {
+ self.match_candidates.clear();
+ self.match_candidates.push(StringMatchCandidate {
+ id: 0,
+ string: user.github_login.clone(),
+ char_bag: user.github_login.chars().collect(),
+ });
+ let matches = executor.block(match_strings(
+ &self.match_candidates,
+ &query,
+ true,
+ usize::MAX,
+ &Default::default(),
+ executor.clone(),
+ ));
+ if !matches.is_empty() {
+ let user_id = user.id;
+ participant_entries.push(ContactEntry::CallParticipant {
+ user,
+ is_pending: false,
+ });
+ let mut projects = room.local_participant().projects.iter().peekable();
+ while let Some(project) = projects.next() {
+ participant_entries.push(ContactEntry::ParticipantProject {
+ project_id: project.id,
+ worktree_root_names: project.worktree_root_names.clone(),
+ host_user_id: user_id,
+ is_host: true,
+ is_last: projects.peek().is_none(),
+ });
+ }
+ }
+ }
+
+ // Populate remote participants.
+ self.match_candidates.clear();
+ self.match_candidates
+ .extend(
+ room.remote_participants()
+ .iter()
+ .map(|(peer_id, participant)| StringMatchCandidate {
+ id: peer_id.0 as usize,
+ string: participant.user.github_login.clone(),
+ char_bag: participant.user.github_login.chars().collect(),
+ }),
+ );
+ let matches = executor.block(match_strings(
+ &self.match_candidates,
+ &query,
+ true,
+ usize::MAX,
+ &Default::default(),
+ executor.clone(),
+ ));
+ for mat in matches {
+ let participant = &room.remote_participants()[&PeerId(mat.candidate_id as u32)];
+ participant_entries.push(ContactEntry::CallParticipant {
+ user: room.remote_participants()[&PeerId(mat.candidate_id as u32)]
+ .user
+ .clone(),
+ is_pending: false,
+ });
+ let mut projects = participant.projects.iter().peekable();
+ while let Some(project) = projects.next() {
+ participant_entries.push(ContactEntry::ParticipantProject {
+ project_id: project.id,
+ worktree_root_names: project.worktree_root_names.clone(),
+ host_user_id: participant.user.id,
+ is_host: false,
+ is_last: projects.peek().is_none(),
+ });
+ }
+ }
+
+ // Populate pending participants.
+ self.match_candidates.clear();
+ self.match_candidates
+ .extend(
+ room.pending_participants()
+ .iter()
+ .enumerate()
+ .map(|(id, participant)| StringMatchCandidate {
+ id,
+ string: participant.github_login.clone(),
+ char_bag: participant.github_login.chars().collect(),
+ }),
+ );
+ let matches = executor.block(match_strings(
+ &self.match_candidates,
+ &query,
+ true,
+ usize::MAX,
+ &Default::default(),
+ executor.clone(),
+ ));
+ participant_entries.extend(matches.iter().map(|mat| ContactEntry::CallParticipant {
+ user: room.pending_participants()[mat.candidate_id].clone(),
+ is_pending: true,
+ }));
+
+ if !participant_entries.is_empty() {
+ self.entries.push(ContactEntry::Header(Section::ActiveCall));
+ if !self.collapsed_sections.contains(&Section::ActiveCall) {
+ self.entries.extend(participant_entries);
+ }
+ }
+ }
+
+ let mut request_entries = Vec::new();
+ let incoming = user_store.incoming_contact_requests();
+ if !incoming.is_empty() {
+ self.match_candidates.clear();
+ self.match_candidates
+ .extend(
+ incoming
+ .iter()
+ .enumerate()
+ .map(|(ix, user)| StringMatchCandidate {
+ id: ix,
+ string: user.github_login.clone(),
+ char_bag: user.github_login.chars().collect(),
+ }),
+ );
+ let matches = executor.block(match_strings(
+ &self.match_candidates,
+ &query,
+ true,
+ usize::MAX,
+ &Default::default(),
+ executor.clone(),
+ ));
+ request_entries.extend(
+ matches
+ .iter()
+ .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
+ );
+ }
+
+ let outgoing = user_store.outgoing_contact_requests();
+ if !outgoing.is_empty() {
+ self.match_candidates.clear();
+ self.match_candidates
+ .extend(
+ outgoing
+ .iter()
+ .enumerate()
+ .map(|(ix, user)| StringMatchCandidate {
+ id: ix,
+ string: user.github_login.clone(),
+ char_bag: user.github_login.chars().collect(),
+ }),
+ );
+ let matches = executor.block(match_strings(
+ &self.match_candidates,
+ &query,
+ true,
+ usize::MAX,
+ &Default::default(),
+ executor.clone(),
+ ));
+ request_entries.extend(
+ matches
+ .iter()
+ .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
+ );
+ }
+
+ if !request_entries.is_empty() {
+ self.entries.push(ContactEntry::Header(Section::Requests));
+ if !self.collapsed_sections.contains(&Section::Requests) {
+ self.entries.append(&mut request_entries);
+ }
+ }
+
+ let contacts = user_store.contacts();
+ if !contacts.is_empty() {
+ self.match_candidates.clear();
+ self.match_candidates
+ .extend(
+ contacts
+ .iter()
+ .enumerate()
+ .map(|(ix, contact)| StringMatchCandidate {
+ id: ix,
+ string: contact.user.github_login.clone(),
+ char_bag: contact.user.github_login.chars().collect(),
+ }),
+ );
+
+ let matches = executor.block(match_strings(
+ &self.match_candidates,
+ &query,
+ true,
+ usize::MAX,
+ &Default::default(),
+ executor.clone(),
+ ));
+
+ let (mut online_contacts, offline_contacts) = matches
+ .iter()
+ .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
+ if let Some(room) = ActiveCall::global(cx).read(cx).room() {
+ let room = room.read(cx);
+ online_contacts.retain(|contact| {
+ let contact = &contacts[contact.candidate_id];
+ !room.contains_participant(contact.user.id)
+ });
+ }
+
+ for (matches, section) in [
+ (online_contacts, Section::Online),
+ (offline_contacts, Section::Offline),
+ ] {
+ if !matches.is_empty() {
+ self.entries.push(ContactEntry::Header(section));
+ if !self.collapsed_sections.contains(§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<Self>,
+ ) -> ElementBox {
+ let font_cache = cx.font_cache();
+ let host_avatar_height = theme
+ .contact_avatar
+ .width
+ .or(theme.contact_avatar.height)
+ .unwrap_or(0.);
+ let row = &theme.project_row.default;
+ let tree_branch = theme.tree_branch;
+ let line_height = row.name.text.line_height(font_cache);
+ let cap_height = row.name.text.cap_height(font_cache);
+ let baseline_offset =
+ row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
+ let project_name = if worktree_root_names.is_empty() {
+ "untitled".to_string()
+ } else {
+ worktree_root_names.join(", ")
+ };
+
+ MouseEventHandler::<JoinProject>::new(project_id as usize, cx, |mouse_state, _| {
+ let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
+ let row = theme.project_row.style_for(mouse_state, is_selected);
+
+ Flex::row()
+ .with_child(
+ Stack::new()
+ .with_child(
+ Canvas::new(move |bounds, _, cx| {
+ let start_x = bounds.min_x() + (bounds.width() / 2.)
+ - (tree_branch.width / 2.);
+ let end_x = bounds.max_x();
+ let start_y = bounds.min_y();
+ let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
+
+ cx.scene.push_quad(gpui::Quad {
+ bounds: RectF::from_points(
+ vec2f(start_x, start_y),
+ vec2f(
+ start_x + tree_branch.width,
+ if is_last { end_y } else { bounds.max_y() },
+ ),
+ ),
+ background: Some(tree_branch.color),
+ border: gpui::Border::default(),
+ corner_radius: 0.,
+ });
+ cx.scene.push_quad(gpui::Quad {
+ bounds: RectF::from_points(
+ vec2f(start_x, end_y),
+ vec2f(end_x, end_y + tree_branch.width),
+ ),
+ background: Some(tree_branch.color),
+ border: gpui::Border::default(),
+ corner_radius: 0.,
+ });
+ })
+ .boxed(),
+ )
+ .constrained()
+ .with_width(host_avatar_height)
+ .boxed(),
+ )
+ .with_child(
+ Label::new(project_name, row.name.text.clone())
+ .aligned()
+ .left()
+ .contained()
+ .with_style(row.name.container)
+ .flex(1., false)
+ .boxed(),
+ )
+ .constrained()
+ .with_height(theme.row_height)
+ .contained()
+ .with_style(row.container)
+ .boxed()
+ })
+ .with_cursor_style(if !is_host {
+ CursorStyle::PointingHand
+ } else {
+ CursorStyle::Arrow
+ })
+ .on_click(MouseButton::Left, move |_, cx| {
+ if !is_host {
+ cx.dispatch_global_action(JoinProject {
+ project_id,
+ follow_user_id: host_user_id,
+ });
+ }
+ })
+ .boxed()
+ }
+
+ fn render_header(
+ section: Section,
+ theme: &theme::ContactList,
+ is_selected: bool,
+ is_collapsed: bool,
+ cx: &mut RenderContext<Self>,
+ ) -> ElementBox {
+ enum Header {}
+
+ let header_style = theme.header_row.style_for(Default::default(), is_selected);
+ let text = match section {
+ Section::ActiveCall => "Collaborators",
+ Section::Requests => "Contact Requests",
+ Section::Online => "Online",
+ Section::Offline => "Offline",
+ };
+ let leave_call = if section == Section::ActiveCall {
+ Some(
+ MouseEventHandler::<LeaveCall>::new(0, cx, |state, _| {
+ let style = theme.leave_call.style_for(state, false);
+ Label::new("Leave Session".into(), style.text.clone())
+ .contained()
+ .with_style(style.container)
+ .boxed()
+ })
+ .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(LeaveCall))
+ .aligned()
+ .boxed(),
+ )
+ } else {
+ None
+ };
+
+ let icon_size = theme.section_icon_size;
+ MouseEventHandler::<Header>::new(section as usize, cx, |_, _| {
+ Flex::row()
+ .with_child(
+ Svg::new(if is_collapsed {
+ "icons/chevron_right_8.svg"
+ } else {
+ "icons/chevron_down_8.svg"
+ })
+ .with_color(header_style.text.color)
+ .constrained()
+ .with_max_width(icon_size)
+ .with_max_height(icon_size)
+ .aligned()
+ .constrained()
+ .with_width(icon_size)
+ .boxed(),
+ )
+ .with_child(
+ Label::new(text.to_string(), header_style.text.clone())
+ .aligned()
+ .left()
+ .contained()
+ .with_margin_left(theme.contact_username.container.margin.left)
+ .flex(1., true)
+ .boxed(),
+ )
+ .with_children(leave_call)
+ .constrained()
+ .with_height(theme.row_height)
+ .contained()
+ .with_style(header_style.container)
+ .boxed()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, cx| {
+ cx.dispatch_action(ToggleExpanded(section))
+ })
+ .boxed()
+ }
+
+ fn render_contact(
+ contact: &Contact,
+ project: &ModelHandle<Project>,
+ theme: &theme::ContactList,
+ is_selected: bool,
+ cx: &mut RenderContext<Self>,
+ ) -> ElementBox {
+ let online = contact.online;
+ let busy = contact.busy;
+ let user_id = contact.user.id;
+ let initial_project = project.clone();
+ let mut element =
+ MouseEventHandler::<Contact>::new(contact.user.id as usize, cx, |_, _| {
+ Flex::row()
+ .with_children(contact.user.avatar.clone().map(|avatar| {
+ let status_badge = if contact.online {
+ Some(
+ Empty::new()
+ .collapsed()
+ .contained()
+ .with_style(if contact.busy {
+ theme.contact_status_busy
+ } else {
+ theme.contact_status_free
+ })
+ .aligned()
+ .boxed(),
+ )
+ } else {
+ None
+ };
+ Stack::new()
+ .with_child(
+ Image::new(avatar)
+ .with_style(theme.contact_avatar)
+ .aligned()
+ .left()
+ .boxed(),
+ )
+ .with_children(status_badge)
+ .boxed()
+ }))
+ .with_child(
+ Label::new(
+ contact.user.github_login.clone(),
+ theme.contact_username.text.clone(),
+ )
+ .contained()
+ .with_style(theme.contact_username.container)
+ .aligned()
+ .left()
+ .flex(1., true)
+ .boxed(),
+ )
+ .constrained()
+ .with_height(theme.row_height)
+ .contained()
+ .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
+ .boxed()
+ })
+ .on_click(MouseButton::Left, move |_, cx| {
+ if online && !busy {
+ cx.dispatch_action(Call {
+ recipient_user_id: user_id,
+ initial_project: Some(initial_project.clone()),
+ });
+ }
+ });
+
+ if online {
+ element = element.with_cursor_style(CursorStyle::PointingHand);
+ }
+
+ element.boxed()
+ }
+
+ fn render_contact_request(
+ user: Arc<User>,
+ user_store: ModelHandle<UserStore>,
+ theme: &theme::ContactList,
+ is_incoming: bool,
+ is_selected: bool,
+ cx: &mut RenderContext<Self>,
+ ) -> ElementBox {
+ enum Decline {}
+ enum Accept {}
+ enum Cancel {}
+
+ let mut row = Flex::row()
+ .with_children(user.avatar.clone().map(|avatar| {
+ Image::new(avatar)
+ .with_style(theme.contact_avatar)
+ .aligned()
+ .left()
+ .boxed()
+ }))
+ .with_child(
+ Label::new(
+ user.github_login.clone(),
+ theme.contact_username.text.clone(),
+ )
+ .contained()
+ .with_style(theme.contact_username.container)
+ .aligned()
+ .left()
+ .flex(1., true)
+ .boxed(),
+ );
+
+ let user_id = user.id;
+ let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
+ let button_spacing = theme.contact_button_spacing;
+
+ if is_incoming {
+ row.add_children([
+ MouseEventHandler::<Decline>::new(user.id as usize, cx, |mouse_state, _| {
+ let button_style = if is_contact_request_pending {
+ &theme.disabled_button
+ } else {
+ theme.contact_button.style_for(mouse_state, false)
+ };
+ render_icon_button(button_style, "icons/x_mark_8.svg")
+ .aligned()
+ .boxed()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, cx| {
+ cx.dispatch_action(RespondToContactRequest {
+ user_id,
+ accept: false,
+ })
+ })
+ .contained()
+ .with_margin_right(button_spacing)
+ .boxed(),
+ MouseEventHandler::<Accept>::new(user.id as usize, cx, |mouse_state, _| {
+ let button_style = if is_contact_request_pending {
+ &theme.disabled_button
+ } else {
+ theme.contact_button.style_for(mouse_state, false)
+ };
+ render_icon_button(button_style, "icons/check_8.svg")
+ .aligned()
+ .flex_float()
+ .boxed()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, cx| {
+ cx.dispatch_action(RespondToContactRequest {
+ user_id,
+ accept: true,
+ })
+ })
+ .boxed(),
+ ]);
+ } else {
+ row.add_child(
+ MouseEventHandler::<Cancel>::new(user.id as usize, cx, |mouse_state, _| {
+ let button_style = if is_contact_request_pending {
+ &theme.disabled_button
+ } else {
+ theme.contact_button.style_for(mouse_state, false)
+ };
+ render_icon_button(button_style, "icons/x_mark_8.svg")
+ .aligned()
+ .flex_float()
+ .boxed()
+ })
+ .with_padding(Padding::uniform(2.))
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, cx| {
+ cx.dispatch_action(RemoveContact(user_id))
+ })
+ .flex_float()
+ .boxed(),
+ );
+ }
+
+ row.constrained()
+ .with_height(theme.row_height)
+ .contained()
+ .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
+ .boxed()
+ }
+
+ fn call(&mut self, action: &Call, cx: &mut ViewContext<Self>) {
+ let recipient_user_id = action.recipient_user_id;
+ let initial_project = action.initial_project.clone();
+ let window_id = cx.window_id();
+
+ let active_call = ActiveCall::global(cx);
+ cx.spawn_weak(|_, mut cx| async move {
+ active_call
+ .update(&mut cx, |active_call, cx| {
+ active_call.invite(recipient_user_id, initial_project.clone(), cx)
+ })
+ .await?;
+ if cx.update(|cx| cx.window_is_active(window_id)) {
+ active_call
+ .update(&mut cx, |call, cx| {
+ call.set_location(initial_project.as_ref(), cx)
+ })
+ .await?;
+ }
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
+ fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext<Self>) {
+ ActiveCall::global(cx)
+ .update(cx, |call, cx| call.hang_up(cx))
+ .log_err();
+ }
+}
+
+impl Entity for ContactList {
+ type Event = Event;
+}
+
+impl View for ContactList {
+ fn ui_name() -> &'static str {
+ "ContactList"
+ }
+
+ fn keymap_context(&self, _: &AppContext) -> keymap::Context {
+ let mut cx = Self::default_keymap_context();
+ cx.set.insert("menu".into());
+ cx
+ }
+
+ fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+ enum AddContact {}
+ let theme = cx.global::<Settings>().theme.clone();
+
+ Flex::column()
+ .with_child(
+ Flex::row()
+ .with_child(
+ ChildView::new(self.filter_editor.clone())
+ .contained()
+ .with_style(theme.contact_list.user_query_editor.container)
+ .flex(1., true)
+ .boxed(),
+ )
+ .with_child(
+ MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
+ render_icon_button(
+ &theme.contact_list.add_contact_button,
+ "icons/user_plus_16.svg",
+ )
+ .boxed()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, cx| {
+ cx.dispatch_action(contacts_popover::ToggleContactFinder)
+ })
+ .with_tooltip::<AddContact, _>(
+ 0,
+ "Add contact".into(),
+ None,
+ theme.tooltip.clone(),
+ cx,
+ )
+ .boxed(),
+ )
+ .constrained()
+ .with_height(theme.contact_list.user_query_editor_height)
+ .boxed(),
+ )
+ .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
+ .boxed()
+ }
+
+ fn on_focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+ if !self.filter_editor.is_focused(cx) {
+ cx.focus(&self.filter_editor);
+ }
+ }
+
+ fn on_focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+ if !self.filter_editor.is_focused(cx) {
+ cx.emit(Event::Dismissed);
+ }
+ }
+}
+
+fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
+ Svg::new(svg_path)
+ .with_color(style.color)
+ .constrained()
+ .with_width(style.icon_width)
+ .aligned()
+ .contained()
+ .with_style(style.container)
+ .constrained()
+ .with_width(style.button_width)
+ .with_height(style.button_width)
+}
@@ -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",
@@ -0,0 +1,162 @@
+use crate::{contact_finder::ContactFinder, contact_list::ContactList};
+use client::UserStore;
+use gpui::{
+ actions, elements::*, ClipboardItem, CursorStyle, Entity, ModelHandle, MouseButton,
+ MutableAppContext, RenderContext, View, ViewContext, ViewHandle,
+};
+use project::Project;
+use settings::Settings;
+
+actions!(contacts_popover, [ToggleContactFinder]);
+
+pub fn init(cx: &mut MutableAppContext) {
+ cx.add_action(ContactsPopover::toggle_contact_finder);
+}
+
+pub enum Event {
+ Dismissed,
+}
+
+enum Child {
+ ContactList(ViewHandle<ContactList>),
+ ContactFinder(ViewHandle<ContactFinder>),
+}
+
+pub struct ContactsPopover {
+ child: Child,
+ project: ModelHandle<Project>,
+ user_store: ModelHandle<UserStore>,
+ _subscription: Option<gpui::Subscription>,
+}
+
+impl ContactsPopover {
+ pub fn new(
+ project: ModelHandle<Project>,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut ViewContext<Self>,
+ ) -> Self {
+ let mut this = Self {
+ child: Child::ContactList(
+ cx.add_view(|cx| ContactList::new(project.clone(), user_store.clone(), cx)),
+ ),
+ project,
+ user_store,
+ _subscription: None,
+ };
+ this.show_contact_list(cx);
+ this
+ }
+
+ fn toggle_contact_finder(&mut self, _: &ToggleContactFinder, cx: &mut ViewContext<Self>) {
+ match &self.child {
+ Child::ContactList(_) => self.show_contact_finder(cx),
+ Child::ContactFinder(_) => self.show_contact_list(cx),
+ }
+ }
+
+ fn show_contact_finder(&mut self, cx: &mut ViewContext<ContactsPopover>) {
+ let child = cx.add_view(|cx| ContactFinder::new(self.user_store.clone(), cx));
+ cx.focus(&child);
+ self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event {
+ crate::contact_finder::Event::Dismissed => cx.emit(Event::Dismissed),
+ }));
+ self.child = Child::ContactFinder(child);
+ cx.notify();
+ }
+
+ fn show_contact_list(&mut self, cx: &mut ViewContext<ContactsPopover>) {
+ let child =
+ cx.add_view(|cx| ContactList::new(self.project.clone(), self.user_store.clone(), cx));
+ cx.focus(&child);
+ self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event {
+ crate::contact_list::Event::Dismissed => cx.emit(Event::Dismissed),
+ }));
+ self.child = Child::ContactList(child);
+ cx.notify();
+ }
+}
+
+impl Entity for ContactsPopover {
+ type Event = Event;
+}
+
+impl View for ContactsPopover {
+ fn ui_name() -> &'static str {
+ "ContactsPopover"
+ }
+
+ fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+ let theme = cx.global::<Settings>().theme.clone();
+ let child = match &self.child {
+ Child::ContactList(child) => ChildView::new(child),
+ Child::ContactFinder(child) => ChildView::new(child),
+ };
+
+ Flex::column()
+ .with_child(child.flex(1., true).boxed())
+ .with_children(
+ self.user_store
+ .read(cx)
+ .invite_info()
+ .cloned()
+ .and_then(|info| {
+ enum InviteLink {}
+
+ if info.count > 0 {
+ Some(
+ MouseEventHandler::<InviteLink>::new(0, cx, |state, cx| {
+ let style = theme
+ .contacts_popover
+ .invite_row
+ .style_for(state, false)
+ .clone();
+
+ let copied = cx.read_from_clipboard().map_or(false, |item| {
+ item.text().as_str() == info.url.as_ref()
+ });
+
+ Label::new(
+ format!(
+ "{} invite link ({} left)",
+ if copied { "Copied" } else { "Copy" },
+ info.count
+ ),
+ style.label.clone(),
+ )
+ .aligned()
+ .left()
+ .constrained()
+ .with_height(theme.contacts_popover.invite_row_height)
+ .contained()
+ .with_style(style.container)
+ .boxed()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, cx| {
+ cx.write_to_clipboard(ClipboardItem::new(info.url.to_string()));
+ cx.notify();
+ })
+ .boxed(),
+ )
+ } else {
+ None
+ }
+ }),
+ )
+ .contained()
+ .with_style(theme.contacts_popover.container)
+ .constrained()
+ .with_width(theme.contacts_popover.width)
+ .with_height(theme.contacts_popover.height)
+ .boxed()
+ }
+
+ fn on_focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+ if cx.is_self_focused() {
+ match &self.child {
+ Child::ContactList(child) => cx.focus(child),
+ Child::ContactFinder(child) => cx.focus(child),
+ }
+ }
+ }
+}
@@ -0,0 +1,232 @@
+use call::{ActiveCall, IncomingCall};
+use client::proto;
+use futures::StreamExt;
+use gpui::{
+ elements::*,
+ geometry::{rect::RectF, vector::vec2f},
+ impl_internal_actions, CursorStyle, Entity, MouseButton, MutableAppContext, RenderContext,
+ View, ViewContext, WindowBounds, WindowKind, WindowOptions,
+};
+use settings::Settings;
+use util::ResultExt;
+use workspace::JoinProject;
+
+impl_internal_actions!(incoming_call_notification, [RespondToCall]);
+
+pub fn init(cx: &mut MutableAppContext) {
+ cx.add_action(IncomingCallNotification::respond_to_call);
+
+ let mut incoming_call = ActiveCall::global(cx).read(cx).incoming();
+ cx.spawn(|mut cx| async move {
+ let mut notification_window = None;
+ while let Some(incoming_call) = incoming_call.next().await {
+ if let Some(window_id) = notification_window.take() {
+ cx.remove_window(window_id);
+ }
+
+ if let Some(incoming_call) = incoming_call {
+ const PADDING: f32 = 16.;
+ let screen_size = cx.platform().screen_size();
+
+ let window_size = cx.read(|cx| {
+ let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+ vec2f(theme.window_width, theme.window_height)
+ });
+ let (window_id, _) = cx.add_window(
+ WindowOptions {
+ bounds: WindowBounds::Fixed(RectF::new(
+ vec2f(screen_size.x() - window_size.x() - PADDING, PADDING),
+ window_size,
+ )),
+ titlebar: None,
+ center: false,
+ kind: WindowKind::PopUp,
+ is_movable: false,
+ },
+ |_| IncomingCallNotification::new(incoming_call),
+ );
+ notification_window = Some(window_id);
+ }
+ }
+ })
+ .detach();
+}
+
+#[derive(Clone, PartialEq)]
+struct RespondToCall {
+ accept: bool,
+}
+
+pub struct IncomingCallNotification {
+ call: IncomingCall,
+}
+
+impl IncomingCallNotification {
+ pub fn new(call: IncomingCall) -> Self {
+ Self { call }
+ }
+
+ fn respond_to_call(&mut self, action: &RespondToCall, cx: &mut ViewContext<Self>) {
+ let active_call = ActiveCall::global(cx);
+ if action.accept {
+ let join = active_call.update(cx, |active_call, cx| active_call.accept_incoming(cx));
+ let caller_user_id = self.call.caller.id;
+ let initial_project_id = self.call.initial_project.as_ref().map(|project| project.id);
+ cx.spawn_weak(|_, mut cx| async move {
+ join.await?;
+ if let Some(project_id) = initial_project_id {
+ cx.update(|cx| {
+ cx.dispatch_global_action(JoinProject {
+ project_id,
+ follow_user_id: caller_user_id,
+ })
+ });
+ }
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ } else {
+ active_call.update(cx, |active_call, _| {
+ active_call.decline_incoming().log_err();
+ });
+ }
+ }
+
+ fn render_caller(&self, cx: &mut RenderContext<Self>) -> ElementBox {
+ let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+ let default_project = proto::ParticipantProject::default();
+ let initial_project = self
+ .call
+ .initial_project
+ .as_ref()
+ .unwrap_or(&default_project);
+ Flex::row()
+ .with_children(self.call.caller.avatar.clone().map(|avatar| {
+ Image::new(avatar)
+ .with_style(theme.caller_avatar)
+ .aligned()
+ .boxed()
+ }))
+ .with_child(
+ Flex::column()
+ .with_child(
+ Label::new(
+ self.call.caller.github_login.clone(),
+ theme.caller_username.text.clone(),
+ )
+ .contained()
+ .with_style(theme.caller_username.container)
+ .boxed(),
+ )
+ .with_child(
+ Label::new(
+ format!(
+ "is sharing a project in Zed{}",
+ if initial_project.worktree_root_names.is_empty() {
+ ""
+ } else {
+ ":"
+ }
+ ),
+ theme.caller_message.text.clone(),
+ )
+ .contained()
+ .with_style(theme.caller_message.container)
+ .boxed(),
+ )
+ .with_children(if initial_project.worktree_root_names.is_empty() {
+ None
+ } else {
+ Some(
+ Label::new(
+ initial_project.worktree_root_names.join(", "),
+ theme.worktree_roots.text.clone(),
+ )
+ .contained()
+ .with_style(theme.worktree_roots.container)
+ .boxed(),
+ )
+ })
+ .contained()
+ .with_style(theme.caller_metadata)
+ .aligned()
+ .boxed(),
+ )
+ .contained()
+ .with_style(theme.caller_container)
+ .flex(1., true)
+ .boxed()
+ }
+
+ fn render_buttons(&self, cx: &mut RenderContext<Self>) -> ElementBox {
+ enum Accept {}
+ enum Decline {}
+
+ Flex::column()
+ .with_child(
+ MouseEventHandler::<Accept>::new(0, cx, |_, cx| {
+ let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+ Label::new("Accept".to_string(), theme.accept_button.text.clone())
+ .aligned()
+ .contained()
+ .with_style(theme.accept_button.container)
+ .boxed()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, cx| {
+ cx.dispatch_action(RespondToCall { accept: true });
+ })
+ .flex(1., true)
+ .boxed(),
+ )
+ .with_child(
+ MouseEventHandler::<Decline>::new(0, cx, |_, cx| {
+ let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+ Label::new("Decline".to_string(), theme.decline_button.text.clone())
+ .aligned()
+ .contained()
+ .with_style(theme.decline_button.container)
+ .boxed()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, cx| {
+ cx.dispatch_action(RespondToCall { accept: false });
+ })
+ .flex(1., true)
+ .boxed(),
+ )
+ .constrained()
+ .with_width(
+ cx.global::<Settings>()
+ .theme
+ .incoming_call_notification
+ .button_width,
+ )
+ .boxed()
+ }
+}
+
+impl Entity for IncomingCallNotification {
+ type Event = ();
+}
+
+impl View for IncomingCallNotification {
+ fn ui_name() -> &'static str {
+ "IncomingCallNotification"
+ }
+
+ fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
+ let background = cx
+ .global::<Settings>()
+ .theme
+ .incoming_call_notification
+ .background;
+ Flex::row()
+ .with_child(self.render_caller(cx))
+ .with_child(self.render_buttons(cx))
+ .contained()
+ .with_background_color(background)
+ .expanded()
+ .boxed()
+ }
+}
@@ -1,9 +1,7 @@
-use crate::render_icon_button;
use client::User;
use gpui::{
- elements::{Flex, Image, Label, MouseEventHandler, Padding, ParentElement, Text},
- platform::CursorStyle,
- Action, Element, ElementBox, MouseButton, RenderContext, View,
+ elements::*, platform::CursorStyle, Action, Element, ElementBox, MouseButton, RenderContext,
+ View,
};
use settings::Settings;
use std::sync::Arc;
@@ -53,11 +51,18 @@ pub fn render_user_notification<V: View, A: Action + Clone>(
)
.with_child(
MouseEventHandler::<Dismiss>::new(user.id as usize, cx, |state, _| {
- render_icon_button(
- theme.dismiss_button.style_for(state, false),
- "icons/x_mark_thin_8.svg",
- )
- .boxed()
+ let style = theme.dismiss_button.style_for(state, false);
+ Svg::new("icons/x_mark_thin_8.svg")
+ .with_color(style.color)
+ .constrained()
+ .with_width(style.icon_width)
+ .aligned()
+ .contained()
+ .with_style(style.container)
+ .constrained()
+ .with_width(style.button_width)
+ .with_height(style.button_width)
+ .boxed()
})
.with_cursor_style(CursorStyle::PointingHand)
.with_padding(Padding::uniform(5.))
@@ -0,0 +1,232 @@
+use call::{room, ActiveCall};
+use client::User;
+use collections::HashMap;
+use gpui::{
+ actions,
+ elements::*,
+ geometry::{rect::RectF, vector::vec2f},
+ CursorStyle, Entity, MouseButton, MutableAppContext, RenderContext, View, ViewContext,
+ WindowBounds, WindowKind, WindowOptions,
+};
+use settings::Settings;
+use std::sync::Arc;
+use workspace::JoinProject;
+
+actions!(project_shared_notification, [DismissProject]);
+
+pub fn init(cx: &mut MutableAppContext) {
+ cx.add_action(ProjectSharedNotification::join);
+ cx.add_action(ProjectSharedNotification::dismiss);
+
+ let active_call = ActiveCall::global(cx);
+ let mut notification_windows = HashMap::default();
+ cx.subscribe(&active_call, move |_, event, cx| match event {
+ room::Event::RemoteProjectShared {
+ owner,
+ project_id,
+ worktree_root_names,
+ } => {
+ const PADDING: f32 = 16.;
+ let screen_size = cx.platform().screen_size();
+
+ let theme = &cx.global::<Settings>().theme.project_shared_notification;
+ let window_size = vec2f(theme.window_width, theme.window_height);
+ let (window_id, _) = cx.add_window(
+ WindowOptions {
+ bounds: WindowBounds::Fixed(RectF::new(
+ vec2f(screen_size.x() - window_size.x() - PADDING, PADDING),
+ window_size,
+ )),
+ titlebar: None,
+ center: false,
+ kind: WindowKind::PopUp,
+ is_movable: false,
+ },
+ |_| {
+ ProjectSharedNotification::new(
+ owner.clone(),
+ *project_id,
+ worktree_root_names.clone(),
+ )
+ },
+ );
+ notification_windows.insert(*project_id, window_id);
+ }
+ room::Event::RemoteProjectUnshared { project_id } => {
+ if let Some(window_id) = notification_windows.remove(&project_id) {
+ cx.remove_window(window_id);
+ }
+ }
+ room::Event::Left => {
+ for (_, window_id) in notification_windows.drain() {
+ cx.remove_window(window_id);
+ }
+ }
+ })
+ .detach();
+}
+
+pub struct ProjectSharedNotification {
+ project_id: u64,
+ worktree_root_names: Vec<String>,
+ owner: Arc<User>,
+}
+
+impl ProjectSharedNotification {
+ fn new(owner: Arc<User>, project_id: u64, worktree_root_names: Vec<String>) -> Self {
+ Self {
+ project_id,
+ worktree_root_names,
+ owner,
+ }
+ }
+
+ fn join(&mut self, _: &JoinProject, cx: &mut ViewContext<Self>) {
+ let window_id = cx.window_id();
+ cx.remove_window(window_id);
+ cx.propagate_action();
+ }
+
+ fn dismiss(&mut self, _: &DismissProject, cx: &mut ViewContext<Self>) {
+ let window_id = cx.window_id();
+ cx.remove_window(window_id);
+ }
+
+ fn render_owner(&self, cx: &mut RenderContext<Self>) -> ElementBox {
+ let theme = &cx.global::<Settings>().theme.project_shared_notification;
+ Flex::row()
+ .with_children(self.owner.avatar.clone().map(|avatar| {
+ Image::new(avatar)
+ .with_style(theme.owner_avatar)
+ .aligned()
+ .boxed()
+ }))
+ .with_child(
+ Flex::column()
+ .with_child(
+ Label::new(
+ self.owner.github_login.clone(),
+ theme.owner_username.text.clone(),
+ )
+ .contained()
+ .with_style(theme.owner_username.container)
+ .boxed(),
+ )
+ .with_child(
+ Label::new(
+ format!(
+ "is sharing a project in Zed{}",
+ if self.worktree_root_names.is_empty() {
+ ""
+ } else {
+ ":"
+ }
+ ),
+ theme.message.text.clone(),
+ )
+ .contained()
+ .with_style(theme.message.container)
+ .boxed(),
+ )
+ .with_children(if self.worktree_root_names.is_empty() {
+ None
+ } else {
+ Some(
+ Label::new(
+ self.worktree_root_names.join(", "),
+ theme.worktree_roots.text.clone(),
+ )
+ .contained()
+ .with_style(theme.worktree_roots.container)
+ .boxed(),
+ )
+ })
+ .contained()
+ .with_style(theme.owner_metadata)
+ .aligned()
+ .boxed(),
+ )
+ .contained()
+ .with_style(theme.owner_container)
+ .flex(1., true)
+ .boxed()
+ }
+
+ fn render_buttons(&self, cx: &mut RenderContext<Self>) -> ElementBox {
+ enum Open {}
+ enum Dismiss {}
+
+ let project_id = self.project_id;
+ let owner_user_id = self.owner.id;
+
+ Flex::column()
+ .with_child(
+ MouseEventHandler::<Open>::new(0, cx, |_, cx| {
+ let theme = &cx.global::<Settings>().theme.project_shared_notification;
+ Label::new("Open".to_string(), theme.open_button.text.clone())
+ .aligned()
+ .contained()
+ .with_style(theme.open_button.container)
+ .boxed()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, cx| {
+ cx.dispatch_action(JoinProject {
+ project_id,
+ follow_user_id: owner_user_id,
+ });
+ })
+ .flex(1., true)
+ .boxed(),
+ )
+ .with_child(
+ MouseEventHandler::<Dismiss>::new(0, cx, |_, cx| {
+ let theme = &cx.global::<Settings>().theme.project_shared_notification;
+ Label::new("Dismiss".to_string(), theme.dismiss_button.text.clone())
+ .aligned()
+ .contained()
+ .with_style(theme.dismiss_button.container)
+ .boxed()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, cx| {
+ cx.dispatch_action(DismissProject);
+ })
+ .flex(1., true)
+ .boxed(),
+ )
+ .constrained()
+ .with_width(
+ cx.global::<Settings>()
+ .theme
+ .project_shared_notification
+ .button_width,
+ )
+ .boxed()
+ }
+}
+
+impl Entity for ProjectSharedNotification {
+ type Event = ();
+}
+
+impl View for ProjectSharedNotification {
+ fn ui_name() -> &'static str {
+ "ProjectSharedNotification"
+ }
+
+ fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
+ let background = cx
+ .global::<Settings>()
+ .theme
+ .project_shared_notification
+ .background;
+ Flex::row()
+ .with_child(self.render_owner(cx))
+ .with_child(self.render_buttons(cx))
+ .contained()
+ .with_background_color(background)
+ .expanded()
+ .boxed()
+ }
+}
@@ -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"] }
@@ -1,1653 +0,0 @@
-mod contact_finder;
-mod contact_notification;
-mod join_project_notification;
-mod notifications;
-
-use client::{Contact, ContactEventKind, User, UserStore};
-use contact_notification::ContactNotification;
-use editor::{Cancel, Editor};
-use fuzzy::{match_strings, StringMatchCandidate};
-use gpui::{
- actions,
- elements::*,
- geometry::{rect::RectF, vector::vec2f},
- impl_actions, impl_internal_actions,
- platform::CursorStyle,
- AnyViewHandle, AppContext, ClipboardItem, Element, ElementBox, Entity, ModelHandle,
- MouseButton, MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle,
- WeakModelHandle, WeakViewHandle,
-};
-use join_project_notification::JoinProjectNotification;
-use menu::{Confirm, SelectNext, SelectPrev};
-use project::{Project, ProjectStore};
-use serde::Deserialize;
-use settings::Settings;
-use std::{ops::DerefMut, sync::Arc};
-use theme::IconButton;
-use workspace::{sidebar::SidebarItem, JoinProject, ToggleProjectOnline, Workspace};
-
-actions!(contacts_panel, [ToggleFocus]);
-
-impl_actions!(
- contacts_panel,
- [RequestContact, RemoveContact, RespondToContactRequest]
-);
-
-impl_internal_actions!(contacts_panel, [ToggleExpanded]);
-
-#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
-enum Section {
- Requests,
- Online,
- Offline,
-}
-
-#[derive(Clone)]
-enum ContactEntry {
- Header(Section),
- IncomingRequest(Arc<User>),
- OutgoingRequest(Arc<User>),
- Contact(Arc<Contact>),
- ContactProject(Arc<Contact>, usize, Option<WeakModelHandle<Project>>),
- OfflineProject(WeakModelHandle<Project>),
-}
-
-#[derive(Clone, PartialEq)]
-struct ToggleExpanded(Section);
-
-pub struct ContactsPanel {
- entries: Vec<ContactEntry>,
- match_candidates: Vec<StringMatchCandidate>,
- list_state: ListState,
- user_store: ModelHandle<UserStore>,
- project_store: ModelHandle<ProjectStore>,
- filter_editor: ViewHandle<Editor>,
- collapsed_sections: Vec<Section>,
- selection: Option<usize>,
- _maintain_contacts: Subscription,
-}
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RequestContact(pub u64);
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RemoveContact(pub u64);
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RespondToContactRequest {
- pub user_id: u64,
- pub accept: bool,
-}
-
-pub fn init(cx: &mut MutableAppContext) {
- contact_finder::init(cx);
- contact_notification::init(cx);
- join_project_notification::init(cx);
- cx.add_action(ContactsPanel::request_contact);
- cx.add_action(ContactsPanel::remove_contact);
- cx.add_action(ContactsPanel::respond_to_contact_request);
- cx.add_action(ContactsPanel::clear_filter);
- cx.add_action(ContactsPanel::select_next);
- cx.add_action(ContactsPanel::select_prev);
- cx.add_action(ContactsPanel::confirm);
- cx.add_action(ContactsPanel::toggle_expanded);
-}
-
-impl ContactsPanel {
- pub fn new(
- user_store: ModelHandle<UserStore>,
- project_store: ModelHandle<ProjectStore>,
- workspace: WeakViewHandle<Workspace>,
- cx: &mut ViewContext<Self>,
- ) -> Self {
- let filter_editor = cx.add_view(|cx| {
- let mut editor = Editor::single_line(
- Some(|theme| theme.contacts_panel.user_query_editor.clone()),
- cx,
- );
- editor.set_placeholder_text("Filter contacts", cx);
- editor
- });
-
- cx.subscribe(&filter_editor, |this, _, event, cx| {
- if let editor::Event::BufferEdited = event {
- let query = this.filter_editor.read(cx).text(cx);
- if !query.is_empty() {
- this.selection.take();
- }
- this.update_entries(cx);
- if !query.is_empty() {
- this.selection = this
- .entries
- .iter()
- .position(|entry| !matches!(entry, ContactEntry::Header(_)));
- }
- }
- })
- .detach();
-
- cx.defer({
- let workspace = workspace.clone();
- move |_, cx| {
- if let Some(workspace_handle) = workspace.upgrade(cx) {
- cx.subscribe(&workspace_handle.read(cx).project().clone(), {
- let workspace = workspace;
- move |_, project, event, cx| {
- if let project::Event::ContactRequestedJoin(user) = event {
- if let Some(workspace) = workspace.upgrade(cx) {
- workspace.update(cx, |workspace, cx| {
- workspace.show_notification(user.id as usize, cx, |cx| {
- cx.add_view(|cx| {
- JoinProjectNotification::new(
- project,
- user.clone(),
- cx,
- )
- })
- })
- });
- }
- }
- }
- })
- .detach();
- }
- }
- });
-
- cx.observe(&project_store, |this, _, cx| this.update_entries(cx))
- .detach();
-
- cx.subscribe(&user_store, move |_, user_store, event, cx| {
- if let Some(workspace) = workspace.upgrade(cx) {
- workspace.update(cx, |workspace, cx| {
- if let client::Event::Contact { user, kind } = event {
- if let ContactEventKind::Requested | ContactEventKind::Accepted = kind {
- workspace.show_notification(user.id as usize, cx, |cx| {
- cx.add_view(|cx| {
- ContactNotification::new(user.clone(), *kind, user_store, cx)
- })
- })
- }
- }
- });
- }
-
- if let client::Event::ShowContacts = event {
- cx.emit(Event::Activate);
- }
- })
- .detach();
-
- let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
- let theme = cx.global::<Settings>().theme.clone();
- let current_user_id = this.user_store.read(cx).current_user().map(|user| user.id);
- let is_selected = this.selection == Some(ix);
-
- match &this.entries[ix] {
- ContactEntry::Header(section) => {
- let is_collapsed = this.collapsed_sections.contains(section);
- Self::render_header(
- *section,
- &theme.contacts_panel,
- is_selected,
- is_collapsed,
- cx,
- )
- }
- ContactEntry::IncomingRequest(user) => Self::render_contact_request(
- user.clone(),
- this.user_store.clone(),
- &theme.contacts_panel,
- true,
- is_selected,
- cx,
- ),
- ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
- user.clone(),
- this.user_store.clone(),
- &theme.contacts_panel,
- false,
- is_selected,
- cx,
- ),
- ContactEntry::Contact(contact) => {
- Self::render_contact(&contact.user, &theme.contacts_panel, is_selected)
- }
- ContactEntry::ContactProject(contact, project_ix, open_project) => {
- let is_last_project_for_contact =
- this.entries.get(ix + 1).map_or(true, |next| {
- if let ContactEntry::ContactProject(next_contact, _, _) = next {
- next_contact.user.id != contact.user.id
- } else {
- true
- }
- });
- Self::render_project(
- contact.clone(),
- current_user_id,
- *project_ix,
- *open_project,
- &theme.contacts_panel,
- &theme.tooltip,
- is_last_project_for_contact,
- is_selected,
- cx,
- )
- }
- ContactEntry::OfflineProject(project) => Self::render_offline_project(
- *project,
- &theme.contacts_panel,
- &theme.tooltip,
- is_selected,
- cx,
- ),
- }
- });
-
- let mut this = Self {
- list_state,
- selection: None,
- collapsed_sections: Default::default(),
- entries: Default::default(),
- match_candidates: Default::default(),
- filter_editor,
- _maintain_contacts: cx.observe(&user_store, |this, _, cx| this.update_entries(cx)),
- user_store,
- project_store,
- };
- this.update_entries(cx);
- this
- }
-
- fn render_header(
- section: Section,
- theme: &theme::ContactsPanel,
- is_selected: bool,
- is_collapsed: bool,
- cx: &mut RenderContext<Self>,
- ) -> ElementBox {
- enum Header {}
-
- let header_style = theme.header_row.style_for(Default::default(), is_selected);
- let text = match section {
- Section::Requests => "Requests",
- Section::Online => "Online",
- Section::Offline => "Offline",
- };
- let icon_size = theme.section_icon_size;
- MouseEventHandler::<Header>::new(section as usize, cx, |_, _| {
- Flex::row()
- .with_child(
- Svg::new(if is_collapsed {
- "icons/chevron_right_8.svg"
- } else {
- "icons/chevron_down_8.svg"
- })
- .with_color(header_style.text.color)
- .constrained()
- .with_max_width(icon_size)
- .with_max_height(icon_size)
- .aligned()
- .constrained()
- .with_width(icon_size)
- .boxed(),
- )
- .with_child(
- Label::new(text.to_string(), header_style.text.clone())
- .aligned()
- .left()
- .contained()
- .with_margin_left(theme.contact_username.container.margin.left)
- .flex(1., true)
- .boxed(),
- )
- .constrained()
- .with_height(theme.row_height)
- .contained()
- .with_style(header_style.container)
- .boxed()
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, cx| {
- cx.dispatch_action(ToggleExpanded(section))
- })
- .boxed()
- }
-
- fn render_contact(user: &User, theme: &theme::ContactsPanel, is_selected: bool) -> ElementBox {
- Flex::row()
- .with_children(user.avatar.clone().map(|avatar| {
- Image::new(avatar)
- .with_style(theme.contact_avatar)
- .aligned()
- .left()
- .boxed()
- }))
- .with_child(
- Label::new(
- user.github_login.clone(),
- theme.contact_username.text.clone(),
- )
- .contained()
- .with_style(theme.contact_username.container)
- .aligned()
- .left()
- .flex(1., true)
- .boxed(),
- )
- .constrained()
- .with_height(theme.row_height)
- .contained()
- .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
- .boxed()
- }
-
- #[allow(clippy::too_many_arguments)]
- fn render_project(
- contact: Arc<Contact>,
- current_user_id: Option<u64>,
- project_index: usize,
- open_project: Option<WeakModelHandle<Project>>,
- theme: &theme::ContactsPanel,
- tooltip_style: &TooltipStyle,
- is_last_project: bool,
- is_selected: bool,
- cx: &mut RenderContext<Self>,
- ) -> ElementBox {
- enum ToggleOnline {}
-
- let project = &contact.projects[project_index];
- let project_id = project.id;
- let is_host = Some(contact.user.id) == current_user_id;
- let open_project = open_project.and_then(|p| p.upgrade(cx.deref_mut()));
-
- let font_cache = cx.font_cache();
- let host_avatar_height = theme
- .contact_avatar
- .width
- .or(theme.contact_avatar.height)
- .unwrap_or(0.);
- let row = &theme.project_row.default;
- let tree_branch = theme.tree_branch;
- let line_height = row.name.text.line_height(font_cache);
- let cap_height = row.name.text.cap_height(font_cache);
- let baseline_offset =
- row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
-
- MouseEventHandler::<JoinProject>::new(project_id as usize, cx, |mouse_state, cx| {
- let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
- let row = theme.project_row.style_for(mouse_state, is_selected);
-
- Flex::row()
- .with_child(
- Stack::new()
- .with_child(
- Canvas::new(move |bounds, _, cx| {
- let start_x = bounds.min_x() + (bounds.width() / 2.)
- - (tree_branch.width / 2.);
- let end_x = bounds.max_x();
- let start_y = bounds.min_y();
- let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
-
- cx.scene.push_quad(gpui::Quad {
- bounds: RectF::from_points(
- vec2f(start_x, start_y),
- vec2f(
- start_x + tree_branch.width,
- if is_last_project {
- end_y
- } else {
- bounds.max_y()
- },
- ),
- ),
- background: Some(tree_branch.color),
- border: gpui::Border::default(),
- corner_radius: 0.,
- });
- cx.scene.push_quad(gpui::Quad {
- bounds: RectF::from_points(
- vec2f(start_x, end_y),
- vec2f(end_x, end_y + tree_branch.width),
- ),
- background: Some(tree_branch.color),
- border: gpui::Border::default(),
- corner_radius: 0.,
- });
- })
- .boxed(),
- )
- .with_children(open_project.and_then(|open_project| {
- let is_going_offline = !open_project.read(cx).is_online();
- if !mouse_state.hovered && !is_going_offline {
- return None;
- }
-
- let button = MouseEventHandler::<ToggleProjectOnline>::new(
- project_id as usize,
- cx,
- |state, _| {
- let mut icon_style =
- *theme.private_button.style_for(state, false);
- icon_style.container.background_color =
- row.container.background_color;
- if is_going_offline {
- icon_style.color = theme.disabled_button.color;
- }
- render_icon_button(&icon_style, "icons/lock_8.svg")
- .aligned()
- .boxed()
- },
- );
-
- if is_going_offline {
- Some(button.boxed())
- } else {
- Some(
- button
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, cx| {
- cx.dispatch_action(ToggleProjectOnline {
- project: Some(open_project.clone()),
- })
- })
- .with_tooltip::<ToggleOnline, _>(
- project_id as usize,
- "Take project offline".to_string(),
- None,
- tooltip_style.clone(),
- cx,
- )
- .boxed(),
- )
- }
- }))
- .constrained()
- .with_width(host_avatar_height)
- .boxed(),
- )
- .with_child(
- Label::new(
- project.visible_worktree_root_names.join(", "),
- row.name.text.clone(),
- )
- .aligned()
- .left()
- .contained()
- .with_style(row.name.container)
- .flex(1., false)
- .boxed(),
- )
- .with_children(project.guests.iter().filter_map(|participant| {
- participant.avatar.clone().map(|avatar| {
- Image::new(avatar)
- .with_style(row.guest_avatar)
- .aligned()
- .left()
- .contained()
- .with_margin_right(row.guest_avatar_spacing)
- .boxed()
- })
- }))
- .constrained()
- .with_height(theme.row_height)
- .contained()
- .with_style(row.container)
- .boxed()
- })
- .with_cursor_style(if !is_host {
- CursorStyle::PointingHand
- } else {
- CursorStyle::Arrow
- })
- .on_click(MouseButton::Left, move |_, cx| {
- if !is_host {
- cx.dispatch_global_action(JoinProject {
- contact: contact.clone(),
- project_index,
- });
- }
- })
- .boxed()
- }
-
- fn render_offline_project(
- project_handle: WeakModelHandle<Project>,
- theme: &theme::ContactsPanel,
- tooltip_style: &TooltipStyle,
- is_selected: bool,
- cx: &mut RenderContext<Self>,
- ) -> ElementBox {
- let host_avatar_height = theme
- .contact_avatar
- .width
- .or(theme.contact_avatar.height)
- .unwrap_or(0.);
-
- enum LocalProject {}
- enum ToggleOnline {}
-
- let project_id = project_handle.id();
- MouseEventHandler::<LocalProject>::new(project_id, cx, |state, cx| {
- let row = theme.project_row.style_for(state, is_selected);
- let mut worktree_root_names = String::new();
- let project = if let Some(project) = project_handle.upgrade(cx.deref_mut()) {
- project.read(cx)
- } else {
- return Empty::new().boxed();
- };
- let is_going_online = project.is_online();
- for tree in project.visible_worktrees(cx) {
- if !worktree_root_names.is_empty() {
- worktree_root_names.push_str(", ");
- }
- worktree_root_names.push_str(tree.read(cx).root_name());
- }
-
- Flex::row()
- .with_child({
- let button =
- MouseEventHandler::<ToggleOnline>::new(project_id, cx, |state, _| {
- let mut style = *theme.private_button.style_for(state, false);
- if is_going_online {
- style.color = theme.disabled_button.color;
- }
- render_icon_button(&style, "icons/lock_8.svg")
- .aligned()
- .constrained()
- .with_width(host_avatar_height)
- .boxed()
- });
-
- if is_going_online {
- button.boxed()
- } else {
- button
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, cx| {
- let project = project_handle.upgrade(cx.app);
- cx.dispatch_action(ToggleProjectOnline { project })
- })
- .with_tooltip::<ToggleOnline, _>(
- project_id,
- "Take project online".to_string(),
- None,
- tooltip_style.clone(),
- cx,
- )
- .boxed()
- }
- })
- .with_child(
- Label::new(worktree_root_names, row.name.text.clone())
- .aligned()
- .left()
- .contained()
- .with_style(row.name.container)
- .flex(1., false)
- .boxed(),
- )
- .constrained()
- .with_height(theme.row_height)
- .contained()
- .with_style(row.container)
- .boxed()
- })
- .boxed()
- }
-
- fn render_contact_request(
- user: Arc<User>,
- user_store: ModelHandle<UserStore>,
- theme: &theme::ContactsPanel,
- is_incoming: bool,
- is_selected: bool,
- cx: &mut RenderContext<ContactsPanel>,
- ) -> ElementBox {
- enum Decline {}
- enum Accept {}
- enum Cancel {}
-
- let mut row = Flex::row()
- .with_children(user.avatar.clone().map(|avatar| {
- Image::new(avatar)
- .with_style(theme.contact_avatar)
- .aligned()
- .left()
- .boxed()
- }))
- .with_child(
- Label::new(
- user.github_login.clone(),
- theme.contact_username.text.clone(),
- )
- .contained()
- .with_style(theme.contact_username.container)
- .aligned()
- .left()
- .flex(1., true)
- .boxed(),
- );
-
- let user_id = user.id;
- let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
- let button_spacing = theme.contact_button_spacing;
-
- if is_incoming {
- row.add_children([
- MouseEventHandler::<Decline>::new(user.id as usize, cx, |mouse_state, _| {
- let button_style = if is_contact_request_pending {
- &theme.disabled_button
- } else {
- theme.contact_button.style_for(mouse_state, false)
- };
- render_icon_button(button_style, "icons/x_mark_8.svg")
- .aligned()
- // .flex_float()
- .boxed()
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, cx| {
- cx.dispatch_action(RespondToContactRequest {
- user_id,
- accept: false,
- })
- })
- // .flex_float()
- .contained()
- .with_margin_right(button_spacing)
- .boxed(),
- MouseEventHandler::<Accept>::new(user.id as usize, cx, |mouse_state, _| {
- let button_style = if is_contact_request_pending {
- &theme.disabled_button
- } else {
- theme.contact_button.style_for(mouse_state, false)
- };
- render_icon_button(button_style, "icons/check_8.svg")
- .aligned()
- .flex_float()
- .boxed()
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, cx| {
- cx.dispatch_action(RespondToContactRequest {
- user_id,
- accept: true,
- })
- })
- .boxed(),
- ]);
- } else {
- row.add_child(
- MouseEventHandler::<Cancel>::new(user.id as usize, cx, |mouse_state, _| {
- let button_style = if is_contact_request_pending {
- &theme.disabled_button
- } else {
- theme.contact_button.style_for(mouse_state, false)
- };
- render_icon_button(button_style, "icons/x_mark_8.svg")
- .aligned()
- .flex_float()
- .boxed()
- })
- .with_padding(Padding::uniform(2.))
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, cx| {
- cx.dispatch_action(RemoveContact(user_id))
- })
- .flex_float()
- .boxed(),
- );
- }
-
- row.constrained()
- .with_height(theme.row_height)
- .contained()
- .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
- .boxed()
- }
-
- fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
- let user_store = self.user_store.read(cx);
- let project_store = self.project_store.read(cx);
- let query = self.filter_editor.read(cx).text(cx);
- let executor = cx.background().clone();
-
- let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
- self.entries.clear();
-
- let mut request_entries = Vec::new();
- let incoming = user_store.incoming_contact_requests();
- if !incoming.is_empty() {
- self.match_candidates.clear();
- self.match_candidates
- .extend(
- incoming
- .iter()
- .enumerate()
- .map(|(ix, user)| StringMatchCandidate {
- id: ix,
- string: user.github_login.clone(),
- char_bag: user.github_login.chars().collect(),
- }),
- );
- let matches = executor.block(match_strings(
- &self.match_candidates,
- &query,
- true,
- usize::MAX,
- &Default::default(),
- executor.clone(),
- ));
- request_entries.extend(
- matches
- .iter()
- .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
- );
- }
-
- let outgoing = user_store.outgoing_contact_requests();
- if !outgoing.is_empty() {
- self.match_candidates.clear();
- self.match_candidates
- .extend(
- outgoing
- .iter()
- .enumerate()
- .map(|(ix, user)| StringMatchCandidate {
- id: ix,
- string: user.github_login.clone(),
- char_bag: user.github_login.chars().collect(),
- }),
- );
- let matches = executor.block(match_strings(
- &self.match_candidates,
- &query,
- true,
- usize::MAX,
- &Default::default(),
- executor.clone(),
- ));
- request_entries.extend(
- matches
- .iter()
- .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
- );
- }
-
- if !request_entries.is_empty() {
- self.entries.push(ContactEntry::Header(Section::Requests));
- if !self.collapsed_sections.contains(&Section::Requests) {
- self.entries.append(&mut request_entries);
- }
- }
-
- let current_user = user_store.current_user();
-
- let contacts = user_store.contacts();
- if !contacts.is_empty() {
- // Always put the current user first.
- self.match_candidates.clear();
- self.match_candidates.reserve(contacts.len());
- self.match_candidates.push(StringMatchCandidate {
- id: 0,
- string: Default::default(),
- char_bag: Default::default(),
- });
- for (ix, contact) in contacts.iter().enumerate() {
- let candidate = StringMatchCandidate {
- id: ix,
- string: contact.user.github_login.clone(),
- char_bag: contact.user.github_login.chars().collect(),
- };
- if current_user
- .as_ref()
- .map_or(false, |current_user| current_user.id == contact.user.id)
- {
- self.match_candidates[0] = candidate;
- } else {
- self.match_candidates.push(candidate);
- }
- }
- if self.match_candidates[0].string.is_empty() {
- self.match_candidates.remove(0);
- }
-
- let matches = executor.block(match_strings(
- &self.match_candidates,
- &query,
- true,
- usize::MAX,
- &Default::default(),
- executor.clone(),
- ));
-
- let (online_contacts, offline_contacts) = matches
- .iter()
- .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
-
- for (matches, section) in [
- (online_contacts, Section::Online),
- (offline_contacts, Section::Offline),
- ] {
- if !matches.is_empty() {
- self.entries.push(ContactEntry::Header(section));
- if !self.collapsed_sections.contains(§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::<Vec<_>>();
- self.entries.extend(
- contact.projects.iter().enumerate().filter_map(
- |(ix, project)| {
- let open_project = open_projects
- .iter()
- .position(|p| {
- p.read(cx).remote_id() == Some(project.id)
- })
- .map(|ix| open_projects.remove(ix).downgrade());
- if project.visible_worktree_root_names.is_empty() {
- None
- } else {
- Some(ContactEntry::ContactProject(
- contact.clone(),
- ix,
- open_project,
- ))
- }
- },
- ),
- );
- self.entries.extend(open_projects.into_iter().filter_map(
- |project| {
- if project.read(cx).visible_worktrees(cx).next().is_none() {
- None
- } else {
- Some(ContactEntry::OfflineProject(project.downgrade()))
- }
- },
- ));
- } else {
- self.entries.extend(
- contact.projects.iter().enumerate().filter_map(
- |(ix, project)| {
- if project.visible_worktree_root_names.is_empty() {
- None
- } else {
- Some(ContactEntry::ContactProject(
- contact.clone(),
- ix,
- None,
- ))
- }
- },
- ),
- );
- }
- }
- }
- }
- }
- }
-
- if let Some(prev_selected_entry) = prev_selected_entry {
- self.selection.take();
- for (ix, entry) in self.entries.iter().enumerate() {
- if *entry == prev_selected_entry {
- self.selection = Some(ix);
- break;
- }
- }
- }
-
- self.list_state.reset(self.entries.len());
- cx.notify();
- }
-
- fn request_contact(&mut self, request: &RequestContact, cx: &mut ViewContext<Self>) {
- self.user_store
- .update(cx, |store, cx| store.request_contact(request.0, cx))
- .detach();
- }
-
- fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
- self.user_store
- .update(cx, |store, cx| store.remove_contact(request.0, cx))
- .detach();
- }
-
- fn respond_to_contact_request(
- &mut self,
- action: &RespondToContactRequest,
- cx: &mut ViewContext<Self>,
- ) {
- self.user_store
- .update(cx, |store, cx| {
- store.respond_to_contact_request(action.user_id, action.accept, cx)
- })
- .detach();
- }
-
- fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
- let did_clear = self.filter_editor.update(cx, |editor, cx| {
- if editor.buffer().read(cx).len(cx) > 0 {
- editor.set_text("", cx);
- true
- } else {
- false
- }
- });
- if !did_clear {
- cx.propagate_action();
- }
- }
-
- fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
- if let Some(ix) = self.selection {
- if self.entries.len() > ix + 1 {
- self.selection = Some(ix + 1);
- }
- } else if !self.entries.is_empty() {
- self.selection = Some(0);
- }
- cx.notify();
- self.list_state.reset(self.entries.len());
- }
-
- fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
- if let Some(ix) = self.selection {
- if ix > 0 {
- self.selection = Some(ix - 1);
- } else {
- self.selection = None;
- }
- }
- cx.notify();
- self.list_state.reset(self.entries.len());
- }
-
- fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
- if let Some(selection) = self.selection {
- if let Some(entry) = self.entries.get(selection) {
- match entry {
- ContactEntry::Header(section) => {
- let section = *section;
- self.toggle_expanded(&ToggleExpanded(section), cx);
- }
- ContactEntry::ContactProject(contact, project_index, open_project) => {
- if let Some(open_project) = open_project {
- workspace::activate_workspace_for_project(cx, |_, cx| {
- cx.model_id() == open_project.id()
- });
- } else {
- cx.dispatch_global_action(JoinProject {
- contact: contact.clone(),
- project_index: *project_index,
- })
- }
- }
- _ => {}
- }
- }
- }
- }
-
- fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
- let section = action.0;
- if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
- self.collapsed_sections.remove(ix);
- } else {
- self.collapsed_sections.push(section);
- }
- self.update_entries(cx);
- }
-}
-
-impl SidebarItem for ContactsPanel {
- fn should_show_badge(&self, cx: &AppContext) -> bool {
- !self
- .user_store
- .read(cx)
- .incoming_contact_requests()
- .is_empty()
- }
-
- fn contains_focused_view(&self, cx: &AppContext) -> bool {
- self.filter_editor.is_focused(cx)
- }
-
- fn should_activate_item_on_event(&self, event: &Event, _: &AppContext) -> bool {
- matches!(event, Event::Activate)
- }
-}
-
-fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
- Svg::new(svg_path)
- .with_color(style.color)
- .constrained()
- .with_width(style.icon_width)
- .aligned()
- .contained()
- .with_style(style.container)
- .constrained()
- .with_width(style.button_width)
- .with_height(style.button_width)
-}
-
-pub enum Event {
- Activate,
-}
-
-impl Entity for ContactsPanel {
- type Event = Event;
-}
-
-impl View for ContactsPanel {
- fn ui_name() -> &'static str {
- "ContactsPanel"
- }
-
- fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
- enum AddContact {}
-
- let theme = cx.global::<Settings>().theme.clone();
- let theme = &theme.contacts_panel;
- Container::new(
- Flex::column()
- .with_child(
- Flex::row()
- .with_child(
- ChildView::new(self.filter_editor.clone())
- .contained()
- .with_style(theme.user_query_editor.container)
- .flex(1., true)
- .boxed(),
- )
- .with_child(
- MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
- Svg::new("icons/user_plus_16.svg")
- .with_color(theme.add_contact_button.color)
- .constrained()
- .with_height(16.)
- .contained()
- .with_style(theme.add_contact_button.container)
- .aligned()
- .boxed()
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, |_, cx| {
- cx.dispatch_action(contact_finder::Toggle)
- })
- .boxed(),
- )
- .constrained()
- .with_height(theme.user_query_editor_height)
- .boxed(),
- )
- .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
- .with_children(
- self.user_store
- .read(cx)
- .invite_info()
- .cloned()
- .and_then(|info| {
- enum InviteLink {}
-
- if info.count > 0 {
- Some(
- MouseEventHandler::<InviteLink>::new(0, cx, |state, cx| {
- let style =
- theme.invite_row.style_for(state, false).clone();
-
- let copied =
- cx.read_from_clipboard().map_or(false, |item| {
- item.text().as_str() == info.url.as_ref()
- });
-
- Label::new(
- format!(
- "{} invite link ({} left)",
- if copied { "Copied" } else { "Copy" },
- info.count
- ),
- style.label.clone(),
- )
- .aligned()
- .left()
- .constrained()
- .with_height(theme.row_height)
- .contained()
- .with_style(style.container)
- .boxed()
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, cx| {
- cx.write_to_clipboard(ClipboardItem::new(
- info.url.to_string(),
- ));
- cx.notify();
- })
- .boxed(),
- )
- } else {
- None
- }
- }),
- )
- .boxed(),
- )
- .with_style(theme.container)
- .boxed()
- }
-
- fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
- cx.focus(&self.filter_editor);
- }
-
- fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
- let mut cx = Self::default_keymap_context();
- cx.set.insert("menu".into());
- cx
- }
-}
-
-impl PartialEq for ContactEntry {
- fn eq(&self, other: &Self) -> bool {
- match self {
- ContactEntry::Header(section_1) => {
- if let ContactEntry::Header(section_2) = other {
- return section_1 == section_2;
- }
- }
- ContactEntry::IncomingRequest(user_1) => {
- if let ContactEntry::IncomingRequest(user_2) = other {
- return user_1.id == user_2.id;
- }
- }
- ContactEntry::OutgoingRequest(user_1) => {
- if let ContactEntry::OutgoingRequest(user_2) = other {
- return user_1.id == user_2.id;
- }
- }
- ContactEntry::Contact(contact_1) => {
- if let ContactEntry::Contact(contact_2) = other {
- return contact_1.user.id == contact_2.user.id;
- }
- }
- ContactEntry::ContactProject(contact_1, ix_1, _) => {
- if let ContactEntry::ContactProject(contact_2, ix_2, _) = other {
- return contact_1.user.id == contact_2.user.id && ix_1 == ix_2;
- }
- }
- ContactEntry::OfflineProject(project_1) => {
- if let ContactEntry::OfflineProject(project_2) = other {
- return project_1.id() == project_2.id();
- }
- }
- }
- false
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use client::{
- proto,
- test::{FakeHttpClient, FakeServer},
- Client,
- };
- use collections::HashSet;
- use gpui::{serde_json::json, TestAppContext};
- use language::LanguageRegistry;
- use project::{FakeFs, Project};
-
- #[gpui::test]
- async fn test_contact_panel(cx: &mut TestAppContext) {
- Settings::test_async(cx);
- let current_user_id = 100;
-
- let languages = Arc::new(LanguageRegistry::test());
- let http_client = FakeHttpClient::with_404_response();
- let client = cx.read(|cx| Client::new(http_client.clone(), cx));
- let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
- let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
- let server = FakeServer::for_client(current_user_id, &client, cx).await;
-
- let fs = FakeFs::new(cx.background());
- fs.insert_tree("/private_dir", json!({ "one.rs": "" }))
- .await;
- let project = cx.update(|cx| {
- Project::local(
- false,
- client.clone(),
- user_store.clone(),
- project_store.clone(),
- languages,
- fs,
- cx,
- )
- });
- let worktree_id = project
- .update(cx, |project, cx| {
- project.find_or_create_local_worktree("/private_dir", true, cx)
- })
- .await
- .unwrap()
- .0
- .read_with(cx, |worktree, _| worktree.id().to_proto());
-
- let (_, workspace) =
- cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
- let panel = cx.add_view(&workspace, |cx| {
- ContactsPanel::new(
- user_store.clone(),
- project_store.clone(),
- workspace.downgrade(),
- cx,
- )
- });
-
- workspace.update(cx, |_, cx| {
- cx.observe(&panel, |_, panel, cx| {
- let entries = render_to_strings(&panel, cx);
- assert!(
- entries.iter().collect::<HashSet<_>>().len() == entries.len(),
- "Duplicate contact panel entries {:?}",
- entries
- )
- })
- .detach();
- });
-
- let get_users_request = server.receive::<proto::GetUsers>().await.unwrap();
- server
- .respond(
- get_users_request.receipt(),
- proto::UsersResponse {
- users: [
- "user_zero",
- "user_one",
- "user_two",
- "user_three",
- "user_four",
- "user_five",
- ]
- .into_iter()
- .enumerate()
- .map(|(id, name)| proto::User {
- id: id as u64,
- github_login: name.to_string(),
- ..Default::default()
- })
- .chain([proto::User {
- id: current_user_id,
- github_login: "the_current_user".to_string(),
- ..Default::default()
- }])
- .collect(),
- },
- )
- .await;
-
- let request = server.receive::<proto::RegisterProject>().await.unwrap();
- server
- .respond(
- request.receipt(),
- proto::RegisterProjectResponse { project_id: 200 },
- )
- .await;
-
- server.send(proto::UpdateContacts {
- incoming_requests: vec![proto::IncomingContactRequest {
- requester_id: 1,
- should_notify: false,
- }],
- outgoing_requests: vec![2],
- contacts: vec![
- proto::Contact {
- user_id: 3,
- online: true,
- should_notify: false,
- projects: vec![proto::ProjectMetadata {
- id: 101,
- visible_worktree_root_names: vec!["dir1".to_string()],
- guests: vec![2],
- }],
- },
- proto::Contact {
- user_id: 4,
- online: true,
- should_notify: false,
- projects: vec![proto::ProjectMetadata {
- id: 102,
- visible_worktree_root_names: vec!["dir2".to_string()],
- guests: vec![2],
- }],
- },
- proto::Contact {
- user_id: 5,
- online: false,
- should_notify: false,
- projects: vec![],
- },
- proto::Contact {
- user_id: current_user_id,
- online: true,
- should_notify: false,
- projects: vec![proto::ProjectMetadata {
- id: 103,
- visible_worktree_root_names: vec!["dir3".to_string()],
- guests: vec![3],
- }],
- },
- ],
- ..Default::default()
- });
-
- assert_eq!(
- server
- .receive::<proto::UpdateProject>()
- .await
- .unwrap()
- .payload,
- proto::UpdateProject {
- project_id: 200,
- online: false,
- worktrees: vec![]
- },
- );
-
- cx.foreground().run_until_parked();
- assert_eq!(
- cx.read(|cx| render_to_strings(&panel, cx)),
- &[
- "v Requests",
- " incoming user_one",
- " outgoing user_two",
- "v Online",
- " the_current_user",
- " dir3",
- " 🔒 private_dir",
- " user_four",
- " dir2",
- " user_three",
- " dir1",
- "v Offline",
- " user_five",
- ]
- );
-
- // Take a project online. It appears as loading, since the project
- // isn't yet visible to other contacts.
- project.update(cx, |project, cx| project.set_online(true, cx));
- cx.foreground().run_until_parked();
- assert_eq!(
- cx.read(|cx| render_to_strings(&panel, cx)),
- &[
- "v Requests",
- " incoming user_one",
- " outgoing user_two",
- "v Online",
- " the_current_user",
- " dir3",
- " 🔒 private_dir (going online...)",
- " user_four",
- " dir2",
- " user_three",
- " dir1",
- "v Offline",
- " user_five",
- ]
- );
-
- // The server receives the project's metadata and updates the contact metadata
- // for the current user. Now the project appears as online.
- assert_eq!(
- server
- .receive::<proto::UpdateProject>()
- .await
- .unwrap()
- .payload,
- proto::UpdateProject {
- project_id: 200,
- online: true,
- worktrees: vec![proto::WorktreeMetadata {
- id: worktree_id,
- root_name: "private_dir".to_string(),
- visible: true,
- }]
- },
- );
- server
- .receive::<proto::UpdateWorktreeExtensions>()
- .await
- .unwrap();
-
- server.send(proto::UpdateContacts {
- contacts: vec![proto::Contact {
- user_id: current_user_id,
- online: true,
- should_notify: false,
- projects: vec![
- proto::ProjectMetadata {
- id: 103,
- visible_worktree_root_names: vec!["dir3".to_string()],
- guests: vec![3],
- },
- proto::ProjectMetadata {
- id: 200,
- visible_worktree_root_names: vec!["private_dir".to_string()],
- guests: vec![3],
- },
- ],
- }],
- ..Default::default()
- });
- cx.foreground().run_until_parked();
- assert_eq!(
- cx.read(|cx| render_to_strings(&panel, cx)),
- &[
- "v Requests",
- " incoming user_one",
- " outgoing user_two",
- "v Online",
- " the_current_user",
- " dir3",
- " private_dir",
- " user_four",
- " dir2",
- " user_three",
- " dir1",
- "v Offline",
- " user_five",
- ]
- );
-
- // Take the project offline. It appears as loading.
- project.update(cx, |project, cx| project.set_online(false, cx));
- cx.foreground().run_until_parked();
- assert_eq!(
- cx.read(|cx| render_to_strings(&panel, cx)),
- &[
- "v Requests",
- " incoming user_one",
- " outgoing user_two",
- "v Online",
- " the_current_user",
- " dir3",
- " private_dir (going offline...)",
- " user_four",
- " dir2",
- " user_three",
- " dir1",
- "v Offline",
- " user_five",
- ]
- );
-
- // The server receives the unregister request and updates the contact
- // metadata for the current user. The project is now offline.
- assert_eq!(
- server
- .receive::<proto::UpdateProject>()
- .await
- .unwrap()
- .payload,
- proto::UpdateProject {
- project_id: 200,
- online: false,
- worktrees: vec![]
- },
- );
-
- server.send(proto::UpdateContacts {
- contacts: vec![proto::Contact {
- user_id: current_user_id,
- online: true,
- should_notify: false,
- projects: vec![proto::ProjectMetadata {
- id: 103,
- visible_worktree_root_names: vec!["dir3".to_string()],
- guests: vec![3],
- }],
- }],
- ..Default::default()
- });
- cx.foreground().run_until_parked();
- assert_eq!(
- cx.read(|cx| render_to_strings(&panel, cx)),
- &[
- "v Requests",
- " incoming user_one",
- " outgoing user_two",
- "v Online",
- " the_current_user",
- " dir3",
- " 🔒 private_dir",
- " user_four",
- " dir2",
- " user_three",
- " dir1",
- "v Offline",
- " user_five",
- ]
- );
-
- panel.update(cx, |panel, cx| {
- panel
- .filter_editor
- .update(cx, |editor, cx| editor.set_text("f", cx))
- });
- cx.foreground().run_until_parked();
- assert_eq!(
- cx.read(|cx| render_to_strings(&panel, cx)),
- &[
- "v Online",
- " user_four <=== selected",
- " dir2",
- "v Offline",
- " user_five",
- ]
- );
-
- panel.update(cx, |panel, cx| {
- panel.select_next(&Default::default(), cx);
- });
- assert_eq!(
- cx.read(|cx| render_to_strings(&panel, cx)),
- &[
- "v Online",
- " user_four",
- " dir2 <=== selected",
- "v Offline",
- " user_five",
- ]
- );
-
- panel.update(cx, |panel, cx| {
- panel.select_next(&Default::default(), cx);
- });
- assert_eq!(
- cx.read(|cx| render_to_strings(&panel, cx)),
- &[
- "v Online",
- " user_four",
- " dir2",
- "v Offline <=== selected",
- " user_five",
- ]
- );
- }
-
- fn render_to_strings(panel: &ViewHandle<ContactsPanel>, cx: &AppContext) -> Vec<String> {
- let panel = panel.read(cx);
- let mut entries = Vec::new();
- entries.extend(panel.entries.iter().enumerate().map(|(ix, entry)| {
- let mut string = match entry {
- ContactEntry::Header(name) => {
- let icon = if panel.collapsed_sections.contains(name) {
- ">"
- } else {
- "v"
- };
- format!("{} {:?}", icon, name)
- }
- ContactEntry::IncomingRequest(user) => {
- format!(" incoming {}", user.github_login)
- }
- ContactEntry::OutgoingRequest(user) => {
- format!(" outgoing {}", user.github_login)
- }
- ContactEntry::Contact(contact) => {
- format!(" {}", contact.user.github_login)
- }
- ContactEntry::ContactProject(contact, project_ix, project) => {
- let project = project
- .and_then(|p| p.upgrade(cx))
- .map(|project| project.read(cx));
- format!(
- " {}{}",
- contact.projects[*project_ix]
- .visible_worktree_root_names
- .join(", "),
- if project.map_or(true, |project| project.is_online()) {
- ""
- } else {
- " (going offline...)"
- },
- )
- }
- ContactEntry::OfflineProject(project) => {
- let project = project.upgrade(cx).unwrap().read(cx);
- format!(
- " 🔒 {}{}",
- project
- .worktree_root_names(cx)
- .collect::<Vec<_>>()
- .join(", "),
- if project.is_online() {
- " (going online...)"
- } else {
- ""
- },
- )
- }
- };
-
- if panel.selection == Some(ix) {
- string.push_str(" <=== selected");
- }
-
- string
- }));
- entries
- }
-}
@@ -1,80 +0,0 @@
-use client::User;
-use gpui::{
- actions, ElementBox, Entity, ModelHandle, MutableAppContext, RenderContext, View, ViewContext,
-};
-use project::Project;
-use std::sync::Arc;
-use workspace::Notification;
-
-use crate::notifications::render_user_notification;
-
-pub fn init(cx: &mut MutableAppContext) {
- cx.add_action(JoinProjectNotification::decline);
- cx.add_action(JoinProjectNotification::accept);
-}
-
-pub enum Event {
- Dismiss,
-}
-
-actions!(contacts_panel, [Accept, Decline]);
-
-pub struct JoinProjectNotification {
- project: ModelHandle<Project>,
- user: Arc<User>,
-}
-
-impl JoinProjectNotification {
- pub fn new(project: ModelHandle<Project>, user: Arc<User>, cx: &mut ViewContext<Self>) -> Self {
- cx.subscribe(&project, |this, _, event, cx| {
- if let project::Event::ContactCancelledJoinRequest(user) = event {
- if *user == this.user {
- cx.emit(Event::Dismiss);
- }
- }
- })
- .detach();
- Self { project, user }
- }
-
- fn decline(&mut self, _: &Decline, cx: &mut ViewContext<Self>) {
- self.project.update(cx, |project, cx| {
- project.respond_to_join_request(self.user.id, false, cx)
- });
- cx.emit(Event::Dismiss)
- }
-
- fn accept(&mut self, _: &Accept, cx: &mut ViewContext<Self>) {
- self.project.update(cx, |project, cx| {
- project.respond_to_join_request(self.user.id, true, cx)
- });
- cx.emit(Event::Dismiss)
- }
-}
-
-impl Entity for JoinProjectNotification {
- type Event = Event;
-}
-
-impl View for JoinProjectNotification {
- fn ui_name() -> &'static str {
- "JoinProjectNotification"
- }
-
- fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
- render_user_notification(
- self.user.clone(),
- "wants to join your project",
- None,
- Decline,
- vec![("Decline", Box::new(Decline)), ("Accept", Box::new(Accept))],
- cx,
- )
- }
-}
-
-impl Notification for JoinProjectNotification {
- fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
- matches!(event, Event::Dismiss)
- }
-}
@@ -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"] }
@@ -1,94 +0,0 @@
-use editor::Editor;
-use gpui::{elements::*, Entity, RenderContext, View, ViewContext, ViewHandle};
-use settings::Settings;
-
-pub enum Event {
- Deactivated,
-}
-
-pub struct ContactsPopover {
- filter_editor: ViewHandle<Editor>,
-}
-
-impl Entity for ContactsPopover {
- type Event = Event;
-}
-
-impl View for ContactsPopover {
- fn ui_name() -> &'static str {
- "ContactsPopover"
- }
-
- fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
- let theme = &cx.global::<Settings>().theme.contacts_popover;
-
- Flex::row()
- .with_child(
- ChildView::new(self.filter_editor.clone())
- .contained()
- .with_style(
- cx.global::<Settings>()
- .theme
- .contacts_panel
- .user_query_editor
- .container,
- )
- .flex(1., true)
- .boxed(),
- )
- // .with_child(
- // MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
- // Svg::new("icons/user_plus_16.svg")
- // .with_color(theme.add_contact_button.color)
- // .constrained()
- // .with_height(16.)
- // .contained()
- // .with_style(theme.add_contact_button.container)
- // .aligned()
- // .boxed()
- // })
- // .with_cursor_style(CursorStyle::PointingHand)
- // .on_click(MouseButton::Left, |_, cx| {
- // cx.dispatch_action(contact_finder::Toggle)
- // })
- // .boxed(),
- // )
- .constrained()
- .with_height(
- cx.global::<Settings>()
- .theme
- .contacts_panel
- .user_query_editor_height,
- )
- .aligned()
- .top()
- .contained()
- .with_background_color(theme.background)
- .with_uniform_padding(4.)
- .boxed()
- }
-}
-
-impl ContactsPopover {
- pub fn new(cx: &mut ViewContext<Self>) -> Self {
- cx.observe_window_activation(Self::window_activation_changed)
- .detach();
-
- let filter_editor = cx.add_view(|cx| {
- let mut editor = Editor::single_line(
- Some(|theme| theme.contacts_panel.user_query_editor.clone()),
- cx,
- );
- editor.set_placeholder_text("Filter contacts", cx);
- editor
- });
-
- Self { filter_editor }
- }
-
- fn window_activation_changed(&mut self, is_active: bool, cx: &mut ViewContext<Self>) {
- if !is_active {
- cx.emit(Event::Deactivated);
- }
- }
-}
@@ -1,94 +0,0 @@
-mod contacts_popover;
-
-use contacts_popover::ContactsPopover;
-use gpui::{
- actions,
- color::Color,
- elements::*,
- geometry::{rect::RectF, vector::vec2f},
- Appearance, Entity, MouseButton, MutableAppContext, RenderContext, View, ViewContext,
- ViewHandle, WindowKind,
-};
-
-actions!(contacts_status_item, [ToggleContactsPopover]);
-
-pub fn init(cx: &mut MutableAppContext) {
- cx.add_action(ContactsStatusItem::toggle_contacts_popover);
-}
-
-pub struct ContactsStatusItem {
- popover: Option<ViewHandle<ContactsPopover>>,
-}
-
-impl Entity for ContactsStatusItem {
- type Event = ();
-}
-
-impl View for ContactsStatusItem {
- fn ui_name() -> &'static str {
- "ContactsStatusItem"
- }
-
- fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
- let color = match cx.appearance {
- Appearance::Light | Appearance::VibrantLight => Color::black(),
- Appearance::Dark | Appearance::VibrantDark => Color::white(),
- };
- MouseEventHandler::<Self>::new(0, cx, |_, _| {
- Svg::new("icons/zed_22.svg")
- .with_color(color)
- .aligned()
- .boxed()
- })
- .on_click(MouseButton::Left, |_, cx| {
- cx.dispatch_action(ToggleContactsPopover);
- })
- .boxed()
- }
-}
-
-impl ContactsStatusItem {
- pub fn new() -> Self {
- Self { popover: None }
- }
-
- fn toggle_contacts_popover(&mut self, _: &ToggleContactsPopover, cx: &mut ViewContext<Self>) {
- match self.popover.take() {
- Some(popover) => {
- cx.remove_window(popover.window_id());
- }
- None => {
- let window_bounds = cx.window_bounds();
- let size = vec2f(360., 460.);
- let origin = window_bounds.lower_left()
- + vec2f(window_bounds.width() / 2. - size.x() / 2., 0.);
- let (_, popover) = cx.add_window(
- gpui::WindowOptions {
- bounds: gpui::WindowBounds::Fixed(RectF::new(origin, size)),
- titlebar: None,
- center: false,
- kind: WindowKind::PopUp,
- is_movable: false,
- },
- |cx| ContactsPopover::new(cx),
- );
- cx.subscribe(&popover, Self::on_popover_event).detach();
- self.popover = Some(popover);
- }
- }
- }
-
- fn on_popover_event(
- &mut self,
- popover: ViewHandle<ContactsPopover>,
- event: &contacts_popover::Event,
- cx: &mut ViewContext<Self>,
- ) {
- match event {
- contacts_popover::Event::Deactivated => {
- self.popover.take();
- cx.remove_window(popover.window_id());
- }
- }
- }
-}
@@ -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(
@@ -786,6 +786,24 @@ impl AsyncAppContext {
self.update(|cx| cx.add_window(window_options, build_root_view))
}
+ pub fn remove_window(&mut self, window_id: usize) {
+ self.update(|cx| cx.remove_window(window_id))
+ }
+
+ pub fn activate_window(&mut self, window_id: usize) {
+ self.update(|cx| cx.activate_window(window_id))
+ }
+
+ pub fn prompt(
+ &mut self,
+ window_id: usize,
+ level: PromptLevel,
+ msg: &str,
+ answers: &[&str],
+ ) -> oneshot::Receiver<usize> {
+ self.update(|cx| cx.prompt(window_id, level, msg, answers))
+ }
+
pub fn platform(&self) -> Arc<dyn Platform> {
self.0.borrow().platform()
}
@@ -1519,6 +1537,17 @@ impl MutableAppContext {
}
}
+ pub fn observe_default_global<G, F>(&mut self, observe: F) -> Subscription
+ where
+ G: Any + Default,
+ F: 'static + FnMut(&mut MutableAppContext),
+ {
+ if !self.has_global::<G>() {
+ self.set_global(G::default());
+ }
+ self.observe_global::<G, F>(observe)
+ }
+
pub fn observe_release<E, H, F>(&mut self, handle: &H, callback: F) -> Subscription
where
E: Entity,
@@ -1887,6 +1916,10 @@ impl MutableAppContext {
})
}
+ pub fn clear_globals(&mut self) {
+ self.cx.globals.clear();
+ }
+
pub fn add_model<T, F>(&mut self, build_model: F) -> ModelHandle<T>
where
T: Entity,
@@ -1967,6 +2000,10 @@ impl MutableAppContext {
})
}
+ pub fn remove_status_bar_item(&mut self, id: usize) {
+ self.remove_window(id);
+ }
+
fn register_platform_window(
&mut self,
window_id: usize,
@@ -4650,6 +4687,12 @@ impl<T> PartialEq for WeakModelHandle<T> {
impl<T> Eq for WeakModelHandle<T> {}
+impl<T: Entity> PartialEq<ModelHandle<T>> for WeakModelHandle<T> {
+ fn eq(&self, other: &ModelHandle<T>) -> bool {
+ self.model_id == other.model_id
+ }
+}
+
impl<T> Clone for WeakModelHandle<T> {
fn clone(&self) -> Self {
Self {
@@ -271,9 +271,6 @@ impl<T: Element> AnyElement for Lifecycle<T> {
mut layout,
} => {
let bounds = RectF::new(origin, size);
- let visible_bounds = visible_bounds
- .intersection(bounds)
- .unwrap_or_else(|| RectF::new(bounds.origin(), Vector2F::default()));
let paint = element.paint(bounds, visible_bounds, &mut layout, cx);
Lifecycle::PostPaint {
element,
@@ -292,9 +289,6 @@ impl<T: Element> AnyElement for Lifecycle<T> {
..
} => {
let bounds = RectF::new(origin, bounds.size());
- let visible_bounds = visible_bounds
- .intersection(bounds)
- .unwrap_or_else(|| RectF::new(bounds.origin(), Vector2F::default()));
let paint = element.paint(bounds, visible_bounds, &mut layout, cx);
Lifecycle::PostPaint {
element,
@@ -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 {
@@ -27,6 +27,8 @@ pub struct ImageStyle {
pub height: Option<f32>,
#[serde(default)]
pub width: Option<f32>,
+ #[serde(default)]
+ pub grayscale: bool,
}
impl Image {
@@ -74,6 +76,7 @@ impl Element for Image {
bounds,
border: self.style.border,
corner_radius: self.style.corner_radius,
+ grayscale: self.style.grayscale,
data: self.data.clone(),
});
}
@@ -261,7 +261,8 @@ impl Element for List {
scroll_top: &mut ListOffset,
cx: &mut PaintContext,
) {
- cx.scene.push_layer(Some(bounds));
+ let visible_bounds = visible_bounds.intersection(bounds).unwrap_or_default();
+ cx.scene.push_layer(Some(visible_bounds));
cx.scene
.push_mouse_region(MouseRegion::new::<Self>(10, 0, bounds).on_scroll({
@@ -169,6 +169,7 @@ impl<Tag> Element for MouseEventHandler<Tag> {
_: &mut Self::LayoutState,
cx: &mut PaintContext,
) -> Self::PaintState {
+ let visible_bounds = visible_bounds.intersection(bounds).unwrap_or_default();
let hit_bounds = self.hit_bounds(visible_bounds);
if let Some(style) = self.cursor_style {
cx.scene.push_cursor_region(CursorRegion {
@@ -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();
}
@@ -284,7 +284,9 @@ impl Element for UniformList {
layout: &mut Self::LayoutState,
cx: &mut PaintContext,
) -> Self::PaintState {
- cx.scene.push_layer(Some(bounds));
+ let visible_bounds = visible_bounds.intersection(bounds).unwrap_or_default();
+
+ cx.scene.push_layer(Some(visible_bounds));
cx.scene.push_mouse_region(
MouseRegion::new::<Self>(self.view_id, 0, visible_bounds).on_scroll({
@@ -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,
@@ -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,
@@ -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);
@@ -90,6 +90,7 @@ typedef struct {
float border_left;
vector_uchar4 border_color;
float corner_radius;
+ uint8_t grayscale;
} GPUIImage;
typedef enum {
@@ -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,
};
}
@@ -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,
@@ -172,6 +172,7 @@ pub struct Image {
pub bounds: RectF,
pub border: Border,
pub corner_radius: f32,
+ pub grayscale: bool,
pub data: Arc<ImageData>,
}
@@ -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 {
@@ -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;
@@ -19,6 +19,7 @@ pub struct Picker<D: PickerDelegate> {
query_editor: ViewHandle<Editor>,
list_state: UniformListState,
max_size: Vector2F,
+ theme: Box<dyn FnMut(&AppContext) -> &theme::Picker>,
confirmed: bool,
}
@@ -51,8 +52,8 @@ impl<D: PickerDelegate> View for Picker<D> {
}
fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
- let settings = cx.global::<Settings>();
- let container_style = settings.theme.picker.container;
+ let theme = (self.theme)(cx);
+ let container_style = theme.container;
let delegate = self.delegate.clone();
let match_count = if let Some(delegate) = delegate.upgrade(cx.app) {
delegate.read(cx).match_count()
@@ -64,17 +65,14 @@ impl<D: PickerDelegate> View for Picker<D> {
.with_child(
ChildView::new(&self.query_editor)
.contained()
- .with_style(settings.theme.picker.input_editor.container)
+ .with_style(theme.input_editor.container)
.boxed(),
)
.with_child(
if match_count == 0 {
- Label::new(
- "No matches".into(),
- settings.theme.picker.empty.label.clone(),
- )
- .contained()
- .with_style(settings.theme.picker.empty.container)
+ Label::new("No matches".into(), theme.empty.label.clone())
+ .contained()
+ .with_style(theme.empty.container)
} else {
UniformList::new(
self.list_state.clone(),
@@ -147,6 +145,7 @@ impl<D: PickerDelegate> Picker<D> {
list_state: Default::default(),
delegate,
max_size: vec2f(540., 420.),
+ theme: Box::new(|cx| &cx.global::<Settings>().theme.picker),
confirmed: false,
};
cx.defer(|this, cx| {
@@ -163,6 +162,14 @@ impl<D: PickerDelegate> Picker<D> {
self
}
+ pub fn with_theme<F>(mut self, theme: F) -> Self
+ where
+ F: 'static + FnMut(&AppContext) -> &theme::Picker,
+ {
+ self.theme = Box::new(theme);
+ self
+ }
+
pub fn query(&self, cx: &AppContext) -> String {
self.query_editor.read(cx).text(cx)
}
@@ -8,7 +8,7 @@ pub mod worktree;
mod project_tests;
use anyhow::{anyhow, Context, Result};
-use client::{proto, Client, PeerId, TypedEnvelope, User, UserStore};
+use client::{proto, Client, PeerId, TypedEnvelope, UserStore};
use clock::ReplicaId;
use collections::{hash_map, BTreeMap, HashMap, HashSet};
use futures::{future::Shared, AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt};
@@ -35,7 +35,6 @@ use lsp::{
};
use lsp_command::*;
use parking_lot::Mutex;
-use postage::stream::Stream;
use postage::watch;
use rand::prelude::*;
use search::SearchQuery;
@@ -74,7 +73,6 @@ pub trait Item: Entity {
}
pub struct ProjectStore {
- db: Arc<Db>,
projects: Vec<WeakModelHandle<Project>>,
}
@@ -127,7 +125,6 @@ pub struct Project {
buffer_snapshots: HashMap<u64, Vec<(i32, TextBufferSnapshot)>>,
buffers_being_formatted: HashSet<usize>,
nonce: u128,
- initialized_persistent_state: bool,
_maintain_buffer_languages: Task<()>,
}
@@ -156,13 +153,8 @@ enum WorktreeHandle {
enum ProjectClientState {
Local {
- is_shared: bool,
- remote_id_tx: watch::Sender<Option<u64>>,
- remote_id_rx: watch::Receiver<Option<u64>>,
- online_tx: watch::Sender<bool>,
- online_rx: watch::Receiver<bool>,
- _maintain_remote_id: Task<Option<()>>,
- _maintain_online_status: Task<Option<()>>,
+ remote_id: Option<u64>,
+ _detect_unshare: Task<Option<()>>,
},
Remote {
sharing_has_stopped: bool,
@@ -174,7 +166,6 @@ enum ProjectClientState {
#[derive(Clone, Debug)]
pub struct Collaborator {
- pub user: Arc<User>,
pub peer_id: PeerId,
pub replica_id: ReplicaId,
}
@@ -197,8 +188,6 @@ pub enum Event {
RemoteIdChanged(Option<u64>),
DisconnectedFromHost,
CollaboratorLeft(PeerId),
- ContactRequestedJoin(Arc<User>),
- ContactCancelledJoinRequest(Arc<User>),
}
pub enum LanguageServerState {
@@ -383,17 +372,14 @@ impl FormatTrigger {
impl Project {
pub fn init(client: &Arc<Client>) {
- client.add_model_message_handler(Self::handle_request_join_project);
client.add_model_message_handler(Self::handle_add_collaborator);
client.add_model_message_handler(Self::handle_buffer_reloaded);
client.add_model_message_handler(Self::handle_buffer_saved);
client.add_model_message_handler(Self::handle_start_language_server);
client.add_model_message_handler(Self::handle_update_language_server);
client.add_model_message_handler(Self::handle_remove_collaborator);
- client.add_model_message_handler(Self::handle_join_project_request_cancelled);
client.add_model_message_handler(Self::handle_update_project);
- client.add_model_message_handler(Self::handle_unregister_project);
- client.add_model_message_handler(Self::handle_project_unshared);
+ client.add_model_message_handler(Self::handle_unshare_project);
client.add_model_message_handler(Self::handle_create_buffer_for_peer);
client.add_model_message_handler(Self::handle_update_buffer_file);
client.add_model_message_handler(Self::handle_update_buffer);
@@ -426,7 +412,6 @@ impl Project {
}
pub fn local(
- online: bool,
client: Arc<Client>,
user_store: ModelHandle<UserStore>,
project_store: ModelHandle<ProjectStore>,
@@ -435,41 +420,19 @@ impl Project {
cx: &mut MutableAppContext,
) -> ModelHandle<Self> {
cx.add_model(|cx: &mut ModelContext<Self>| {
- let (remote_id_tx, remote_id_rx) = watch::channel();
- let _maintain_remote_id = cx.spawn_weak({
- let mut status_rx = client.clone().status();
- move |this, mut cx| async move {
- while let Some(status) = status_rx.recv().await {
- let this = this.upgrade(&cx)?;
- if status.is_connected() {
- this.update(&mut cx, |this, cx| this.register(cx))
- .await
- .log_err()?;
- } else {
- this.update(&mut cx, |this, cx| this.unregister(cx))
- .await
- .log_err();
+ let mut status = client.status();
+ let _detect_unshare = cx.spawn_weak(move |this, mut cx| {
+ async move {
+ let is_connected = status.next().await.map_or(false, |s| s.is_connected());
+ // Even if we're initially connected, any future change of the status means we momentarily disconnected.
+ if !is_connected || status.next().await.is_some() {
+ if let Some(this) = this.upgrade(&cx) {
+ let _ = this.update(&mut cx, |this, cx| this.unshare(cx));
}
}
- None
- }
- });
-
- let (online_tx, online_rx) = watch::channel_with(online);
- let _maintain_online_status = cx.spawn_weak({
- let mut online_rx = online_rx.clone();
- move |this, mut cx| async move {
- while let Some(online) = online_rx.recv().await {
- let this = this.upgrade(&cx)?;
- this.update(&mut cx, |this, cx| {
- if !online {
- this.unshared(cx);
- }
- this.metadata_changed(false, cx)
- });
- }
- None
+ Ok(())
}
+ .log_err()
});
let handle = cx.weak_handle();
@@ -485,13 +448,8 @@ impl Project {
loading_local_worktrees: Default::default(),
buffer_snapshots: Default::default(),
client_state: ProjectClientState::Local {
- is_shared: false,
- remote_id_tx,
- remote_id_rx,
- online_tx,
- online_rx,
- _maintain_remote_id,
- _maintain_online_status,
+ remote_id: None,
+ _detect_unshare,
},
opened_buffer: watch::channel(),
client_subscriptions: Vec::new(),
@@ -513,7 +471,6 @@ impl Project {
buffers_being_formatted: Default::default(),
next_language_server_id: 0,
nonce: StdRng::from_entropy().gen(),
- initialized_persistent_state: false,
}
})
}
@@ -535,24 +492,6 @@ impl Project {
})
.await?;
- let response = match response.variant.ok_or_else(|| anyhow!("missing variant"))? {
- proto::join_project_response::Variant::Accept(response) => response,
- proto::join_project_response::Variant::Decline(decline) => {
- match proto::join_project_response::decline::Reason::from_i32(decline.reason) {
- Some(proto::join_project_response::decline::Reason::Declined) => {
- Err(JoinProjectError::HostDeclined)?
- }
- Some(proto::join_project_response::decline::Reason::Closed) => {
- Err(JoinProjectError::HostClosedProject)?
- }
- Some(proto::join_project_response::decline::Reason::WentOffline) => {
- Err(JoinProjectError::HostWentOffline)?
- }
- None => Err(anyhow!("missing decline reason"))?,
- }
- }
- };
-
let replica_id = response.replica_id as ReplicaId;
let mut worktrees = Vec::new();
@@ -629,7 +568,6 @@ impl Project {
buffers_being_formatted: Default::default(),
buffer_snapshots: Default::default(),
nonce: StdRng::from_entropy().gen(),
- initialized_persistent_state: false,
};
for worktree in worktrees {
this.add_worktree(&worktree, cx);
@@ -647,7 +585,7 @@ impl Project {
.await?;
let mut collaborators = HashMap::default();
for message in response.collaborators {
- let collaborator = Collaborator::from_proto(message, &user_store, &mut cx).await?;
+ let collaborator = Collaborator::from_proto(message);
collaborators.insert(collaborator.peer_id, collaborator);
}
@@ -672,10 +610,9 @@ impl Project {
let http_client = client::test::FakeHttpClient::with_404_response();
let client = cx.update(|cx| client::Client::new(http_client.clone(), cx));
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
- let project_store = cx.add_model(|_| ProjectStore::new(Db::open_fake()));
- let project = cx.update(|cx| {
- Project::local(true, client, user_store, project_store, languages, fs, cx)
- });
+ let project_store = cx.add_model(|_| ProjectStore::new());
+ let project =
+ cx.update(|cx| Project::local(client, user_store, project_store, languages, fs, cx));
for path in root_paths {
let (tree, _) = project
.update(cx, |project, cx| {
@@ -689,53 +626,6 @@ impl Project {
project
}
- pub fn restore_state(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
- if self.is_remote() {
- return Task::ready(Ok(()));
- }
-
- let db = self.project_store.read(cx).db.clone();
- let keys = self.db_keys_for_online_state(cx);
- let online_by_default = cx.global::<Settings>().projects_online_by_default;
- let read_online = cx.background().spawn(async move {
- let values = db.read(keys)?;
- anyhow::Ok(
- values
- .into_iter()
- .all(|e| e.map_or(online_by_default, |e| e == [true as u8])),
- )
- });
- cx.spawn(|this, mut cx| async move {
- let online = read_online.await.log_err().unwrap_or(false);
- this.update(&mut cx, |this, cx| {
- this.initialized_persistent_state = true;
- if let ProjectClientState::Local { online_tx, .. } = &mut this.client_state {
- let mut online_tx = online_tx.borrow_mut();
- if *online_tx != online {
- *online_tx = online;
- drop(online_tx);
- this.metadata_changed(false, cx);
- }
- }
- });
- Ok(())
- })
- }
-
- fn persist_state(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
- if self.is_remote() || !self.initialized_persistent_state {
- return Task::ready(Ok(()));
- }
-
- let db = self.project_store.read(cx).db.clone();
- let keys = self.db_keys_for_online_state(cx);
- let is_online = self.is_online();
- cx.background().spawn(async move {
- let value = &[is_online as u8];
- db.write(keys.into_iter().map(|key| (key, value)))
- })
- }
-
fn on_settings_changed(&mut self, cx: &mut ModelContext<Self>) {
let settings = cx.global::<Settings>();
@@ -864,136 +754,9 @@ impl Project {
&self.fs
}
- pub fn set_online(&mut self, online: bool, _: &mut ModelContext<Self>) {
- if let ProjectClientState::Local { online_tx, .. } = &mut self.client_state {
- let mut online_tx = online_tx.borrow_mut();
- if *online_tx != online {
- *online_tx = online;
- }
- }
- }
-
- pub fn is_online(&self) -> bool {
- match &self.client_state {
- ProjectClientState::Local { online_rx, .. } => *online_rx.borrow(),
- ProjectClientState::Remote { .. } => true,
- }
- }
-
- fn unregister(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
- self.unshared(cx);
- if let ProjectClientState::Local { remote_id_rx, .. } = &mut self.client_state {
- if let Some(remote_id) = *remote_id_rx.borrow() {
- let request = self.client.request(proto::UnregisterProject {
- project_id: remote_id,
- });
- return cx.spawn(|this, mut cx| async move {
- let response = request.await;
-
- // Unregistering the project causes the server to send out a
- // contact update removing this project from the host's list
- // of online projects. Wait until this contact update has been
- // processed before clearing out this project's remote id, so
- // that there is no moment where this project appears in the
- // contact metadata and *also* has no remote id.
- this.update(&mut cx, |this, cx| {
- this.user_store()
- .update(cx, |store, _| store.contact_updates_done())
- })
- .await;
-
- this.update(&mut cx, |this, cx| {
- if let ProjectClientState::Local { remote_id_tx, .. } =
- &mut this.client_state
- {
- *remote_id_tx.borrow_mut() = None;
- }
- this.client_subscriptions.clear();
- this.metadata_changed(false, cx);
- });
- response.map(drop)
- });
- }
- }
- Task::ready(Ok(()))
- }
-
- fn register(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
- if let ProjectClientState::Local {
- remote_id_rx,
- online_rx,
- ..
- } = &self.client_state
- {
- if remote_id_rx.borrow().is_some() {
- return Task::ready(Ok(()));
- }
-
- let response = self.client.request(proto::RegisterProject {
- online: *online_rx.borrow(),
- });
- cx.spawn(|this, mut cx| async move {
- let remote_id = response.await?.project_id;
- this.update(&mut cx, |this, cx| {
- if let ProjectClientState::Local { remote_id_tx, .. } = &mut this.client_state {
- *remote_id_tx.borrow_mut() = Some(remote_id);
- }
-
- this.metadata_changed(false, cx);
- cx.emit(Event::RemoteIdChanged(Some(remote_id)));
- this.client_subscriptions
- .push(this.client.add_model_for_remote_entity(remote_id, cx));
- Ok(())
- })
- })
- } else {
- Task::ready(Err(anyhow!("can't register a remote project")))
- }
- }
-
pub fn remote_id(&self) -> Option<u64> {
match &self.client_state {
- ProjectClientState::Local { remote_id_rx, .. } => *remote_id_rx.borrow(),
- ProjectClientState::Remote { remote_id, .. } => Some(*remote_id),
- }
- }
-
- pub fn next_remote_id(&self) -> impl Future<Output = u64> {
- let mut id = None;
- let mut watch = None;
- match &self.client_state {
- ProjectClientState::Local { remote_id_rx, .. } => watch = Some(remote_id_rx.clone()),
- ProjectClientState::Remote { remote_id, .. } => id = Some(*remote_id),
- }
-
- async move {
- if let Some(id) = id {
- return id;
- }
- let mut watch = watch.unwrap();
- loop {
- let id = *watch.borrow();
- if let Some(id) = id {
- return id;
- }
- watch.next().await;
- }
- }
- }
-
- pub fn shared_remote_id(&self) -> Option<u64> {
- match &self.client_state {
- ProjectClientState::Local {
- remote_id_rx,
- is_shared,
- ..
- } => {
- if *is_shared {
- *remote_id_rx.borrow()
- } else {
- None
- }
- }
+ ProjectClientState::Local { remote_id, .. } => *remote_id,
ProjectClientState::Remote { remote_id, .. } => Some(*remote_id),
}
}
@@ -1005,65 +768,50 @@ impl Project {
}
}
- fn metadata_changed(&mut self, persist: bool, cx: &mut ModelContext<Self>) {
- if let ProjectClientState::Local {
- remote_id_rx,
- online_rx,
- ..
- } = &self.client_state
- {
+ fn metadata_changed(&mut self, cx: &mut ModelContext<Self>) {
+ if let ProjectClientState::Local { remote_id, .. } = &self.client_state {
// Broadcast worktrees only if the project is online.
- let worktrees = if *online_rx.borrow() {
- self.worktrees
- .iter()
- .filter_map(|worktree| {
- worktree
- .upgrade(cx)
- .map(|worktree| worktree.read(cx).as_local().unwrap().metadata_proto())
- })
- .collect()
- } else {
- Default::default()
- };
- if let Some(project_id) = *remote_id_rx.borrow() {
- let online = *online_rx.borrow();
+ let worktrees = self
+ .worktrees
+ .iter()
+ .filter_map(|worktree| {
+ worktree
+ .upgrade(cx)
+ .map(|worktree| worktree.read(cx).as_local().unwrap().metadata_proto())
+ })
+ .collect();
+ if let Some(project_id) = *remote_id {
self.client
.send(proto::UpdateProject {
project_id,
worktrees,
- online,
})
.log_err();
- if online {
- let worktrees = self.visible_worktrees(cx).collect::<Vec<_>>();
- let scans_complete =
- futures::future::join_all(worktrees.iter().filter_map(|worktree| {
- Some(worktree.read(cx).as_local()?.scan_complete())
- }));
+ let worktrees = self.visible_worktrees(cx).collect::<Vec<_>>();
+ let scans_complete =
+ futures::future::join_all(worktrees.iter().filter_map(|worktree| {
+ Some(worktree.read(cx).as_local()?.scan_complete())
+ }));
- let worktrees = worktrees.into_iter().map(|handle| handle.downgrade());
- cx.spawn_weak(move |_, cx| async move {
- scans_complete.await;
- cx.read(|cx| {
- for worktree in worktrees {
- if let Some(worktree) = worktree
- .upgrade(cx)
- .and_then(|worktree| worktree.read(cx).as_local())
- {
- worktree.send_extension_counts(project_id);
- }
+ let worktrees = worktrees.into_iter().map(|handle| handle.downgrade());
+ cx.spawn_weak(move |_, cx| async move {
+ scans_complete.await;
+ cx.read(|cx| {
+ for worktree in worktrees {
+ if let Some(worktree) = worktree
+ .upgrade(cx)
+ .and_then(|worktree| worktree.read(cx).as_local())
+ {
+ worktree.send_extension_counts(project_id);
}
- })
+ }
})
- .detach();
- }
+ })
+ .detach();
}
self.project_store.update(cx, |_, cx| cx.notify());
- if persist {
- self.persist_state(cx).detach_and_log_err(cx);
- }
cx.notify();
}
}
@@ -1101,23 +849,6 @@ impl Project {
.map(|tree| tree.read(cx).root_name())
}
- fn db_keys_for_online_state(&self, cx: &AppContext) -> Vec<String> {
- self.worktrees
- .iter()
- .filter_map(|worktree| {
- let worktree = worktree.upgrade(cx)?.read(cx);
- if worktree.is_visible() {
- Some(format!(
- "project-path-online:{}",
- worktree.as_local().unwrap().abs_path().to_string_lossy()
- ))
- } else {
- None
- }
- })
- .collect::<Vec<_>>()
- }
-
pub fn worktree_for_id(
&self,
id: WorktreeId,
@@ -1321,142 +1052,106 @@ impl Project {
}
}
- fn share(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
- if !self.is_online() {
- return Task::ready(Err(anyhow!("can't share an offline project")));
- }
-
- let project_id;
- if let ProjectClientState::Local {
- remote_id_rx,
- is_shared,
- ..
- } = &mut self.client_state
- {
- if *is_shared {
- return Task::ready(Ok(()));
- }
- *is_shared = true;
- if let Some(id) = *remote_id_rx.borrow() {
- project_id = id;
- } else {
- return Task::ready(Err(anyhow!("project hasn't been registered")));
+ pub fn shared(&mut self, project_id: u64, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+ if let ProjectClientState::Local { remote_id, .. } = &mut self.client_state {
+ if remote_id.is_some() {
+ return Task::ready(Err(anyhow!("project was already shared")));
}
- } else {
- return Task::ready(Err(anyhow!("can't share a remote project")));
- };
- for open_buffer in self.opened_buffers.values_mut() {
- match open_buffer {
- OpenBuffer::Strong(_) => {}
- OpenBuffer::Weak(buffer) => {
- if let Some(buffer) = buffer.upgrade(cx) {
- *open_buffer = OpenBuffer::Strong(buffer);
+ *remote_id = Some(project_id);
+
+ let mut worktree_share_tasks = Vec::new();
+
+ for open_buffer in self.opened_buffers.values_mut() {
+ match open_buffer {
+ OpenBuffer::Strong(_) => {}
+ OpenBuffer::Weak(buffer) => {
+ if let Some(buffer) = buffer.upgrade(cx) {
+ *open_buffer = OpenBuffer::Strong(buffer);
+ }
}
+ OpenBuffer::Operations(_) => unreachable!(),
}
- OpenBuffer::Operations(_) => unreachable!(),
}
- }
- for worktree_handle in self.worktrees.iter_mut() {
- match worktree_handle {
- WorktreeHandle::Strong(_) => {}
- WorktreeHandle::Weak(worktree) => {
- if let Some(worktree) = worktree.upgrade(cx) {
- *worktree_handle = WorktreeHandle::Strong(worktree);
+ for worktree_handle in self.worktrees.iter_mut() {
+ match worktree_handle {
+ WorktreeHandle::Strong(_) => {}
+ WorktreeHandle::Weak(worktree) => {
+ if let Some(worktree) = worktree.upgrade(cx) {
+ *worktree_handle = WorktreeHandle::Strong(worktree);
+ }
}
}
}
- }
-
- let mut tasks = Vec::new();
- for worktree in self.worktrees(cx).collect::<Vec<_>>() {
- worktree.update(cx, |worktree, cx| {
- let worktree = worktree.as_local_mut().unwrap();
- tasks.push(worktree.share(project_id, cx));
- });
- }
- for (server_id, status) in &self.language_server_statuses {
- self.client
- .send(proto::StartLanguageServer {
- project_id,
- server: Some(proto::LanguageServer {
- id: *server_id as u64,
- name: status.name.clone(),
- }),
- })
- .log_err();
- }
+ for worktree in self.worktrees(cx).collect::<Vec<_>>() {
+ worktree.update(cx, |worktree, cx| {
+ let worktree = worktree.as_local_mut().unwrap();
+ worktree_share_tasks.push(worktree.share(project_id, cx));
+ });
+ }
- cx.spawn(|this, mut cx| async move {
- for task in tasks {
- task.await?;
+ for (server_id, status) in &self.language_server_statuses {
+ self.client
+ .send(proto::StartLanguageServer {
+ project_id,
+ server: Some(proto::LanguageServer {
+ id: *server_id as u64,
+ name: status.name.clone(),
+ }),
+ })
+ .log_err();
}
- this.update(&mut cx, |_, cx| cx.notify());
- Ok(())
- })
+
+ self.client_subscriptions
+ .push(self.client.add_model_for_remote_entity(project_id, cx));
+ self.metadata_changed(cx);
+ cx.emit(Event::RemoteIdChanged(Some(project_id)));
+ cx.notify();
+
+ cx.foreground().spawn(async move {
+ futures::future::try_join_all(worktree_share_tasks).await?;
+ Ok(())
+ })
+ } else {
+ Task::ready(Err(anyhow!("can't share a remote project")))
+ }
}
- fn unshared(&mut self, cx: &mut ModelContext<Self>) {
- if let ProjectClientState::Local { is_shared, .. } = &mut self.client_state {
- if !*is_shared {
- return;
- }
+ pub fn unshare(&mut self, cx: &mut ModelContext<Self>) -> Result<()> {
+ if let ProjectClientState::Local { remote_id, .. } = &mut self.client_state {
+ if let Some(project_id) = remote_id.take() {
+ self.collaborators.clear();
+ self.shared_buffers.clear();
+ self.client_subscriptions.clear();
- *is_shared = false;
- self.collaborators.clear();
- self.shared_buffers.clear();
- for worktree_handle in self.worktrees.iter_mut() {
- if let WorktreeHandle::Strong(worktree) = worktree_handle {
- let is_visible = worktree.update(cx, |worktree, _| {
- worktree.as_local_mut().unwrap().unshare();
- worktree.is_visible()
- });
- if !is_visible {
- *worktree_handle = WorktreeHandle::Weak(worktree.downgrade());
+ for worktree_handle in self.worktrees.iter_mut() {
+ if let WorktreeHandle::Strong(worktree) = worktree_handle {
+ let is_visible = worktree.update(cx, |worktree, _| {
+ worktree.as_local_mut().unwrap().unshare();
+ worktree.is_visible()
+ });
+ if !is_visible {
+ *worktree_handle = WorktreeHandle::Weak(worktree.downgrade());
+ }
}
}
- }
- for open_buffer in self.opened_buffers.values_mut() {
- if let OpenBuffer::Strong(buffer) = open_buffer {
- *open_buffer = OpenBuffer::Weak(buffer.downgrade());
+ for open_buffer in self.opened_buffers.values_mut() {
+ if let OpenBuffer::Strong(buffer) = open_buffer {
+ *open_buffer = OpenBuffer::Weak(buffer.downgrade());
+ }
}
+
+ self.metadata_changed(cx);
+ cx.notify();
+ self.client.send(proto::UnshareProject { project_id })?;
}
- cx.notify();
+ Ok(())
} else {
- log::error!("attempted to unshare a remote project");
- }
- }
-
- pub fn respond_to_join_request(
- &mut self,
- requester_id: u64,
- allow: bool,
- cx: &mut ModelContext<Self>,
- ) {
- if let Some(project_id) = self.remote_id() {
- let share = if self.is_online() && allow {
- Some(self.share(cx))
- } else {
- None
- };
- let client = self.client.clone();
- cx.foreground()
- .spawn(async move {
- client.send(proto::RespondToJoinProjectRequest {
- requester_id,
- project_id,
- allow,
- })?;
- if let Some(share) = share {
- share.await?;
- }
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
+ Err(anyhow!("attempted to unshare a remote project"))
}
}
@@ -1930,7 +1625,7 @@ impl Project {
) -> Option<()> {
match event {
BufferEvent::Operation(operation) => {
- if let Some(project_id) = self.shared_remote_id() {
+ if let Some(project_id) = self.remote_id() {
let request = self.client.request(proto::UpdateBuffer {
project_id,
buffer_id: buffer.read(cx).remote_id(),
@@ -2335,7 +2030,7 @@ impl Project {
)
.ok();
- if let Some(project_id) = this.shared_remote_id() {
+ if let Some(project_id) = this.remote_id() {
this.client
.send(proto::StartLanguageServer {
project_id,
@@ -2742,7 +2437,7 @@ impl Project {
language_server_id: usize,
event: proto::update_language_server::Variant,
) {
- if let Some(project_id) = self.shared_remote_id() {
+ if let Some(project_id) = self.remote_id() {
self.client
.send(proto::UpdateLanguageServer {
project_id,
@@ -4472,7 +4167,7 @@ impl Project {
pub fn is_shared(&self) -> bool {
match &self.client_state {
- ProjectClientState::Local { is_shared, .. } => *is_shared,
+ ProjectClientState::Local { remote_id, .. } => remote_id.is_some(),
ProjectClientState::Remote { .. } => false,
}
}
@@ -4509,7 +4204,7 @@ impl Project {
let project_id = project.update(&mut cx, |project, cx| {
project.add_worktree(&worktree, cx);
- project.shared_remote_id()
+ project.remote_id()
});
if let Some(project_id) = project_id {
@@ -4550,7 +4245,7 @@ impl Project {
false
}
});
- self.metadata_changed(true, cx);
+ self.metadata_changed(cx);
cx.notify();
}
@@ -4578,7 +4273,7 @@ impl Project {
.push(WorktreeHandle::Weak(worktree.downgrade()));
}
- self.metadata_changed(true, cx);
+ self.metadata_changed(cx);
cx.observe_release(worktree, |this, worktree, cx| {
this.remove_worktree(worktree.id(), cx);
cx.notify();
@@ -4641,7 +4336,7 @@ impl Project {
renamed_buffers.push((cx.handle(), old_path));
}
- if let Some(project_id) = self.shared_remote_id() {
+ if let Some(project_id) = self.remote_id() {
self.client
.send(proto::UpdateBufferFile {
project_id,
@@ -4697,7 +4392,7 @@ impl Project {
Err(_) => return,
};
- let shared_remote_id = self.shared_remote_id();
+ let remote_id = self.remote_id();
let client = self.client.clone();
cx.spawn(|_, mut cx| async move {
@@ -4711,7 +4406,7 @@ impl Project {
buffer.remote_id()
});
- if let Some(project_id) = shared_remote_id {
+ if let Some(project_id) = remote_id {
client
.send(proto::UpdateDiffBase {
project_id,
@@ -4811,47 +4506,20 @@ impl Project {
// RPC message handlers
- async fn handle_request_join_project(
- this: ModelHandle<Self>,
- message: TypedEnvelope<proto::RequestJoinProject>,
- _: Arc<Client>,
- mut cx: AsyncAppContext,
- ) -> Result<()> {
- let user_id = message.payload.requester_id;
- if this.read_with(&cx, |project, _| {
- project.collaborators.values().any(|c| c.user.id == user_id)
- }) {
- this.update(&mut cx, |this, cx| {
- this.respond_to_join_request(user_id, true, cx)
- });
- } else {
- let user_store = this.read_with(&cx, |this, _| this.user_store.clone());
- let user = user_store
- .update(&mut cx, |store, cx| store.fetch_user(user_id, cx))
- .await?;
- this.update(&mut cx, |_, cx| cx.emit(Event::ContactRequestedJoin(user)));
- }
- Ok(())
- }
-
- async fn handle_unregister_project(
+ async fn handle_unshare_project(
this: ModelHandle<Self>,
- _: TypedEnvelope<proto::UnregisterProject>,
+ _: TypedEnvelope<proto::UnshareProject>,
_: Arc<Client>,
mut cx: AsyncAppContext,
) -> Result<()> {
- this.update(&mut cx, |this, cx| this.disconnected_from_host(cx));
- Ok(())
- }
-
- async fn handle_project_unshared(
- this: ModelHandle<Self>,
- _: TypedEnvelope<proto::ProjectUnshared>,
- _: Arc<Client>,
- mut cx: AsyncAppContext,
- ) -> Result<()> {
- this.update(&mut cx, |this, cx| this.unshared(cx));
- Ok(())
+ this.update(&mut cx, |this, cx| {
+ if this.is_local() {
+ this.unshare(cx)?;
+ } else {
+ this.disconnected_from_host(cx);
+ }
+ Ok(())
+ })
}
async fn handle_add_collaborator(
@@ -4860,14 +4528,13 @@ impl Project {
_: Arc<Client>,
mut cx: AsyncAppContext,
) -> Result<()> {
- let user_store = this.read_with(&cx, |this, _| this.user_store.clone());
let collaborator = envelope
.payload
.collaborator
.take()
.ok_or_else(|| anyhow!("empty collaborator"))?;
- let collaborator = Collaborator::from_proto(collaborator, &user_store, &mut cx).await?;
+ let collaborator = Collaborator::from_proto(collaborator);
this.update(&mut cx, |this, cx| {
this.collaborators
.insert(collaborator.peer_id, collaborator);
@@ -4902,27 +4569,6 @@ impl Project {
})
}
- async fn handle_join_project_request_cancelled(
- this: ModelHandle<Self>,
- envelope: TypedEnvelope<proto::JoinProjectRequestCancelled>,
- _: Arc<Client>,
- mut cx: AsyncAppContext,
- ) -> Result<()> {
- let user = this
- .update(&mut cx, |this, cx| {
- this.user_store.update(cx, |user_store, cx| {
- user_store.fetch_user(envelope.payload.requester_id, cx)
- })
- })
- .await?;
-
- this.update(&mut cx, |_, cx| {
- cx.emit(Event::ContactCancelledJoinRequest(user));
- });
-
- Ok(())
- }
-
async fn handle_update_project(
this: ModelHandle<Self>,
envelope: TypedEnvelope<proto::UpdateProject>,
@@ -4954,7 +4600,7 @@ impl Project {
}
}
- this.metadata_changed(true, cx);
+ this.metadata_changed(cx);
for (id, _) in old_worktrees_by_id {
cx.emit(Event::WorktreeRemoved(id));
}
@@ -6182,9 +5828,8 @@ impl Project {
}
impl ProjectStore {
- pub fn new(db: Arc<Db>) -> Self {
+ pub fn new() -> Self {
Self {
- db,
projects: Default::default(),
}
}
@@ -6313,10 +5958,10 @@ impl Entity for Project {
self.project_store.update(cx, ProjectStore::prune_projects);
match &self.client_state {
- ProjectClientState::Local { remote_id_rx, .. } => {
- if let Some(project_id) = *remote_id_rx.borrow() {
+ ProjectClientState::Local { remote_id, .. } => {
+ if let Some(project_id) = *remote_id {
self.client
- .send(proto::UnregisterProject { project_id })
+ .send(proto::UnshareProject { project_id })
.log_err();
}
}
@@ -6357,21 +6002,10 @@ impl Entity for Project {
}
impl Collaborator {
- fn from_proto(
- message: proto::Collaborator,
- user_store: &ModelHandle<UserStore>,
- cx: &mut AsyncAppContext,
- ) -> impl Future<Output = Result<Self>> {
- let user = user_store.update(cx, |user_store, cx| {
- user_store.fetch_user(message.user_id, cx)
- });
-
- async move {
- Ok(Self {
- peer_id: PeerId(message.peer_id),
- user: user.await?,
- replica_id: message.replica_id as ReplicaId,
- })
+ fn from_proto(message: proto::Collaborator) -> Self {
+ Self {
+ peer_id: PeerId(message.peer_id),
+ replica_id: message.replica_id as ReplicaId,
}
}
}
@@ -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;
@@ -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"))
@@ -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,
@@ -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;
@@ -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);
@@ -20,7 +20,7 @@ pub struct Theme {
pub context_menu: ContextMenu,
pub chat_panel: ChatPanel,
pub contacts_popover: ContactsPopover,
- pub contacts_panel: ContactsPanel,
+ pub contact_list: ContactList,
pub contact_finder: ContactFinder,
pub project_panel: ProjectPanel,
pub command_palette: CommandPalette,
@@ -31,6 +31,8 @@ pub struct Theme {
pub breadcrumbs: ContainedText,
pub contact_notification: ContactNotification,
pub update_notification: UpdateNotification,
+ pub project_shared_notification: ProjectSharedNotification,
+ pub incoming_call_notification: IncomingCallNotification,
pub tooltip: TooltipStyle,
pub terminal: TerminalStyle,
}
@@ -58,6 +60,7 @@ pub struct Workspace {
pub notifications: Notifications,
pub joining_project_avatar: ImageStyle,
pub joining_project_message: ContainedText,
+ pub external_location_message: ContainedText,
pub dock: Dock,
}
@@ -72,8 +75,67 @@ pub struct Titlebar {
pub avatar_ribbon: AvatarRibbon,
pub offline_icon: OfflineIcon,
pub avatar: ImageStyle,
+ pub inactive_avatar: ImageStyle,
pub sign_in_prompt: Interactive<ContainedText>,
pub outdated_warning: ContainedText,
+ pub share_button: Interactive<ContainedText>,
+ pub toggle_contacts_button: Interactive<IconButton>,
+ pub toggle_contacts_badge: ContainerStyle,
+}
+
+#[derive(Deserialize, Default)]
+pub struct ContactsPopover {
+ #[serde(flatten)]
+ pub container: ContainerStyle,
+ pub height: f32,
+ pub width: f32,
+ pub invite_row_height: f32,
+ pub invite_row: Interactive<ContainedLabel>,
+}
+
+#[derive(Deserialize, Default)]
+pub struct ContactList {
+ pub user_query_editor: FieldEditor,
+ pub user_query_editor_height: f32,
+ pub add_contact_button: IconButton,
+ pub header_row: Interactive<ContainedText>,
+ pub leave_call: Interactive<ContainedText>,
+ pub contact_row: Interactive<ContainerStyle>,
+ pub row_height: f32,
+ pub project_row: Interactive<ProjectRow>,
+ pub tree_branch: Interactive<TreeBranch>,
+ pub contact_avatar: ImageStyle,
+ pub contact_status_free: ContainerStyle,
+ pub contact_status_busy: ContainerStyle,
+ pub contact_username: ContainedText,
+ pub contact_button: Interactive<IconButton>,
+ pub contact_button_spacing: f32,
+ pub disabled_button: IconButton,
+ pub section_icon_size: f32,
+ pub calling_indicator: ContainedText,
+}
+
+#[derive(Deserialize, Default)]
+pub struct ProjectRow {
+ #[serde(flatten)]
+ pub container: ContainerStyle,
+ pub name: ContainedText,
+}
+
+#[derive(Deserialize, Default, Clone, Copy)]
+pub struct TreeBranch {
+ pub width: f32,
+ pub color: Color,
+}
+
+#[derive(Deserialize, Default)]
+pub struct ContactFinder {
+ pub picker: Picker,
+ pub row_height: f32,
+ pub contact_avatar: ImageStyle,
+ pub contact_username: ContainerStyle,
+ pub contact_button: IconButton,
+ pub disabled_contact_button: IconButton,
}
#[derive(Clone, Deserialize, Default)]
@@ -315,33 +377,6 @@ pub struct CommandPalette {
pub keystroke_spacing: f32,
}
-#[derive(Deserialize, Default)]
-pub struct ContactsPopover {
- pub background: Color,
-}
-
-#[derive(Deserialize, Default)]
-pub struct ContactsPanel {
- #[serde(flatten)]
- pub container: ContainerStyle,
- pub user_query_editor: FieldEditor,
- pub user_query_editor_height: f32,
- pub add_contact_button: IconButton,
- pub header_row: Interactive<ContainedText>,
- pub contact_row: Interactive<ContainerStyle>,
- pub project_row: Interactive<ProjectRow>,
- pub row_height: f32,
- pub contact_avatar: ImageStyle,
- pub contact_username: ContainedText,
- pub contact_button: Interactive<IconButton>,
- pub contact_button_spacing: f32,
- pub disabled_button: IconButton,
- pub tree_branch: Interactive<TreeBranch>,
- pub private_button: Interactive<IconButton>,
- pub section_icon_size: f32,
- pub invite_row: Interactive<ContainedLabel>,
-}
-
#[derive(Deserialize, Default)]
pub struct InviteLink {
#[serde(flatten)]
@@ -351,21 +386,6 @@ pub struct InviteLink {
pub icon: Icon,
}
-#[derive(Deserialize, Default, Clone, Copy)]
-pub struct TreeBranch {
- pub width: f32,
- pub color: Color,
-}
-
-#[derive(Deserialize, Default)]
-pub struct ContactFinder {
- pub row_height: f32,
- pub contact_avatar: ImageStyle,
- pub contact_username: ContainerStyle,
- pub contact_button: IconButton,
- pub disabled_contact_button: IconButton,
-}
-
#[derive(Deserialize, Default)]
pub struct Icon {
#[serde(flatten)]
@@ -384,16 +404,6 @@ pub struct IconButton {
pub button_width: f32,
}
-#[derive(Deserialize, Default)]
-pub struct ProjectRow {
- #[serde(flatten)]
- pub container: ContainerStyle,
- pub name: ContainedText,
- pub guests: ContainerStyle,
- pub guest_avatar: ImageStyle,
- pub guest_avatar_spacing: f32,
-}
-
#[derive(Deserialize, Default)]
pub struct ChatMessage {
#[serde(flatten)]
@@ -475,6 +485,40 @@ pub struct UpdateNotification {
pub dismiss_button: Interactive<IconButton>,
}
+#[derive(Deserialize, Default)]
+pub struct ProjectSharedNotification {
+ pub window_height: f32,
+ pub window_width: f32,
+ #[serde(default)]
+ pub background: Color,
+ pub owner_container: ContainerStyle,
+ pub owner_avatar: ImageStyle,
+ pub owner_metadata: ContainerStyle,
+ pub owner_username: ContainedText,
+ pub message: ContainedText,
+ pub worktree_roots: ContainedText,
+ pub button_width: f32,
+ pub open_button: ContainedText,
+ pub dismiss_button: ContainedText,
+}
+
+#[derive(Deserialize, Default)]
+pub struct IncomingCallNotification {
+ pub window_height: f32,
+ pub window_width: f32,
+ #[serde(default)]
+ pub background: Color,
+ pub caller_container: ContainerStyle,
+ pub caller_avatar: ImageStyle,
+ pub caller_metadata: ContainerStyle,
+ pub caller_username: ContainedText,
+ pub caller_message: ContainedText,
+ pub worktree_roots: ContainedText,
+ pub button_width: f32,
+ pub accept_button: ContainedText,
+ pub decline_button: ContainedText,
+}
+
#[derive(Clone, Deserialize, Default)]
pub struct Editor {
pub text_color: Color,
@@ -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"] }
@@ -1,9 +1,10 @@
-use crate::{FollowerStatesByLeader, Pane};
+use crate::{FollowerStatesByLeader, JoinProject, Pane, Workspace};
use anyhow::{anyhow, Result};
-use client::PeerId;
-use collections::HashMap;
-use gpui::{elements::*, Axis, Border, ViewHandle};
-use project::Collaborator;
+use call::ActiveCall;
+use gpui::{
+ elements::*, Axis, Border, CursorStyle, ModelHandle, MouseButton, RenderContext, ViewHandle,
+};
+use project::Project;
use serde::Deserialize;
use theme::Theme;
@@ -56,11 +57,14 @@ impl PaneGroup {
pub(crate) fn render(
&self,
+ project: &ModelHandle<Project>,
theme: &Theme,
follower_states: &FollowerStatesByLeader,
- collaborators: &HashMap<PeerId, Collaborator>,
+ active_call: Option<&ModelHandle<ActiveCall>>,
+ cx: &mut RenderContext<Workspace>,
) -> ElementBox {
- self.root.render(theme, follower_states, collaborators)
+ self.root
+ .render(project, theme, follower_states, active_call, cx)
}
pub(crate) fn panes(&self) -> Vec<&ViewHandle<Pane>> {
@@ -100,13 +104,16 @@ impl Member {
pub fn render(
&self,
+ project: &ModelHandle<Project>,
theme: &Theme,
follower_states: &FollowerStatesByLeader,
- collaborators: &HashMap<PeerId, Collaborator>,
+ active_call: Option<&ModelHandle<ActiveCall>>,
+ cx: &mut RenderContext<Workspace>,
) -> ElementBox {
+ enum FollowIntoExternalProject {}
+
match self {
Member::Pane(pane) => {
- let mut border = Border::default();
let leader = follower_states
.iter()
.find_map(|(leader_id, follower_states)| {
@@ -116,21 +123,110 @@ impl Member {
None
}
})
- .and_then(|leader_id| collaborators.get(leader_id));
- if let Some(leader) = leader {
- let leader_color = theme
- .editor
- .replica_selection_style(leader.replica_id)
- .cursor;
+ .and_then(|leader_id| {
+ let room = active_call?.read(cx).room()?.read(cx);
+ let collaborator = project.read(cx).collaborators().get(leader_id)?;
+ let participant = room.remote_participants().get(&leader_id)?;
+ Some((collaborator.replica_id, participant))
+ });
+
+ let mut border = Border::default();
+
+ let prompt = if let Some((replica_id, leader)) = leader {
+ let leader_color = theme.editor.replica_selection_style(replica_id).cursor;
border = Border::all(theme.workspace.leader_border_width, leader_color);
border
.color
.fade_out(1. - theme.workspace.leader_border_opacity);
border.overlay = true;
- }
- ChildView::new(pane).contained().with_border(border).boxed()
+
+ match leader.location {
+ call::ParticipantLocation::SharedProject {
+ project_id: leader_project_id,
+ } => {
+ if Some(leader_project_id) == project.read(cx).remote_id() {
+ None
+ } else {
+ let leader_user = leader.user.clone();
+ let leader_user_id = leader.user.id;
+ Some(
+ MouseEventHandler::<FollowIntoExternalProject>::new(
+ pane.id(),
+ cx,
+ |_, _| {
+ Label::new(
+ format!(
+ "Follow {} on their active project",
+ leader_user.github_login,
+ ),
+ theme
+ .workspace
+ .external_location_message
+ .text
+ .clone(),
+ )
+ .contained()
+ .with_style(
+ theme.workspace.external_location_message.container,
+ )
+ .boxed()
+ },
+ )
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, cx| {
+ cx.dispatch_action(JoinProject {
+ project_id: leader_project_id,
+ follow_user_id: leader_user_id,
+ })
+ })
+ .aligned()
+ .bottom()
+ .right()
+ .boxed(),
+ )
+ }
+ }
+ call::ParticipantLocation::UnsharedProject => Some(
+ Label::new(
+ format!(
+ "{} is viewing an unshared Zed project",
+ leader.user.github_login
+ ),
+ theme.workspace.external_location_message.text.clone(),
+ )
+ .contained()
+ .with_style(theme.workspace.external_location_message.container)
+ .aligned()
+ .bottom()
+ .right()
+ .boxed(),
+ ),
+ call::ParticipantLocation::External => Some(
+ Label::new(
+ format!(
+ "{} is viewing a window outside of Zed",
+ leader.user.github_login
+ ),
+ theme.workspace.external_location_message.text.clone(),
+ )
+ .contained()
+ .with_style(theme.workspace.external_location_message.container)
+ .aligned()
+ .bottom()
+ .right()
+ .boxed(),
+ ),
+ }
+ } else {
+ None
+ };
+
+ Stack::new()
+ .with_child(ChildView::new(pane).contained().with_border(border).boxed())
+ .with_children(prompt)
+ .boxed()
}
- Member::Axis(axis) => axis.render(theme, follower_states, collaborators),
+ Member::Axis(axis) => axis.render(project, theme, follower_states, active_call, cx),
}
}
@@ -232,14 +328,16 @@ impl PaneAxis {
fn render(
&self,
+ project: &ModelHandle<Project>,
theme: &Theme,
follower_state: &FollowerStatesByLeader,
- collaborators: &HashMap<PeerId, Collaborator>,
+ active_call: Option<&ModelHandle<ActiveCall>>,
+ cx: &mut RenderContext<Workspace>,
) -> ElementBox {
let last_member_ix = self.members.len() - 1;
Flex::new(self.axis)
.with_children(self.members.iter().enumerate().map(|(ix, member)| {
- let mut member = member.render(theme, follower_state, collaborators);
+ let mut member = member.render(project, theme, follower_state, active_call, cx);
if ix < last_member_ix {
let mut border = theme.workspace.pane_divider;
border.left = false;
@@ -1,185 +0,0 @@
-use crate::{sidebar::SidebarSide, AppState, ToggleFollow, Workspace};
-use anyhow::Result;
-use client::{proto, Client, Contact};
-use gpui::{
- elements::*, ElementBox, Entity, ImageData, MutableAppContext, RenderContext, Task, View,
- ViewContext,
-};
-use project::Project;
-use settings::Settings;
-use std::sync::Arc;
-use util::ResultExt;
-
-pub struct WaitingRoom {
- project_id: u64,
- avatar: Option<Arc<ImageData>>,
- message: String,
- waiting: bool,
- client: Arc<Client>,
- _join_task: Task<Result<()>>,
-}
-
-impl Entity for WaitingRoom {
- type Event = ();
-
- fn release(&mut self, _: &mut MutableAppContext) {
- if self.waiting {
- self.client
- .send(proto::LeaveProject {
- project_id: self.project_id,
- })
- .log_err();
- }
- }
-}
-
-impl View for WaitingRoom {
- fn ui_name() -> &'static str {
- "WaitingRoom"
- }
-
- fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
- let theme = &cx.global::<Settings>().theme.workspace;
-
- Flex::column()
- .with_children(self.avatar.clone().map(|avatar| {
- Image::new(avatar)
- .with_style(theme.joining_project_avatar)
- .aligned()
- .boxed()
- }))
- .with_child(
- Text::new(
- self.message.clone(),
- theme.joining_project_message.text.clone(),
- )
- .contained()
- .with_style(theme.joining_project_message.container)
- .aligned()
- .boxed(),
- )
- .aligned()
- .contained()
- .with_background_color(theme.background)
- .boxed()
- }
-}
-
-impl WaitingRoom {
- pub fn new(
- contact: Arc<Contact>,
- project_index: usize,
- app_state: Arc<AppState>,
- cx: &mut ViewContext<Self>,
- ) -> Self {
- let project_id = contact.projects[project_index].id;
- let client = app_state.client.clone();
- let _join_task = cx.spawn_weak({
- let contact = contact.clone();
- |this, mut cx| async move {
- let project = Project::remote(
- project_id,
- app_state.client.clone(),
- app_state.user_store.clone(),
- app_state.project_store.clone(),
- app_state.languages.clone(),
- app_state.fs.clone(),
- cx.clone(),
- )
- .await;
-
- if let Some(this) = this.upgrade(&cx) {
- this.update(&mut cx, |this, cx| {
- this.waiting = false;
- match project {
- Ok(project) => {
- cx.replace_root_view(|cx| {
- let mut workspace =
- Workspace::new(project, app_state.default_item_factory, cx);
- (app_state.initialize_workspace)(
- &mut workspace,
- &app_state,
- cx,
- );
- workspace.toggle_sidebar(SidebarSide::Left, cx);
- if let Some((host_peer_id, _)) = workspace
- .project
- .read(cx)
- .collaborators()
- .iter()
- .find(|(_, collaborator)| collaborator.replica_id == 0)
- {
- if let Some(follow) = workspace
- .toggle_follow(&ToggleFollow(*host_peer_id), cx)
- {
- follow.detach_and_log_err(cx);
- }
- }
- workspace
- });
- }
- Err(error) => {
- let login = &contact.user.github_login;
- let message = match error {
- project::JoinProjectError::HostDeclined => {
- format!("@{} declined your request.", login)
- }
- project::JoinProjectError::HostClosedProject => {
- format!(
- "@{} closed their copy of {}.",
- login,
- humanize_list(
- &contact.projects[project_index]
- .visible_worktree_root_names
- )
- )
- }
- project::JoinProjectError::HostWentOffline => {
- format!("@{} went offline.", login)
- }
- project::JoinProjectError::Other(error) => {
- log::error!("error joining project: {}", error);
- "An error occurred.".to_string()
- }
- };
- this.message = message;
- cx.notify();
- }
- }
- })
- }
-
- Ok(())
- }
- });
-
- Self {
- project_id,
- avatar: contact.user.avatar.clone(),
- message: format!(
- "Asking to join @{}'s copy of {}...",
- contact.user.github_login,
- humanize_list(&contact.projects[project_index].visible_worktree_root_names)
- ),
- waiting: true,
- client,
- _join_task,
- }
- }
-}
-
-fn humanize_list<'a>(items: impl IntoIterator<Item = &'a String>) -> String {
- let mut list = String::new();
- let mut items = items.into_iter().enumerate().peekable();
- while let Some((ix, item)) = items.next() {
- if ix > 0 {
- list.push_str(", ");
- if items.peek().is_none() {
- list.push_str("and ");
- }
- }
-
- list.push_str(item);
- }
- list
-}
@@ -10,28 +10,22 @@ pub mod searchable;
pub mod sidebar;
mod status_bar;
mod toolbar;
-mod waiting_room;
use anyhow::{anyhow, Context, Result};
-use client::{
- proto, Authenticate, Client, Contact, PeerId, Subscription, TypedEnvelope, User, UserStore,
-};
-use clock::ReplicaId;
+use call::ActiveCall;
+use client::{proto, Client, PeerId, TypedEnvelope, UserStore};
use collections::{hash_map, HashMap, HashSet};
use dock::{DefaultItemFactory, Dock, ToggleDockButton};
use drag_and_drop::DragAndDrop;
-use futures::{channel::oneshot, FutureExt};
+use futures::{channel::oneshot, FutureExt, StreamExt};
use gpui::{
actions,
- color::Color,
elements::*,
- geometry::{rect::RectF, vector::vec2f, PathBuilder},
impl_actions, impl_internal_actions,
- json::{self, ToJson},
platform::{CursorStyle, WindowOptions},
- AnyModelHandle, AnyViewHandle, AppContext, AsyncAppContext, Border, Entity, ImageData,
- ModelContext, ModelHandle, MouseButton, MutableAppContext, PathPromptOptions, PromptLevel,
- RenderContext, Task, View, ViewContext, ViewHandle, WeakViewHandle,
+ AnyModelHandle, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
+ MouseButton, MutableAppContext, PathPromptOptions, PromptLevel, RenderContext, Task, View,
+ ViewContext, ViewHandle, WeakViewHandle,
};
use language::LanguageRegistry;
use log::{error, warn};
@@ -52,7 +46,6 @@ use std::{
cell::RefCell,
fmt,
future::Future,
- ops::Range,
path::{Path, PathBuf},
rc::Rc,
sync::{
@@ -64,7 +57,6 @@ use std::{
use theme::{Theme, ThemeRegistry};
pub use toolbar::{ToolbarItemLocation, ToolbarItemView};
use util::ResultExt;
-use waiting_room::WaitingRoom;
type ProjectItemBuilders = HashMap<
TypeId,
@@ -115,12 +107,6 @@ pub struct OpenPaths {
pub paths: Vec<PathBuf>,
}
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct ToggleProjectOnline {
- #[serde(skip_deserializing)]
- pub project: Option<ModelHandle<Project>>,
-}
-
#[derive(Clone, Deserialize, PartialEq)]
pub struct ActivatePane(pub usize);
@@ -129,8 +115,8 @@ pub struct ToggleFollow(pub PeerId);
#[derive(Clone, PartialEq)]
pub struct JoinProject {
- pub contact: Arc<Contact>,
- pub project_index: usize,
+ pub project_id: u64,
+ pub follow_user_id: u64,
}
impl_internal_actions!(
@@ -142,7 +128,7 @@ impl_internal_actions!(
RemoveWorktreeFromProject
]
);
-impl_actions!(workspace, [ToggleProjectOnline, ActivatePane]);
+impl_actions!(workspace, [ActivatePane]);
pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
pane::init(cx);
@@ -173,14 +159,6 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
}
}
});
- cx.add_global_action({
- let app_state = Arc::downgrade(&app_state);
- move |action: &JoinProject, cx: &mut MutableAppContext| {
- if let Some(app_state) = app_state.upgrade() {
- join_project(action.contact.clone(), action.project_index, &app_state, cx);
- }
- }
- });
cx.add_async_action(Workspace::toggle_follow);
cx.add_async_action(Workspace::follow_next_collaborator);
@@ -188,7 +166,6 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
cx.add_async_action(Workspace::save_all);
cx.add_action(Workspace::add_folder_to_project);
cx.add_action(Workspace::remove_folder_from_project);
- cx.add_action(Workspace::toggle_project_online);
cx.add_action(
|workspace: &mut Workspace, _: &Unfollow, cx: &mut ViewContext<Workspace>| {
let pane = workspace.active_pane().clone();
@@ -957,7 +934,7 @@ impl AppState {
let languages = Arc::new(LanguageRegistry::test());
let http_client = client::test::FakeHttpClient::with_404_response();
let client = Client::new(http_client.clone(), cx);
- let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
+ let project_store = cx.add_model(|_| ProjectStore::new());
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
let themes = ThemeRegistry::new((), cx.font_cache().clone());
Arc::new(Self {
@@ -984,7 +961,7 @@ pub struct Workspace {
weak_self: WeakViewHandle<Self>,
client: Arc<Client>,
user_store: ModelHandle<client::UserStore>,
- remote_entity_subscription: Option<Subscription>,
+ remote_entity_subscription: Option<client::Subscription>,
fs: Arc<dyn Fs>,
modal: Option<AnyViewHandle>,
center: PaneGroup,
@@ -995,6 +972,7 @@ pub struct Workspace {
active_pane: ViewHandle<Pane>,
last_active_center_pane: Option<ViewHandle<Pane>>,
status_bar: ViewHandle<StatusBar>,
+ titlebar_item: Option<AnyViewHandle>,
dock: Dock,
notifications: Vec<(TypeId, usize, Box<dyn NotificationHandle>)>,
project: ModelHandle<Project>,
@@ -1002,7 +980,9 @@ pub struct Workspace {
follower_states_by_leader: FollowerStatesByLeader,
last_leaders_by_pane: HashMap<WeakViewHandle<Pane>, PeerId>,
window_edited: bool,
+ active_call: Option<ModelHandle<ActiveCall>>,
_observe_current_user: Task<()>,
+ _active_call_observation: Option<gpui::Subscription>,
}
#[derive(Default)]
@@ -1111,6 +1091,14 @@ impl Workspace {
drag_and_drop.register_container(weak_handle.clone());
});
+ let mut active_call = None;
+ let mut active_call_observation = None;
+ if cx.has_global::<ModelHandle<ActiveCall>>() {
+ let call = cx.global::<ModelHandle<ActiveCall>>().clone();
+ active_call_observation = Some(cx.observe(&call, |_, _, cx| cx.notify()));
+ active_call = Some(call);
+ }
+
let mut this = Workspace {
modal: None,
weak_self: weak_handle,
@@ -1124,6 +1112,7 @@ impl Workspace {
active_pane: center_pane.clone(),
last_active_center_pane: Some(center_pane.clone()),
status_bar,
+ titlebar_item: None,
notifications: Default::default(),
client,
remote_entity_subscription: None,
@@ -1136,7 +1125,9 @@ impl Workspace {
follower_states_by_leader: Default::default(),
last_leaders_by_pane: Default::default(),
window_edited: false,
+ active_call,
_observe_current_user,
+ _active_call_observation: active_call_observation,
};
this.project_remote_id_changed(this.project.read(cx).remote_id(), cx);
cx.defer(|this, cx| this.update_window_title(cx));
@@ -1168,6 +1159,19 @@ impl Workspace {
&self.project
}
+ pub fn client(&self) -> &Arc<Client> {
+ &self.client
+ }
+
+ pub fn set_titlebar_item(
+ &mut self,
+ item: impl Into<AnyViewHandle>,
+ cx: &mut ViewContext<Self>,
+ ) {
+ self.titlebar_item = Some(item.into());
+ cx.notify();
+ }
+
/// Call the given callback with a workspace whose project is local.
///
/// If the given workspace has a local project, then it will be passed
@@ -1188,7 +1192,6 @@ impl Workspace {
let (_, workspace) = cx.add_window((app_state.build_window_options)(), |cx| {
let mut workspace = Workspace::new(
Project::local(
- false,
app_state.client.clone(),
app_state.user_store.clone(),
app_state.project_store.clone(),
@@ -1238,7 +1241,7 @@ impl Workspace {
_: &CloseWindow,
cx: &mut ViewContext<Self>,
) -> Option<Task<Result<()>>> {
- let prepare = self.prepare_to_close(cx);
+ let prepare = self.prepare_to_close(false, cx);
Some(cx.spawn(|this, mut cx| async move {
if prepare.await? {
this.update(&mut cx, |_, cx| {
@@ -1250,8 +1253,44 @@ impl Workspace {
}))
}
- pub fn prepare_to_close(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<bool>> {
- self.save_all_internal(true, cx)
+ pub fn prepare_to_close(
+ &mut self,
+ quitting: bool,
+ cx: &mut ViewContext<Self>,
+ ) -> Task<Result<bool>> {
+ let active_call = self.active_call.clone();
+ let window_id = cx.window_id();
+ let workspace_count = cx
+ .window_ids()
+ .flat_map(|window_id| cx.root_view::<Workspace>(window_id))
+ .count();
+ cx.spawn(|this, mut cx| async move {
+ if let Some(active_call) = active_call {
+ if !quitting
+ && workspace_count == 1
+ && active_call.read_with(&cx, |call, _| call.room().is_some())
+ {
+ let answer = cx
+ .prompt(
+ window_id,
+ PromptLevel::Warning,
+ "Do you want to leave the current call?",
+ &["Close window and hang up", "Cancel"],
+ )
+ .next()
+ .await;
+ if answer == Some(1) {
+ return anyhow::Ok(false);
+ } else {
+ active_call.update(&mut cx, |call, cx| call.hang_up(cx))?;
+ }
+ }
+ }
+
+ Ok(this
+ .update(&mut cx, |this, cx| this.save_all_internal(true, cx))
+ .await?)
+ })
}
fn save_all(&mut self, _: &SaveAll, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
@@ -1393,17 +1432,6 @@ impl Workspace {
.update(cx, |project, cx| project.remove_worktree(*worktree_id, cx));
}
- fn toggle_project_online(&mut self, action: &ToggleProjectOnline, cx: &mut ViewContext<Self>) {
- let project = action
- .project
- .clone()
- .unwrap_or_else(|| self.project.clone());
- project.update(cx, |project, cx| {
- let public = !project.is_online();
- project.set_online(public, cx);
- });
- }
-
fn project_path_for_path(
&self,
abs_path: &Path,
@@ -2068,46 +2096,12 @@ impl Workspace {
None
}
- fn render_connection_status(&self, cx: &mut RenderContext<Self>) -> Option<ElementBox> {
- let theme = &cx.global::<Settings>().theme;
- match &*self.client.status().borrow() {
- client::Status::ConnectionError
- | client::Status::ConnectionLost
- | client::Status::Reauthenticating { .. }
- | client::Status::Reconnecting { .. }
- | client::Status::ReconnectionError { .. } => Some(
- Container::new(
- Align::new(
- ConstrainedBox::new(
- Svg::new("icons/cloud_slash_12.svg")
- .with_color(theme.workspace.titlebar.offline_icon.color)
- .boxed(),
- )
- .with_width(theme.workspace.titlebar.offline_icon.width)
- .boxed(),
- )
- .boxed(),
- )
- .with_style(theme.workspace.titlebar.offline_icon.container)
- .boxed(),
- ),
- client::Status::UpgradeRequired => Some(
- Label::new(
- "Please update Zed to collaborate".to_string(),
- theme.workspace.titlebar.outdated_warning.text.clone(),
- )
- .contained()
- .with_style(theme.workspace.titlebar.outdated_warning.container)
- .aligned()
- .boxed(),
- ),
- _ => None,
- }
+ pub fn is_following(&self, peer_id: PeerId) -> bool {
+ self.follower_states_by_leader.contains_key(&peer_id)
}
fn render_titlebar(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
let project = &self.project.read(cx);
- let replica_id = project.replica_id();
let mut worktree_root_names = String::new();
for (i, name) in project.worktree_root_names(cx).enumerate() {
if i > 0 {
@@ -2129,7 +2123,7 @@ impl Workspace {
enum TitleBar {}
ConstrainedBox::new(
- MouseEventHandler::<TitleBar>::new(0, cx, |_, cx| {
+ MouseEventHandler::<TitleBar>::new(0, cx, |_, _| {
Container::new(
Stack::new()
.with_child(
@@ -2138,21 +2132,10 @@ impl Workspace {
.left()
.boxed(),
)
- .with_child(
- Align::new(
- Flex::row()
- .with_children(self.render_collaborators(theme, cx))
- .with_children(self.render_current_user(
- self.user_store.read(cx).current_user().as_ref(),
- replica_id,
- theme,
- cx,
- ))
- .with_children(self.render_connection_status(cx))
- .boxed(),
- )
- .right()
- .boxed(),
+ .with_children(
+ self.titlebar_item
+ .as_ref()
+ .map(|item| ChildView::new(item).aligned().right().boxed()),
)
.boxed(),
)
@@ -2221,125 +2204,6 @@ impl Workspace {
}
}
- fn render_collaborators(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> Vec<ElementBox> {
- let mut collaborators = self
- .project
- .read(cx)
- .collaborators()
- .values()
- .cloned()
- .collect::<Vec<_>>();
- collaborators.sort_unstable_by_key(|collaborator| collaborator.replica_id);
- collaborators
- .into_iter()
- .filter_map(|collaborator| {
- Some(self.render_avatar(
- collaborator.user.avatar.clone()?,
- collaborator.replica_id,
- Some((collaborator.peer_id, &collaborator.user.github_login)),
- theme,
- cx,
- ))
- })
- .collect()
- }
-
- fn render_current_user(
- &self,
- user: Option<&Arc<User>>,
- replica_id: ReplicaId,
- theme: &Theme,
- cx: &mut RenderContext<Self>,
- ) -> Option<ElementBox> {
- let status = *self.client.status().borrow();
- if let Some(avatar) = user.and_then(|user| user.avatar.clone()) {
- Some(self.render_avatar(avatar, replica_id, None, theme, cx))
- } else if matches!(status, client::Status::UpgradeRequired) {
- None
- } else {
- Some(
- MouseEventHandler::<Authenticate>::new(0, cx, |state, _| {
- let style = theme
- .workspace
- .titlebar
- .sign_in_prompt
- .style_for(state, false);
- Label::new("Sign in".to_string(), style.text.clone())
- .contained()
- .with_style(style.container)
- .boxed()
- })
- .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate))
- .with_cursor_style(CursorStyle::PointingHand)
- .aligned()
- .boxed(),
- )
- }
- }
-
- fn render_avatar(
- &self,
- avatar: Arc<ImageData>,
- replica_id: ReplicaId,
- peer: Option<(PeerId, &str)>,
- theme: &Theme,
- cx: &mut RenderContext<Self>,
- ) -> ElementBox {
- let replica_color = theme.editor.replica_selection_style(replica_id).cursor;
- let is_followed = peer.map_or(false, |(peer_id, _)| {
- self.follower_states_by_leader.contains_key(&peer_id)
- });
- let mut avatar_style = theme.workspace.titlebar.avatar;
- if is_followed {
- avatar_style.border = Border::all(1.0, replica_color);
- }
- let content = Stack::new()
- .with_child(
- Image::new(avatar)
- .with_style(avatar_style)
- .constrained()
- .with_width(theme.workspace.titlebar.avatar_width)
- .aligned()
- .boxed(),
- )
- .with_child(
- AvatarRibbon::new(replica_color)
- .constrained()
- .with_width(theme.workspace.titlebar.avatar_ribbon.width)
- .with_height(theme.workspace.titlebar.avatar_ribbon.height)
- .aligned()
- .bottom()
- .boxed(),
- )
- .constrained()
- .with_width(theme.workspace.titlebar.avatar_width)
- .contained()
- .with_margin_left(theme.workspace.titlebar.avatar_margin)
- .boxed();
-
- if let Some((peer_id, peer_github_login)) = peer {
- MouseEventHandler::<ToggleFollow>::new(replica_id.into(), cx, move |_, _| content)
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, cx| {
- cx.dispatch_action(ToggleFollow(peer_id))
- })
- .with_tooltip::<ToggleFollow, _>(
- peer_id.0 as usize,
- if is_followed {
- format!("Unfollow {}", peer_github_login)
- } else {
- format!("Follow {}", peer_github_login)
- },
- Some(Box::new(FollowNextCollaborator)),
- theme.tooltip.clone(),
- cx,
- )
- .boxed()
- } else {
- content
- }
- }
-
fn render_disconnected_overlay(&self, cx: &mut RenderContext<Workspace>) -> Option<ElementBox> {
if self.project.read(cx).is_read_only() {
enum DisconnectedOverlay {}
@@ -2698,6 +2562,7 @@ impl View for Workspace {
.with_child(
Stack::new()
.with_child({
+ let project = self.project.clone();
Flex::row()
.with_children(
if self.left_sidebar.read(cx).active_item().is_some() {
@@ -2715,9 +2580,11 @@ impl View for Workspace {
Flex::column()
.with_child(
FlexItem::new(self.center.render(
+ &project,
&theme,
&self.follower_states_by_leader,
- self.project.read(cx).collaborators(),
+ self.active_call.as_ref(),
+ cx,
))
.flex(1., true)
.boxed(),
@@ -2814,87 +2681,6 @@ impl WorkspaceHandle for ViewHandle<Workspace> {
}
}
-pub struct AvatarRibbon {
- color: Color,
-}
-
-impl AvatarRibbon {
- pub fn new(color: Color) -> AvatarRibbon {
- AvatarRibbon { color }
- }
-}
-
-impl Element for AvatarRibbon {
- type LayoutState = ();
-
- type PaintState = ();
-
- fn layout(
- &mut self,
- constraint: gpui::SizeConstraint,
- _: &mut gpui::LayoutContext,
- ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
- (constraint.max, ())
- }
-
- fn paint(
- &mut self,
- bounds: gpui::geometry::rect::RectF,
- _: gpui::geometry::rect::RectF,
- _: &mut Self::LayoutState,
- cx: &mut gpui::PaintContext,
- ) -> Self::PaintState {
- let mut path = PathBuilder::new();
- path.reset(bounds.lower_left());
- path.curve_to(
- bounds.origin() + vec2f(bounds.height(), 0.),
- bounds.origin(),
- );
- path.line_to(bounds.upper_right() - vec2f(bounds.height(), 0.));
- path.curve_to(bounds.lower_right(), bounds.upper_right());
- path.line_to(bounds.lower_left());
- cx.scene.push_path(path.build(self.color, None));
- }
-
- fn dispatch_event(
- &mut self,
- _: &gpui::Event,
- _: RectF,
- _: RectF,
- _: &mut Self::LayoutState,
- _: &mut Self::PaintState,
- _: &mut gpui::EventContext,
- ) -> bool {
- false
- }
-
- fn rect_for_text_range(
- &self,
- _: Range<usize>,
- _: RectF,
- _: RectF,
- _: &Self::LayoutState,
- _: &Self::PaintState,
- _: &gpui::MeasurementContext,
- ) -> Option<RectF> {
- None
- }
-
- fn debug(
- &self,
- bounds: gpui::geometry::rect::RectF,
- _: &Self::LayoutState,
- _: &Self::PaintState,
- _: &gpui::DebugContext,
- ) -> gpui::json::Value {
- json::json!({
- "type": "AvatarRibbon",
- "bounds": bounds.to_json(),
- "color": self.color.to_json(),
- })
- }
-}
-
impl std::fmt::Debug for OpenPaths {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenPaths")
@@ -2964,7 +2750,6 @@ pub fn open_paths(
cx.add_window((app_state.build_window_options)(), |cx| {
let project = Project::local(
- false,
app_state.client.clone(),
app_state.user_store.clone(),
app_state.project_store.clone(),
@@ -2989,44 +2774,14 @@ pub fn open_paths(
})
.await;
- if let Some(project) = new_project {
- project
- .update(&mut cx, |project, cx| project.restore_state(cx))
- .await
- .log_err();
- }
-
(workspace, items)
})
}
-pub fn join_project(
- contact: Arc<Contact>,
- project_index: usize,
- app_state: &Arc<AppState>,
- cx: &mut MutableAppContext,
-) {
- let project_id = contact.projects[project_index].id;
-
- for window_id in cx.window_ids().collect::<Vec<_>>() {
- if let Some(workspace) = cx.root_view::<Workspace>(window_id) {
- if workspace.read(cx).project().read(cx).remote_id() == Some(project_id) {
- cx.activate_window(window_id);
- return;
- }
- }
- }
-
- cx.add_window((app_state.build_window_options)(), |cx| {
- WaitingRoom::new(contact, project_index, app_state.clone(), cx)
- });
-}
-
fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
let (window_id, workspace) = cx.add_window((app_state.build_window_options)(), |cx| {
let mut workspace = Workspace::new(
Project::local(
- false,
app_state.client.clone(),
app_state.user_store.clone(),
app_state.project_store.clone(),
@@ -3236,7 +2991,7 @@ mod tests {
// When there are no dirty items, there's nothing to do.
let item1 = cx.add_view(&workspace, |_| TestItem::new());
workspace.update(cx, |w, cx| w.add_item(Box::new(item1.clone()), cx));
- let task = workspace.update(cx, |w, cx| w.prepare_to_close(cx));
+ let task = workspace.update(cx, |w, cx| w.prepare_to_close(false, cx));
assert!(task.await.unwrap());
// When there are dirty untitled items, prompt to save each one. If the user
@@ -3256,7 +3011,7 @@ mod tests {
w.add_item(Box::new(item2.clone()), cx);
w.add_item(Box::new(item3.clone()), cx);
});
- let task = workspace.update(cx, |w, cx| w.prepare_to_close(cx));
+ let task = workspace.update(cx, |w, cx| w.prepare_to_close(false, cx));
cx.foreground().run_until_parked();
cx.simulate_prompt_answer(window_id, 2 /* cancel */);
cx.foreground().run_until_parked();
@@ -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"
@@ -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());
@@ -244,10 +244,6 @@ pub fn menus() -> Vec<Menu<'static>> {
name: "Project Panel",
action: Box::new(project_panel::ToggleFocus),
},
- MenuItem::Action {
- name: "Contacts Panel",
- action: Box::new(contacts_panel::ToggleFocus),
- },
MenuItem::Action {
name: "Command Palette",
action: Box::new(command_palette::Toggle),
@@ -10,9 +10,8 @@ use anyhow::{anyhow, Context, Result};
use assets::Assets;
use breadcrumbs::Breadcrumbs;
pub use client;
+use collab_ui::CollabTitlebarItem;
use collections::VecDeque;
-pub use contacts_panel;
-use contacts_panel::ContactsPanel;
pub use editor;
use editor::{Editor, MultiBuffer};
use gpui::{
@@ -214,15 +213,9 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
workspace.toggle_sidebar_item_focus(SidebarSide::Left, 0, cx);
},
);
- cx.add_action(
- |workspace: &mut Workspace,
- _: &contacts_panel::ToggleFocus,
- cx: &mut ViewContext<Workspace>| {
- workspace.toggle_sidebar_item_focus(SidebarSide::Right, 0, cx);
- },
- );
activity_indicator::init(cx);
+ call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
settings::KeymapFileContent::load_defaults(cx);
}
@@ -231,7 +224,8 @@ pub fn initialize_workspace(
app_state: &Arc<AppState>,
cx: &mut ViewContext<Workspace>,
) {
- cx.subscribe(&cx.handle(), {
+ let workspace_handle = cx.handle();
+ cx.subscribe(&workspace_handle, {
move |_, _, event, cx| {
if let workspace::Event::PaneAdded(pane) = event {
pane.update(cx, |pane, cx| {
@@ -285,16 +279,11 @@ pub fn initialize_workspace(
}));
});
- let project_panel = ProjectPanel::new(workspace.project().clone(), cx);
- let contact_panel = cx.add_view(|cx| {
- ContactsPanel::new(
- app_state.user_store.clone(),
- app_state.project_store.clone(),
- workspace.weak_handle(),
- cx,
- )
- });
+ let collab_titlebar_item =
+ cx.add_view(|cx| CollabTitlebarItem::new(&workspace_handle, &app_state.user_store, cx));
+ workspace.set_titlebar_item(collab_titlebar_item, cx);
+ let project_panel = ProjectPanel::new(workspace.project().clone(), cx);
workspace.left_sidebar().update(cx, |sidebar, cx| {
sidebar.add_item(
"icons/folder_tree_16.svg",
@@ -303,14 +292,6 @@ pub fn initialize_workspace(
cx,
)
});
- workspace.right_sidebar().update(cx, |sidebar, cx| {
- sidebar.add_item(
- "icons/user_group_16.svg",
- "Contacts Panel".to_string(),
- contact_panel,
- cx,
- )
- });
let diagnostic_summary =
cx.add_view(|cx| diagnostics::items::DiagnosticIndicator::new(workspace.project(), cx));
@@ -363,7 +344,9 @@ fn quit(_: &Quit, cx: &mut gpui::MutableAppContext) {
// If the user cancels any save prompt, then keep the app open.
for workspace in workspaces {
if !workspace
- .update(&mut cx, |workspace, cx| workspace.prepare_to_close(cx))
+ .update(&mut cx, |workspace, cx| {
+ workspace.prepare_to_close(true, cx)
+ })
.await?
{
return Ok(());
@@ -1772,6 +1755,7 @@ mod tests {
let state = Arc::get_mut(&mut app_state).unwrap();
state.initialize_workspace = initialize_workspace;
state.build_window_options = build_window_options;
+ call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
workspace::init(app_state.clone(), cx);
editor::init(cx);
pane::init(cx);
@@ -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),
};
@@ -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,
@@ -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" }),
- },
- },
- };
+ }
}
@@ -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" }),
+ },
+ },
}
}
@@ -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" })
+ },
+ };
+}
@@ -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" })
+ },
+ };
+}
@@ -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,