Create copilot auth popup UI

Mikayla Maki created

Change summary

Cargo.lock                            |   1 
assets/icons/github-copilot-dummy.svg |   0 
crates/copilot/Cargo.toml             |   1 
crates/copilot/src/copilot.rs         |   2 
crates/copilot/src/sign_in.rs         |  90 +++++++++++++++++++++----
crates/theme/src/theme.rs             |  23 ++++-
crates/theme/src/ui.rs                |  82 +++++++++++++++++++++--
crates/welcome/src/welcome.rs         | 101 ++--------------------------
styles/src/styleTree/components.ts    |  12 +++
styles/src/styleTree/copilot.ts       |  60 ++++++++++++++---
styles/src/styleTree/welcome.ts       |  19 ----
styles/src/styleTree/workspace.ts     |  31 ++------
12 files changed, 252 insertions(+), 170 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1347,6 +1347,7 @@ dependencies = [
  "serde_derive",
  "settings",
  "smol",
+ "theme",
  "util",
  "workspace",
 ]

crates/copilot/Cargo.toml 🔗

@@ -12,6 +12,7 @@ doctest = false
 gpui = { path = "../gpui" }
 language = { path = "../language" }
 settings = { path = "../settings" }
+theme = { path = "../theme" }
 lsp = { path = "../lsp" }
 util = { path = "../util" }
 client = { path = "../client" }

crates/copilot/src/copilot.rs 🔗

@@ -475,7 +475,7 @@ mod tests {
             .update(cx, |copilot, cx| copilot.sign_in(cx))
             .await
             .unwrap();
-        dbg!(copilot.read_with(cx, |copilot, _| copilot.status()));
+        copilot.read_with(cx, |copilot, _| copilot.status());
 
         let buffer = cx.add_model(|cx| language::Buffer::new(0, "fn foo() -> ", cx));
         dbg!(copilot

crates/copilot/src/sign_in.rs 🔗

@@ -1,11 +1,18 @@
 use crate::{request::PromptUserDeviceFlow, Copilot};
 use gpui::{
-    elements::*,
-    geometry::{rect::RectF, vector::vec2f},
-    Axis, Element, Entity, MutableAppContext, View, WindowKind, WindowOptions,
+    elements::*, geometry::rect::RectF, impl_internal_actions, ClipboardItem, Element, Entity,
+    MutableAppContext, View, WindowKind, WindowOptions,
 };
 use settings::Settings;
 
+#[derive(PartialEq, Eq, Debug, Clone)]
+struct CopyUserCode;
+
+#[derive(PartialEq, Eq, Debug, Clone)]
+struct OpenGithub;
+
+impl_internal_actions!(copilot_sign_in, [CopyUserCode, OpenGithub]);
+
 pub fn init(cx: &mut MutableAppContext) {
     let copilot = Copilot::global(cx).unwrap();
 
@@ -19,16 +26,24 @@ pub fn init(cx: &mut MutableAppContext) {
                     cx.remove_window(window_id);
                 }
 
+                let window_size = cx
+                    .global::<Settings>()
+                    .theme
+                    .copilot
+                    .auth
+                    .popup_dimensions
+                    .to_vec();
+
                 let (window_id, _) = cx.add_window(
                     WindowOptions {
                         bounds: gpui::WindowBounds::Fixed(RectF::new(
                             Default::default(),
-                            vec2f(600., 400.),
+                            window_size,
                         )),
                         titlebar: None,
                         center: true,
                         focus: false,
-                        kind: WindowKind::Normal,
+                        kind: WindowKind::PopUp,
                         is_movable: true,
                         screen: None,
                     },
@@ -62,23 +77,68 @@ impl View for CopilotCodeVerification {
     fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
         let style = cx.global::<Settings>().theme.copilot.clone();
 
-        let auth_text = style.auth_text.clone();
-        let prompt = self.prompt.clone();
-        Flex::new(Axis::Vertical)
-            .with_child(Label::new(prompt.user_code.clone(), auth_text.clone()).boxed())
+        let instruction_text = style.auth.instruction_text;
+        let user_code_text = style.auth.user_code;
+        let button = style.auth.button;
+        let button_width = style.auth.button_width;
+        let height = style.auth.popup_dimensions.height;
+
+        let user_code = self.prompt.user_code.replace("-", " - ");
+
+        Flex::column()
             .with_child(
-                MouseEventHandler::<Self>::new(1, cx, move |_state, _cx| {
-                    Label::new("Click here to open GitHub!", auth_text.clone()).boxed()
+                MouseEventHandler::<Self>::new(0, cx, |state, _cx| {
+                    let style = style.auth.close_icon.style_for(state, false);
+                    theme::ui::icon(style).boxed()
                 })
-                .on_click(gpui::MouseButton::Left, move |_click, cx| {
-                    cx.platform().open_url(&prompt.verification_uri)
+                .on_click(gpui::MouseButton::Left, move |_, cx| {
+                    let window_id = cx.window_id();
+                    cx.remove_window(window_id);
                 })
                 .with_cursor_style(gpui::CursorStyle::PointingHand)
+                .aligned()
+                .right()
                 .boxed(),
             )
+            .with_child(
+                Flex::column()
+                    .align_children_center()
+                    .with_children([
+                        theme::ui::svg(&style.auth.copilot_icon).boxed(),
+                        Label::new(
+                            "Here is your code to authenticate with github",
+                            instruction_text.clone(),
+                        )
+                        .boxed(),
+                        Label::new(user_code, user_code_text.clone()).boxed(),
+                        theme::ui::cta_button_with_click("Copy Code", button_width, &button, cx, {
+                            let user_code = self.prompt.user_code.clone();
+                            move |_, cx| {
+                                cx.platform()
+                                    .write_to_clipboard(ClipboardItem::new(user_code.clone()))
+                            }
+                        }),
+                        Label::new("Copy it and enter it on GitHub", instruction_text.clone())
+                            .boxed(),
+                        theme::ui::cta_button_with_click(
+                            "Go to Github",
+                            button_width,
+                            &button,
+                            cx,
+                            {
+                                let verification_uri = self.prompt.verification_uri.clone();
+                                move |_, cx| cx.platform().open_url(&verification_uri)
+                            },
+                        ),
+                    ])
+                    .aligned()
+                    .boxed(),
+            )
             .contained()
-            .with_style(style.auth_modal)
-            .named("Copilot Authentication status modal")
+            .with_style(style.auth.popup_container)
+            .constrained()
+            .with_height(height)
+            .boxed()
     }
 }
 

crates/theme/src/theme.rs 🔗

@@ -9,7 +9,7 @@ use gpui::{
 use serde::{de::DeserializeOwned, Deserialize};
 use serde_json::Value;
 use std::{collections::HashMap, sync::Arc};
-use ui::{CheckboxStyle, IconStyle};
+use ui::{ButtonStyle, CheckboxStyle, Dimensions, IconStyle, SvgStyle};
 
 pub mod ui;
 
@@ -76,8 +76,8 @@ pub struct Workspace {
 
 #[derive(Clone, Deserialize, Default)]
 pub struct BlankPaneStyle {
-    pub logo: IconStyle,
-    pub logo_shadow: IconStyle,
+    pub logo: SvgStyle,
+    pub logo_shadow: SvgStyle,
     pub logo_container: ContainerStyle,
     pub keyboard_hints: ContainerStyle,
     pub keyboard_hint: Interactive<ContainedText>,
@@ -118,8 +118,19 @@ pub struct AvatarStyle {
 
 #[derive(Deserialize, Default, Clone)]
 pub struct Copilot {
-    pub auth_modal: ContainerStyle,
-    pub auth_text: TextStyle,
+    pub auth: CopilotAuth,
+}
+
+#[derive(Deserialize, Default, Clone)]
+pub struct CopilotAuth {
+    pub popup_container: ContainerStyle,
+    pub popup_dimensions: Dimensions,
+    pub instruction_text: TextStyle,
+    pub user_code: TextStyle,
+    pub button: ButtonStyle,
+    pub button_width: f32,
+    pub copilot_icon: SvgStyle,
+    pub close_icon: Interactive<IconStyle>,
 }
 
 #[derive(Deserialize, Default)]
@@ -876,7 +887,7 @@ pub struct FeedbackStyle {
 #[derive(Clone, Deserialize, Default)]
 pub struct WelcomeStyle {
     pub page_width: f32,
-    pub logo: IconStyle,
+    pub logo: SvgStyle,
     pub logo_subheading: ContainedText,
     pub usage_note: ContainedText,
     pub checkbox: CheckboxStyle,

crates/theme/src/ui.rs 🔗

@@ -1,18 +1,22 @@
+use std::borrow::Cow;
+
 use gpui::{
     color::Color,
     elements::{
         ConstrainedBox, Container, ContainerStyle, Empty, Flex, KeystrokeLabel, Label,
         MouseEventHandler, ParentElement, Svg,
     },
-    Action, Element, ElementBox, EventContext, RenderContext, View,
+    geometry::vector::{vec2f, Vector2F},
+    scene::MouseClick,
+    Action, Element, ElementBox, EventContext, MouseButton, MouseState, RenderContext, View,
 };
 use serde::Deserialize;
 
-use crate::ContainedText;
+use crate::{ContainedText, Interactive};
 
 #[derive(Clone, Deserialize, Default)]
 pub struct CheckboxStyle {
-    pub icon: IconStyle,
+    pub icon: SvgStyle,
     pub label: ContainedText,
     pub default: ContainerStyle,
     pub checked: ContainerStyle,
@@ -44,7 +48,7 @@ pub fn checkbox_with_label<T: 'static, V: View>(
 ) -> MouseEventHandler<T> {
     MouseEventHandler::<T>::new(0, cx, |state, _| {
         let indicator = if checked {
-            icon(&style.icon)
+            svg(&style.icon)
         } else {
             Empty::new()
                 .constrained()
@@ -80,9 +84,9 @@ pub fn checkbox_with_label<T: 'static, V: View>(
 }
 
 #[derive(Clone, Deserialize, Default)]
-pub struct IconStyle {
+pub struct SvgStyle {
     pub color: Color,
-    pub icon: String,
+    pub asset: String,
     pub dimensions: Dimensions,
 }
 
@@ -92,14 +96,30 @@ pub struct Dimensions {
     pub height: f32,
 }
 
-pub fn icon(style: &IconStyle) -> ConstrainedBox {
-    Svg::new(style.icon.clone())
+impl Dimensions {
+    pub fn to_vec(&self) -> Vector2F {
+        vec2f(self.width, self.height)
+    }
+}
+
+pub fn svg(style: &SvgStyle) -> ConstrainedBox {
+    Svg::new(style.asset.clone())
         .with_color(style.color)
         .constrained()
         .with_width(style.dimensions.width)
         .with_height(style.dimensions.height)
 }
 
+#[derive(Clone, Deserialize, Default)]
+pub struct IconStyle {
+    icon: SvgStyle,
+    container: ContainerStyle,
+}
+
+pub fn icon(style: &IconStyle) -> Container {
+    svg(&style.icon).contained().with_style(style.container)
+}
+
 pub fn keystroke_label<V: View>(
     label_text: &'static str,
     label_style: &ContainedText,
@@ -147,3 +167,49 @@ pub fn keystroke_label_for(
         .contained()
         .with_style(label_style.container)
 }
+
+pub type ButtonStyle = Interactive<ContainedText>;
+
+pub fn cta_button<L, A, V>(
+    label: L,
+    action: A,
+    max_width: f32,
+    style: &ButtonStyle,
+    cx: &mut RenderContext<V>,
+) -> ElementBox
+where
+    L: Into<Cow<'static, str>>,
+    A: 'static + Action + Clone,
+    V: View,
+{
+    cta_button_with_click(label, max_width, style, cx, move |_, cx| {
+        cx.dispatch_action(action.clone())
+    })
+}
+
+pub fn cta_button_with_click<L, V, F>(
+    label: L,
+    max_width: f32,
+    style: &ButtonStyle,
+    cx: &mut RenderContext<V>,
+    f: F,
+) -> ElementBox
+where
+    L: Into<Cow<'static, str>>,
+    V: View,
+    F: Fn(MouseClick, &mut EventContext) + 'static,
+{
+    MouseEventHandler::<F>::new(0, cx, |state, _| {
+        let style = style.style_for(state, false);
+        Label::new(label, style.text.to_owned())
+            .aligned()
+            .contained()
+            .with_style(style.container)
+            .constrained()
+            .with_max_width(max_width)
+            .boxed()
+    })
+    .on_click(MouseButton::Left, f)
+    .with_cursor_style(gpui::CursorStyle::PointingHand)
+    .boxed()
+}

crates/welcome/src/welcome.rs 🔗

@@ -1,12 +1,11 @@
 mod base_keymap_picker;
 
-use std::{borrow::Cow, sync::Arc};
+use std::sync::Arc;
 
 use db::kvp::KEY_VALUE_STORE;
 use gpui::{
-    elements::{Flex, Label, MouseEventHandler, ParentElement},
-    Action, Element, ElementBox, Entity, MouseButton, MutableAppContext, RenderContext,
-    Subscription, View, ViewContext,
+    elements::{Flex, Label, ParentElement},
+    Element, ElementBox, Entity, MutableAppContext, Subscription, View, ViewContext,
 };
 use settings::{settings_file::SettingsFile, Settings};
 
@@ -77,7 +76,7 @@ impl View for WelcomePage {
                 .with_children([
                     Flex::column()
                         .with_children([
-                            theme::ui::icon(&theme.welcome.logo)
+                            theme::ui::svg(&theme.welcome.logo)
                                 .aligned()
                                 .contained()
                                 .aligned()
@@ -98,22 +97,25 @@ impl View for WelcomePage {
                         .boxed(),
                     Flex::column()
                         .with_children([
-                            self.render_cta_button(
+                            theme::ui::cta_button(
                                 "Choose a theme",
                                 theme_selector::Toggle,
                                 width,
+                                &theme.welcome.button,
                                 cx,
                             ),
-                            self.render_cta_button(
+                            theme::ui::cta_button(
                                 "Choose a keymap",
                                 ToggleBaseKeymapSelector,
                                 width,
+                                &theme.welcome.button,
                                 cx,
                             ),
-                            self.render_cta_button(
+                            theme::ui::cta_button(
                                 "Install the CLI",
                                 install_cli::Install,
                                 width,
+                                &theme.welcome.button,
                                 cx,
                             ),
                         ])
@@ -201,89 +203,6 @@ impl WelcomePage {
             _settings_subscription: settings_subscription,
         }
     }
-
-    fn render_cta_button<L, A>(
-        &self,
-        label: L,
-        action: A,
-        width: f32,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox
-    where
-        L: Into<Cow<'static, str>>,
-        A: 'static + Action + Clone,
-    {
-        let theme = cx.global::<Settings>().theme.clone();
-        MouseEventHandler::<A>::new(0, cx, |state, _| {
-            let style = theme.welcome.button.style_for(state, false);
-            Label::new(label, style.text.clone())
-                .aligned()
-                .contained()
-                .with_style(style.container)
-                .constrained()
-                .with_max_width(width)
-                .boxed()
-        })
-        .on_click(MouseButton::Left, move |_, cx| {
-            cx.dispatch_action(action.clone())
-        })
-        .with_cursor_style(gpui::CursorStyle::PointingHand)
-        .boxed()
-    }
-
-    // fn render_settings_checkbox<T: 'static>(
-    //     &self,
-    //     label: &'static str,
-    //     style: &CheckboxStyle,
-    //     checked: bool,
-    //     cx: &mut RenderContext<Self>,
-    //     set_value: fn(&mut SettingsFileContent, checked: bool) -> (),
-    // ) -> ElementBox {
-    //     MouseEventHandler::<T>::new(0, cx, |state, _| {
-    //         let indicator = if checked {
-    //             Svg::new(style.check_icon.clone())
-    //                 .with_color(style.check_icon_color)
-    //                 .constrained()
-    //         } else {
-    //             Empty::new().constrained()
-    //         };
-
-    //         Flex::row()
-    //             .with_children([
-    //                 indicator
-    //                     .with_width(style.width)
-    //                     .with_height(style.height)
-    //                     .contained()
-    //                     .with_style(if checked {
-    //                         if state.hovered() {
-    //                             style.hovered_and_checked
-    //                         } else {
-    //                             style.checked
-    //                         }
-    //                     } else {
-    //                         if state.hovered() {
-    //                             style.hovered
-    //                         } else {
-    //                             style.default
-    //                         }
-    //                     })
-    //                     .boxed(),
-    //                 Label::new(label, style.label.text.clone())
-    //                     .contained()
-    //                     .with_style(style.label.container)
-    //                     .boxed(),
-    //             ])
-    //             .align_children_center()
-    //             .boxed()
-    //     })
-    //     .on_click(gpui::MouseButton::Left, move |_, cx| {
-    //         SettingsFile::update(cx, move |content| set_value(content, !checked))
-    //     })
-    //     .with_cursor_style(gpui::CursorStyle::PointingHand)
-    //     .contained()
-    //     .with_style(style.container)
-    //     .boxed()
-    // }
 }
 
 impl Item for WelcomePage {

styles/src/styleTree/components.ts 🔗

@@ -280,3 +280,15 @@ export function border(
         ...properties,
     }
 }
+
+
+export function svg(color: string, asset: String, width: Number, height: Number) {
+    return {
+        color,
+        asset,
+        dimensions: {
+            width,
+            height,
+        }
+    }
+}

styles/src/styleTree/copilot.ts 🔗

@@ -1,21 +1,59 @@
 import { ColorScheme } from "../themes/common/colorScheme"
-import { background, border, text } from "./components";
+import { background, border, foreground, svg, text } from "./components";
 
 
 export default function copilot(colorScheme: ColorScheme) {
     let layer = colorScheme.highest;
 
-
     return {
-        authModal: {
-            background: background(colorScheme.lowest),
-            border: border(colorScheme.lowest),
-            shadow: colorScheme.modalShadow,
-            cornerRadius: 12,
-            padding: {
-                bottom: 4,
+        auth: {
+            popupContainer: {
+                background: background(colorScheme.highest),
+            },
+            popupDimensions: {
+                width: 336,
+                height: 256,
+            },
+            instructionText: text(layer, "sans"),
+            userCode:
+                text(layer, "sans", { size: "lg" }),
+            button: { // Copied from welcome screen. FIXME: Move this into a ZDS component
+                background: background(layer),
+                border: border(layer, "active"),
+                cornerRadius: 4,
+                margin: {
+                    top: 4,
+                    bottom: 4,
+                },
+                padding: {
+                    top: 3,
+                    bottom: 3,
+                    left: 7,
+                    right: 7,
+                },
+                ...text(layer, "sans", "default", { size: "sm" }),
+                hover: {
+                    ...text(layer, "sans", "default", { size: "sm" }),
+                    background: background(layer, "hovered"),
+                    border: border(layer, "active"),
+                },
+            },
+            buttonWidth: 320,
+            copilotIcon: svg(foreground(layer, "default"), "icons/github-copilot-dummy.svg", 64, 64),
+            closeIcon: {
+                icon: svg(background(layer, "on"), "icons/x_mark_16.svg", 16, 16),
+                container: {
+                    padding: {
+                        top: 3,
+                        bottom: 3,
+                        left: 7,
+                        right: 7,
+                    }
+                },
+                hover: {
+                    icon: svg(foreground(layer, "on"), "icons/x_mark_16.svg", 16, 16),
+                }
             },
-        },
-        authText: text(layer, "sans")
+        }
     }
 }

styles/src/styleTree/welcome.ts 🔗

@@ -6,6 +6,7 @@ import {
     foreground,
     text,
     TextProperties,
+    svg,
 } from "./components"
 
 export default function welcome(colorScheme: ColorScheme) {
@@ -32,14 +33,7 @@ export default function welcome(colorScheme: ColorScheme) {
 
     return {
         pageWidth: 320,
-        logo: {
-            color: foreground(layer, "default"),
-            icon: "icons/logo_96.svg",
-            dimensions: {
-                width: 64,
-                height: 64,
-            },
-        },
+        logo: svg(foreground(layer, "default"), "icons/logo_96.svg", 64, 64),
         logoSubheading: {
             ...text(layer, "sans", "variant", { size: "md" }),
             margin: {
@@ -109,14 +103,7 @@ export default function welcome(colorScheme: ColorScheme) {
                 ...text(layer, "sans", interactive_text_size),
                 // Also supports margin, container, border, etc.
             },
-            icon: {
-                color: foreground(layer, "on"),
-                icon: "icons/check_12.svg",
-                dimensions: {
-                    width: 12,
-                    height: 12,
-                },
-            },
+            icon: svg(foreground(layer, "on"), "icons/check_12.svg", 12, 12),
             default: {
                 ...checkboxBase,
                 background: background(layer, "default"),

styles/src/styleTree/workspace.ts 🔗

@@ -1,6 +1,6 @@
 import { ColorScheme } from "../themes/common/colorScheme"
 import { withOpacity } from "../utils/color"
-import { background, border, borderColor, foreground, text } from "./components"
+import { background, border, borderColor, foreground, svg, text } from "./components"
 import statusBar from "./statusBar"
 import tabBar from "./tabBar"
 
@@ -46,27 +46,14 @@ export default function workspace(colorScheme: ColorScheme) {
                 width: 256,
                 height: 256,
             },
-            logo: {
-                color: withOpacity("#000000", colorScheme.isLight ? 0.6 : 0.8),
-                icon: "icons/logo_96.svg",
-                dimensions: {
-                    width: 256,
-                    height: 256,
-                },
-            },
-            logoShadow: {
-                color: withOpacity(
-                    colorScheme.isLight
-                        ? "#FFFFFF"
-                        : colorScheme.lowest.base.default.background,
-                    colorScheme.isLight ? 1 : 0.6
-                ),
-                icon: "icons/logo_96.svg",
-                dimensions: {
-                    width: 256,
-                    height: 256,
-                },
-            },
+            logo: svg(withOpacity("#000000", colorScheme.isLight ? 0.6 : 0.8), "icons/logo_96.svg", 256, 256),
+
+            logoShadow: svg(withOpacity(
+                colorScheme.isLight
+                    ? "#FFFFFF"
+                    : colorScheme.lowest.base.default.background,
+                colorScheme.isLight ? 1 : 0.6
+            ), "icons/logo_96.svg", 256, 256),
             keyboardHints: {
                 margin: {
                     top: 96,