mirror of
https://github.com/zed-industries/zed.git
synced 2024-11-08 15:44:31 +03:00
Merge pull request #2114 from zed-industries/new-collaboration-ui
New collaboration UI part 1/N
This commit is contained in:
commit
b73423daaa
3
assets/icons/leave_12.svg
Normal file
3
assets/icons/leave_12.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1C0 0.585786 0.335786 0.25 0.75 0.25H7.25C7.66421 0.25 8 0.585786 8 1C8 1.41421 7.66421 1.75 7.25 1.75H1.5V10.25H7.25C7.66421 10.25 8 10.5858 8 11C8 11.4142 7.66421 11.75 7.25 11.75H0.75C0.335786 11.75 0 11.4142 0 11V1ZM8.78148 2.91435C9.10493 2.65559 9.57689 2.70803 9.83565 3.03148L11.8357 5.53148C12.0548 5.80539 12.0548 6.19461 11.8357 6.46852L9.83565 8.96852C9.57689 9.29197 9.10493 9.34441 8.78148 9.08565C8.45803 8.82689 8.40559 8.35493 8.66435 8.03148L9.68953 6.75H3.75C3.33579 6.75 3 6.41421 3 6C3 5.58579 3.33579 5.25 3.75 5.25H9.68953L8.66435 3.96852C8.40559 3.64507 8.45803 3.17311 8.78148 2.91435Z" fill="#ABB2BF"/>
|
||||
</svg>
|
After Width: | Height: | Size: 784 B |
@ -284,6 +284,18 @@ impl ActiveCall {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unshare_project(
|
||||
&mut self,
|
||||
project: ModelHandle<Project>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Result<()> {
|
||||
if let Some((room, _)) = self.room.as_ref() {
|
||||
room.update(cx, |room, cx| room.unshare_project(project, cx))
|
||||
} else {
|
||||
Err(anyhow!("no active call"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_location(
|
||||
&mut self,
|
||||
project: Option<&ModelHandle<Project>>,
|
||||
|
@ -55,6 +55,7 @@ pub struct Room {
|
||||
leave_when_empty: bool,
|
||||
client: Arc<Client>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
follows_by_leader_id: HashMap<PeerId, Vec<PeerId>>,
|
||||
subscriptions: Vec<client::Subscription>,
|
||||
pending_room_update: Option<Task<()>>,
|
||||
maintain_connection: Option<Task<Option<()>>>,
|
||||
@ -148,6 +149,7 @@ impl Room {
|
||||
pending_room_update: None,
|
||||
client,
|
||||
user_store,
|
||||
follows_by_leader_id: Default::default(),
|
||||
maintain_connection: Some(maintain_connection),
|
||||
}
|
||||
}
|
||||
@ -457,6 +459,12 @@ impl Room {
|
||||
self.participant_user_ids.contains(&user_id)
|
||||
}
|
||||
|
||||
pub fn followers_for(&self, leader_id: PeerId) -> &[PeerId] {
|
||||
self.follows_by_leader_id
|
||||
.get(&leader_id)
|
||||
.map_or(&[], |v| v.as_slice())
|
||||
}
|
||||
|
||||
async fn handle_room_updated(
|
||||
this: ModelHandle<Self>,
|
||||
envelope: TypedEnvelope<proto::RoomUpdated>,
|
||||
@ -487,11 +495,13 @@ impl Room {
|
||||
.iter()
|
||||
.map(|p| p.user_id)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let remote_participant_user_ids = room
|
||||
.participants
|
||||
.iter()
|
||||
.map(|p| p.user_id)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let (remote_participants, pending_participants) =
|
||||
self.user_store.update(cx, move |user_store, cx| {
|
||||
(
|
||||
@ -499,6 +509,7 @@ impl Room {
|
||||
user_store.get_users(pending_participant_user_ids, cx),
|
||||
)
|
||||
});
|
||||
|
||||
self.pending_room_update = Some(cx.spawn(|this, mut cx| async move {
|
||||
let (remote_participants, pending_participants) =
|
||||
futures::join!(remote_participants, pending_participants);
|
||||
@ -620,6 +631,26 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
this.follows_by_leader_id.clear();
|
||||
for follower in room.followers {
|
||||
let (leader, follower) = match (follower.leader_id, follower.follower_id) {
|
||||
(Some(leader), Some(follower)) => (leader, follower),
|
||||
|
||||
_ => {
|
||||
log::error!("Follower message {follower:?} missing some state");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let list = this
|
||||
.follows_by_leader_id
|
||||
.entry(leader)
|
||||
.or_insert(Vec::new());
|
||||
if !list.contains(&follower) {
|
||||
list.push(follower);
|
||||
}
|
||||
}
|
||||
|
||||
this.pending_room_update.take();
|
||||
if this.should_leave() {
|
||||
log::info!("room is empty, leaving");
|
||||
@ -793,6 +824,20 @@ impl Room {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn unshare_project(
|
||||
&mut self,
|
||||
project: ModelHandle<Project>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Result<()> {
|
||||
let project_id = match project.read(cx).remote_id() {
|
||||
Some(project_id) => project_id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
self.client.send(proto::UnshareProject { project_id })?;
|
||||
project.update(cx, |this, cx| this.unshare(cx))
|
||||
}
|
||||
|
||||
pub(crate) fn set_location(
|
||||
&mut self,
|
||||
project: Option<&ModelHandle<Project>>,
|
||||
|
@ -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");
|
||||
|
15
crates/collab/migrations/20230202155735_followers.sql
Normal file
15
crates/collab/migrations/20230202155735_followers.sql
Normal file
@ -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");
|
@ -1,5 +1,6 @@
|
||||
mod access_token;
|
||||
mod contact;
|
||||
mod follower;
|
||||
mod language_server;
|
||||
mod project;
|
||||
mod project_collaborator;
|
||||
@ -1717,6 +1718,88 @@ impl Database {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn follow(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
leader_connection: ConnectionId,
|
||||
follower_connection: ConnectionId,
|
||||
) -> Result<RoomGuard<proto::Room>> {
|
||||
self.room_transaction(|tx| async move {
|
||||
let room_id = self.room_id_for_project(project_id, &*tx).await?;
|
||||
follower::ActiveModel {
|
||||
room_id: ActiveValue::set(room_id),
|
||||
project_id: ActiveValue::set(project_id),
|
||||
leader_connection_server_id: ActiveValue::set(ServerId(
|
||||
leader_connection.owner_id as i32,
|
||||
)),
|
||||
leader_connection_id: ActiveValue::set(leader_connection.id as i32),
|
||||
follower_connection_server_id: ActiveValue::set(ServerId(
|
||||
follower_connection.owner_id as i32,
|
||||
)),
|
||||
follower_connection_id: ActiveValue::set(follower_connection.id as i32),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok((room_id, self.get_room(room_id, &*tx).await?))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unfollow(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
leader_connection: ConnectionId,
|
||||
follower_connection: ConnectionId,
|
||||
) -> Result<RoomGuard<proto::Room>> {
|
||||
self.room_transaction(|tx| async move {
|
||||
let room_id = self.room_id_for_project(project_id, &*tx).await?;
|
||||
follower::Entity::delete_many()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(follower::Column::ProjectId.eq(project_id))
|
||||
.add(
|
||||
follower::Column::LeaderConnectionServerId
|
||||
.eq(leader_connection.owner_id)
|
||||
.and(follower::Column::LeaderConnectionId.eq(leader_connection.id)),
|
||||
)
|
||||
.add(
|
||||
follower::Column::FollowerConnectionServerId
|
||||
.eq(follower_connection.owner_id)
|
||||
.and(
|
||||
follower::Column::FollowerConnectionId
|
||||
.eq(follower_connection.id),
|
||||
),
|
||||
),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok((room_id, self.get_room(room_id, &*tx).await?))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn room_id_for_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<RoomId> {
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryAs {
|
||||
RoomId,
|
||||
}
|
||||
|
||||
Ok(project::Entity::find_by_id(project_id)
|
||||
.select_only()
|
||||
.column(project::Column::RoomId)
|
||||
.into_values::<_, QueryAs>()
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such project"))?)
|
||||
}
|
||||
|
||||
pub async fn update_room_participant_location(
|
||||
&self,
|
||||
room_id: RoomId,
|
||||
@ -1926,12 +2009,24 @@ impl Database {
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(db_projects);
|
||||
|
||||
let mut db_followers = db_room.find_related(follower::Entity).stream(tx).await?;
|
||||
let mut followers = Vec::new();
|
||||
while let Some(db_follower) = db_followers.next().await {
|
||||
let db_follower = db_follower?;
|
||||
followers.push(proto::Follower {
|
||||
leader_id: Some(db_follower.leader_connection().into()),
|
||||
follower_id: Some(db_follower.follower_connection().into()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(proto::Room {
|
||||
id: db_room.id.to_proto(),
|
||||
live_kit_room: db_room.live_kit_room,
|
||||
participants: participants.into_values().collect(),
|
||||
pending_participants,
|
||||
followers,
|
||||
})
|
||||
}
|
||||
|
||||
@ -3011,6 +3106,7 @@ macro_rules! id_type {
|
||||
|
||||
id_type!(AccessTokenId);
|
||||
id_type!(ContactId);
|
||||
id_type!(FollowerId);
|
||||
id_type!(RoomId);
|
||||
id_type!(RoomParticipantId);
|
||||
id_type!(ProjectId);
|
||||
|
51
crates/collab/src/db/follower.rs
Normal file
51
crates/collab/src/db/follower.rs
Normal file
@ -0,0 +1,51 @@
|
||||
use super::{FollowerId, ProjectId, RoomId, ServerId};
|
||||
use rpc::ConnectionId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)]
|
||||
#[sea_orm(table_name = "followers")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: FollowerId,
|
||||
pub room_id: RoomId,
|
||||
pub project_id: ProjectId,
|
||||
pub leader_connection_server_id: ServerId,
|
||||
pub leader_connection_id: i32,
|
||||
pub follower_connection_server_id: ServerId,
|
||||
pub follower_connection_id: i32,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn leader_connection(&self) -> ConnectionId {
|
||||
ConnectionId {
|
||||
owner_id: self.leader_connection_server_id.0 as u32,
|
||||
id: self.leader_connection_id as u32,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn follower_connection(&self) -> ConnectionId {
|
||||
ConnectionId {
|
||||
owner_id: self.follower_connection_server_id.0 as u32,
|
||||
id: self.follower_connection_id as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::room::Entity",
|
||||
from = "Column::RoomId",
|
||||
to = "super::room::Column::Id"
|
||||
)]
|
||||
Room,
|
||||
}
|
||||
|
||||
impl Related<super::room::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Room.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
@ -15,6 +15,8 @@ pub enum Relation {
|
||||
RoomParticipant,
|
||||
#[sea_orm(has_many = "super::project::Entity")]
|
||||
Project,
|
||||
#[sea_orm(has_many = "super::follower::Entity")]
|
||||
Follower,
|
||||
}
|
||||
|
||||
impl Related<super::room_participant::Entity> for Entity {
|
||||
@ -29,4 +31,10 @@ impl Related<super::project::Entity> for Entity {
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::follower::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Follower.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
@ -1312,6 +1312,7 @@ async fn join_project(
|
||||
.filter(|collaborator| collaborator.connection_id != session.connection_id)
|
||||
.map(|collaborator| collaborator.to_proto())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let worktrees = project
|
||||
.worktrees
|
||||
.iter()
|
||||
@ -1724,6 +1725,7 @@ async fn follow(
|
||||
.ok_or_else(|| anyhow!("invalid leader id"))?
|
||||
.into();
|
||||
let follower_id = session.connection_id;
|
||||
|
||||
{
|
||||
let project_connection_ids = session
|
||||
.db()
|
||||
@ -1744,6 +1746,14 @@ async fn follow(
|
||||
.views
|
||||
.retain(|view| view.leader_id != Some(follower_id.into()));
|
||||
response.send(response_payload)?;
|
||||
|
||||
let room = session
|
||||
.db()
|
||||
.await
|
||||
.follow(project_id, leader_id, follower_id)
|
||||
.await?;
|
||||
room_updated(&room, &session.peer);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -1753,17 +1763,29 @@ async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> {
|
||||
.leader_id
|
||||
.ok_or_else(|| anyhow!("invalid leader id"))?
|
||||
.into();
|
||||
let project_connection_ids = session
|
||||
let follower_id = session.connection_id;
|
||||
|
||||
if !session
|
||||
.db()
|
||||
.await
|
||||
.project_connection_ids(project_id, session.connection_id)
|
||||
.await?;
|
||||
if !project_connection_ids.contains(&leader_id) {
|
||||
.await?
|
||||
.contains(&leader_id)
|
||||
{
|
||||
Err(anyhow!("no such peer"))?;
|
||||
}
|
||||
|
||||
session
|
||||
.peer
|
||||
.forward_send(session.connection_id, leader_id, request)?;
|
||||
|
||||
let room = session
|
||||
.db()
|
||||
.await
|
||||
.unfollow(project_id, leader_id, follower_id)
|
||||
.await?;
|
||||
room_updated(&room, &session.peer);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -5786,6 +5786,7 @@ async fn test_following(
|
||||
deterministic: Arc<Deterministic>,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
cx_c: &mut TestAppContext,
|
||||
) {
|
||||
deterministic.forbid_parking();
|
||||
cx_a.update(editor::init);
|
||||
@ -5794,9 +5795,13 @@ async fn test_following(
|
||||
let mut server = TestServer::start(&deterministic).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
let client_c = server.create_client(cx_c, "user_c").await;
|
||||
server
|
||||
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
|
||||
.await;
|
||||
server
|
||||
.make_contacts(&mut [(&client_a, cx_a), (&client_c, cx_c)])
|
||||
.await;
|
||||
let active_call_a = cx_a.read(ActiveCall::global);
|
||||
let active_call_b = cx_b.read(ActiveCall::global);
|
||||
|
||||
@ -5827,8 +5832,10 @@ async fn test_following(
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client A opens some editors.
|
||||
let workspace_a = client_a.build_workspace(&project_a, cx_a);
|
||||
let workspace_b = client_b.build_workspace(&project_b, cx_b);
|
||||
|
||||
// Client A opens some editors.
|
||||
let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone());
|
||||
let editor_a1 = workspace_a
|
||||
.update(cx_a, |workspace, cx| {
|
||||
@ -5848,7 +5855,6 @@ async fn test_following(
|
||||
.unwrap();
|
||||
|
||||
// Client B opens an editor.
|
||||
let workspace_b = client_b.build_workspace(&project_b, cx_b);
|
||||
let editor_b1 = workspace_b
|
||||
.update(cx_b, |workspace, cx| {
|
||||
workspace.open_path((worktree_id, "1.txt"), None, true, cx)
|
||||
@ -5858,29 +5864,97 @@ async fn test_following(
|
||||
.downcast::<Editor>()
|
||||
.unwrap();
|
||||
|
||||
let client_a_id = project_b.read_with(cx_b, |project, _| {
|
||||
project.collaborators().values().next().unwrap().peer_id
|
||||
});
|
||||
let client_b_id = project_a.read_with(cx_a, |project, _| {
|
||||
project.collaborators().values().next().unwrap().peer_id
|
||||
});
|
||||
let peer_id_a = client_a.peer_id().unwrap();
|
||||
let peer_id_b = client_b.peer_id().unwrap();
|
||||
let peer_id_c = client_c.peer_id().unwrap();
|
||||
|
||||
// When client B starts following client A, all visible view states are replicated to client B.
|
||||
// Client A updates their selections in those editors
|
||||
editor_a1.update(cx_a, |editor, cx| {
|
||||
editor.change_selections(None, cx, |s| s.select_ranges([0..1]))
|
||||
});
|
||||
editor_a2.update(cx_a, |editor, cx| {
|
||||
editor.change_selections(None, cx, |s| s.select_ranges([2..3]))
|
||||
});
|
||||
|
||||
// When client B starts following client A, all visible view states are replicated to client B.
|
||||
workspace_b
|
||||
.update(cx_b, |workspace, cx| {
|
||||
workspace
|
||||
.toggle_follow(&ToggleFollow(client_a_id), cx)
|
||||
.toggle_follow(&ToggleFollow(peer_id_a), cx)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client A invites client C to the call.
|
||||
active_call_a
|
||||
.update(cx_a, |call, cx| {
|
||||
call.invite(client_c.current_user_id(cx_c).to_proto(), None, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx_c.foreground().run_until_parked();
|
||||
let active_call_c = cx_c.read(ActiveCall::global);
|
||||
active_call_c
|
||||
.update(cx_c, |call, cx| call.accept_incoming(cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let project_c = client_c.build_remote_project(project_id, cx_c).await;
|
||||
let workspace_c = client_c.build_workspace(&project_c, cx_c);
|
||||
active_call_c
|
||||
.update(cx_c, |call, cx| call.set_location(Some(&project_c), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client C also follows client A.
|
||||
workspace_c
|
||||
.update(cx_c, |workspace, cx| {
|
||||
workspace
|
||||
.toggle_follow(&ToggleFollow(peer_id_a), cx)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// All clients see that clients B and C are following client A.
|
||||
cx_c.foreground().run_until_parked();
|
||||
for (name, active_call, cx) in [
|
||||
("A", &active_call_a, &cx_a),
|
||||
("B", &active_call_b, &cx_b),
|
||||
("C", &active_call_c, &cx_c),
|
||||
] {
|
||||
active_call.read_with(*cx, |call, cx| {
|
||||
let room = call.room().unwrap().read(cx);
|
||||
assert_eq!(
|
||||
room.followers_for(peer_id_a),
|
||||
&[peer_id_b, peer_id_c],
|
||||
"checking followers for A as {name}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Client C unfollows client A.
|
||||
workspace_c.update(cx_c, |workspace, cx| {
|
||||
workspace.toggle_follow(&ToggleFollow(peer_id_a), cx);
|
||||
});
|
||||
|
||||
// All clients see that clients B is following client A.
|
||||
cx_c.foreground().run_until_parked();
|
||||
for (name, active_call, cx) in [
|
||||
("A", &active_call_a, &cx_a),
|
||||
("B", &active_call_b, &cx_b),
|
||||
("C", &active_call_c, &cx_c),
|
||||
] {
|
||||
active_call.read_with(*cx, |call, cx| {
|
||||
let room = call.room().unwrap().read(cx);
|
||||
assert_eq!(
|
||||
room.followers_for(peer_id_a),
|
||||
&[peer_id_b],
|
||||
"checking followers for A as {name}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| {
|
||||
workspace
|
||||
.active_item(cx)
|
||||
@ -6033,14 +6107,14 @@ async fn test_following(
|
||||
workspace_a
|
||||
.update(cx_a, |workspace, cx| {
|
||||
workspace
|
||||
.toggle_follow(&ToggleFollow(client_b_id), cx)
|
||||
.toggle_follow(&ToggleFollow(peer_id_b), cx)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)),
|
||||
Some(client_b_id)
|
||||
Some(peer_id_b)
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_a.read_with(cx_a, |workspace, cx| workspace
|
||||
|
@ -1,5 +1,9 @@
|
||||
use crate::{contact_notification::ContactNotification, contacts_popover, ToggleScreenSharing};
|
||||
use call::{ActiveCall, ParticipantLocation};
|
||||
use crate::{
|
||||
collaborator_list_popover, collaborator_list_popover::CollaboratorListPopover,
|
||||
contact_notification::ContactNotification, contacts_popover, face_pile::FacePile,
|
||||
ToggleScreenSharing,
|
||||
};
|
||||
use call::{ActiveCall, ParticipantLocation, Room};
|
||||
use client::{proto::PeerId, Authenticate, ContactEventKind, User, UserStore};
|
||||
use clock::ReplicaId;
|
||||
use contacts_popover::ContactsPopover;
|
||||
@ -8,26 +12,52 @@ use gpui::{
|
||||
color::Color,
|
||||
elements::*,
|
||||
geometry::{rect::RectF, vector::vec2f, PathBuilder},
|
||||
impl_internal_actions,
|
||||
json::{self, ToJson},
|
||||
Border, CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext,
|
||||
CursorStyle, Entity, ImageData, ModelHandle, MouseButton, MutableAppContext, RenderContext,
|
||||
Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
|
||||
};
|
||||
use settings::Settings;
|
||||
use std::ops::Range;
|
||||
use theme::Theme;
|
||||
use std::{ops::Range, sync::Arc};
|
||||
use theme::{AvatarStyle, Theme};
|
||||
use util::ResultExt;
|
||||
use workspace::{FollowNextCollaborator, JoinProject, ToggleFollow, Workspace};
|
||||
|
||||
actions!(collab, [ToggleCollaborationMenu, ShareProject]);
|
||||
actions!(
|
||||
collab,
|
||||
[
|
||||
ToggleCollaboratorList,
|
||||
ToggleContactsMenu,
|
||||
ShareProject,
|
||||
UnshareProject
|
||||
]
|
||||
);
|
||||
|
||||
impl_internal_actions!(collab, [LeaveCall]);
|
||||
|
||||
#[derive(Copy, Clone, PartialEq)]
|
||||
pub(crate) struct LeaveCall;
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
enum ContactsPopoverSide {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
cx.add_action(CollabTitlebarItem::toggle_collaborator_list_popover);
|
||||
cx.add_action(CollabTitlebarItem::toggle_contacts_popover);
|
||||
cx.add_action(CollabTitlebarItem::share_project);
|
||||
cx.add_action(CollabTitlebarItem::unshare_project);
|
||||
cx.add_action(CollabTitlebarItem::leave_call);
|
||||
}
|
||||
|
||||
pub struct CollabTitlebarItem {
|
||||
workspace: WeakViewHandle<Workspace>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
contacts_popover: Option<ViewHandle<ContactsPopover>>,
|
||||
contacts_popover_side: ContactsPopoverSide,
|
||||
collaborator_list_popover: Option<ViewHandle<CollaboratorListPopover>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
@ -47,27 +77,71 @@ impl View for CollabTitlebarItem {
|
||||
return Empty::new().boxed();
|
||||
};
|
||||
|
||||
let theme = cx.global::<Settings>().theme.clone();
|
||||
|
||||
let mut container = Flex::row();
|
||||
|
||||
container.add_children(self.render_toggle_screen_sharing_button(&theme, cx));
|
||||
|
||||
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));
|
||||
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);
|
||||
}
|
||||
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()
|
||||
if project_title.is_empty() {
|
||||
project_title = "empty project".to_owned();
|
||||
}
|
||||
|
||||
let theme = cx.global::<Settings>().theme.clone();
|
||||
let user = workspace.read(cx).user_store().read(cx).current_user();
|
||||
|
||||
let mut left_container = Flex::row();
|
||||
|
||||
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 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));
|
||||
}
|
||||
|
||||
let mut right_container = Flex::row();
|
||||
|
||||
if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
|
||||
right_container.add_child(self.render_toggle_screen_sharing_button(&theme, &room, cx));
|
||||
right_container.add_child(self.render_leave_call_button(&theme, cx));
|
||||
right_container
|
||||
.add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
|
||||
} else {
|
||||
right_container.add_child(self.render_outside_call_share_button(&theme, cx));
|
||||
}
|
||||
|
||||
right_container.add_children(self.render_connection_status(&workspace, cx));
|
||||
|
||||
if let Some(user) = user {
|
||||
//TODO: Add style
|
||||
right_container.add_child(
|
||||
Label::new(
|
||||
user.github_login.clone(),
|
||||
theme.workspace.titlebar.title.clone(),
|
||||
)
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_margin_left(theme.workspace.titlebar.item_spacing)
|
||||
.boxed(),
|
||||
);
|
||||
} else {
|
||||
right_container.add_child(Self::render_authenticate(&theme, cx));
|
||||
}
|
||||
|
||||
Stack::new()
|
||||
.with_child(left_container.boxed())
|
||||
.with_child(right_container.aligned().right().boxed())
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,7 +154,7 @@ impl CollabTitlebarItem {
|
||||
let active_call = ActiveCall::global(cx);
|
||||
let mut subscriptions = Vec::new();
|
||||
subscriptions.push(cx.observe(workspace, |_, _, cx| cx.notify()));
|
||||
subscriptions.push(cx.observe(&active_call, |_, _, cx| cx.notify()));
|
||||
subscriptions.push(cx.observe(&active_call, |this, _, cx| this.active_call_changed(cx)));
|
||||
subscriptions.push(cx.observe_window_activation(|this, active, cx| {
|
||||
this.window_activation_changed(active, cx)
|
||||
}));
|
||||
@ -112,6 +186,8 @@ impl CollabTitlebarItem {
|
||||
workspace: workspace.downgrade(),
|
||||
user_store: user_store.clone(),
|
||||
contacts_popover: None,
|
||||
contacts_popover_side: ContactsPopoverSide::Right,
|
||||
collaborator_list_popover: None,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
@ -129,6 +205,13 @@ impl CollabTitlebarItem {
|
||||
}
|
||||
}
|
||||
|
||||
fn active_call_changed(&mut self, cx: &mut ViewContext<Self>) {
|
||||
if ActiveCall::global(cx).read(cx).room().is_none() {
|
||||
self.contacts_popover = None;
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext<Self>) {
|
||||
if let Some(workspace) = self.workspace.upgrade(cx) {
|
||||
let active_call = ActiveCall::global(cx);
|
||||
@ -139,41 +222,88 @@ impl CollabTitlebarItem {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_contacts_popover(
|
||||
fn unshare_project(&mut self, _: &UnshareProject, cx: &mut ViewContext<Self>) {
|
||||
if let Some(workspace) = self.workspace.upgrade(cx) {
|
||||
let active_call = ActiveCall::global(cx);
|
||||
let project = workspace.read(cx).project().clone();
|
||||
active_call
|
||||
.update(cx, |call, cx| call.unshare_project(project, cx))
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_collaborator_list_popover(
|
||||
&mut self,
|
||||
_: &ToggleCollaborationMenu,
|
||||
_: &ToggleCollaboratorList,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match self.contacts_popover.take() {
|
||||
match self.collaborator_list_popover.take() {
|
||||
Some(_) => {}
|
||||
None => {
|
||||
if let Some(workspace) = self.workspace.upgrade(cx) {
|
||||
let project = workspace.read(cx).project().clone();
|
||||
let user_store = workspace.read(cx).user_store().clone();
|
||||
let view = cx.add_view(|cx| ContactsPopover::new(project, user_store, cx));
|
||||
let view = cx.add_view(|cx| CollaboratorListPopover::new(user_store, cx));
|
||||
|
||||
cx.subscribe(&view, |this, _, event, cx| {
|
||||
match event {
|
||||
contacts_popover::Event::Dismissed => {
|
||||
this.contacts_popover = None;
|
||||
collaborator_list_popover::Event::Dismissed => {
|
||||
this.collaborator_list_popover = None;
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.detach();
|
||||
self.contacts_popover = Some(view);
|
||||
|
||||
self.collaborator_list_popover = Some(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn toggle_contacts_popover(&mut self, _: &ToggleContactsMenu, cx: &mut ViewContext<Self>) {
|
||||
if self.contacts_popover.take().is_none() {
|
||||
if let Some(workspace) = self.workspace.upgrade(cx) {
|
||||
let project = workspace.read(cx).project().clone();
|
||||
let user_store = workspace.read(cx).user_store().clone();
|
||||
let view = cx.add_view(|cx| ContactsPopover::new(project, user_store, cx));
|
||||
cx.subscribe(&view, |this, _, event, cx| {
|
||||
match event {
|
||||
contacts_popover::Event::Dismissed => {
|
||||
this.contacts_popover = None;
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.detach();
|
||||
|
||||
self.contacts_popover_side = match ActiveCall::global(cx).read(cx).room() {
|
||||
Some(_) => ContactsPopoverSide::Left,
|
||||
None => ContactsPopoverSide::Right,
|
||||
};
|
||||
|
||||
self.contacts_popover = Some(view);
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext<Self>) {
|
||||
ActiveCall::global(cx)
|
||||
.update(cx, |call, cx| call.hang_up(cx))
|
||||
.log_err();
|
||||
}
|
||||
|
||||
fn render_toggle_contacts_button(
|
||||
&self,
|
||||
theme: &Theme,
|
||||
cx: &mut RenderContext<Self>,
|
||||
) -> ElementBox {
|
||||
let titlebar = &theme.workspace.titlebar;
|
||||
|
||||
let badge = if self
|
||||
.user_store
|
||||
.read(cx)
|
||||
@ -194,12 +324,15 @@ impl CollabTitlebarItem {
|
||||
.boxed(),
|
||||
)
|
||||
};
|
||||
|
||||
Stack::new()
|
||||
.with_child(
|
||||
MouseEventHandler::<ToggleCollaborationMenu>::new(0, cx, |state, _| {
|
||||
let style = titlebar
|
||||
.toggle_contacts_button
|
||||
.style_for(state, self.contacts_popover.is_some());
|
||||
MouseEventHandler::<ToggleContactsMenu>::new(0, cx, |state, _| {
|
||||
let style = titlebar.toggle_contacts_button.style_for(
|
||||
state,
|
||||
self.contacts_popover.is_some()
|
||||
&& self.contacts_popover_side == ContactsPopoverSide::Left,
|
||||
);
|
||||
Svg::new("icons/plus_8.svg")
|
||||
.with_color(style.color)
|
||||
.constrained()
|
||||
@ -214,39 +347,28 @@ impl CollabTitlebarItem {
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, cx| {
|
||||
cx.dispatch_action(ToggleCollaborationMenu);
|
||||
cx.dispatch_action(ToggleContactsMenu);
|
||||
})
|
||||
.aligned()
|
||||
.boxed(),
|
||||
)
|
||||
.with_children(badge)
|
||||
.with_children(self.contacts_popover.as_ref().map(|popover| {
|
||||
Overlay::new(
|
||||
ChildView::new(popover, cx)
|
||||
.contained()
|
||||
.with_margin_top(titlebar.height)
|
||||
.with_margin_left(titlebar.toggle_contacts_button.default.button_width)
|
||||
.with_margin_right(-titlebar.toggle_contacts_button.default.button_width)
|
||||
.boxed(),
|
||||
)
|
||||
.with_fit_mode(OverlayFitMode::SwitchAnchor)
|
||||
.with_anchor_corner(AnchorCorner::BottomLeft)
|
||||
.with_z_index(999)
|
||||
.boxed()
|
||||
}))
|
||||
.with_children(self.render_contacts_popover_host(
|
||||
ContactsPopoverSide::Left,
|
||||
titlebar,
|
||||
cx,
|
||||
))
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn render_toggle_screen_sharing_button(
|
||||
&self,
|
||||
theme: &Theme,
|
||||
room: &ModelHandle<Room>,
|
||||
cx: &mut RenderContext<Self>,
|
||||
) -> Option<ElementBox> {
|
||||
let active_call = ActiveCall::global(cx);
|
||||
let room = active_call.read(cx).room().cloned()?;
|
||||
) -> ElementBox {
|
||||
let icon;
|
||||
let tooltip;
|
||||
|
||||
if room.read(cx).is_screen_sharing() {
|
||||
icon = "icons/disable_screen_sharing_12.svg";
|
||||
tooltip = "Stop Sharing Screen"
|
||||
@ -256,203 +378,409 @@ impl CollabTitlebarItem {
|
||||
}
|
||||
|
||||
let titlebar = &theme.workspace.titlebar;
|
||||
Some(
|
||||
MouseEventHandler::<ToggleScreenSharing>::new(0, cx, |state, _| {
|
||||
let style = titlebar.call_control.style_for(state, false);
|
||||
Svg::new(icon)
|
||||
.with_color(style.color)
|
||||
.constrained()
|
||||
.with_width(style.icon_width)
|
||||
.aligned()
|
||||
.constrained()
|
||||
.with_width(style.button_width)
|
||||
.with_height(style.button_width)
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.boxed()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, cx| {
|
||||
cx.dispatch_action(ToggleScreenSharing);
|
||||
})
|
||||
.with_tooltip::<ToggleScreenSharing, _>(
|
||||
0,
|
||||
tooltip.into(),
|
||||
Some(Box::new(ToggleScreenSharing)),
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
.aligned()
|
||||
.boxed(),
|
||||
)
|
||||
}
|
||||
|
||||
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", style.text.clone())
|
||||
MouseEventHandler::<ToggleScreenSharing>::new(0, cx, |state, _| {
|
||||
let style = titlebar.call_control.style_for(state, false);
|
||||
Svg::new(icon)
|
||||
.with_color(style.color)
|
||||
.constrained()
|
||||
.with_width(style.icon_width)
|
||||
.aligned()
|
||||
.constrained()
|
||||
.with_width(style.button_width)
|
||||
.with_height(style.button_width)
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.boxed()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, |_, cx| cx.dispatch_action(ShareProject))
|
||||
.with_tooltip::<Share, _>(
|
||||
.on_click(MouseButton::Left, move |_, cx| {
|
||||
cx.dispatch_action(ToggleScreenSharing);
|
||||
})
|
||||
.with_tooltip::<ToggleScreenSharing, _>(
|
||||
0,
|
||||
"Share project with call participants".into(),
|
||||
None,
|
||||
tooltip.into(),
|
||||
Some(Box::new(ToggleScreenSharing)),
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_margin_left(theme.workspace.titlebar.avatar_margin)
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn render_leave_call_button(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
let titlebar = &theme.workspace.titlebar;
|
||||
|
||||
MouseEventHandler::<LeaveCall>::new(0, cx, |state, _| {
|
||||
let style = titlebar.call_control.style_for(state, false);
|
||||
Svg::new("icons/leave_12.svg")
|
||||
.with_color(style.color)
|
||||
.constrained()
|
||||
.with_width(style.icon_width)
|
||||
.aligned()
|
||||
.constrained()
|
||||
.with_width(style.button_width)
|
||||
.with_height(style.button_width)
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.boxed()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, cx| {
|
||||
cx.dispatch_action(LeaveCall);
|
||||
})
|
||||
.with_tooltip::<LeaveCall, _>(
|
||||
0,
|
||||
"Leave call".to_owned(),
|
||||
Some(Box::new(LeaveCall)),
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
.contained()
|
||||
.with_margin_left(theme.workspace.titlebar.item_spacing)
|
||||
.aligned()
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn render_in_call_share_unshare_button(
|
||||
&self,
|
||||
workspace: &ViewHandle<Workspace>,
|
||||
theme: &Theme,
|
||||
cx: &mut RenderContext<Self>,
|
||||
) -> Option<ElementBox> {
|
||||
let project = workspace.read(cx).project();
|
||||
if project.read(cx).is_remote() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let is_shared = project.read(cx).is_shared();
|
||||
let label = if is_shared { "Unshare" } else { "Share" };
|
||||
let tooltip = if is_shared {
|
||||
"Unshare project from call participants"
|
||||
} else {
|
||||
"Share project with call participants"
|
||||
};
|
||||
|
||||
let titlebar = &theme.workspace.titlebar;
|
||||
|
||||
enum ShareUnshare {}
|
||||
Some(
|
||||
Stack::new()
|
||||
.with_child(
|
||||
MouseEventHandler::<ShareUnshare>::new(0, cx, |state, _| {
|
||||
//TODO: Ensure this button has consistant width for both text variations
|
||||
let style = titlebar.share_button.style_for(
|
||||
state,
|
||||
self.contacts_popover.is_some()
|
||||
&& self.contacts_popover_side == ContactsPopoverSide::Right,
|
||||
);
|
||||
Label::new(label, style.text.clone())
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.boxed()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, cx| {
|
||||
if is_shared {
|
||||
cx.dispatch_action(UnshareProject);
|
||||
} else {
|
||||
cx.dispatch_action(ShareProject);
|
||||
}
|
||||
})
|
||||
.with_tooltip::<ShareUnshare, _>(
|
||||
0,
|
||||
tooltip.to_owned(),
|
||||
None,
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
.boxed(),
|
||||
)
|
||||
.with_children(self.render_contacts_popover_host(
|
||||
ContactsPopoverSide::Right,
|
||||
titlebar,
|
||||
cx,
|
||||
))
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_margin_left(theme.workspace.titlebar.item_spacing)
|
||||
.boxed(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_outside_call_share_button(
|
||||
&self,
|
||||
theme: &Theme,
|
||||
cx: &mut RenderContext<Self>,
|
||||
) -> ElementBox {
|
||||
let tooltip = "Share project with new call";
|
||||
let titlebar = &theme.workspace.titlebar;
|
||||
|
||||
enum OutsideCallShare {}
|
||||
Stack::new()
|
||||
.with_child(
|
||||
MouseEventHandler::<OutsideCallShare>::new(0, cx, |state, _| {
|
||||
//TODO: Ensure this button has consistant width for both text variations
|
||||
let style = titlebar.share_button.style_for(
|
||||
state,
|
||||
self.contacts_popover.is_some()
|
||||
&& self.contacts_popover_side == ContactsPopoverSide::Right,
|
||||
);
|
||||
Label::new("Share".to_owned(), style.text.clone())
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.boxed()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, cx| {
|
||||
cx.dispatch_action(ToggleContactsMenu);
|
||||
})
|
||||
.with_tooltip::<OutsideCallShare, _>(
|
||||
0,
|
||||
tooltip.to_owned(),
|
||||
None,
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
.boxed(),
|
||||
)
|
||||
.with_children(self.render_contacts_popover_host(
|
||||
ContactsPopoverSide::Right,
|
||||
titlebar,
|
||||
cx,
|
||||
))
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_margin_left(theme.workspace.titlebar.item_spacing)
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn render_contacts_popover_host<'a>(
|
||||
&'a self,
|
||||
side: ContactsPopoverSide,
|
||||
theme: &'a theme::Titlebar,
|
||||
cx: &'a RenderContext<Self>,
|
||||
) -> impl Iterator<Item = ElementBox> + 'a {
|
||||
self.contacts_popover
|
||||
.iter()
|
||||
.filter(move |_| self.contacts_popover_side == side)
|
||||
.map(|popover| {
|
||||
Overlay::new(
|
||||
ChildView::new(popover, cx)
|
||||
.contained()
|
||||
.with_margin_top(theme.height)
|
||||
.with_margin_left(theme.toggle_contacts_button.default.button_width)
|
||||
.with_margin_right(-theme.toggle_contacts_button.default.button_width)
|
||||
.boxed(),
|
||||
)
|
||||
.with_fit_mode(OverlayFitMode::SwitchAnchor)
|
||||
.with_anchor_corner(AnchorCorner::BottomLeft)
|
||||
.with_z_index(999)
|
||||
.boxed()
|
||||
})
|
||||
}
|
||||
|
||||
fn render_collaborators(
|
||||
&self,
|
||||
workspace: &ViewHandle<Workspace>,
|
||||
theme: &Theme,
|
||||
room: ModelHandle<Room>,
|
||||
cx: &mut RenderContext<Self>,
|
||||
) -> Vec<ElementBox> {
|
||||
let active_call = ActiveCall::global(cx);
|
||||
if let Some(room) = active_call.read(cx).room().cloned() {
|
||||
let project = workspace.read(cx).project().read(cx);
|
||||
let mut participants = room
|
||||
.read(cx)
|
||||
.remote_participants()
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
participants.sort_by_key(|p| Some(project.collaborators().get(&p.peer_id)?.replica_id));
|
||||
participants
|
||||
.into_iter()
|
||||
.filter_map(|participant| {
|
||||
let project = workspace.read(cx).project().read(cx);
|
||||
let replica_id = project
|
||||
.collaborators()
|
||||
.get(&participant.peer_id)
|
||||
.map(|collaborator| collaborator.replica_id);
|
||||
let user = participant.user.clone();
|
||||
Some(self.render_avatar(
|
||||
let project = workspace.read(cx).project().read(cx);
|
||||
|
||||
let mut participants = room
|
||||
.read(cx)
|
||||
.remote_participants()
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
participants.sort_by_key(|p| Some(project.collaborators().get(&p.peer_id)?.replica_id));
|
||||
|
||||
participants
|
||||
.into_iter()
|
||||
.filter_map(|participant| {
|
||||
let project = workspace.read(cx).project().read(cx);
|
||||
let replica_id = project
|
||||
.collaborators()
|
||||
.get(&participant.peer_id)
|
||||
.map(|collaborator| collaborator.replica_id);
|
||||
let user = participant.user.clone();
|
||||
Some(
|
||||
Container::new(self.render_face_pile(
|
||||
&user,
|
||||
replica_id,
|
||||
Some((
|
||||
participant.peer_id,
|
||||
&user.github_login,
|
||||
participant.location,
|
||||
)),
|
||||
participant.peer_id,
|
||||
Some(participant.location),
|
||||
workspace,
|
||||
theme,
|
||||
cx,
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Default::default()
|
||||
}
|
||||
.with_margin_left(theme.workspace.titlebar.face_pile_spacing)
|
||||
.boxed(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_current_user(
|
||||
&self,
|
||||
workspace: &ViewHandle<Workspace>,
|
||||
theme: &Theme,
|
||||
user: &Option<Arc<User>>,
|
||||
cx: &mut RenderContext<Self>,
|
||||
) -> Option<ElementBox> {
|
||||
let user = workspace.read(cx).user_store().read(cx).current_user();
|
||||
) -> ElementBox {
|
||||
let user = user.as_ref().expect("Active call without user");
|
||||
let replica_id = workspace.read(cx).project().read(cx).replica_id();
|
||||
let status = *workspace.read(cx).client().status().borrow();
|
||||
if let Some(user) = user {
|
||||
Some(self.render_avatar(&user, Some(replica_id), None, workspace, theme, cx))
|
||||
} else if matches!(status, client::Status::UpgradeRequired) {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
MouseEventHandler::<Authenticate>::new(0, cx, |state, _| {
|
||||
let style = theme
|
||||
.workspace
|
||||
.titlebar
|
||||
.sign_in_prompt
|
||||
.style_for(state, false);
|
||||
Label::new("Sign in", style.text.clone())
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.boxed()
|
||||
})
|
||||
.on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate))
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.aligned()
|
||||
.boxed(),
|
||||
)
|
||||
}
|
||||
let peer_id = workspace
|
||||
.read(cx)
|
||||
.client()
|
||||
.peer_id()
|
||||
.expect("Active call without peer id");
|
||||
self.render_face_pile(user, Some(replica_id), peer_id, None, workspace, theme, cx)
|
||||
}
|
||||
|
||||
fn render_avatar(
|
||||
fn render_authenticate(theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
MouseEventHandler::<Authenticate>::new(0, cx, |state, _| {
|
||||
let style = theme
|
||||
.workspace
|
||||
.titlebar
|
||||
.sign_in_prompt
|
||||
.style_for(state, false);
|
||||
Label::new("Sign in", style.text.clone())
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.with_margin_left(theme.workspace.titlebar.item_spacing)
|
||||
.boxed()
|
||||
})
|
||||
.on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate))
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.aligned()
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn render_face_pile(
|
||||
&self,
|
||||
user: &User,
|
||||
replica_id: Option<ReplicaId>,
|
||||
peer: Option<(PeerId, &str, ParticipantLocation)>,
|
||||
peer_id: PeerId,
|
||||
location: Option<ParticipantLocation>,
|
||||
workspace: &ViewHandle<Workspace>,
|
||||
theme: &Theme,
|
||||
cx: &mut RenderContext<Self>,
|
||||
) -> ElementBox {
|
||||
let is_followed = peer.map_or(false, |(peer_id, _, _)| {
|
||||
workspace.read(cx).is_following(peer_id)
|
||||
});
|
||||
let room = ActiveCall::global(cx).read(cx).room();
|
||||
let is_being_followed = workspace.read(cx).is_being_followed(peer_id);
|
||||
let followed_by_self = room
|
||||
.map(|room| {
|
||||
is_being_followed
|
||||
&& room
|
||||
.read(cx)
|
||||
.followers_for(peer_id)
|
||||
.iter()
|
||||
.any(|&follower| Some(follower) == workspace.read(cx).client().peer_id())
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut avatar_style;
|
||||
if let Some((_, _, location)) = peer.as_ref() {
|
||||
if let ParticipantLocation::SharedProject { project_id } = *location {
|
||||
let avatar_style;
|
||||
if let Some(location) = location {
|
||||
if let ParticipantLocation::SharedProject { project_id } = location {
|
||||
if Some(project_id) == workspace.read(cx).project().read(cx).remote_id() {
|
||||
avatar_style = theme.workspace.titlebar.avatar;
|
||||
avatar_style = &theme.workspace.titlebar.avatar;
|
||||
} else {
|
||||
avatar_style = theme.workspace.titlebar.inactive_avatar;
|
||||
avatar_style = &theme.workspace.titlebar.inactive_avatar;
|
||||
}
|
||||
} else {
|
||||
avatar_style = theme.workspace.titlebar.inactive_avatar;
|
||||
avatar_style = &theme.workspace.titlebar.inactive_avatar;
|
||||
}
|
||||
} else {
|
||||
avatar_style = theme.workspace.titlebar.avatar;
|
||||
avatar_style = &theme.workspace.titlebar.avatar;
|
||||
}
|
||||
|
||||
let mut replica_color = None;
|
||||
let mut background_color = theme
|
||||
.workspace
|
||||
.titlebar
|
||||
.container
|
||||
.background_color
|
||||
.unwrap_or_default();
|
||||
if let Some(replica_id) = replica_id {
|
||||
let color = theme.editor.replica_selection_style(replica_id).cursor;
|
||||
replica_color = Some(color);
|
||||
if is_followed {
|
||||
avatar_style.border = Border::all(1.0, color);
|
||||
if followed_by_self {
|
||||
let selection = theme.editor.replica_selection_style(replica_id).selection;
|
||||
background_color = Color::blend(selection, background_color);
|
||||
background_color.a = 255;
|
||||
}
|
||||
}
|
||||
|
||||
let content = Stack::new()
|
||||
.with_children(user.avatar.as_ref().map(|avatar| {
|
||||
Image::new(avatar.clone())
|
||||
.with_style(avatar_style)
|
||||
.constrained()
|
||||
.with_width(theme.workspace.titlebar.avatar_width)
|
||||
.aligned()
|
||||
.boxed()
|
||||
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()
|
||||
}))
|
||||
.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)
|
||||
.with_children((|| {
|
||||
let replica_id = replica_id?;
|
||||
let color = theme.editor.replica_selection_style(replica_id).cursor;
|
||||
Some(
|
||||
AvatarRibbon::new(color)
|
||||
.constrained()
|
||||
.with_width(theme.workspace.titlebar.avatar_ribbon.width)
|
||||
.with_height(theme.workspace.titlebar.avatar_ribbon.height)
|
||||
.aligned()
|
||||
.bottom()
|
||||
.boxed(),
|
||||
)
|
||||
})())
|
||||
.boxed();
|
||||
|
||||
if let Some((peer_id, peer_github_login, location)) = peer {
|
||||
if let Some(location) = location {
|
||||
if let Some(replica_id) = replica_id {
|
||||
MouseEventHandler::<ToggleFollow>::new(replica_id.into(), cx, move |_, _| content)
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
@ -461,10 +789,10 @@ impl CollabTitlebarItem {
|
||||
})
|
||||
.with_tooltip::<ToggleFollow, _>(
|
||||
peer_id.as_u64() as usize,
|
||||
if is_followed {
|
||||
format!("Unfollow {}", peer_github_login)
|
||||
if is_being_followed {
|
||||
format!("Unfollow {}", user.github_login)
|
||||
} else {
|
||||
format!("Follow {}", peer_github_login)
|
||||
format!("Follow {}", user.github_login)
|
||||
},
|
||||
Some(Box::new(FollowNextCollaborator)),
|
||||
theme.tooltip.clone(),
|
||||
@ -485,7 +813,7 @@ impl CollabTitlebarItem {
|
||||
})
|
||||
.with_tooltip::<JoinProject, _>(
|
||||
peer_id.as_u64() as usize,
|
||||
format!("Follow {} into external project", peer_github_login),
|
||||
format!("Follow {} into external project", user.github_login),
|
||||
Some(Box::new(FollowNextCollaborator)),
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
@ -499,6 +827,24 @@ impl CollabTitlebarItem {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_face(
|
||||
avatar: Arc<ImageData>,
|
||||
avatar_style: AvatarStyle,
|
||||
background_color: Color,
|
||||
) -> ElementBox {
|
||||
Image::new(avatar)
|
||||
.with_style(avatar_style.image)
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_background_color(background_color)
|
||||
.with_corner_radius(avatar_style.outer_corner_radius)
|
||||
.constrained()
|
||||
.with_width(avatar_style.outer_width)
|
||||
.with_height(avatar_style.outer_width)
|
||||
.aligned()
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn render_connection_status(
|
||||
&self,
|
||||
workspace: &ViewHandle<Workspace>,
|
||||
|
@ -1,8 +1,10 @@
|
||||
mod collab_titlebar_item;
|
||||
mod collaborator_list_popover;
|
||||
mod contact_finder;
|
||||
mod contact_list;
|
||||
mod contact_notification;
|
||||
mod contacts_popover;
|
||||
mod face_pile;
|
||||
mod incoming_call_notification;
|
||||
mod notifications;
|
||||
mod project_shared_notification;
|
||||
@ -10,7 +12,7 @@ mod sharing_status_indicator;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use call::ActiveCall;
|
||||
pub use collab_titlebar_item::{CollabTitlebarItem, ToggleCollaborationMenu};
|
||||
pub use collab_titlebar_item::{CollabTitlebarItem, ToggleContactsMenu};
|
||||
use gpui::{actions, MutableAppContext, Task};
|
||||
use std::sync::Arc;
|
||||
use workspace::{AppState, JoinProject, ToggleFollow, Workspace};
|
||||
@ -116,7 +118,7 @@ fn join_project(action: &JoinProject, app_state: Arc<AppState>, cx: &mut Mutable
|
||||
});
|
||||
|
||||
if let Some(follow_peer_id) = follow_peer_id {
|
||||
if !workspace.is_following(follow_peer_id) {
|
||||
if !workspace.is_being_followed(follow_peer_id) {
|
||||
workspace
|
||||
.toggle_follow(&ToggleFollow(follow_peer_id), cx)
|
||||
.map(|follow| follow.detach_and_log_err(cx));
|
||||
|
165
crates/collab_ui/src/collaborator_list_popover.rs
Normal file
165
crates/collab_ui/src/collaborator_list_popover.rs
Normal file
@ -0,0 +1,165 @@
|
||||
use call::ActiveCall;
|
||||
use client::UserStore;
|
||||
use gpui::Action;
|
||||
use gpui::{
|
||||
actions, elements::*, Entity, ModelHandle, MouseButton, RenderContext, View, ViewContext,
|
||||
};
|
||||
use settings::Settings;
|
||||
|
||||
use crate::collab_titlebar_item::ToggleCollaboratorList;
|
||||
|
||||
pub(crate) enum Event {
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
enum Collaborator {
|
||||
SelfUser { username: String },
|
||||
RemoteUser { username: String },
|
||||
}
|
||||
|
||||
actions!(collaborator_list_popover, [NoOp]);
|
||||
|
||||
pub(crate) struct CollaboratorListPopover {
|
||||
list_state: ListState,
|
||||
}
|
||||
|
||||
impl Entity for CollaboratorListPopover {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
impl View for CollaboratorListPopover {
|
||||
fn ui_name() -> &'static str {
|
||||
"CollaboratorListPopover"
|
||||
}
|
||||
|
||||
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
let theme = cx.global::<Settings>().theme.clone();
|
||||
|
||||
MouseEventHandler::<Self>::new(0, cx, |_, _| {
|
||||
List::new(self.list_state.clone())
|
||||
.contained()
|
||||
.with_style(theme.contacts_popover.container) //TODO: Change the name of this theme key
|
||||
.constrained()
|
||||
.with_width(theme.contacts_popover.width)
|
||||
.with_height(theme.contacts_popover.height)
|
||||
.boxed()
|
||||
})
|
||||
.on_down_out(MouseButton::Left, move |_, cx| {
|
||||
cx.dispatch_action(ToggleCollaboratorList);
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
|
||||
cx.emit(Event::Dismissed);
|
||||
}
|
||||
}
|
||||
|
||||
impl CollaboratorListPopover {
|
||||
pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
|
||||
let active_call = ActiveCall::global(cx);
|
||||
|
||||
let mut collaborators = user_store
|
||||
.read(cx)
|
||||
.current_user()
|
||||
.map(|u| Collaborator::SelfUser {
|
||||
username: u.github_login.clone(),
|
||||
})
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
//TODO: What should the canonical sort here look like, consult contacts list implementation
|
||||
if let Some(room) = active_call.read(cx).room() {
|
||||
for participant in room.read(cx).remote_participants() {
|
||||
collaborators.push(Collaborator::RemoteUser {
|
||||
username: participant.1.user.github_login.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
list_state: ListState::new(
|
||||
collaborators.len(),
|
||||
Orientation::Top,
|
||||
0.,
|
||||
cx,
|
||||
move |_, index, cx| match &collaborators[index] {
|
||||
Collaborator::SelfUser { username } => render_collaborator_list_entry(
|
||||
index,
|
||||
username,
|
||||
None::<NoOp>,
|
||||
None,
|
||||
Svg::new("icons/chevron_right_12.svg"),
|
||||
NoOp,
|
||||
"Leave call".to_owned(),
|
||||
cx,
|
||||
),
|
||||
|
||||
Collaborator::RemoteUser { username } => render_collaborator_list_entry(
|
||||
index,
|
||||
username,
|
||||
Some(NoOp),
|
||||
Some(format!("Follow {username}")),
|
||||
Svg::new("icons/x_mark_12.svg"),
|
||||
NoOp,
|
||||
format!("Remove {username} from call"),
|
||||
cx,
|
||||
),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_collaborator_list_entry<UA: Action + Clone, IA: Action + Clone>(
|
||||
index: usize,
|
||||
username: &str,
|
||||
username_action: Option<UA>,
|
||||
username_tooltip: Option<String>,
|
||||
icon: Svg,
|
||||
icon_action: IA,
|
||||
icon_tooltip: String,
|
||||
cx: &mut RenderContext<CollaboratorListPopover>,
|
||||
) -> ElementBox {
|
||||
enum Username {}
|
||||
enum UsernameTooltip {}
|
||||
enum Icon {}
|
||||
enum IconTooltip {}
|
||||
|
||||
let theme = &cx.global::<Settings>().theme;
|
||||
let username_theme = theme.contact_list.contact_username.text.clone();
|
||||
let tooltip_theme = theme.tooltip.clone();
|
||||
|
||||
let username = MouseEventHandler::<Username>::new(index, cx, |_, _| {
|
||||
Label::new(username.to_owned(), username_theme.clone()).boxed()
|
||||
})
|
||||
.on_click(MouseButton::Left, move |_, cx| {
|
||||
if let Some(username_action) = username_action.clone() {
|
||||
cx.dispatch_action(username_action);
|
||||
}
|
||||
});
|
||||
|
||||
Flex::row()
|
||||
.with_child(if let Some(username_tooltip) = username_tooltip {
|
||||
username
|
||||
.with_tooltip::<UsernameTooltip, _>(
|
||||
index,
|
||||
username_tooltip,
|
||||
None,
|
||||
tooltip_theme.clone(),
|
||||
cx,
|
||||
)
|
||||
.boxed()
|
||||
} else {
|
||||
username.boxed()
|
||||
})
|
||||
.with_child(
|
||||
MouseEventHandler::<Icon>::new(index, cx, |_, _| icon.boxed())
|
||||
.on_click(MouseButton::Left, move |_, cx| {
|
||||
cx.dispatch_action(icon_action.clone())
|
||||
})
|
||||
.with_tooltip::<IconTooltip, _>(index, icon_tooltip, None, tooltip_theme, cx)
|
||||
.boxed(),
|
||||
)
|
||||
.boxed()
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
use super::collab_titlebar_item::LeaveCall;
|
||||
use crate::contacts_popover;
|
||||
use call::ActiveCall;
|
||||
use client::{proto::PeerId, Contact, User, UserStore};
|
||||
@ -18,22 +19,20 @@ use serde::Deserialize;
|
||||
use settings::Settings;
|
||||
use std::{mem, sync::Arc};
|
||||
use theme::IconButton;
|
||||
use util::ResultExt;
|
||||
use workspace::{JoinProject, OpenSharedScreen};
|
||||
|
||||
impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]);
|
||||
impl_internal_actions!(contact_list, [ToggleExpanded, Call, LeaveCall]);
|
||||
impl_internal_actions!(contact_list, [ToggleExpanded, Call]);
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
cx.add_action(ContactList::remove_contact);
|
||||
cx.add_action(ContactList::respond_to_contact_request);
|
||||
cx.add_action(ContactList::clear_filter);
|
||||
cx.add_action(ContactList::cancel);
|
||||
cx.add_action(ContactList::select_next);
|
||||
cx.add_action(ContactList::select_prev);
|
||||
cx.add_action(ContactList::confirm);
|
||||
cx.add_action(ContactList::toggle_expanded);
|
||||
cx.add_action(ContactList::call);
|
||||
cx.add_action(ContactList::leave_call);
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
@ -45,9 +44,6 @@ struct Call {
|
||||
initial_project: Option<ModelHandle<Project>>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq)]
|
||||
struct LeaveCall;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
|
||||
enum Section {
|
||||
ActiveCall,
|
||||
@ -326,7 +322,7 @@ impl ContactList {
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
|
||||
fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
|
||||
let did_clear = self.filter_editor.update(cx, |editor, cx| {
|
||||
if editor.buffer().read(cx).len(cx) > 0 {
|
||||
editor.set_text("", cx);
|
||||
@ -335,6 +331,7 @@ impl ContactList {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if !did_clear {
|
||||
cx.emit(Event::Dismissed);
|
||||
}
|
||||
@ -980,6 +977,7 @@ impl ContactList {
|
||||
cx: &mut RenderContext<Self>,
|
||||
) -> ElementBox {
|
||||
enum Header {}
|
||||
enum LeaveCallContactList {}
|
||||
|
||||
let header_style = theme
|
||||
.header_row
|
||||
@ -992,9 +990,9 @@ impl ContactList {
|
||||
};
|
||||
let leave_call = if section == Section::ActiveCall {
|
||||
Some(
|
||||
MouseEventHandler::<LeaveCall>::new(0, cx, |state, _| {
|
||||
MouseEventHandler::<LeaveCallContactList>::new(0, cx, |state, _| {
|
||||
let style = theme.leave_call.style_for(state, false);
|
||||
Label::new("Leave Session", style.text.clone())
|
||||
Label::new("Leave Call", style.text.clone())
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.boxed()
|
||||
@ -1283,12 +1281,6 @@ impl ContactList {
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext<Self>) {
|
||||
ActiveCall::global(cx)
|
||||
.update(cx, |call, cx| call.hang_up(cx))
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ContactList {
|
||||
@ -1334,7 +1326,7 @@ impl View for ContactList {
|
||||
})
|
||||
.with_tooltip::<AddContact, _>(
|
||||
0,
|
||||
"Add contact".into(),
|
||||
"Search for new contact".into(),
|
||||
None,
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
|
@ -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()
|
||||
}
|
||||
|
101
crates/collab_ui/src/face_pile.rs
Normal file
101
crates/collab_ui/src/face_pile.rs
Normal file
@ -0,0 +1,101 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
geometry::{
|
||||
rect::RectF,
|
||||
vector::{vec2f, Vector2F},
|
||||
},
|
||||
json::ToJson,
|
||||
serde_json::{self, json},
|
||||
Axis, DebugContext, Element, ElementBox, MeasurementContext, PaintContext,
|
||||
};
|
||||
|
||||
pub(crate) struct FacePile {
|
||||
overlap: f32,
|
||||
faces: Vec<ElementBox>,
|
||||
}
|
||||
|
||||
impl FacePile {
|
||||
pub fn new(overlap: f32) -> FacePile {
|
||||
FacePile {
|
||||
overlap,
|
||||
faces: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for FacePile {
|
||||
type LayoutState = ();
|
||||
type PaintState = ();
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: gpui::SizeConstraint,
|
||||
cx: &mut gpui::LayoutContext,
|
||||
) -> (Vector2F, Self::LayoutState) {
|
||||
debug_assert!(constraint.max_along(Axis::Horizontal) == f32::INFINITY);
|
||||
|
||||
let mut width = 0.;
|
||||
for face in &mut self.faces {
|
||||
width += face.layout(constraint, cx).x();
|
||||
}
|
||||
width -= self.overlap * self.faces.len().saturating_sub(1) as f32;
|
||||
|
||||
(Vector2F::new(width, constraint.max.y()), ())
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
bounds: RectF,
|
||||
visible_bounds: RectF,
|
||||
_layout: &mut Self::LayoutState,
|
||||
cx: &mut PaintContext,
|
||||
) -> Self::PaintState {
|
||||
let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
|
||||
|
||||
let origin_y = bounds.upper_right().y();
|
||||
let mut origin_x = bounds.upper_right().x();
|
||||
|
||||
for face in self.faces.iter_mut().rev() {
|
||||
let size = face.size();
|
||||
origin_x -= size.x();
|
||||
cx.paint_layer(None, |cx| {
|
||||
face.paint(vec2f(origin_x, origin_y), visible_bounds, cx);
|
||||
});
|
||||
origin_x += self.overlap;
|
||||
}
|
||||
|
||||
()
|
||||
}
|
||||
|
||||
fn rect_for_text_range(
|
||||
&self,
|
||||
_: Range<usize>,
|
||||
_: RectF,
|
||||
_: RectF,
|
||||
_: &Self::LayoutState,
|
||||
_: &Self::PaintState,
|
||||
_: &MeasurementContext,
|
||||
) -> Option<RectF> {
|
||||
None
|
||||
}
|
||||
|
||||
fn debug(
|
||||
&self,
|
||||
bounds: RectF,
|
||||
_: &Self::LayoutState,
|
||||
_: &Self::PaintState,
|
||||
_: &DebugContext,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "FacePile",
|
||||
"bounds": bounds.to_json()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Extend<ElementBox> for FacePile {
|
||||
fn extend<T: IntoIterator<Item = ElementBox>>(&mut self, children: T) {
|
||||
self.faces.extend(children);
|
||||
}
|
||||
}
|
@ -363,6 +363,7 @@ impl<T: Element> AnyElement for Lifecycle<T> {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
_ => panic!("invalid element lifecycle state"),
|
||||
}
|
||||
}
|
||||
|
@ -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()),
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -74,12 +74,15 @@ pub struct Titlebar {
|
||||
pub container: ContainerStyle,
|
||||
pub height: f32,
|
||||
pub title: TextStyle,
|
||||
pub avatar_width: f32,
|
||||
pub avatar_margin: f32,
|
||||
pub item_spacing: f32,
|
||||
pub face_pile_spacing: f32,
|
||||
pub avatar_ribbon: AvatarRibbon,
|
||||
pub follower_avatar_overlap: f32,
|
||||
pub leader_selection: ContainerStyle,
|
||||
pub offline_icon: OfflineIcon,
|
||||
pub avatar: ImageStyle,
|
||||
pub inactive_avatar: ImageStyle,
|
||||
pub avatar: AvatarStyle,
|
||||
pub inactive_avatar: AvatarStyle,
|
||||
pub follower_avatar: AvatarStyle,
|
||||
pub sign_in_prompt: Interactive<ContainedText>,
|
||||
pub outdated_warning: ContainedText,
|
||||
pub share_button: Interactive<ContainedText>,
|
||||
@ -88,6 +91,14 @@ pub struct Titlebar {
|
||||
pub toggle_contacts_badge: ContainerStyle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Default)]
|
||||
pub struct AvatarStyle {
|
||||
#[serde(flatten)]
|
||||
pub image: ImageStyle,
|
||||
pub outer_width: f32,
|
||||
pub outer_corner_radius: f32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct ContactsPopover {
|
||||
#[serde(flatten)]
|
||||
@ -381,7 +392,7 @@ pub struct InviteLink {
|
||||
pub icon: Icon,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[derive(Deserialize, Clone, Copy, Default)]
|
||||
pub struct Icon {
|
||||
#[serde(flatten)]
|
||||
pub container: ContainerStyle,
|
||||
|
@ -837,7 +837,7 @@ impl Workspace {
|
||||
&self.project
|
||||
}
|
||||
|
||||
pub fn client(&self) -> &Arc<Client> {
|
||||
pub fn client(&self) -> &Client {
|
||||
&self.client
|
||||
}
|
||||
|
||||
@ -1832,24 +1832,15 @@ impl Workspace {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn is_following(&self, peer_id: PeerId) -> bool {
|
||||
pub fn is_being_followed(&self, peer_id: PeerId) -> bool {
|
||||
self.follower_states_by_leader.contains_key(&peer_id)
|
||||
}
|
||||
|
||||
pub fn is_followed(&self, peer_id: PeerId) -> bool {
|
||||
pub fn is_followed_by(&self, peer_id: PeerId) -> bool {
|
||||
self.leader_state.followers.contains(&peer_id)
|
||||
}
|
||||
|
||||
fn render_titlebar(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
let project = &self.project.read(cx);
|
||||
let mut worktree_root_names = String::new();
|
||||
for (i, name) in project.worktree_root_names(cx).enumerate() {
|
||||
if i > 0 {
|
||||
worktree_root_names.push_str(", ");
|
||||
}
|
||||
worktree_root_names.push_str(name);
|
||||
}
|
||||
|
||||
// TODO: There should be a better system in place for this
|
||||
// (https://github.com/zed-industries/zed/issues/1290)
|
||||
let is_fullscreen = cx.window_is_fullscreen(cx.window_id());
|
||||
@ -1866,16 +1857,10 @@ impl Workspace {
|
||||
MouseEventHandler::<TitleBar>::new(0, cx, |_, cx| {
|
||||
Container::new(
|
||||
Stack::new()
|
||||
.with_child(
|
||||
Label::new(worktree_root_names, theme.workspace.titlebar.title.clone())
|
||||
.aligned()
|
||||
.left()
|
||||
.boxed(),
|
||||
)
|
||||
.with_children(
|
||||
self.titlebar_item
|
||||
.as_ref()
|
||||
.map(|item| ChildView::new(item, cx).aligned().right().boxed()),
|
||||
.map(|item| ChildView::new(item, cx).boxed()),
|
||||
)
|
||||
.boxed(),
|
||||
)
|
||||
|
@ -6,7 +6,7 @@ use anyhow::{anyhow, Context, Result};
|
||||
use assets::Assets;
|
||||
use breadcrumbs::Breadcrumbs;
|
||||
pub use client;
|
||||
use collab_ui::{CollabTitlebarItem, ToggleCollaborationMenu};
|
||||
use collab_ui::{CollabTitlebarItem, ToggleContactsMenu};
|
||||
use collections::VecDeque;
|
||||
pub use editor;
|
||||
use editor::{Editor, MultiBuffer};
|
||||
@ -99,9 +99,7 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
|
||||
},
|
||||
);
|
||||
cx.add_action(
|
||||
|workspace: &mut Workspace,
|
||||
_: &ToggleCollaborationMenu,
|
||||
cx: &mut ViewContext<Workspace>| {
|
||||
|workspace: &mut Workspace, _: &ToggleContactsMenu, cx: &mut ViewContext<Workspace>| {
|
||||
if let Some(item) = workspace
|
||||
.titlebar_item()
|
||||
.and_then(|item| item.downcast::<CollabTitlebarItem>())
|
||||
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user