diff --git a/assets/icons/leave_12.svg b/assets/icons/leave_12.svg new file mode 100644 index 0000000000000000000000000000000000000000..84491384b8cc7f80d4a727e75c142ee509b451ac --- /dev/null +++ b/assets/icons/leave_12.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index 64584e61400b414620ba640f2fbc0b79825c535e..dfe4f39e0e85bef14a52a22a3c8c0f1b9bfa84b6 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -284,6 +284,18 @@ impl ActiveCall { } } + pub fn unshare_project( + &mut self, + project: ModelHandle, + cx: &mut ModelContext, + ) -> Result<()> { + if let Some((room, _)) = self.room.as_ref() { + room.update(cx, |room, cx| room.unshare_project(project, cx)) + } else { + Err(anyhow!("no active call")) + } + } + pub fn set_location( &mut self, project: Option<&ModelHandle>, diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index 7527a693269725f0a6eae3bea48790e7c79dfb00..51b125577db11d8c0e8d48d9c2f146a2cec2f630 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -55,6 +55,7 @@ pub struct Room { leave_when_empty: bool, client: Arc, user_store: ModelHandle, + follows_by_leader_id: HashMap>, subscriptions: Vec, pending_room_update: Option>, maintain_connection: Option>>, @@ -148,6 +149,7 @@ impl Room { pending_room_update: None, client, user_store, + follows_by_leader_id: Default::default(), maintain_connection: Some(maintain_connection), } } @@ -457,6 +459,12 @@ impl Room { self.participant_user_ids.contains(&user_id) } + pub fn followers_for(&self, leader_id: PeerId) -> &[PeerId] { + self.follows_by_leader_id + .get(&leader_id) + .map_or(&[], |v| v.as_slice()) + } + async fn handle_room_updated( this: ModelHandle, envelope: TypedEnvelope, @@ -487,11 +495,13 @@ impl Room { .iter() .map(|p| p.user_id) .collect::>(); + let remote_participant_user_ids = room .participants .iter() .map(|p| p.user_id) .collect::>(); + let (remote_participants, pending_participants) = self.user_store.update(cx, move |user_store, cx| { ( @@ -499,6 +509,7 @@ impl Room { user_store.get_users(pending_participant_user_ids, cx), ) }); + self.pending_room_update = Some(cx.spawn(|this, mut cx| async move { let (remote_participants, pending_participants) = futures::join!(remote_participants, pending_participants); @@ -620,6 +631,26 @@ impl Room { } } + this.follows_by_leader_id.clear(); + for follower in room.followers { + let (leader, follower) = match (follower.leader_id, follower.follower_id) { + (Some(leader), Some(follower)) => (leader, follower), + + _ => { + log::error!("Follower message {follower:?} missing some state"); + continue; + } + }; + + let list = this + .follows_by_leader_id + .entry(leader) + .or_insert(Vec::new()); + if !list.contains(&follower) { + list.push(follower); + } + } + this.pending_room_update.take(); if this.should_leave() { log::info!("room is empty, leaving"); @@ -793,6 +824,20 @@ impl Room { }) } + pub(crate) fn unshare_project( + &mut self, + project: ModelHandle, + cx: &mut ModelContext, + ) -> Result<()> { + let project_id = match project.read(cx).remote_id() { + Some(project_id) => project_id, + None => return Ok(()), + }; + + self.client.send(proto::UnshareProject { project_id })?; + project.update(cx, |this, cx| this.unshare(cx)) + } + pub(crate) fn set_location( &mut self, project: Option<&ModelHandle>, diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 32254d5757da77f7b90f6c675b0a432418d32624..89b924087ef987c89ec58e65f2b165a7d11b4afa 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -143,3 +143,17 @@ CREATE TABLE "servers" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "environment" VARCHAR NOT NULL ); + +CREATE TABLE "followers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "room_id" INTEGER NOT NULL REFERENCES rooms (id) ON DELETE CASCADE, + "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + "leader_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, + "leader_connection_id" INTEGER NOT NULL, + "follower_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, + "follower_connection_id" INTEGER NOT NULL +); +CREATE UNIQUE INDEX + "index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id" +ON "followers" ("project_id", "leader_connection_server_id", "leader_connection_id", "follower_connection_server_id", "follower_connection_id"); +CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id"); diff --git a/crates/collab/migrations/20230202155735_followers.sql b/crates/collab/migrations/20230202155735_followers.sql new file mode 100644 index 0000000000000000000000000000000000000000..c82d6ba3bdaa4f2b2a60771bca7401c47678f247 --- /dev/null +++ b/crates/collab/migrations/20230202155735_followers.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS "followers" ( + "id" SERIAL PRIMARY KEY, + "room_id" INTEGER NOT NULL REFERENCES rooms (id) ON DELETE CASCADE, + "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + "leader_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, + "leader_connection_id" INTEGER NOT NULL, + "follower_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, + "follower_connection_id" INTEGER NOT NULL +); + +CREATE UNIQUE INDEX + "index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id" +ON "followers" ("project_id", "leader_connection_server_id", "leader_connection_id", "follower_connection_server_id", "follower_connection_id"); + +CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id"); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index af30073ab4ef424b8c9f84557cdd692cdfbfcf46..97c9b6d344f7c3c3e57182b58185ad4ca8f5869d 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1,5 +1,6 @@ mod access_token; mod contact; +mod follower; mod language_server; mod project; mod project_collaborator; @@ -1717,6 +1718,88 @@ impl Database { .await } + pub async fn follow( + &self, + project_id: ProjectId, + leader_connection: ConnectionId, + follower_connection: ConnectionId, + ) -> Result> { + self.room_transaction(|tx| async move { + let room_id = self.room_id_for_project(project_id, &*tx).await?; + follower::ActiveModel { + room_id: ActiveValue::set(room_id), + project_id: ActiveValue::set(project_id), + leader_connection_server_id: ActiveValue::set(ServerId( + leader_connection.owner_id as i32, + )), + leader_connection_id: ActiveValue::set(leader_connection.id as i32), + follower_connection_server_id: ActiveValue::set(ServerId( + follower_connection.owner_id as i32, + )), + follower_connection_id: ActiveValue::set(follower_connection.id as i32), + ..Default::default() + } + .insert(&*tx) + .await?; + + Ok((room_id, self.get_room(room_id, &*tx).await?)) + }) + .await + } + + pub async fn unfollow( + &self, + project_id: ProjectId, + leader_connection: ConnectionId, + follower_connection: ConnectionId, + ) -> Result> { + self.room_transaction(|tx| async move { + let room_id = self.room_id_for_project(project_id, &*tx).await?; + follower::Entity::delete_many() + .filter( + Condition::all() + .add(follower::Column::ProjectId.eq(project_id)) + .add( + follower::Column::LeaderConnectionServerId + .eq(leader_connection.owner_id) + .and(follower::Column::LeaderConnectionId.eq(leader_connection.id)), + ) + .add( + follower::Column::FollowerConnectionServerId + .eq(follower_connection.owner_id) + .and( + follower::Column::FollowerConnectionId + .eq(follower_connection.id), + ), + ), + ) + .exec(&*tx) + .await?; + + Ok((room_id, self.get_room(room_id, &*tx).await?)) + }) + .await + } + + async fn room_id_for_project( + &self, + project_id: ProjectId, + tx: &DatabaseTransaction, + ) -> Result { + #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] + enum QueryAs { + RoomId, + } + + Ok(project::Entity::find_by_id(project_id) + .select_only() + .column(project::Column::RoomId) + .into_values::<_, QueryAs>() + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("no such project"))?) + } + pub async fn update_room_participant_location( &self, room_id: RoomId, @@ -1926,12 +2009,24 @@ impl Database { } } } + drop(db_projects); + + let mut db_followers = db_room.find_related(follower::Entity).stream(tx).await?; + let mut followers = Vec::new(); + while let Some(db_follower) = db_followers.next().await { + let db_follower = db_follower?; + followers.push(proto::Follower { + leader_id: Some(db_follower.leader_connection().into()), + follower_id: Some(db_follower.follower_connection().into()), + }); + } Ok(proto::Room { id: db_room.id.to_proto(), live_kit_room: db_room.live_kit_room, participants: participants.into_values().collect(), pending_participants, + followers, }) } @@ -3011,6 +3106,7 @@ macro_rules! id_type { id_type!(AccessTokenId); id_type!(ContactId); +id_type!(FollowerId); id_type!(RoomId); id_type!(RoomParticipantId); id_type!(ProjectId); diff --git a/crates/collab/src/db/follower.rs b/crates/collab/src/db/follower.rs new file mode 100644 index 0000000000000000000000000000000000000000..f1243dc99eb34371bdd433c431b8a12eafeadbb6 --- /dev/null +++ b/crates/collab/src/db/follower.rs @@ -0,0 +1,51 @@ +use super::{FollowerId, ProjectId, RoomId, ServerId}; +use rpc::ConnectionId; +use sea_orm::entity::prelude::*; +use serde::Serialize; + +#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)] +#[sea_orm(table_name = "followers")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: FollowerId, + pub room_id: RoomId, + pub project_id: ProjectId, + pub leader_connection_server_id: ServerId, + pub leader_connection_id: i32, + pub follower_connection_server_id: ServerId, + pub follower_connection_id: i32, +} + +impl Model { + pub fn leader_connection(&self) -> ConnectionId { + ConnectionId { + owner_id: self.leader_connection_server_id.0 as u32, + id: self.leader_connection_id as u32, + } + } + + pub fn follower_connection(&self) -> ConnectionId { + ConnectionId { + owner_id: self.follower_connection_server_id.0 as u32, + id: self.follower_connection_id as u32, + } + } +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::room::Entity", + from = "Column::RoomId", + to = "super::room::Column::Id" + )] + Room, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Room.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/room.rs b/crates/collab/src/db/room.rs index 7dbf03a780adbd69c1d3b492e4bcf82557ae70ab..c3e88670ebe00afa523b008e6bf669de01ec1d1e 100644 --- a/crates/collab/src/db/room.rs +++ b/crates/collab/src/db/room.rs @@ -15,6 +15,8 @@ pub enum Relation { RoomParticipant, #[sea_orm(has_many = "super::project::Entity")] Project, + #[sea_orm(has_many = "super::follower::Entity")] + Follower, } impl Related for Entity { @@ -29,4 +31,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::Follower.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 32cce1e6815451ce9ac7b8e6e261ed60bca41e3d..dbfdf748651441d5c57adf4f6d9015f4137b4494 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -1312,6 +1312,7 @@ async fn join_project( .filter(|collaborator| collaborator.connection_id != session.connection_id) .map(|collaborator| collaborator.to_proto()) .collect::>(); + let worktrees = project .worktrees .iter() @@ -1724,6 +1725,7 @@ async fn follow( .ok_or_else(|| anyhow!("invalid leader id"))? .into(); let follower_id = session.connection_id; + { let project_connection_ids = session .db() @@ -1744,6 +1746,14 @@ async fn follow( .views .retain(|view| view.leader_id != Some(follower_id.into())); response.send(response_payload)?; + + let room = session + .db() + .await + .follow(project_id, leader_id, follower_id) + .await?; + room_updated(&room, &session.peer); + Ok(()) } @@ -1753,17 +1763,29 @@ async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> { .leader_id .ok_or_else(|| anyhow!("invalid leader id"))? .into(); - let project_connection_ids = session + let follower_id = session.connection_id; + + if !session .db() .await .project_connection_ids(project_id, session.connection_id) - .await?; - if !project_connection_ids.contains(&leader_id) { + .await? + .contains(&leader_id) + { Err(anyhow!("no such peer"))?; } + session .peer .forward_send(session.connection_id, leader_id, request)?; + + let room = session + .db() + .await + .unfollow(project_id, leader_id, follower_id) + .await?; + room_updated(&room, &session.peer); + Ok(()) } diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 2fc19b005b7ded5a1212fb09e41f545d7191503c..7c4087f54036302c589f75ff85bc9a25340be58d 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -5786,6 +5786,7 @@ async fn test_following( deterministic: Arc, cx_a: &mut TestAppContext, cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, ) { deterministic.forbid_parking(); cx_a.update(editor::init); @@ -5794,9 +5795,13 @@ async fn test_following( let mut server = TestServer::start(&deterministic).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; server .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + server + .make_contacts(&mut [(&client_a, cx_a), (&client_c, cx_c)]) + .await; let active_call_a = cx_a.read(ActiveCall::global); let active_call_b = cx_b.read(ActiveCall::global); @@ -5827,8 +5832,10 @@ async fn test_following( .await .unwrap(); - // Client A opens some editors. let workspace_a = client_a.build_workspace(&project_a, cx_a); + let workspace_b = client_b.build_workspace(&project_b, cx_b); + + // Client A opens some editors. let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone()); let editor_a1 = workspace_a .update(cx_a, |workspace, cx| { @@ -5848,7 +5855,6 @@ async fn test_following( .unwrap(); // Client B opens an editor. - let workspace_b = client_b.build_workspace(&project_b, cx_b); let editor_b1 = workspace_b .update(cx_b, |workspace, cx| { workspace.open_path((worktree_id, "1.txt"), None, true, cx) @@ -5858,29 +5864,97 @@ async fn test_following( .downcast::() .unwrap(); - let client_a_id = project_b.read_with(cx_b, |project, _| { - project.collaborators().values().next().unwrap().peer_id - }); - let client_b_id = project_a.read_with(cx_a, |project, _| { - project.collaborators().values().next().unwrap().peer_id - }); + let peer_id_a = client_a.peer_id().unwrap(); + let peer_id_b = client_b.peer_id().unwrap(); + let peer_id_c = client_c.peer_id().unwrap(); - // When client B starts following client A, all visible view states are replicated to client B. + // Client A updates their selections in those editors editor_a1.update(cx_a, |editor, cx| { editor.change_selections(None, cx, |s| s.select_ranges([0..1])) }); editor_a2.update(cx_a, |editor, cx| { editor.change_selections(None, cx, |s| s.select_ranges([2..3])) }); + + // When client B starts following client A, all visible view states are replicated to client B. workspace_b .update(cx_b, |workspace, cx| { workspace - .toggle_follow(&ToggleFollow(client_a_id), cx) + .toggle_follow(&ToggleFollow(peer_id_a), cx) + .unwrap() + }) + .await + .unwrap(); + + // Client A invites client C to the call. + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_c.current_user_id(cx_c).to_proto(), None, cx) + }) + .await + .unwrap(); + cx_c.foreground().run_until_parked(); + let active_call_c = cx_c.read(ActiveCall::global); + active_call_c + .update(cx_c, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + let project_c = client_c.build_remote_project(project_id, cx_c).await; + let workspace_c = client_c.build_workspace(&project_c, cx_c); + active_call_c + .update(cx_c, |call, cx| call.set_location(Some(&project_c), cx)) + .await + .unwrap(); + + // Client C also follows client A. + workspace_c + .update(cx_c, |workspace, cx| { + workspace + .toggle_follow(&ToggleFollow(peer_id_a), cx) .unwrap() }) .await .unwrap(); + // All clients see that clients B and C are following client A. + cx_c.foreground().run_until_parked(); + for (name, active_call, cx) in [ + ("A", &active_call_a, &cx_a), + ("B", &active_call_b, &cx_b), + ("C", &active_call_c, &cx_c), + ] { + active_call.read_with(*cx, |call, cx| { + let room = call.room().unwrap().read(cx); + assert_eq!( + room.followers_for(peer_id_a), + &[peer_id_b, peer_id_c], + "checking followers for A as {name}" + ); + }); + } + + // Client C unfollows client A. + workspace_c.update(cx_c, |workspace, cx| { + workspace.toggle_follow(&ToggleFollow(peer_id_a), cx); + }); + + // All clients see that clients B is following client A. + cx_c.foreground().run_until_parked(); + for (name, active_call, cx) in [ + ("A", &active_call_a, &cx_a), + ("B", &active_call_b, &cx_b), + ("C", &active_call_c, &cx_c), + ] { + active_call.read_with(*cx, |call, cx| { + let room = call.room().unwrap().read(cx); + assert_eq!( + room.followers_for(peer_id_a), + &[peer_id_b], + "checking followers for A as {name}" + ); + }); + } + let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| { workspace .active_item(cx) @@ -6033,14 +6107,14 @@ async fn test_following( workspace_a .update(cx_a, |workspace, cx| { workspace - .toggle_follow(&ToggleFollow(client_b_id), cx) + .toggle_follow(&ToggleFollow(peer_id_b), cx) .unwrap() }) .await .unwrap(); assert_eq!( workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), - Some(client_b_id) + Some(peer_id_b) ); assert_eq!( workspace_a.read_with(cx_a, |workspace, cx| workspace diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index a767e5056564ac42cc004ec2b6047d3919f17eb1..e190deef84f4965af1dd2d94fe1be9963d1683ec 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -1,5 +1,9 @@ -use crate::{contact_notification::ContactNotification, contacts_popover, ToggleScreenSharing}; -use call::{ActiveCall, ParticipantLocation}; +use crate::{ + collaborator_list_popover, collaborator_list_popover::CollaboratorListPopover, + contact_notification::ContactNotification, contacts_popover, face_pile::FacePile, + ToggleScreenSharing, +}; +use call::{ActiveCall, ParticipantLocation, Room}; use client::{proto::PeerId, Authenticate, ContactEventKind, User, UserStore}; use clock::ReplicaId; use contacts_popover::ContactsPopover; @@ -8,26 +12,52 @@ use gpui::{ color::Color, elements::*, geometry::{rect::RectF, vector::vec2f, PathBuilder}, + impl_internal_actions, json::{self, ToJson}, - Border, CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext, + CursorStyle, Entity, ImageData, ModelHandle, MouseButton, MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle, }; use settings::Settings; -use std::ops::Range; -use theme::Theme; +use std::{ops::Range, sync::Arc}; +use theme::{AvatarStyle, Theme}; +use util::ResultExt; use workspace::{FollowNextCollaborator, JoinProject, ToggleFollow, Workspace}; -actions!(collab, [ToggleCollaborationMenu, ShareProject]); +actions!( + collab, + [ + ToggleCollaboratorList, + ToggleContactsMenu, + ShareProject, + UnshareProject + ] +); + +impl_internal_actions!(collab, [LeaveCall]); + +#[derive(Copy, Clone, PartialEq)] +pub(crate) struct LeaveCall; + +#[derive(PartialEq, Eq)] +enum ContactsPopoverSide { + Left, + Right, +} pub fn init(cx: &mut MutableAppContext) { + cx.add_action(CollabTitlebarItem::toggle_collaborator_list_popover); cx.add_action(CollabTitlebarItem::toggle_contacts_popover); cx.add_action(CollabTitlebarItem::share_project); + cx.add_action(CollabTitlebarItem::unshare_project); + cx.add_action(CollabTitlebarItem::leave_call); } pub struct CollabTitlebarItem { workspace: WeakViewHandle, user_store: ModelHandle, contacts_popover: Option>, + contacts_popover_side: ContactsPopoverSide, + collaborator_list_popover: Option>, _subscriptions: Vec, } @@ -47,27 +77,71 @@ impl View for CollabTitlebarItem { return Empty::new().boxed(); }; + let project = workspace.read(cx).project().read(cx); + let mut project_title = String::new(); + for (i, name) in project.worktree_root_names(cx).enumerate() { + if i > 0 { + project_title.push_str(", "); + } + project_title.push_str(name); + } + if project_title.is_empty() { + project_title = "empty project".to_owned(); + } + let theme = cx.global::().theme.clone(); + let user = workspace.read(cx).user_store().read(cx).current_user(); - let mut container = Flex::row(); + let mut left_container = Flex::row(); - container.add_children(self.render_toggle_screen_sharing_button(&theme, cx)); + left_container.add_child( + Label::new(project_title, theme.workspace.titlebar.title.clone()) + .contained() + .with_margin_right(theme.workspace.titlebar.item_spacing) + .aligned() + .left() + .boxed(), + ); - if workspace.read(cx).client().status().borrow().is_connected() { - let project = workspace.read(cx).project().read(cx); - if project.is_shared() - || project.is_remote() - || ActiveCall::global(cx).read(cx).room().is_none() - { - container.add_child(self.render_toggle_contacts_button(&theme, cx)); - } else { - container.add_child(self.render_share_button(&theme, cx)); - } + if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { + left_container.add_child(self.render_current_user(&workspace, &theme, &user, cx)); + left_container.add_children(self.render_collaborators(&workspace, &theme, room, cx)); + left_container.add_child(self.render_toggle_contacts_button(&theme, cx)); } - container.add_children(self.render_collaborators(&workspace, &theme, cx)); - container.add_children(self.render_current_user(&workspace, &theme, cx)); - container.add_children(self.render_connection_status(&workspace, cx)); - container.boxed() + + let mut right_container = Flex::row(); + + if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { + right_container.add_child(self.render_toggle_screen_sharing_button(&theme, &room, cx)); + right_container.add_child(self.render_leave_call_button(&theme, cx)); + right_container + .add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx)); + } else { + right_container.add_child(self.render_outside_call_share_button(&theme, cx)); + } + + right_container.add_children(self.render_connection_status(&workspace, cx)); + + if let Some(user) = user { + //TODO: Add style + right_container.add_child( + Label::new( + user.github_login.clone(), + theme.workspace.titlebar.title.clone(), + ) + .aligned() + .contained() + .with_margin_left(theme.workspace.titlebar.item_spacing) + .boxed(), + ); + } else { + right_container.add_child(Self::render_authenticate(&theme, cx)); + } + + Stack::new() + .with_child(left_container.boxed()) + .with_child(right_container.aligned().right().boxed()) + .boxed() } } @@ -80,7 +154,7 @@ impl CollabTitlebarItem { let active_call = ActiveCall::global(cx); let mut subscriptions = Vec::new(); subscriptions.push(cx.observe(workspace, |_, _, cx| cx.notify())); - subscriptions.push(cx.observe(&active_call, |_, _, cx| cx.notify())); + subscriptions.push(cx.observe(&active_call, |this, _, cx| this.active_call_changed(cx))); subscriptions.push(cx.observe_window_activation(|this, active, cx| { this.window_activation_changed(active, cx) })); @@ -112,6 +186,8 @@ impl CollabTitlebarItem { workspace: workspace.downgrade(), user_store: user_store.clone(), contacts_popover: None, + contacts_popover_side: ContactsPopoverSide::Right, + collaborator_list_popover: None, _subscriptions: subscriptions, } } @@ -129,6 +205,13 @@ impl CollabTitlebarItem { } } + fn active_call_changed(&mut self, cx: &mut ViewContext) { + if ActiveCall::global(cx).read(cx).room().is_none() { + self.contacts_popover = None; + } + cx.notify(); + } + fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext) { if let Some(workspace) = self.workspace.upgrade(cx) { let active_call = ActiveCall::global(cx); @@ -139,41 +222,88 @@ impl CollabTitlebarItem { } } - pub fn toggle_contacts_popover( + fn unshare_project(&mut self, _: &UnshareProject, cx: &mut ViewContext) { + if let Some(workspace) = self.workspace.upgrade(cx) { + let active_call = ActiveCall::global(cx); + let project = workspace.read(cx).project().clone(); + active_call + .update(cx, |call, cx| call.unshare_project(project, cx)) + .log_err(); + } + } + + pub fn toggle_collaborator_list_popover( &mut self, - _: &ToggleCollaborationMenu, + _: &ToggleCollaboratorList, cx: &mut ViewContext, ) { - match self.contacts_popover.take() { + match self.collaborator_list_popover.take() { Some(_) => {} None => { if let Some(workspace) = self.workspace.upgrade(cx) { - let project = workspace.read(cx).project().clone(); let user_store = workspace.read(cx).user_store().clone(); - let view = cx.add_view(|cx| ContactsPopover::new(project, user_store, cx)); + let view = cx.add_view(|cx| CollaboratorListPopover::new(user_store, cx)); + cx.subscribe(&view, |this, _, event, cx| { match event { - contacts_popover::Event::Dismissed => { - this.contacts_popover = None; + collaborator_list_popover::Event::Dismissed => { + this.collaborator_list_popover = None; } } cx.notify(); }) .detach(); - self.contacts_popover = Some(view); + + self.collaborator_list_popover = Some(view); } } } cx.notify(); } + pub fn toggle_contacts_popover(&mut self, _: &ToggleContactsMenu, cx: &mut ViewContext) { + if self.contacts_popover.take().is_none() { + if let Some(workspace) = self.workspace.upgrade(cx) { + let project = workspace.read(cx).project().clone(); + let user_store = workspace.read(cx).user_store().clone(); + let view = cx.add_view(|cx| ContactsPopover::new(project, user_store, cx)); + cx.subscribe(&view, |this, _, event, cx| { + match event { + contacts_popover::Event::Dismissed => { + this.contacts_popover = None; + } + } + + cx.notify(); + }) + .detach(); + + self.contacts_popover_side = match ActiveCall::global(cx).read(cx).room() { + Some(_) => ContactsPopoverSide::Left, + None => ContactsPopoverSide::Right, + }; + + self.contacts_popover = Some(view); + } + } + + cx.notify(); + } + + fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext) { + ActiveCall::global(cx) + .update(cx, |call, cx| call.hang_up(cx)) + .log_err(); + } + fn render_toggle_contacts_button( &self, theme: &Theme, cx: &mut RenderContext, ) -> ElementBox { let titlebar = &theme.workspace.titlebar; + let badge = if self .user_store .read(cx) @@ -194,12 +324,15 @@ impl CollabTitlebarItem { .boxed(), ) }; + Stack::new() .with_child( - MouseEventHandler::::new(0, cx, |state, _| { - let style = titlebar - .toggle_contacts_button - .style_for(state, self.contacts_popover.is_some()); + MouseEventHandler::::new(0, cx, |state, _| { + let style = titlebar.toggle_contacts_button.style_for( + state, + self.contacts_popover.is_some() + && self.contacts_popover_side == ContactsPopoverSide::Left, + ); Svg::new("icons/plus_8.svg") .with_color(style.color) .constrained() @@ -214,39 +347,28 @@ impl CollabTitlebarItem { }) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, move |_, cx| { - cx.dispatch_action(ToggleCollaborationMenu); + cx.dispatch_action(ToggleContactsMenu); }) .aligned() .boxed(), ) .with_children(badge) - .with_children(self.contacts_popover.as_ref().map(|popover| { - Overlay::new( - ChildView::new(popover, cx) - .contained() - .with_margin_top(titlebar.height) - .with_margin_left(titlebar.toggle_contacts_button.default.button_width) - .with_margin_right(-titlebar.toggle_contacts_button.default.button_width) - .boxed(), - ) - .with_fit_mode(OverlayFitMode::SwitchAnchor) - .with_anchor_corner(AnchorCorner::BottomLeft) - .with_z_index(999) - .boxed() - })) + .with_children(self.render_contacts_popover_host( + ContactsPopoverSide::Left, + titlebar, + cx, + )) .boxed() } fn render_toggle_screen_sharing_button( &self, theme: &Theme, + room: &ModelHandle, cx: &mut RenderContext, - ) -> Option { - let active_call = ActiveCall::global(cx); - let room = active_call.read(cx).room().cloned()?; + ) -> ElementBox { let icon; let tooltip; - if room.read(cx).is_screen_sharing() { icon = "icons/disable_screen_sharing_12.svg"; tooltip = "Stop Sharing Screen" @@ -256,203 +378,409 @@ impl CollabTitlebarItem { } let titlebar = &theme.workspace.titlebar; - Some( - MouseEventHandler::::new(0, cx, |state, _| { - let style = titlebar.call_control.style_for(state, false); - Svg::new(icon) - .with_color(style.color) - .constrained() - .with_width(style.icon_width) - .aligned() - .constrained() - .with_width(style.button_width) - .with_height(style.button_width) - .contained() - .with_style(style.container) - .boxed() - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, cx| { - cx.dispatch_action(ToggleScreenSharing); - }) - .with_tooltip::( - 0, - tooltip.into(), - Some(Box::new(ToggleScreenSharing)), - theme.tooltip.clone(), - cx, - ) - .aligned() - .boxed(), + MouseEventHandler::::new(0, cx, |state, _| { + let style = titlebar.call_control.style_for(state, false); + Svg::new(icon) + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) + .contained() + .with_style(style.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(ToggleScreenSharing); + }) + .with_tooltip::( + 0, + tooltip.into(), + Some(Box::new(ToggleScreenSharing)), + theme.tooltip.clone(), + cx, ) + .aligned() + .boxed() } - fn render_share_button(&self, theme: &Theme, cx: &mut RenderContext) -> ElementBox { - enum Share {} - + fn render_leave_call_button(&self, theme: &Theme, cx: &mut RenderContext) -> ElementBox { let titlebar = &theme.workspace.titlebar; - MouseEventHandler::::new(0, cx, |state, _| { - let style = titlebar.share_button.style_for(state, false); - Label::new("Share", style.text.clone()) + + MouseEventHandler::::new(0, cx, |state, _| { + let style = titlebar.call_control.style_for(state, false); + Svg::new("icons/leave_12.svg") + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) .contained() .with_style(style.container) .boxed() }) .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(ShareProject)) - .with_tooltip::( + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(LeaveCall); + }) + .with_tooltip::( 0, - "Share project with call participants".into(), - None, + "Leave call".to_owned(), + Some(Box::new(LeaveCall)), theme.tooltip.clone(), cx, ) - .aligned() .contained() - .with_margin_left(theme.workspace.titlebar.avatar_margin) + .with_margin_left(theme.workspace.titlebar.item_spacing) + .aligned() .boxed() } + fn render_in_call_share_unshare_button( + &self, + workspace: &ViewHandle, + theme: &Theme, + cx: &mut RenderContext, + ) -> Option { + let project = workspace.read(cx).project(); + if project.read(cx).is_remote() { + return None; + } + + let is_shared = project.read(cx).is_shared(); + let label = if is_shared { "Unshare" } else { "Share" }; + let tooltip = if is_shared { + "Unshare project from call participants" + } else { + "Share project with call participants" + }; + + let titlebar = &theme.workspace.titlebar; + + enum ShareUnshare {} + Some( + Stack::new() + .with_child( + MouseEventHandler::::new(0, cx, |state, _| { + //TODO: Ensure this button has consistant width for both text variations + let style = titlebar.share_button.style_for( + state, + self.contacts_popover.is_some() + && self.contacts_popover_side == ContactsPopoverSide::Right, + ); + Label::new(label, style.text.clone()) + .contained() + .with_style(style.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + if is_shared { + cx.dispatch_action(UnshareProject); + } else { + cx.dispatch_action(ShareProject); + } + }) + .with_tooltip::( + 0, + tooltip.to_owned(), + None, + theme.tooltip.clone(), + cx, + ) + .boxed(), + ) + .with_children(self.render_contacts_popover_host( + ContactsPopoverSide::Right, + titlebar, + cx, + )) + .aligned() + .contained() + .with_margin_left(theme.workspace.titlebar.item_spacing) + .boxed(), + ) + } + + fn render_outside_call_share_button( + &self, + theme: &Theme, + cx: &mut RenderContext, + ) -> ElementBox { + let tooltip = "Share project with new call"; + let titlebar = &theme.workspace.titlebar; + + enum OutsideCallShare {} + Stack::new() + .with_child( + MouseEventHandler::::new(0, cx, |state, _| { + //TODO: Ensure this button has consistant width for both text variations + let style = titlebar.share_button.style_for( + state, + self.contacts_popover.is_some() + && self.contacts_popover_side == ContactsPopoverSide::Right, + ); + Label::new("Share".to_owned(), style.text.clone()) + .contained() + .with_style(style.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(ToggleContactsMenu); + }) + .with_tooltip::( + 0, + tooltip.to_owned(), + None, + theme.tooltip.clone(), + cx, + ) + .boxed(), + ) + .with_children(self.render_contacts_popover_host( + ContactsPopoverSide::Right, + titlebar, + cx, + )) + .aligned() + .contained() + .with_margin_left(theme.workspace.titlebar.item_spacing) + .boxed() + } + + fn render_contacts_popover_host<'a>( + &'a self, + side: ContactsPopoverSide, + theme: &'a theme::Titlebar, + cx: &'a RenderContext, + ) -> impl Iterator + 'a { + self.contacts_popover + .iter() + .filter(move |_| self.contacts_popover_side == side) + .map(|popover| { + Overlay::new( + ChildView::new(popover, cx) + .contained() + .with_margin_top(theme.height) + .with_margin_left(theme.toggle_contacts_button.default.button_width) + .with_margin_right(-theme.toggle_contacts_button.default.button_width) + .boxed(), + ) + .with_fit_mode(OverlayFitMode::SwitchAnchor) + .with_anchor_corner(AnchorCorner::BottomLeft) + .with_z_index(999) + .boxed() + }) + } + fn render_collaborators( &self, workspace: &ViewHandle, theme: &Theme, + room: ModelHandle, cx: &mut RenderContext, ) -> Vec { - let active_call = ActiveCall::global(cx); - if let Some(room) = active_call.read(cx).room().cloned() { - let project = workspace.read(cx).project().read(cx); - let mut participants = room - .read(cx) - .remote_participants() - .values() - .cloned() - .collect::>(); - participants.sort_by_key(|p| Some(project.collaborators().get(&p.peer_id)?.replica_id)); - participants - .into_iter() - .filter_map(|participant| { - let project = workspace.read(cx).project().read(cx); - let replica_id = project - .collaborators() - .get(&participant.peer_id) - .map(|collaborator| collaborator.replica_id); - let user = participant.user.clone(); - Some(self.render_avatar( + let project = workspace.read(cx).project().read(cx); + + let mut participants = room + .read(cx) + .remote_participants() + .values() + .cloned() + .collect::>(); + participants.sort_by_key(|p| Some(project.collaborators().get(&p.peer_id)?.replica_id)); + + participants + .into_iter() + .filter_map(|participant| { + let project = workspace.read(cx).project().read(cx); + let replica_id = project + .collaborators() + .get(&participant.peer_id) + .map(|collaborator| collaborator.replica_id); + let user = participant.user.clone(); + Some( + Container::new(self.render_face_pile( &user, replica_id, - Some(( - participant.peer_id, - &user.github_login, - participant.location, - )), + participant.peer_id, + Some(participant.location), workspace, theme, cx, )) - }) - .collect() - } else { - Default::default() - } + .with_margin_left(theme.workspace.titlebar.face_pile_spacing) + .boxed(), + ) + }) + .collect() } fn render_current_user( &self, workspace: &ViewHandle, theme: &Theme, + user: &Option>, cx: &mut RenderContext, - ) -> Option { - let user = workspace.read(cx).user_store().read(cx).current_user(); + ) -> ElementBox { + let user = user.as_ref().expect("Active call without user"); let replica_id = workspace.read(cx).project().read(cx).replica_id(); - let status = *workspace.read(cx).client().status().borrow(); - if let Some(user) = user { - Some(self.render_avatar(&user, Some(replica_id), None, workspace, theme, cx)) - } else if matches!(status, client::Status::UpgradeRequired) { - None - } else { - Some( - MouseEventHandler::::new(0, cx, |state, _| { - let style = theme - .workspace - .titlebar - .sign_in_prompt - .style_for(state, false); - Label::new("Sign in", style.text.clone()) - .contained() - .with_style(style.container) - .boxed() - }) - .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate)) - .with_cursor_style(CursorStyle::PointingHand) - .aligned() - .boxed(), - ) - } + let peer_id = workspace + .read(cx) + .client() + .peer_id() + .expect("Active call without peer id"); + self.render_face_pile(user, Some(replica_id), peer_id, None, workspace, theme, cx) } - fn render_avatar( + fn render_authenticate(theme: &Theme, cx: &mut RenderContext) -> ElementBox { + MouseEventHandler::::new(0, cx, |state, _| { + let style = theme + .workspace + .titlebar + .sign_in_prompt + .style_for(state, false); + Label::new("Sign in", style.text.clone()) + .contained() + .with_style(style.container) + .with_margin_left(theme.workspace.titlebar.item_spacing) + .boxed() + }) + .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate)) + .with_cursor_style(CursorStyle::PointingHand) + .aligned() + .boxed() + } + + fn render_face_pile( &self, user: &User, replica_id: Option, - peer: Option<(PeerId, &str, ParticipantLocation)>, + peer_id: PeerId, + location: Option, workspace: &ViewHandle, theme: &Theme, cx: &mut RenderContext, ) -> ElementBox { - let is_followed = peer.map_or(false, |(peer_id, _, _)| { - workspace.read(cx).is_following(peer_id) - }); + let room = ActiveCall::global(cx).read(cx).room(); + let is_being_followed = workspace.read(cx).is_being_followed(peer_id); + let followed_by_self = room + .map(|room| { + is_being_followed + && room + .read(cx) + .followers_for(peer_id) + .iter() + .any(|&follower| Some(follower) == workspace.read(cx).client().peer_id()) + }) + .unwrap_or(false); - let mut avatar_style; - if let Some((_, _, location)) = peer.as_ref() { - if let ParticipantLocation::SharedProject { project_id } = *location { + let avatar_style; + if let Some(location) = location { + if let ParticipantLocation::SharedProject { project_id } = location { if Some(project_id) == workspace.read(cx).project().read(cx).remote_id() { - avatar_style = theme.workspace.titlebar.avatar; + avatar_style = &theme.workspace.titlebar.avatar; } else { - avatar_style = theme.workspace.titlebar.inactive_avatar; + avatar_style = &theme.workspace.titlebar.inactive_avatar; } } else { - avatar_style = theme.workspace.titlebar.inactive_avatar; + avatar_style = &theme.workspace.titlebar.inactive_avatar; } } else { - avatar_style = theme.workspace.titlebar.avatar; + avatar_style = &theme.workspace.titlebar.avatar; } - let mut replica_color = None; + let mut background_color = theme + .workspace + .titlebar + .container + .background_color + .unwrap_or_default(); if let Some(replica_id) = replica_id { - let color = theme.editor.replica_selection_style(replica_id).cursor; - replica_color = Some(color); - if is_followed { - avatar_style.border = Border::all(1.0, color); + if followed_by_self { + let selection = theme.editor.replica_selection_style(replica_id).selection; + background_color = Color::blend(selection, background_color); + background_color.a = 255; } } let content = Stack::new() .with_children(user.avatar.as_ref().map(|avatar| { - Image::new(avatar.clone()) - .with_style(avatar_style) - .constrained() - .with_width(theme.workspace.titlebar.avatar_width) - .aligned() - .boxed() - })) - .with_children(replica_color.map(|replica_color| { - AvatarRibbon::new(replica_color) - .constrained() - .with_width(theme.workspace.titlebar.avatar_ribbon.width) - .with_height(theme.workspace.titlebar.avatar_ribbon.height) - .aligned() - .bottom() - .boxed() + let face_pile = FacePile::new(theme.workspace.titlebar.follower_avatar_overlap) + .with_child(Self::render_face( + avatar.clone(), + avatar_style.clone(), + background_color, + )) + .with_children( + (|| { + let room = room?.read(cx); + let followers = room.followers_for(peer_id); + + Some(followers.into_iter().flat_map(|&follower| { + let avatar = room + .remote_participant_for_peer_id(follower) + .and_then(|participant| participant.user.avatar.clone()) + .or_else(|| { + if follower == workspace.read(cx).client().peer_id()? { + workspace + .read(cx) + .user_store() + .read(cx) + .current_user()? + .avatar + .clone() + } else { + None + } + })?; + + Some(Self::render_face( + avatar.clone(), + theme.workspace.titlebar.follower_avatar.clone(), + background_color, + )) + })) + })() + .into_iter() + .flatten(), + ); + + let mut container = face_pile + .contained() + .with_style(theme.workspace.titlebar.leader_selection); + + if let Some(replica_id) = replica_id { + if followed_by_self { + let color = theme.editor.replica_selection_style(replica_id).selection; + container = container.with_background_color(color); + } + } + + container.boxed() })) - .constrained() - .with_width(theme.workspace.titlebar.avatar_width) - .contained() - .with_margin_left(theme.workspace.titlebar.avatar_margin) + .with_children((|| { + let replica_id = replica_id?; + let color = theme.editor.replica_selection_style(replica_id).cursor; + Some( + AvatarRibbon::new(color) + .constrained() + .with_width(theme.workspace.titlebar.avatar_ribbon.width) + .with_height(theme.workspace.titlebar.avatar_ribbon.height) + .aligned() + .bottom() + .boxed(), + ) + })()) .boxed(); - if let Some((peer_id, peer_github_login, location)) = peer { + if let Some(location) = location { if let Some(replica_id) = replica_id { MouseEventHandler::::new(replica_id.into(), cx, move |_, _| content) .with_cursor_style(CursorStyle::PointingHand) @@ -461,10 +789,10 @@ impl CollabTitlebarItem { }) .with_tooltip::( peer_id.as_u64() as usize, - if is_followed { - format!("Unfollow {}", peer_github_login) + if is_being_followed { + format!("Unfollow {}", user.github_login) } else { - format!("Follow {}", peer_github_login) + format!("Follow {}", user.github_login) }, Some(Box::new(FollowNextCollaborator)), theme.tooltip.clone(), @@ -485,7 +813,7 @@ impl CollabTitlebarItem { }) .with_tooltip::( peer_id.as_u64() as usize, - format!("Follow {} into external project", peer_github_login), + format!("Follow {} into external project", user.github_login), Some(Box::new(FollowNextCollaborator)), theme.tooltip.clone(), cx, @@ -499,6 +827,24 @@ impl CollabTitlebarItem { } } + fn render_face( + avatar: Arc, + avatar_style: AvatarStyle, + background_color: Color, + ) -> ElementBox { + Image::new(avatar) + .with_style(avatar_style.image) + .aligned() + .contained() + .with_background_color(background_color) + .with_corner_radius(avatar_style.outer_corner_radius) + .constrained() + .with_width(avatar_style.outer_width) + .with_height(avatar_style.outer_width) + .aligned() + .boxed() + } + fn render_connection_status( &self, workspace: &ViewHandle, diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index d26e2c99ccfb53d9b5e83160464df1a5c73aeafe..6abfec21f74f0918e6338bc77a2e8aa8acfe783b 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -1,8 +1,10 @@ mod collab_titlebar_item; +mod collaborator_list_popover; mod contact_finder; mod contact_list; mod contact_notification; mod contacts_popover; +mod face_pile; mod incoming_call_notification; mod notifications; mod project_shared_notification; @@ -10,7 +12,7 @@ mod sharing_status_indicator; use anyhow::anyhow; use call::ActiveCall; -pub use collab_titlebar_item::{CollabTitlebarItem, ToggleCollaborationMenu}; +pub use collab_titlebar_item::{CollabTitlebarItem, ToggleContactsMenu}; use gpui::{actions, MutableAppContext, Task}; use std::sync::Arc; use workspace::{AppState, JoinProject, ToggleFollow, Workspace}; @@ -116,7 +118,7 @@ fn join_project(action: &JoinProject, app_state: Arc, cx: &mut Mutable }); if let Some(follow_peer_id) = follow_peer_id { - if !workspace.is_following(follow_peer_id) { + if !workspace.is_being_followed(follow_peer_id) { workspace .toggle_follow(&ToggleFollow(follow_peer_id), cx) .map(|follow| follow.detach_and_log_err(cx)); diff --git a/crates/collab_ui/src/collaborator_list_popover.rs b/crates/collab_ui/src/collaborator_list_popover.rs new file mode 100644 index 0000000000000000000000000000000000000000..e6bebf861b596d8b4518c7a5c2f4fb1595266e4b --- /dev/null +++ b/crates/collab_ui/src/collaborator_list_popover.rs @@ -0,0 +1,165 @@ +use call::ActiveCall; +use client::UserStore; +use gpui::Action; +use gpui::{ + actions, elements::*, Entity, ModelHandle, MouseButton, RenderContext, View, ViewContext, +}; +use settings::Settings; + +use crate::collab_titlebar_item::ToggleCollaboratorList; + +pub(crate) enum Event { + Dismissed, +} + +enum Collaborator { + SelfUser { username: String }, + RemoteUser { username: String }, +} + +actions!(collaborator_list_popover, [NoOp]); + +pub(crate) struct CollaboratorListPopover { + list_state: ListState, +} + +impl Entity for CollaboratorListPopover { + type Event = Event; +} + +impl View for CollaboratorListPopover { + fn ui_name() -> &'static str { + "CollaboratorListPopover" + } + + fn render(&mut self, cx: &mut RenderContext) -> ElementBox { + let theme = cx.global::().theme.clone(); + + MouseEventHandler::::new(0, cx, |_, _| { + List::new(self.list_state.clone()) + .contained() + .with_style(theme.contacts_popover.container) //TODO: Change the name of this theme key + .constrained() + .with_width(theme.contacts_popover.width) + .with_height(theme.contacts_popover.height) + .boxed() + }) + .on_down_out(MouseButton::Left, move |_, cx| { + cx.dispatch_action(ToggleCollaboratorList); + }) + .boxed() + } + + fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { + cx.emit(Event::Dismissed); + } +} + +impl CollaboratorListPopover { + pub fn new(user_store: ModelHandle, cx: &mut ViewContext) -> Self { + let active_call = ActiveCall::global(cx); + + let mut collaborators = user_store + .read(cx) + .current_user() + .map(|u| Collaborator::SelfUser { + username: u.github_login.clone(), + }) + .into_iter() + .collect::>(); + + //TODO: What should the canonical sort here look like, consult contacts list implementation + if let Some(room) = active_call.read(cx).room() { + for participant in room.read(cx).remote_participants() { + collaborators.push(Collaborator::RemoteUser { + username: participant.1.user.github_login.clone(), + }); + } + } + + Self { + list_state: ListState::new( + collaborators.len(), + Orientation::Top, + 0., + cx, + move |_, index, cx| match &collaborators[index] { + Collaborator::SelfUser { username } => render_collaborator_list_entry( + index, + username, + None::, + None, + Svg::new("icons/chevron_right_12.svg"), + NoOp, + "Leave call".to_owned(), + cx, + ), + + Collaborator::RemoteUser { username } => render_collaborator_list_entry( + index, + username, + Some(NoOp), + Some(format!("Follow {username}")), + Svg::new("icons/x_mark_12.svg"), + NoOp, + format!("Remove {username} from call"), + cx, + ), + }, + ), + } + } +} + +fn render_collaborator_list_entry( + index: usize, + username: &str, + username_action: Option, + username_tooltip: Option, + icon: Svg, + icon_action: IA, + icon_tooltip: String, + cx: &mut RenderContext, +) -> ElementBox { + enum Username {} + enum UsernameTooltip {} + enum Icon {} + enum IconTooltip {} + + let theme = &cx.global::().theme; + let username_theme = theme.contact_list.contact_username.text.clone(); + let tooltip_theme = theme.tooltip.clone(); + + let username = MouseEventHandler::::new(index, cx, |_, _| { + Label::new(username.to_owned(), username_theme.clone()).boxed() + }) + .on_click(MouseButton::Left, move |_, cx| { + if let Some(username_action) = username_action.clone() { + cx.dispatch_action(username_action); + } + }); + + Flex::row() + .with_child(if let Some(username_tooltip) = username_tooltip { + username + .with_tooltip::( + index, + username_tooltip, + None, + tooltip_theme.clone(), + cx, + ) + .boxed() + } else { + username.boxed() + }) + .with_child( + MouseEventHandler::::new(index, cx, |_, _| icon.boxed()) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(icon_action.clone()) + }) + .with_tooltip::(index, icon_tooltip, None, tooltip_theme, cx) + .boxed(), + ) + .boxed() +} diff --git a/crates/collab_ui/src/contact_list.rs b/crates/collab_ui/src/contact_list.rs index a1607750c902abd84ba4b7f55a97cd2fc85e4a1a..ba9bc8ad63dd75188a646ae2fc5ff875191889a5 100644 --- a/crates/collab_ui/src/contact_list.rs +++ b/crates/collab_ui/src/contact_list.rs @@ -1,3 +1,4 @@ +use super::collab_titlebar_item::LeaveCall; use crate::contacts_popover; use call::ActiveCall; use client::{proto::PeerId, Contact, User, UserStore}; @@ -18,22 +19,20 @@ use serde::Deserialize; use settings::Settings; use std::{mem, sync::Arc}; use theme::IconButton; -use util::ResultExt; use workspace::{JoinProject, OpenSharedScreen}; impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]); -impl_internal_actions!(contact_list, [ToggleExpanded, Call, LeaveCall]); +impl_internal_actions!(contact_list, [ToggleExpanded, Call]); pub fn init(cx: &mut MutableAppContext) { cx.add_action(ContactList::remove_contact); cx.add_action(ContactList::respond_to_contact_request); - cx.add_action(ContactList::clear_filter); + cx.add_action(ContactList::cancel); cx.add_action(ContactList::select_next); cx.add_action(ContactList::select_prev); cx.add_action(ContactList::confirm); cx.add_action(ContactList::toggle_expanded); cx.add_action(ContactList::call); - cx.add_action(ContactList::leave_call); } #[derive(Clone, PartialEq)] @@ -45,9 +44,6 @@ struct Call { initial_project: Option>, } -#[derive(Copy, Clone, PartialEq)] -struct LeaveCall; - #[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)] enum Section { ActiveCall, @@ -326,7 +322,7 @@ impl ContactList { .detach(); } - fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext) { + fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { let did_clear = self.filter_editor.update(cx, |editor, cx| { if editor.buffer().read(cx).len(cx) > 0 { editor.set_text("", cx); @@ -335,6 +331,7 @@ impl ContactList { false } }); + if !did_clear { cx.emit(Event::Dismissed); } @@ -980,6 +977,7 @@ impl ContactList { cx: &mut RenderContext, ) -> ElementBox { enum Header {} + enum LeaveCallContactList {} let header_style = theme .header_row @@ -992,9 +990,9 @@ impl ContactList { }; let leave_call = if section == Section::ActiveCall { Some( - MouseEventHandler::::new(0, cx, |state, _| { + MouseEventHandler::::new(0, cx, |state, _| { let style = theme.leave_call.style_for(state, false); - Label::new("Leave Session", style.text.clone()) + Label::new("Leave Call", style.text.clone()) .contained() .with_style(style.container) .boxed() @@ -1283,12 +1281,6 @@ impl ContactList { }) .detach_and_log_err(cx); } - - fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext) { - ActiveCall::global(cx) - .update(cx, |call, cx| call.hang_up(cx)) - .log_err(); - } } impl Entity for ContactList { @@ -1334,7 +1326,7 @@ impl View for ContactList { }) .with_tooltip::( 0, - "Add contact".into(), + "Search for new contact".into(), None, theme.tooltip.clone(), cx, diff --git a/crates/collab_ui/src/contacts_popover.rs b/crates/collab_ui/src/contacts_popover.rs index 37280f929e7c9a5536588a4543d26f8fb8214282..0c67ef4c7c094a17758284bc5b2e8aeac8d30586 100644 --- a/crates/collab_ui/src/contacts_popover.rs +++ b/crates/collab_ui/src/contacts_popover.rs @@ -1,4 +1,4 @@ -use crate::{contact_finder::ContactFinder, contact_list::ContactList, ToggleCollaborationMenu}; +use crate::{contact_finder::ContactFinder, contact_list::ContactList, ToggleContactsMenu}; use client::UserStore; use gpui::{ actions, elements::*, ClipboardItem, CursorStyle, Entity, ModelHandle, MouseButton, @@ -155,7 +155,7 @@ impl View for ContactsPopover { .boxed() }) .on_down_out(MouseButton::Left, move |_, cx| { - cx.dispatch_action(ToggleCollaborationMenu); + cx.dispatch_action(ToggleContactsMenu); }) .boxed() } diff --git a/crates/collab_ui/src/face_pile.rs b/crates/collab_ui/src/face_pile.rs new file mode 100644 index 0000000000000000000000000000000000000000..3b95443fee822a32b79c15617e13689aa8f6fac4 --- /dev/null +++ b/crates/collab_ui/src/face_pile.rs @@ -0,0 +1,101 @@ +use std::ops::Range; + +use gpui::{ + geometry::{ + rect::RectF, + vector::{vec2f, Vector2F}, + }, + json::ToJson, + serde_json::{self, json}, + Axis, DebugContext, Element, ElementBox, MeasurementContext, PaintContext, +}; + +pub(crate) struct FacePile { + overlap: f32, + faces: Vec, +} + +impl FacePile { + pub fn new(overlap: f32) -> FacePile { + FacePile { + overlap, + faces: Vec::new(), + } + } +} + +impl Element for FacePile { + type LayoutState = (); + type PaintState = (); + + fn layout( + &mut self, + constraint: gpui::SizeConstraint, + cx: &mut gpui::LayoutContext, + ) -> (Vector2F, Self::LayoutState) { + debug_assert!(constraint.max_along(Axis::Horizontal) == f32::INFINITY); + + let mut width = 0.; + for face in &mut self.faces { + width += face.layout(constraint, cx).x(); + } + width -= self.overlap * self.faces.len().saturating_sub(1) as f32; + + (Vector2F::new(width, constraint.max.y()), ()) + } + + fn paint( + &mut self, + bounds: RectF, + visible_bounds: RectF, + _layout: &mut Self::LayoutState, + cx: &mut PaintContext, + ) -> Self::PaintState { + let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default(); + + let origin_y = bounds.upper_right().y(); + let mut origin_x = bounds.upper_right().x(); + + for face in self.faces.iter_mut().rev() { + let size = face.size(); + origin_x -= size.x(); + cx.paint_layer(None, |cx| { + face.paint(vec2f(origin_x, origin_y), visible_bounds, cx); + }); + origin_x += self.overlap; + } + + () + } + + fn rect_for_text_range( + &self, + _: Range, + _: RectF, + _: RectF, + _: &Self::LayoutState, + _: &Self::PaintState, + _: &MeasurementContext, + ) -> Option { + None + } + + fn debug( + &self, + bounds: RectF, + _: &Self::LayoutState, + _: &Self::PaintState, + _: &DebugContext, + ) -> serde_json::Value { + json!({ + "type": "FacePile", + "bounds": bounds.to_json() + }) + } +} + +impl Extend for FacePile { + fn extend>(&mut self, children: T) { + self.faces.extend(children); + } +} diff --git a/crates/gpui/src/elements.rs b/crates/gpui/src/elements.rs index 41a802feb37fcda12c8b68b76759f173b350a793..b77d46536de2da0c59c901d913146704a764bcba 100644 --- a/crates/gpui/src/elements.rs +++ b/crates/gpui/src/elements.rs @@ -363,6 +363,7 @@ impl AnyElement for Lifecycle { value } } + _ => panic!("invalid element lifecycle state"), } } diff --git a/crates/gpui/src/elements/flex.rs b/crates/gpui/src/elements/flex.rs index f6a1a5d8e6e52ec572835d34fe7cc9e0655f664a..ce595222f3f8a3c2e95019f19f790114f05653fd 100644 --- a/crates/gpui/src/elements/flex.rs +++ b/crates/gpui/src/elements/flex.rs @@ -308,7 +308,9 @@ impl Element for Flex { } } } + child.paint(child_origin, visible_bounds, cx); + match self.axis { Axis::Horizontal => child_origin += vec2f(child.size().x(), 0.0), Axis::Vertical => child_origin += vec2f(0.0, child.size().y()), diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index ba481ce45ba7ab148d2f9b347d5d483e448b44f6..6b46f09e268ca96c75235200b26249cb523dda4f 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -16,7 +16,7 @@ message Envelope { Error error = 6; Ping ping = 7; Test test = 8; - + CreateRoom create_room = 9; CreateRoomResponse create_room_response = 10; JoinRoom join_room = 11; @@ -206,7 +206,8 @@ message Room { uint64 id = 1; repeated Participant participants = 2; repeated PendingParticipant pending_participants = 3; - string live_kit_room = 4; + repeated Follower followers = 4; + string live_kit_room = 5; } message Participant { @@ -227,6 +228,11 @@ message ParticipantProject { repeated string worktree_root_names = 2; } +message Follower { + PeerId leader_id = 1; + PeerId follower_id = 2; +} + message ParticipantLocation { oneof variant { SharedProject shared_project = 1; diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index b05bc17906063fe51dc4ec349496f26a1338a9dc..439ed8774637af088bd5bea72cd0ee56e76db4ac 100644 --- a/crates/rpc/src/rpc.rs +++ b/crates/rpc/src/rpc.rs @@ -6,4 +6,4 @@ pub use conn::Connection; pub use peer::*; mod macros; -pub const PROTOCOL_VERSION: u32 = 46; +pub const PROTOCOL_VERSION: u32 = 47; diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index bc338bbe269369f29fdea1d5794d30c172d2e316..17a7c876bb79691ef9503593064cd06acc523951 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -74,12 +74,15 @@ pub struct Titlebar { pub container: ContainerStyle, pub height: f32, pub title: TextStyle, - pub avatar_width: f32, - pub avatar_margin: f32, + pub item_spacing: f32, + pub face_pile_spacing: f32, pub avatar_ribbon: AvatarRibbon, + pub follower_avatar_overlap: f32, + pub leader_selection: ContainerStyle, pub offline_icon: OfflineIcon, - pub avatar: ImageStyle, - pub inactive_avatar: ImageStyle, + pub avatar: AvatarStyle, + pub inactive_avatar: AvatarStyle, + pub follower_avatar: AvatarStyle, pub sign_in_prompt: Interactive, pub outdated_warning: ContainedText, pub share_button: Interactive, @@ -88,6 +91,14 @@ pub struct Titlebar { pub toggle_contacts_badge: ContainerStyle, } +#[derive(Clone, Deserialize, Default)] +pub struct AvatarStyle { + #[serde(flatten)] + pub image: ImageStyle, + pub outer_width: f32, + pub outer_corner_radius: f32, +} + #[derive(Deserialize, Default)] pub struct ContactsPopover { #[serde(flatten)] @@ -381,7 +392,7 @@ pub struct InviteLink { pub icon: Icon, } -#[derive(Deserialize, Default)] +#[derive(Deserialize, Clone, Copy, Default)] pub struct Icon { #[serde(flatten)] pub container: ContainerStyle, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 2c08b5417a4c073a77bcb871cef6a5cafdac5067..898e4bd27f9e1508d70a3455ab6c6236248b54bd 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -837,7 +837,7 @@ impl Workspace { &self.project } - pub fn client(&self) -> &Arc { + pub fn client(&self) -> &Client { &self.client } @@ -1832,24 +1832,15 @@ impl Workspace { None } - pub fn is_following(&self, peer_id: PeerId) -> bool { + pub fn is_being_followed(&self, peer_id: PeerId) -> bool { self.follower_states_by_leader.contains_key(&peer_id) } - pub fn is_followed(&self, peer_id: PeerId) -> bool { + pub fn is_followed_by(&self, peer_id: PeerId) -> bool { self.leader_state.followers.contains(&peer_id) } fn render_titlebar(&self, theme: &Theme, cx: &mut RenderContext) -> ElementBox { - let project = &self.project.read(cx); - let mut worktree_root_names = String::new(); - for (i, name) in project.worktree_root_names(cx).enumerate() { - if i > 0 { - worktree_root_names.push_str(", "); - } - worktree_root_names.push_str(name); - } - // TODO: There should be a better system in place for this // (https://github.com/zed-industries/zed/issues/1290) let is_fullscreen = cx.window_is_fullscreen(cx.window_id()); @@ -1866,16 +1857,10 @@ impl Workspace { MouseEventHandler::::new(0, cx, |_, cx| { Container::new( Stack::new() - .with_child( - Label::new(worktree_root_names, theme.workspace.titlebar.title.clone()) - .aligned() - .left() - .boxed(), - ) .with_children( self.titlebar_item .as_ref() - .map(|item| ChildView::new(item, cx).aligned().right().boxed()), + .map(|item| ChildView::new(item, cx).boxed()), ) .boxed(), ) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 754195e09943c59d61140430ca99550130614f44..19721547bda187bb3be53083cf52edfacf37e0cb 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -6,7 +6,7 @@ use anyhow::{anyhow, Context, Result}; use assets::Assets; use breadcrumbs::Breadcrumbs; pub use client; -use collab_ui::{CollabTitlebarItem, ToggleCollaborationMenu}; +use collab_ui::{CollabTitlebarItem, ToggleContactsMenu}; use collections::VecDeque; pub use editor; use editor::{Editor, MultiBuffer}; @@ -99,9 +99,7 @@ pub fn init(app_state: &Arc, cx: &mut gpui::MutableAppContext) { }, ); cx.add_action( - |workspace: &mut Workspace, - _: &ToggleCollaborationMenu, - cx: &mut ViewContext| { + |workspace: &mut Workspace, _: &ToggleContactsMenu, cx: &mut ViewContext| { if let Some(item) = workspace .titlebar_item() .and_then(|item| item.downcast::()) diff --git a/styles/src/styleTree/workspace.ts b/styles/src/styleTree/workspace.ts index 769ea85a00276cc410b3165b326e4360e59f59f2..659a0e67455fbad45f5eb939f8bf7a982887a41e 100644 --- a/styles/src/styleTree/workspace.ts +++ b/styles/src/styleTree/workspace.ts @@ -12,7 +12,7 @@ import tabBar from "./tabBar"; export default function workspace(colorScheme: ColorScheme) { const layer = colorScheme.lowest; - const titlebarPadding = 6; + const itemSpacing = 8; const titlebarButton = { cornerRadius: 6, padding: { @@ -29,8 +29,21 @@ export default function workspace(colorScheme: ColorScheme) { background: background(layer, "variant", "hovered"), border: border(layer, "variant", "hovered"), }, + clicked: { + ...text(layer, "sans", "variant", "pressed", { size: "xs" }), + background: background(layer, "variant", "pressed"), + border: border(layer, "variant", "pressed"), + }, + active: { + ...text(layer, "sans", "variant", "active", { size: "xs" }), + background: background(layer, "variant", "active"), + border: border(layer, "variant", "active"), + }, }; const avatarWidth = 18; + const avatarOuterWidth = avatarWidth + 4; + const followerAvatarWidth = 14; + const followerAvatarOuterWidth = followerAvatarWidth + 4; return { background: background(layer), @@ -70,14 +83,14 @@ export default function workspace(colorScheme: ColorScheme) { }, statusBar: statusBar(colorScheme), titlebar: { - avatarWidth, - avatarMargin: 8, + itemSpacing, + facePileSpacing: 2, height: 33, // 32px + 1px for overlaid border background: background(layer), border: border(layer, { bottom: true, overlay: true }), padding: { left: 80, - right: titlebarPadding, + right: itemSpacing, }, // Project @@ -85,20 +98,38 @@ export default function workspace(colorScheme: ColorScheme) { // Collaborators avatar: { + width: avatarWidth, + outerWidth: avatarOuterWidth, cornerRadius: avatarWidth / 2, - border: { - color: "#00000088", - width: 1, - }, + outerCornerRadius: avatarOuterWidth / 2, }, inactiveAvatar: { + width: avatarWidth, + outerWidth: avatarOuterWidth, cornerRadius: avatarWidth / 2, - border: { - color: "#00000088", - width: 1, - }, + outerCornerRadius: avatarOuterWidth / 2, grayscale: true, }, + followerAvatar: { + width: followerAvatarWidth, + outerWidth: followerAvatarOuterWidth, + cornerRadius: followerAvatarWidth / 2, + outerCornerRadius: followerAvatarOuterWidth / 2, + }, + followerAvatarOverlap: 8, + leaderSelection: { + margin: { + top: 4, + bottom: 4, + }, + padding: { + left: 2, + right: 2, + top: 4, + bottom: 4, + }, + cornerRadius: 6, + }, avatarRibbon: { height: 3, width: 12, @@ -108,7 +139,7 @@ export default function workspace(colorScheme: ColorScheme) { // Sign in buttom // FlatButton, Variant signInPrompt: { - ...titlebarButton + ...titlebarButton, }, // Offline Indicator @@ -116,7 +147,7 @@ export default function workspace(colorScheme: ColorScheme) { color: foreground(layer, "variant"), width: 16, margin: { - left: titlebarPadding, + left: itemSpacing, }, padding: { right: 4, @@ -129,7 +160,7 @@ export default function workspace(colorScheme: ColorScheme) { background: withOpacity(background(layer, "warning"), 0.3), border: border(layer, "warning"), margin: { - left: titlebarPadding, + left: itemSpacing, }, padding: { left: 8, @@ -148,7 +179,7 @@ export default function workspace(colorScheme: ColorScheme) { }, }, toggleContactsButton: { - margin: { left: 6 }, + margin: { left: itemSpacing }, cornerRadius: 6, color: foreground(layer, "variant"), iconWidth: 8, @@ -157,6 +188,10 @@ export default function workspace(colorScheme: ColorScheme) { background: background(layer, "variant", "active"), color: foreground(layer, "variant", "active"), }, + clicked: { + background: background(layer, "variant", "pressed"), + color: foreground(layer, "variant", "pressed"), + }, hover: { background: background(layer, "variant", "hovered"), color: foreground(layer, "variant", "hovered"), @@ -170,8 +205,8 @@ export default function workspace(colorScheme: ColorScheme) { background: foreground(layer, "accent"), }, shareButton: { - ...titlebarButton - } + ...titlebarButton, + }, }, toolbar: { @@ -227,9 +262,6 @@ export default function workspace(colorScheme: ColorScheme) { shadow: colorScheme.modalShadow, }, }, - dropTargetOverlayColor: withOpacity( - foreground(layer, "variant"), - 0.5 - ), + dropTargetOverlayColor: withOpacity(foreground(layer, "variant"), 0.5), }; }