diff --git a/Cargo.lock b/Cargo.lock
index 136adcbd41..85f2ebe172 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1257,6 +1257,7 @@ dependencies = [
"client",
"clock",
"collections",
+ "context_menu",
"editor",
"futures 0.3.25",
"fuzzy",
diff --git a/assets/icons/ellipsis_14.svg b/assets/icons/ellipsis_14.svg
new file mode 100644
index 0000000000..5d45af2b6f
--- /dev/null
+++ b/assets/icons/ellipsis_14.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs
index eba58304d7..cbfa9183b5 100644
--- a/crates/client/src/client.rs
+++ b/crates/client/src/client.rs
@@ -66,7 +66,7 @@ pub const ZED_SECRET_CLIENT_TOKEN: &str = "618033988749894";
pub const INITIAL_RECONNECTION_DELAY: Duration = Duration::from_millis(100);
pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
-actions!(client, [Authenticate]);
+actions!(client, [Authenticate, SignOut]);
pub fn init(client: Arc, cx: &mut MutableAppContext) {
cx.add_global_action({
@@ -79,6 +79,16 @@ pub fn init(client: Arc, cx: &mut MutableAppContext) {
.detach();
}
});
+ cx.add_global_action({
+ let client = client.clone();
+ move |_: &SignOut, cx| {
+ let client = client.clone();
+ cx.spawn(|cx| async move {
+ client.set_status(Status::SignedOut, &cx);
+ })
+ .detach();
+ }
+ });
}
pub struct Client {
diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml
index 2dc4cc769a..899f8cc8b4 100644
--- a/crates/collab_ui/Cargo.toml
+++ b/crates/collab_ui/Cargo.toml
@@ -27,6 +27,7 @@ call = { path = "../call" }
client = { path = "../client" }
clock = { path = "../clock" }
collections = { path = "../collections" }
+context_menu = { path = "../context_menu" }
editor = { path = "../editor" }
fuzzy = { path = "../fuzzy" }
gpui = { path = "../gpui" }
diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs
index 061de78b9f..fb80e7efd6 100644
--- a/crates/collab_ui/src/collab_titlebar_item.rs
+++ b/crates/collab_ui/src/collab_titlebar_item.rs
@@ -4,9 +4,10 @@ use crate::{
ToggleScreenSharing,
};
use call::{ActiveCall, ParticipantLocation, Room};
-use client::{proto::PeerId, Authenticate, ContactEventKind, User, UserStore};
+use client::{proto::PeerId, Authenticate, ContactEventKind, SignOut, User, UserStore};
use clock::ReplicaId;
use contacts_popover::ContactsPopover;
+use context_menu::{ContextMenu, ContextMenuItem};
use gpui::{
actions,
color::Color,
@@ -28,8 +29,9 @@ actions!(
[
ToggleCollaboratorList,
ToggleContactsMenu,
+ ToggleUserMenu,
ShareProject,
- UnshareProject
+ UnshareProject,
]
);
@@ -38,25 +40,20 @@ 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);
+ cx.add_action(CollabTitlebarItem::toggle_user_menu);
}
pub struct CollabTitlebarItem {
workspace: WeakViewHandle,
user_store: ModelHandle,
contacts_popover: Option>,
- contacts_popover_side: ContactsPopoverSide,
+ user_menu: ViewHandle,
collaborator_list_popover: Option>,
_subscriptions: Vec,
}
@@ -93,6 +90,7 @@ impl View for CollabTitlebarItem {
let user = workspace.read(cx).user_store().read(cx).current_user();
let mut left_container = Flex::row();
+ let mut right_container = Flex::row();
left_container.add_child(
Label::new(project_title, theme.workspace.titlebar.title.clone())
@@ -104,40 +102,25 @@ impl View for CollabTitlebarItem {
);
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));
- }
+ left_container
+ .add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
- let mut right_container = Flex::row();
-
- if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
+ right_container.add_children(self.render_collaborators(&workspace, &theme, &room, cx));
+ right_container.add_child(self.render_current_user(&workspace, &theme, &user, cx));
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(),
- );
+ let status = workspace.read(cx).client().status();
+ let status = &*status.borrow();
+ if matches!(status, client::Status::Connected { .. }) {
+ right_container.add_child(self.render_toggle_contacts_button(&theme, cx));
} else {
- right_container.add_child(Self::render_authenticate(&theme, cx));
+ right_container.add_children(self.render_connection_status(status, cx));
}
+ right_container.add_child(self.render_user_menu_button(&theme, cx));
+
Stack::new()
.with_child(left_container.boxed())
.with_child(right_container.aligned().right().boxed())
@@ -186,7 +169,11 @@ impl CollabTitlebarItem {
workspace: workspace.downgrade(),
user_store: user_store.clone(),
contacts_popover: None,
- contacts_popover_side: ContactsPopoverSide::Right,
+ user_menu: cx.add_view(|cx| {
+ let mut menu = ContextMenu::new(cx);
+ menu.set_position_mode(OverlayPositionMode::Local);
+ menu
+ }),
collaborator_list_popover: None,
_subscriptions: subscriptions,
}
@@ -278,12 +265,6 @@ impl CollabTitlebarItem {
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);
}
}
@@ -291,6 +272,44 @@ impl CollabTitlebarItem {
cx.notify();
}
+ pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext) {
+ let theme = cx.global::().theme.clone();
+ let label_style = theme.context_menu.item.disabled_style().label.clone();
+ self.user_menu.update(cx, |user_menu, cx| {
+ let items = if let Some(user) = self.user_store.read(cx).current_user() {
+ vec![
+ ContextMenuItem::Static(Box::new(move |_| {
+ Label::new(user.github_login.clone(), label_style.clone()).boxed()
+ })),
+ ContextMenuItem::Item {
+ label: "Sign out".into(),
+ action: Box::new(SignOut),
+ },
+ ]
+ } else {
+ vec![ContextMenuItem::Item {
+ label: "Sign in".into(),
+ action: Box::new(Authenticate),
+ }]
+ };
+
+ user_menu.show(
+ vec2f(
+ theme
+ .workspace
+ .titlebar
+ .user_menu_button
+ .default
+ .button_width,
+ theme.workspace.titlebar.height,
+ ),
+ AnchorCorner::TopRight,
+ items,
+ cx,
+ );
+ });
+ }
+
fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext) {
ActiveCall::global(cx)
.update(cx, |call, cx| call.hang_up(cx))
@@ -328,11 +347,9 @@ impl CollabTitlebarItem {
Stack::new()
.with_child(
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,
- );
+ let style = titlebar
+ .toggle_contacts_button
+ .style_for(state, self.contacts_popover.is_some());
Svg::new("icons/plus_8.svg")
.with_color(style.color)
.constrained()
@@ -360,11 +377,7 @@ impl CollabTitlebarItem {
.boxed(),
)
.with_children(badge)
- .with_children(self.render_contacts_popover_host(
- ContactsPopoverSide::Left,
- titlebar,
- cx,
- ))
+ .with_children(self.render_contacts_popover_host(titlebar, cx))
.boxed()
}
@@ -475,11 +488,9 @@ impl CollabTitlebarItem {
.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,
- );
+ let style = titlebar
+ .share_button
+ .style_for(state, self.contacts_popover.is_some());
Label::new(label, style.text.clone())
.contained()
.with_style(style.container)
@@ -502,11 +513,6 @@ impl CollabTitlebarItem {
)
.boxed(),
)
- .with_children(self.render_contacts_popover_host(
- ContactsPopoverSide::Right,
- titlebar,
- cx,
- ))
.aligned()
.contained()
.with_margin_left(theme.workspace.titlebar.item_spacing)
@@ -514,83 +520,71 @@ impl CollabTitlebarItem {
)
}
- fn render_outside_call_share_button(
- &self,
- theme: &Theme,
- cx: &mut RenderContext,
- ) -> ElementBox {
- let tooltip = "Share project with new call";
+ fn render_user_menu_button(&self, theme: &Theme, cx: &mut RenderContext) -> ElementBox {
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())
+ MouseEventHandler::::new(0, cx, |state, _| {
+ let style = titlebar.call_control.style_for(state, false);
+ Svg::new("icons/ellipsis_14.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(ToggleContactsMenu);
+ cx.dispatch_action(ToggleUserMenu);
})
- .with_tooltip::(
+ .with_tooltip::(
0,
- tooltip.to_owned(),
- None,
+ "Toggle user menu".to_owned(),
+ Some(Box::new(ToggleUserMenu)),
theme.tooltip.clone(),
cx,
)
+ .contained()
+ .with_margin_left(theme.workspace.titlebar.item_spacing)
+ .aligned()
.boxed(),
)
- .with_children(self.render_contacts_popover_host(
- ContactsPopoverSide::Right,
- titlebar,
- cx,
- ))
- .aligned()
- .contained()
- .with_margin_left(theme.workspace.titlebar.item_spacing)
+ .with_child(ChildView::new(&self.user_menu, cx).boxed())
.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()
- })
+ ) -> Option {
+ self.contacts_popover.as_ref().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,
+ room: &ModelHandle,
cx: &mut RenderContext,
) -> Vec {
let project = workspace.read(cx).project().read(cx);
@@ -622,7 +616,7 @@ impl CollabTitlebarItem {
theme,
cx,
))
- .with_margin_left(theme.workspace.titlebar.face_pile_spacing)
+ .with_margin_right(theme.workspace.titlebar.face_pile_spacing)
.boxed(),
)
})
@@ -643,25 +637,16 @@ impl CollabTitlebarItem {
.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_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()
+ Container::new(self.render_face_pile(
+ user,
+ Some(replica_id),
+ peer_id,
+ None,
+ workspace,
+ theme,
+ cx,
+ ))
+ .with_margin_right(theme.workspace.titlebar.item_spacing)
.boxed()
}
@@ -717,7 +702,7 @@ impl CollabTitlebarItem {
}
}
- let content = Stack::new()
+ let mut content = Stack::new()
.with_children(user.avatar.as_ref().map(|avatar| {
let face_pile = FacePile::new(theme.workspace.titlebar.follower_avatar_overlap)
.with_child(Self::render_face(
@@ -789,7 +774,10 @@ impl CollabTitlebarItem {
if let Some(location) = location {
if let Some(replica_id) = replica_id {
- MouseEventHandler::::new(replica_id.into(), cx, move |_, _| content)
+ content =
+ MouseEventHandler::::new(replica_id.into(), cx, move |_, _| {
+ content
+ })
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_action(ToggleFollow(peer_id))
@@ -805,12 +793,14 @@ impl CollabTitlebarItem {
theme.tooltip.clone(),
cx,
)
- .boxed()
+ .boxed();
} else if let ParticipantLocation::SharedProject { project_id } = location {
let user_id = user.id;
- MouseEventHandler::::new(peer_id.as_u64() as usize, cx, move |_, _| {
- content
- })
+ content = MouseEventHandler::::new(
+ peer_id.as_u64() as usize,
+ cx,
+ move |_, _| content,
+ )
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_action(JoinProject {
@@ -825,13 +815,10 @@ impl CollabTitlebarItem {
theme.tooltip.clone(),
cx,
)
- .boxed()
- } else {
- content
+ .boxed();
}
- } else {
- content
}
+ content
}
fn render_face(
@@ -854,13 +841,13 @@ impl CollabTitlebarItem {
fn render_connection_status(
&self,
- workspace: &ViewHandle,
+ status: &client::Status,
cx: &mut RenderContext,
) -> Option {
enum ConnectionStatusButton {}
let theme = &cx.global::().theme.clone();
- match &*workspace.read(cx).client().status().borrow() {
+ match status {
client::Status::ConnectionError
| client::Status::ConnectionLost
| client::Status::Reauthenticating { .. }
diff --git a/crates/context_menu/src/context_menu.rs b/crates/context_menu/src/context_menu.rs
index 6d5a5cb549..eb02334c70 100644
--- a/crates/context_menu/src/context_menu.rs
+++ b/crates/context_menu/src/context_menu.rs
@@ -5,7 +5,9 @@ use gpui::{
};
use menu::*;
use settings::Settings;
-use std::{any::TypeId, time::Duration};
+use std::{any::TypeId, borrow::Cow, time::Duration};
+
+pub type StaticItem = Box ElementBox>;
#[derive(Copy, Clone, PartialEq)]
struct Clicked;
@@ -24,16 +26,17 @@ pub fn init(cx: &mut MutableAppContext) {
pub enum ContextMenuItem {
Item {
- label: String,
+ label: Cow<'static, str>,
action: Box,
},
+ Static(StaticItem),
Separator,
}
impl ContextMenuItem {
- pub fn item(label: impl ToString, action: impl 'static + Action) -> Self {
+ pub fn item(label: impl Into>, action: impl 'static + Action) -> Self {
Self::Item {
- label: label.to_string(),
+ label: label.into(),
action: Box::new(action),
}
}
@@ -42,14 +45,14 @@ impl ContextMenuItem {
Self::Separator
}
- fn is_separator(&self) -> bool {
- matches!(self, Self::Separator)
+ fn is_action(&self) -> bool {
+ matches!(self, Self::Item { .. })
}
fn action_id(&self) -> Option {
match self {
ContextMenuItem::Item { action, .. } => Some(action.id()),
- ContextMenuItem::Separator => None,
+ ContextMenuItem::Static(..) | ContextMenuItem::Separator => None,
}
}
}
@@ -58,6 +61,7 @@ pub struct ContextMenu {
show_count: usize,
anchor_position: Vector2F,
anchor_corner: AnchorCorner,
+ position_mode: OverlayPositionMode,
items: Vec,
selected_index: Option,
visible: bool,
@@ -105,6 +109,7 @@ impl View for ContextMenu {
.with_fit_mode(OverlayFitMode::SnapToWindow)
.with_anchor_position(self.anchor_position)
.with_anchor_corner(self.anchor_corner)
+ .with_position_mode(self.position_mode)
.boxed()
}
@@ -121,6 +126,7 @@ impl ContextMenu {
show_count: 0,
anchor_position: Default::default(),
anchor_corner: AnchorCorner::TopLeft,
+ position_mode: OverlayPositionMode::Window,
items: Default::default(),
selected_index: Default::default(),
visible: Default::default(),
@@ -188,13 +194,13 @@ impl ContextMenu {
}
fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext) {
- self.selected_index = self.items.iter().position(|item| !item.is_separator());
+ self.selected_index = self.items.iter().position(|item| item.is_action());
cx.notify();
}
fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext) {
for (ix, item) in self.items.iter().enumerate().rev() {
- if !item.is_separator() {
+ if item.is_action() {
self.selected_index = Some(ix);
cx.notify();
break;
@@ -205,7 +211,7 @@ impl ContextMenu {
fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) {
if let Some(ix) = self.selected_index {
for (ix, item) in self.items.iter().enumerate().skip(ix + 1) {
- if !item.is_separator() {
+ if item.is_action() {
self.selected_index = Some(ix);
cx.notify();
break;
@@ -219,7 +225,7 @@ impl ContextMenu {
fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) {
if let Some(ix) = self.selected_index {
for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
- if !item.is_separator() {
+ if item.is_action() {
self.selected_index = Some(ix);
cx.notify();
break;
@@ -234,7 +240,7 @@ impl ContextMenu {
&mut self,
anchor_position: Vector2F,
anchor_corner: AnchorCorner,
- items: impl IntoIterator
- ,
+ items: Vec,
cx: &mut ViewContext,
) {
let mut items = items.into_iter().peekable();
@@ -254,6 +260,10 @@ impl ContextMenu {
cx.notify();
}
+ pub fn set_position_mode(&mut self, mode: OverlayPositionMode) {
+ self.position_mode = mode;
+ }
+
fn render_menu_for_measurement(&self, cx: &mut RenderContext) -> impl Element {
let window_id = cx.window_id();
let style = cx.global::().theme.context_menu.clone();
@@ -273,6 +283,9 @@ impl ContextMenu {
.with_style(style.container)
.boxed()
}
+
+ ContextMenuItem::Static(f) => f(cx),
+
ContextMenuItem::Separator => Empty::new()
.collapsed()
.contained()
@@ -302,6 +315,9 @@ impl ContextMenu {
)
.boxed()
}
+
+ ContextMenuItem::Static(_) => Empty::new().boxed(),
+
ContextMenuItem::Separator => Empty::new()
.collapsed()
.constrained()
@@ -339,7 +355,7 @@ impl ContextMenu {
Flex::row()
.with_child(
- Label::new(label.to_string(), style.label.clone())
+ Label::new(label.clone(), style.label.clone())
.contained()
.boxed(),
)
@@ -366,6 +382,9 @@ impl ContextMenu {
.on_drag(MouseButton::Left, |_, _| {})
.boxed()
}
+
+ ContextMenuItem::Static(f) => f(cx),
+
ContextMenuItem::Separator => Empty::new()
.constrained()
.with_height(1.)
diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs
index 17a7c876bb..43e3b6deec 100644
--- a/crates/theme/src/theme.rs
+++ b/crates/theme/src/theme.rs
@@ -88,6 +88,7 @@ pub struct Titlebar {
pub share_button: Interactive,
pub call_control: Interactive,
pub toggle_contacts_button: Interactive,
+ pub user_menu_button: Interactive,
pub toggle_contacts_badge: ContainerStyle,
}
diff --git a/styles/src/styleTree/workspace.ts b/styles/src/styleTree/workspace.ts
index 659a0e6745..db1f0e7286 100644
--- a/styles/src/styleTree/workspace.ts
+++ b/styles/src/styleTree/workspace.ts
@@ -197,6 +197,11 @@ export default function workspace(colorScheme: ColorScheme) {
color: foreground(layer, "variant", "hovered"),
},
},
+ userMenuButton: {
+ buttonWidth: 20,
+ iconWidth: 12,
+ ...titlebarButton,
+ },
toggleContactsBadge: {
cornerRadius: 3,
padding: 2,