Add basic copilot modal

Mikayla Maki created

Change summary

crates/copilot/src/auth_modal.rs |  72 +++++++++++++++++++++-
crates/copilot/src/copilot.rs    | 106 +++++++++++++++++++++++----------
crates/theme/src/theme.rs        |   5 
styles/src/styleTree/copilot.ts  |  14 +++
4 files changed, 157 insertions(+), 40 deletions(-)

Detailed changes

crates/copilot/src/auth_modal.rs 🔗

@@ -1,10 +1,19 @@
-use gpui::{elements::Label, Element, Entity, View};
+use gpui::{
+    elements::{Flex, Label, MouseEventHandler, ParentElement, Stack},
+    Axis, Element, Entity, View, ViewContext,
+};
 use settings::Settings;
 
+use crate::{Copilot, PromptingUser};
+
+pub enum Event {
+    Dismiss,
+}
+
 pub struct AuthModal {}
 
 impl Entity for AuthModal {
-    type Event = ();
+    type Event = Event;
 }
 
 impl View for AuthModal {
@@ -13,8 +22,63 @@ impl View for AuthModal {
     }
 
     fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
-        let style = &cx.global::<Settings>().theme.copilot;
+        let style = cx.global::<Settings>().theme.copilot.clone();
+
+        let user_code_and_url = Copilot::global(cx).read(cx).prompting_user().cloned();
+        let auth_text = style.auth_text.clone();
+        MouseEventHandler::<AuthModal>::new(0, cx, move |_state, cx| {
+            Stack::new()
+                .with_child(match user_code_and_url {
+                    Some(PromptingUser {
+                        user_code,
+                        verification_uri,
+                    }) => Flex::new(Axis::Vertical)
+                        .with_children([
+                            Label::new(user_code, auth_text.clone())
+                                .constrained()
+                                .with_width(540.)
+                                .boxed(),
+                            MouseEventHandler::<AuthModal>::new(1, cx, move |_state, _cx| {
+                                Label::new("Click here to open github!", auth_text.clone())
+                                    .constrained()
+                                    .with_width(540.)
+                                    .boxed()
+                            })
+                            .on_click(gpui::MouseButton::Left, move |_click, cx| {
+                                cx.platform().open_url(&verification_uri)
+                            })
+                            .with_cursor_style(gpui::CursorStyle::PointingHand)
+                            .boxed(),
+                        ])
+                        .boxed(),
+                    None => Label::new("Not signing in", style.auth_text.clone())
+                        .constrained()
+                        .with_width(540.)
+                        .boxed(),
+                })
+                .contained()
+                .with_style(style.auth_modal)
+                .constrained()
+                .with_max_width(540.)
+                .with_max_height(420.)
+                .named("Copilot Authentication status modal")
+        })
+        .on_hover(|_, _| {})
+        .on_click(gpui::MouseButton::Left, |_, _| {})
+        .on_click(gpui::MouseButton::Left, |_, _| {})
+        .boxed()
+    }
+
+    fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        cx.emit(Event::Dismiss)
+    }
+}
+
+impl AuthModal {
+    pub fn new(cx: &mut ViewContext<Self>) -> Self {
+        cx.observe(&Copilot::global(cx), |_, _, cx| cx.notify())
+            .detach();
 
-        Label::new("[COPILOT AUTH INFO]", style.auth_modal.clone()).boxed()
+        AuthModal {}
     }
 }

crates/copilot/src/copilot.rs 🔗

@@ -26,28 +26,42 @@ pub fn init(client: Arc<Client>, cx: &mut MutableAppContext) {
     let copilot = cx.add_model(|cx| Copilot::start(client.http_client(), cx));
     cx.set_global(copilot.clone());
     cx.add_action(|workspace: &mut Workspace, _: &SignIn, cx| {
-        if let Some(copilot) = Copilot::global(cx) {
-            if copilot.read(cx).status() == Status::Authorized {
-                return;
-            }
+        let copilot = Copilot::global(cx);
+        if copilot.read(cx).status() == Status::Authorized {
+            return;
+        }
 
-            copilot
-                .update(cx, |copilot, cx| copilot.sign_in(cx))
-                .detach_and_log_err(cx);
+        copilot
+            .update(cx, |copilot, cx| copilot.sign_in(cx))
+            .detach_and_log_err(cx);
 
-            workspace.toggle_modal(cx, |_workspace, cx| cx.add_view(|_cx| AuthModal {}));
-        }
+        workspace.toggle_modal(cx, |_workspace, cx| build_auth_modal(cx));
     });
-    cx.add_action(|_: &mut Workspace, _: &SignOut, cx| {
-        if let Some(copilot) = Copilot::global(cx) {
-            copilot
-                .update(cx, |copilot, cx| copilot.sign_out(cx))
-                .detach_and_log_err(cx);
+    cx.add_action(|workspace: &mut Workspace, _: &SignOut, cx| {
+        let copilot = Copilot::global(cx);
+
+        copilot
+            .update(cx, |copilot, cx| copilot.sign_out(cx))
+            .detach_and_log_err(cx);
+
+        if workspace.modal::<AuthModal>().is_some() {
+            workspace.dismiss_modal(cx)
         }
     });
     cx.add_action(|workspace: &mut Workspace, _: &ToggleAuthStatus, cx| {
-        workspace.toggle_modal(cx, |_workspace, cx| cx.add_view(|_cx| AuthModal {}))
+        workspace.toggle_modal(cx, |_workspace, cx| build_auth_modal(cx))
+    })
+}
+
+fn build_auth_modal(cx: &mut gpui::ViewContext<Workspace>) -> gpui::ViewHandle<AuthModal> {
+    let modal = cx.add_view(|cx| AuthModal::new(cx));
+
+    cx.subscribe(&modal, |workspace, _, e: &auth_modal::Event, cx| match e {
+        auth_modal::Event::Dismiss => workspace.dismiss_modal(cx),
     })
+    .detach();
+
+    modal
 }
 
 enum CopilotServer {
@@ -59,10 +73,17 @@ enum CopilotServer {
     },
 }
 
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct PromptingUser {
+    user_code: String,
+    verification_uri: String,
+}
+
 #[derive(Clone, Debug, PartialEq, Eq)]
 enum SignInStatus {
     Authorized { user: String },
     Unauthorized { user: String },
+    PromptingUser(PromptingUser),
     SignedOut,
 }
 
@@ -104,20 +125,12 @@ impl Entity for Copilot {
 }
 
 impl Copilot {
-    fn global(cx: &AppContext) -> Option<ModelHandle<Self>> {
-        if cx.has_global::<ModelHandle<Self>>() {
-            let copilot = cx.global::<ModelHandle<Self>>().clone();
-            if copilot.read(cx).status().is_authorized() {
-                Some(copilot)
-            } else {
-                None
-            }
-        } else {
-            None
-        }
+    fn global(cx: &AppContext) -> ModelHandle<Self> {
+        cx.global::<ModelHandle<Self>>().clone()
     }
 
     fn start(http: Arc<dyn HttpClient>, cx: &mut ModelContext<Self>) -> Self {
+        // TODO: Don't eagerly download the LSP
         cx.spawn(|this, mut cx| async move {
             let start_language_server = async {
                 let server_path = get_lsp_binary(http).await?;
@@ -164,17 +177,20 @@ impl Copilot {
                     .request::<request::SignInInitiate>(request::SignInInitiateParams {})
                     .await?;
                 if let request::SignInInitiateResult::PromptUserDeviceFlow(flow) = sign_in {
-                    this.update(&mut cx, |_, cx| {
-                        cx.emit(Event::PromptUserDeviceFlow {
-                            user_code: flow.user_code.clone(),
-                            verification_uri: flow.verification_uri,
-                        });
+                    this.update(&mut cx, |this, cx| {
+                        this.update_prompting_user(
+                            flow.user_code.clone(),
+                            flow.verification_uri,
+                            cx,
+                        );
                     });
+                    // TODO: catch an error here and clear the corresponding user code
                     let response = server
                         .request::<request::SignInConfirm>(request::SignInConfirmParams {
                             user_code: flow.user_code,
                         })
                         .await?;
+
                     this.update(&mut cx, |this, cx| this.update_sign_in_status(response, cx));
                 }
                 anyhow::Ok(())
@@ -268,12 +284,38 @@ impl Copilot {
             CopilotServer::Error(error) => Status::Error(error.clone()),
             CopilotServer::Started { status, .. } => match status {
                 SignInStatus::Authorized { .. } => Status::Authorized,
-                SignInStatus::Unauthorized { .. } => Status::Unauthorized,
+                SignInStatus::Unauthorized { .. } | SignInStatus::PromptingUser { .. } => {
+                    Status::Unauthorized
+                }
                 SignInStatus::SignedOut => Status::SignedOut,
             },
         }
     }
 
+    pub fn prompting_user(&self) -> Option<&PromptingUser> {
+        if let CopilotServer::Started { status, .. } = &self.server {
+            if let SignInStatus::PromptingUser(prompt) = status {
+                return Some(prompt);
+            }
+        }
+        None
+    }
+
+    fn update_prompting_user(
+        &mut self,
+        user_code: String,
+        verification_uri: String,
+        cx: &mut ModelContext<Self>,
+    ) {
+        if let CopilotServer::Started { status, .. } = &mut self.server {
+            *status = SignInStatus::PromptingUser(PromptingUser {
+                user_code,
+                verification_uri,
+            });
+            cx.notify();
+        }
+    }
+
     fn update_sign_in_status(
         &mut self,
         lsp_status: request::SignInStatus,

crates/theme/src/theme.rs 🔗

@@ -116,9 +116,10 @@ pub struct AvatarStyle {
     pub outer_corner_radius: f32,
 }
 
-#[derive(Deserialize, Default)]
+#[derive(Deserialize, Default, Clone)]
 pub struct Copilot {
-    pub auth_modal: TextStyle,
+    pub auth_modal: ContainerStyle,
+    pub auth_text: TextStyle,
 }
 
 #[derive(Deserialize, Default)]

styles/src/styleTree/copilot.ts 🔗

@@ -1,11 +1,21 @@
 import { ColorScheme } from "../themes/common/colorScheme"
-import { text } from "./components";
+import { background, border, text } from "./components";
 
 
 export default function copilot(colorScheme: ColorScheme) {
     let layer = colorScheme.highest;
 
+
     return {
-        authModal: text(layer, "sans")
+        authModal: {
+            background: background(colorScheme.lowest),
+            border: border(colorScheme.lowest),
+            shadow: colorScheme.modalShadow,
+            cornerRadius: 12,
+            padding: {
+                bottom: 4,
+            },
+        },
+        authText: text(layer, "sans")
     }
 }