Implement user menu (#3636)

Conrad Irwin created

Release Notes:

- N/A

Change summary

Cargo.lock                                    |   3 
crates/auto_update2/src/auto_update.rs        |   4 
crates/client2/src/client2.rs                 | 177 ++++++----
crates/collab_ui2/Cargo.toml                  |   6 
crates/collab_ui2/src/collab_titlebar_item.rs | 344 +++++++-------------
crates/ui2/src/components/icon.rs             |   2 
crates/zed2/src/zed2.rs                       |   3 
crates/zed_actions2/src/lib.rs                |   2 
8 files changed, 236 insertions(+), 305 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1949,6 +1949,7 @@ name = "collab_ui2"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "auto_update2",
  "call2",
  "channel2",
  "client2",
@@ -1957,6 +1958,7 @@ dependencies = [
  "db2",
  "editor2",
  "feature_flags2",
+ "feedback2",
  "futures 0.3.28",
  "fuzzy2",
  "gpui2",
@@ -1978,6 +1980,7 @@ dependencies = [
  "settings2",
  "smallvec",
  "theme2",
+ "theme_selector2",
  "time",
  "tree-sitter-markdown",
  "ui2",

crates/auto_update2/src/auto_update.rs 🔗

@@ -6,7 +6,7 @@ use db::kvp::KEY_VALUE_STORE;
 use db::RELEASE_CHANNEL;
 use gpui::{
     actions, AppContext, AsyncAppContext, Context as _, Model, ModelContext, SemanticVersion, Task,
-    ViewContext, VisualContext,
+    ViewContext, VisualContext, WindowContext,
 };
 use isahc::AsyncBody;
 
@@ -118,7 +118,7 @@ pub fn init(http_client: Arc<dyn HttpClient>, server_url: String, cx: &mut AppCo
     }
 }
 
-pub fn check(_: &Check, cx: &mut ViewContext<Workspace>) {
+pub fn check(_: &Check, cx: &mut WindowContext) {
     if let Some(updater) = AutoUpdater::get(cx) {
         updater.update(cx, |updater, cx| updater.poll(cx));
     } else {

crates/client2/src/client2.rs 🔗

@@ -11,8 +11,8 @@ use async_tungstenite::tungstenite::{
     http::{Request, StatusCode},
 };
 use futures::{
-    future::LocalBoxFuture, AsyncReadExt, FutureExt, SinkExt, StreamExt, TryFutureExt as _,
-    TryStreamExt,
+    channel::oneshot, future::LocalBoxFuture, AsyncReadExt, FutureExt, SinkExt, StreamExt,
+    TryFutureExt as _, TryStreamExt,
 };
 use gpui::{
     actions, serde_json, AnyModel, AnyWeakModel, AppContext, AsyncAppContext, Model,
@@ -1020,91 +1020,116 @@ impl Client {
     ) -> Task<Result<Credentials>> {
         let http = self.http.clone();
         cx.spawn(|cx| async move {
-            // Generate a pair of asymmetric encryption keys. The public key will be used by the
-            // zed server to encrypt the user's access token, so that it can'be intercepted by
-            // any other app running on the user's device.
-            let (public_key, private_key) =
-                rpc::auth::keypair().expect("failed to generate keypair for auth");
-            let public_key_string =
-                String::try_from(public_key).expect("failed to serialize public key for auth");
-
-            if let Some((login, token)) = IMPERSONATE_LOGIN.as_ref().zip(ADMIN_API_TOKEN.as_ref()) {
-                return Self::authenticate_as_admin(http, login.clone(), token.clone()).await;
-            }
-
-            // Start an HTTP server to receive the redirect from Zed's sign-in page.
-            let server = tiny_http::Server::http("127.0.0.1:0").expect("failed to find open port");
-            let port = server.server_addr().port();
+            let background = cx.background_executor().clone();
 
-            // Open the Zed sign-in page in the user's browser, with query parameters that indicate
-            // that the user is signing in from a Zed app running on the same device.
-            let mut url = format!(
-                "{}/native_app_signin?native_app_port={}&native_app_public_key={}",
-                *ZED_SERVER_URL, port, public_key_string
-            );
+            let (open_url_tx, open_url_rx) = oneshot::channel::<String>();
+            cx.update(|cx| {
+                cx.spawn(move |cx| async move {
+                    let url = open_url_rx.await?;
+                    cx.update(|cx| cx.open_url(&url))
+                })
+                .detach_and_log_err(cx);
+            })
+            .log_err();
+
+            let credentials = background
+                .clone()
+                .spawn(async move {
+                    // Generate a pair of asymmetric encryption keys. The public key will be used by the
+                    // zed server to encrypt the user's access token, so that it can'be intercepted by
+                    // any other app running on the user's device.
+                    let (public_key, private_key) =
+                        rpc::auth::keypair().expect("failed to generate keypair for auth");
+                    let public_key_string = String::try_from(public_key)
+                        .expect("failed to serialize public key for auth");
+
+                    if let Some((login, token)) =
+                        IMPERSONATE_LOGIN.as_ref().zip(ADMIN_API_TOKEN.as_ref())
+                    {
+                        return Self::authenticate_as_admin(http, login.clone(), token.clone())
+                            .await;
+                    }
 
-            if let Some(impersonate_login) = IMPERSONATE_LOGIN.as_ref() {
-                log::info!("impersonating user @{}", impersonate_login);
-                write!(&mut url, "&impersonate={}", impersonate_login).unwrap();
-            }
+                    // Start an HTTP server to receive the redirect from Zed's sign-in page.
+                    let server =
+                        tiny_http::Server::http("127.0.0.1:0").expect("failed to find open port");
+                    let port = server.server_addr().port();
+
+                    // Open the Zed sign-in page in the user's browser, with query parameters that indicate
+                    // that the user is signing in from a Zed app running on the same device.
+                    let mut url = format!(
+                        "{}/native_app_signin?native_app_port={}&native_app_public_key={}",
+                        *ZED_SERVER_URL, port, public_key_string
+                    );
+
+                    if let Some(impersonate_login) = IMPERSONATE_LOGIN.as_ref() {
+                        log::info!("impersonating user @{}", impersonate_login);
+                        write!(&mut url, "&impersonate={}", impersonate_login).unwrap();
+                    }
 
-            cx.update(|cx| cx.open_url(&url))?;
-
-            // Receive the HTTP request from the user's browser. Retrieve the user id and encrypted
-            // access token from the query params.
-            //
-            // TODO - Avoid ever starting more than one HTTP server. Maybe switch to using a
-            // custom URL scheme instead of this local HTTP server.
-            let (user_id, access_token) = cx
-                .spawn(|_| async move {
-                    for _ in 0..100 {
-                        if let Some(req) = server.recv_timeout(Duration::from_secs(1))? {
-                            let path = req.url();
-                            let mut user_id = None;
-                            let mut access_token = None;
-                            let url = Url::parse(&format!("http://example.com{}", path))
-                                .context("failed to parse login notification url")?;
-                            for (key, value) in url.query_pairs() {
-                                if key == "access_token" {
-                                    access_token = Some(value.to_string());
-                                } else if key == "user_id" {
-                                    user_id = Some(value.to_string());
+                    open_url_tx.send(url).log_err();
+
+                    // Receive the HTTP request from the user's browser. Retrieve the user id and encrypted
+                    // access token from the query params.
+                    //
+                    // TODO - Avoid ever starting more than one HTTP server. Maybe switch to using a
+                    // custom URL scheme instead of this local HTTP server.
+                    let (user_id, access_token) = background
+                        .spawn(async move {
+                            for _ in 0..100 {
+                                if let Some(req) = server.recv_timeout(Duration::from_secs(1))? {
+                                    let path = req.url();
+                                    let mut user_id = None;
+                                    let mut access_token = None;
+                                    let url = Url::parse(&format!("http://example.com{}", path))
+                                        .context("failed to parse login notification url")?;
+                                    for (key, value) in url.query_pairs() {
+                                        if key == "access_token" {
+                                            access_token = Some(value.to_string());
+                                        } else if key == "user_id" {
+                                            user_id = Some(value.to_string());
+                                        }
+                                    }
+
+                                    let post_auth_url =
+                                        format!("{}/native_app_signin_succeeded", *ZED_SERVER_URL);
+                                    req.respond(
+                                        tiny_http::Response::empty(302).with_header(
+                                            tiny_http::Header::from_bytes(
+                                                &b"Location"[..],
+                                                post_auth_url.as_bytes(),
+                                            )
+                                            .unwrap(),
+                                        ),
+                                    )
+                                    .context("failed to respond to login http request")?;
+                                    return Ok((
+                                        user_id
+                                            .ok_or_else(|| anyhow!("missing user_id parameter"))?,
+                                        access_token.ok_or_else(|| {
+                                            anyhow!("missing access_token parameter")
+                                        })?,
+                                    ));
                                 }
                             }
 
-                            let post_auth_url =
-                                format!("{}/native_app_signin_succeeded", *ZED_SERVER_URL);
-                            req.respond(
-                                tiny_http::Response::empty(302).with_header(
-                                    tiny_http::Header::from_bytes(
-                                        &b"Location"[..],
-                                        post_auth_url.as_bytes(),
-                                    )
-                                    .unwrap(),
-                                ),
-                            )
-                            .context("failed to respond to login http request")?;
-                            return Ok((
-                                user_id.ok_or_else(|| anyhow!("missing user_id parameter"))?,
-                                access_token
-                                    .ok_or_else(|| anyhow!("missing access_token parameter"))?,
-                            ));
-                        }
-                    }
+                            Err(anyhow!("didn't receive login redirect"))
+                        })
+                        .await?;
 
-                    Err(anyhow!("didn't receive login redirect"))
+                    let access_token = private_key
+                        .decrypt_string(&access_token)
+                        .context("failed to decrypt access token")?;
+
+                    Ok(Credentials {
+                        user_id: user_id.parse()?,
+                        access_token,
+                    })
                 })
                 .await?;
 
-            let access_token = private_key
-                .decrypt_string(&access_token)
-                .context("failed to decrypt access token")?;
             cx.update(|cx| cx.activate(true))?;
-
-            Ok(Credentials {
-                user_id: user_id.parse()?,
-                access_token,
-            })
+            Ok(credentials)
         })
     }
 

crates/collab_ui2/Cargo.toml 🔗

@@ -22,7 +22,7 @@ test-support = [
 ]
 
 [dependencies]
-# auto_update = { path = "../auto_update" }
+auto_update = { package = "auto_update2", path = "../auto_update2" }
 db = { package = "db2", path = "../db2" }
 call = { package = "call2", path = "../call2" }
 client = { package = "client2", path = "../client2" }
@@ -32,7 +32,7 @@ collections = { path = "../collections" }
 # context_menu = { path = "../context_menu" }
 # drag_and_drop = { path = "../drag_and_drop" }
 editor = { package="editor2", path = "../editor2" }
-#feedback = { path = "../feedback" }
+feedback = { package = "feedback2", path = "../feedback2" }
 fuzzy = { package = "fuzzy2", path = "../fuzzy2" }
 gpui = { package = "gpui2", path = "../gpui2" }
 language = { package = "language2", path = "../language2" }
@@ -46,7 +46,7 @@ rpc = { package ="rpc2",  path = "../rpc2" }
 settings = { package = "settings2", path = "../settings2" }
 feature_flags = { package = "feature_flags2", path = "../feature_flags2"}
 theme = { package = "theme2", path = "../theme2" }
-# theme_selector = { path = "../theme_selector" }
+theme_selector = { package = "theme_selector2", path = "../theme_selector2" }
 vcs_menu = { package = "vcs_menu2", path = "../vcs_menu2" }
 ui = { package = "ui2", path = "../ui2" }
 util = { path = "../util" }

crates/collab_ui2/src/collab_titlebar_item.rs 🔗

@@ -1,9 +1,10 @@
 use crate::face_pile::FacePile;
+use auto_update::AutoUpdateStatus;
 use call::{ActiveCall, ParticipantLocation, Room};
 use client::{proto::PeerId, Client, ParticipantIndex, User, UserStore};
 use gpui::{
-    actions, canvas, div, overlay, point, px, rems, AnyElement, AppContext, DismissEvent, Div,
-    Element, FocusableView, Hsla, InteractiveElement, IntoElement, Model, ParentElement, Path,
+    actions, canvas, div, overlay, point, px, rems, Action, AnyElement, AppContext, DismissEvent,
+    Div, Element, FocusableView, Hsla, InteractiveElement, IntoElement, Model, ParentElement, Path,
     Render, Stateful, StatefulInteractiveElement, Styled, Subscription, View, ViewContext,
     VisualContext, WeakView, WindowBounds,
 };
@@ -53,7 +54,6 @@ pub struct CollabTitlebarItem {
     workspace: WeakView<Workspace>,
     branch_popover: Option<View<BranchList>>,
     project_popover: Option<recent_projects::RecentProjects>,
-    //user_menu: ViewHandle<ContextMenu>,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -233,68 +233,17 @@ impl Render for CollabTitlebarItem {
                                 }),
                         )
                     })
-                    .child(
-                        h_stack()
-                            .border_color(gpui::red())
-                            .border_1()
-                            .px_1p5()
-                            .map(|this| {
-                                if let Some(user) = current_user {
-                                    // TODO: Finish implementing user menu popover
-                                    //
-                                    this.child(
-                                        popover_menu("user-menu")
-                                            .menu(|cx| {
-                                                ContextMenu::build(cx, |menu, _| {
-                                                    menu.header("ADADA")
-                                                })
-                                            })
-                                            .trigger(
-                                                ButtonLike::new("user-menu")
-                                                    .child(
-                                                        h_stack()
-                                                            .gap_0p5()
-                                                            .child(Avatar::new(
-                                                                user.avatar_uri.clone(),
-                                                            ))
-                                                            .child(
-                                                                IconElement::new(Icon::ChevronDown)
-                                                                    .color(Color::Muted),
-                                                            ),
-                                                    )
-                                                    .style(ButtonStyle::Subtle)
-                                                    .tooltip(move |cx| {
-                                                        Tooltip::text("Toggle User Menu", cx)
-                                                    }),
-                                            )
-                                            .anchor(gpui::AnchorCorner::TopRight),
-                                    )
-                                    // this.child(
-                                    //     ButtonLike::new("user-menu")
-                                    //         .child(
-                                    //             h_stack().gap_0p5().child(Avatar::data(avatar)).child(
-                                    //                 IconElement::new(Icon::ChevronDown).color(Color::Muted),
-                                    //             ),
-                                    //         )
-                                    //         .style(ButtonStyle::Subtle)
-                                    //         .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)),
-                                    // )
-                                } else {
-                                    this.child(Button::new("sign_in", "Sign in").on_click(
-                                        move |_, cx| {
-                                            let client = client.clone();
-                                            cx.spawn(move |mut cx| async move {
-                                                client
-                                                    .authenticate_and_connect(true, &cx)
-                                                    .await
-                                                    .notify_async_err(&mut cx);
-                                            })
-                                            .detach();
-                                        },
-                                    ))
-                                }
-                            }),
-                    ),
+                    .map(|el| {
+                        let status = self.client.status();
+                        let status = &*status.borrow();
+                        if matches!(status, client::Status::Connected { .. }) {
+                            el.child(self.render_user_menu_button(cx))
+                        } else {
+                            el.children(self.render_connection_status(status, cx))
+                                .child(self.render_sign_in_button(cx))
+                                .child(self.render_user_menu_button(cx))
+                        }
+                    }),
             )
     }
 }
@@ -336,12 +285,6 @@ impl CollabTitlebarItem {
             project,
             user_store,
             client,
-            //         user_menu: cx.add_view(|cx| {
-            //             let view_id = cx.view_id();
-            //             let mut menu = ContextMenu::new(view_id, cx);
-            //             menu.set_position_mode(OverlayPositionMode::Local);
-            //             menu
-            //         }),
             branch_popover: None,
             project_popover: None,
             _subscriptions: subscriptions,
@@ -582,154 +525,113 @@ impl CollabTitlebarItem {
         cx.notify();
     }
 
-    // fn render_user_menu_button(
-    //     &self,
-    //     theme: &Theme,
-    //     avatar: Option<Arc<ImageData>>,
-    //     cx: &mut ViewContext<Self>,
-    // ) -> AnyElement<Self> {
-    //     let tooltip = theme.tooltip.clone();
-    //     let user_menu_button_style = if avatar.is_some() {
-    //         &theme.titlebar.user_menu.user_menu_button_online
-    //     } else {
-    //         &theme.titlebar.user_menu.user_menu_button_offline
-    //     };
-
-    //     let avatar_style = &user_menu_button_style.avatar;
-    //     Stack::new()
-    //         .with_child(
-    //             MouseEventHandler::new::<ToggleUserMenu, _>(0, cx, |state, _| {
-    //                 let style = user_menu_button_style
-    //                     .user_menu
-    //                     .inactive_state()
-    //                     .style_for(state);
-
-    //                 let mut dropdown = Flex::row().align_children_center();
-
-    //                 if let Some(avatar_img) = avatar {
-    //                     dropdown = dropdown.with_child(Self::render_face(
-    //                         avatar_img,
-    //                         *avatar_style,
-    //                         Color::transparent_black(),
-    //                         None,
-    //                     ));
-    //                 };
-
-    //                 dropdown
-    //                     .with_child(
-    //                         Svg::new("icons/caret_down.svg")
-    //                             .with_color(user_menu_button_style.icon.color)
-    //                             .constrained()
-    //                             .with_width(user_menu_button_style.icon.width)
-    //                             .contained()
-    //                             .into_any(),
-    //                     )
-    //                     .aligned()
-    //                     .constrained()
-    //                     .with_height(style.width)
-    //                     .contained()
-    //                     .with_style(style.container)
-    //                     .into_any()
-    //             })
-    //             .with_cursor_style(CursorStyle::PointingHand)
-    //             .on_down(MouseButton::Left, move |_, this, cx| {
-    //                 this.user_menu.update(cx, |menu, _| menu.delay_cancel());
-    //             })
-    //             .on_click(MouseButton::Left, move |_, this, cx| {
-    //                 this.toggle_user_menu(&Default::default(), cx)
-    //             })
-    //             .with_tooltip::<ToggleUserMenu>(
-    //                 0,
-    //                 "Toggle User Menu".to_owned(),
-    //                 Some(Box::new(ToggleUserMenu)),
-    //                 tooltip,
-    //                 cx,
-    //             )
-    //             .contained(),
-    //         )
-    //         .with_child(
-    //             ChildView::new(&self.user_menu, cx)
-    //                 .aligned()
-    //                 .bottom()
-    //                 .right(),
-    //         )
-    //         .into_any()
-    // }
-
-    // fn render_sign_in_button(&self, theme: &Theme, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-    //     let titlebar = &theme.titlebar;
-    //     MouseEventHandler::new::<SignIn, _>(0, cx, |state, _| {
-    //         let style = titlebar.sign_in_button.inactive_state().style_for(state);
-    //         Label::new("Sign In", style.text.clone())
-    //             .contained()
-    //             .with_style(style.container)
-    //     })
-    //     .with_cursor_style(CursorStyle::PointingHand)
-    //     .on_click(MouseButton::Left, move |_, this, cx| {
-    //         let client = this.client.clone();
-    //         cx.app_context()
-    //             .spawn(|cx| async move { client.authenticate_and_connect(true, &cx).await })
-    //             .detach_and_log_err(cx);
-    //     })
-    //     .into_any()
-    // }
-
-    // fn render_connection_status(
-    //     &self,
-    //     status: &client::Status,
-    //     cx: &mut ViewContext<Self>,
-    // ) -> Option<AnyElement<Self>> {
-    //     enum ConnectionStatusButton {}
-
-    //     let theme = &theme::current(cx).clone();
-    //     match status {
-    //         client::Status::ConnectionError
-    //         | client::Status::ConnectionLost
-    //         | client::Status::Reauthenticating { .. }
-    //         | client::Status::Reconnecting { .. }
-    //         | client::Status::ReconnectionError { .. } => Some(
-    //             Svg::new("icons/disconnected.svg")
-    //                 .with_color(theme.titlebar.offline_icon.color)
-    //                 .constrained()
-    //                 .with_width(theme.titlebar.offline_icon.width)
-    //                 .aligned()
-    //                 .contained()
-    //                 .with_style(theme.titlebar.offline_icon.container)
-    //                 .into_any(),
-    //         ),
-    //         client::Status::UpgradeRequired => {
-    //             let auto_updater = auto_update::AutoUpdater::get(cx);
-    //             let label = match auto_updater.map(|auto_update| auto_update.read(cx).status()) {
-    //                 Some(AutoUpdateStatus::Updated) => "Please restart Zed to Collaborate",
-    //                 Some(AutoUpdateStatus::Installing)
-    //                 | Some(AutoUpdateStatus::Downloading)
-    //                 | Some(AutoUpdateStatus::Checking) => "Updating...",
-    //                 Some(AutoUpdateStatus::Idle) | Some(AutoUpdateStatus::Errored) | None => {
-    //                     "Please update Zed to Collaborate"
-    //                 }
-    //             };
-
-    //             Some(
-    //                 MouseEventHandler::new::<ConnectionStatusButton, _>(0, cx, |_, _| {
-    //                     Label::new(label, theme.titlebar.outdated_warning.text.clone())
-    //                         .contained()
-    //                         .with_style(theme.titlebar.outdated_warning.container)
-    //                         .aligned()
-    //                 })
-    //                 .with_cursor_style(CursorStyle::PointingHand)
-    //                 .on_click(MouseButton::Left, |_, _, cx| {
-    //                     if let Some(auto_updater) = auto_update::AutoUpdater::get(cx) {
-    //                         if auto_updater.read(cx).status() == AutoUpdateStatus::Updated {
-    //                             workspace::restart(&Default::default(), cx);
-    //                             return;
-    //                         }
-    //                     }
-    //                     auto_update::check(&Default::default(), cx);
-    //                 })
-    //                 .into_any(),
-    //             )
-    //         }
-    //         _ => None,
-    //     }
-    // }
+    fn render_connection_status(
+        &self,
+        status: &client::Status,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<AnyElement> {
+        match status {
+            client::Status::ConnectionError
+            | client::Status::ConnectionLost
+            | client::Status::Reauthenticating { .. }
+            | client::Status::Reconnecting { .. }
+            | client::Status::ReconnectionError { .. } => Some(
+                div()
+                    .id("disconnected")
+                    .bg(gpui::red()) // todo!() @nate
+                    .child(IconElement::new(Icon::Disconnected))
+                    .tooltip(|cx| Tooltip::text("Disconnected", cx))
+                    .into_any_element(),
+            ),
+            client::Status::UpgradeRequired => {
+                let auto_updater = auto_update::AutoUpdater::get(cx);
+                let label = match auto_updater.map(|auto_update| auto_update.read(cx).status()) {
+                    Some(AutoUpdateStatus::Updated) => "Please restart Zed to Collaborate",
+                    Some(AutoUpdateStatus::Installing)
+                    | Some(AutoUpdateStatus::Downloading)
+                    | Some(AutoUpdateStatus::Checking) => "Updating...",
+                    Some(AutoUpdateStatus::Idle) | Some(AutoUpdateStatus::Errored) | None => {
+                        "Please update Zed to Collaborate"
+                    }
+                };
+
+                Some(
+                    div()
+                        .bg(gpui::red()) // todo!() @nate
+                        .child(Button::new("connection-status", label).on_click(|_, cx| {
+                            if let Some(auto_updater) = auto_update::AutoUpdater::get(cx) {
+                                if auto_updater.read(cx).status() == AutoUpdateStatus::Updated {
+                                    workspace::restart(&Default::default(), cx);
+                                    return;
+                                }
+                            }
+                            auto_update::check(&Default::default(), cx);
+                        }))
+                        .into_any_element(),
+                )
+            }
+            _ => None,
+        }
+    }
+
+    pub fn render_sign_in_button(&mut self, _: &mut ViewContext<Self>) -> Button {
+        let client = self.client.clone();
+        Button::new("sign_in", "Sign in").on_click(move |_, cx| {
+            let client = client.clone();
+            cx.spawn(move |mut cx| async move {
+                client
+                    .authenticate_and_connect(true, &cx)
+                    .await
+                    .notify_async_err(&mut cx);
+            })
+            .detach();
+        })
+    }
+
+    pub fn render_user_menu_button(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
+        if let Some(user) = self.user_store.read(cx).current_user() {
+            popover_menu("user-menu")
+                .menu(|cx| {
+                    ContextMenu::build(cx, |menu, _| {
+                        menu.action("Settings", zed_actions::OpenSettings.boxed_clone())
+                            .action("Theme", theme_selector::Toggle.boxed_clone())
+                            .separator()
+                            .action("Share Feedback", feedback::GiveFeedback.boxed_clone())
+                            .action("Sign Out", client::SignOut.boxed_clone())
+                    })
+                })
+                .trigger(
+                    ButtonLike::new("user-menu")
+                        .child(
+                            h_stack()
+                                .gap_0p5()
+                                .child(Avatar::new(user.avatar_uri.clone()))
+                                .child(IconElement::new(Icon::ChevronDown).color(Color::Muted)),
+                        )
+                        .style(ButtonStyle::Subtle)
+                        .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)),
+                )
+                .anchor(gpui::AnchorCorner::TopRight)
+        } else {
+            popover_menu("user-menu")
+                .menu(|cx| {
+                    ContextMenu::build(cx, |menu, _| {
+                        menu.action("Settings", zed_actions::OpenSettings.boxed_clone())
+                            .action("Theme", theme_selector::Toggle.boxed_clone())
+                            .separator()
+                            .action("Share Feedback", feedback::GiveFeedback.boxed_clone())
+                    })
+                })
+                .trigger(
+                    ButtonLike::new("user-menu")
+                        .child(
+                            h_stack()
+                                .gap_0p5()
+                                .child(IconElement::new(Icon::ChevronDown).color(Color::Muted)),
+                        )
+                        .style(ButtonStyle::Subtle)
+                        .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)),
+                )
+        }
+    }
 }

crates/ui2/src/components/icon.rs 🔗

@@ -50,6 +50,7 @@ pub enum Icon {
     CopilotError,
     CopilotDisabled,
     Dash,
+    Disconnected,
     Envelope,
     ExternalLink,
     ExclamationTriangle,
@@ -129,6 +130,7 @@ impl Icon {
             Icon::CopilotError => "icons/copilot_error.svg",
             Icon::CopilotDisabled => "icons/copilot_disabled.svg",
             Icon::Dash => "icons/dash.svg",
+            Icon::Disconnected => "icons/disconnected.svg",
             Icon::Envelope => "icons/feedback.svg",
             Icon::ExclamationTriangle => "icons/warning.svg",
             Icon::ExternalLink => "icons/external_link.svg",

crates/zed2/src/zed2.rs 🔗

@@ -41,7 +41,7 @@ use workspace::{
     notifications::simple_message_notification::MessageNotification, open_new, AppState, NewFile,
     NewWindow, Workspace, WorkspaceSettings,
 };
-use zed_actions::{OpenBrowser, OpenZedURL, Quit};
+use zed_actions::{OpenBrowser, OpenSettings, OpenZedURL, Quit};
 
 actions!(
     zed,
@@ -59,7 +59,6 @@ actions!(
         OpenLicenses,
         OpenLocalSettings,
         OpenLog,
-        OpenSettings,
         OpenTelemetryLog,
         ResetBufferFontSize,
         ResetDatabase,

crates/zed_actions2/src/lib.rs 🔗

@@ -22,4 +22,4 @@ pub struct OpenZedURL {
 
 impl_actions!(zed, [OpenBrowser, OpenZedURL]);
 
-actions!(zed, [Quit]);
+actions!(zed, [OpenSettings, Quit]);