sign_in.rs

  1use crate::{Copilot, Status, request::PromptUserDeviceFlow};
  2use anyhow::Context as _;
  3use gpui::{
  4    App, ClipboardItem, Context, DismissEvent, Element, Entity, EventEmitter, FocusHandle,
  5    Focusable, InteractiveElement, IntoElement, MouseDownEvent, ParentElement, Render, Styled,
  6    Subscription, Window, WindowBounds, WindowOptions, div, point,
  7};
  8use ui::{ButtonLike, CommonAnimationExt, ConfiguredApiCard, Vector, VectorName, prelude::*};
  9use util::ResultExt as _;
 10use workspace::{Toast, Workspace, notifications::NotificationId};
 11
 12const COPILOT_SIGN_UP_URL: &str = "https://github.com/features/copilot";
 13const ERROR_LABEL: &str =
 14    "Copilot had issues starting. You can try reinstalling it and signing in again.";
 15
 16struct CopilotStatusToast;
 17
 18pub fn initiate_sign_in(window: &mut Window, cx: &mut App) {
 19    let is_reinstall = false;
 20    initiate_sign_in_impl(is_reinstall, window, cx)
 21}
 22
 23pub fn initiate_sign_out(window: &mut Window, cx: &mut App) {
 24    let Some(copilot) = Copilot::global(cx) else {
 25        return;
 26    };
 27
 28    copilot_toast(Some("Signing out of Copilot…"), window, cx);
 29
 30    let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx));
 31    window
 32        .spawn(cx, async move |cx| match sign_out_task.await {
 33            Ok(()) => {
 34                cx.update(|window, cx| copilot_toast(Some("Signed out of Copilot"), window, cx))
 35            }
 36            Err(err) => cx.update(|window, cx| {
 37                if let Some(workspace) = window.root::<Workspace>().flatten() {
 38                    workspace.update(cx, |workspace, cx| {
 39                        workspace.show_error(&err, cx);
 40                    })
 41                } else {
 42                    log::error!("{:?}", err);
 43                }
 44            }),
 45        })
 46        .detach();
 47}
 48
 49pub fn reinstall_and_sign_in(window: &mut Window, cx: &mut App) {
 50    let Some(copilot) = Copilot::global(cx) else {
 51        return;
 52    };
 53    let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx));
 54    let is_reinstall = true;
 55    initiate_sign_in_impl(is_reinstall, window, cx);
 56}
 57
 58fn open_copilot_code_verification_window(copilot: &Entity<Copilot>, window: &Window, cx: &mut App) {
 59    let current_window_center = window.bounds().center();
 60    let height = px(450.);
 61    let width = px(350.);
 62    let window_bounds = WindowBounds::Windowed(gpui::bounds(
 63        current_window_center - point(height / 2.0, width / 2.0),
 64        gpui::size(height, width),
 65    ));
 66    cx.open_window(
 67        WindowOptions {
 68            kind: gpui::WindowKind::PopUp,
 69            window_bounds: Some(window_bounds),
 70            is_resizable: false,
 71            is_movable: true,
 72            titlebar: Some(gpui::TitlebarOptions {
 73                appears_transparent: true,
 74                ..Default::default()
 75            }),
 76            ..Default::default()
 77        },
 78        |window, cx| cx.new(|cx| CopilotCodeVerification::new(&copilot, window, cx)),
 79    )
 80    .context("Failed to open Copilot code verification window")
 81    .log_err();
 82}
 83
 84fn copilot_toast(message: Option<&'static str>, window: &Window, cx: &mut App) {
 85    const NOTIFICATION_ID: NotificationId = NotificationId::unique::<CopilotStatusToast>();
 86
 87    let Some(workspace) = window.root::<Workspace>().flatten() else {
 88        return;
 89    };
 90
 91    workspace.update(cx, |workspace, cx| match message {
 92        Some(message) => workspace.show_toast(Toast::new(NOTIFICATION_ID, message), cx),
 93        None => workspace.dismiss_toast(&NOTIFICATION_ID, cx),
 94    });
 95}
 96
 97pub fn initiate_sign_in_impl(is_reinstall: bool, window: &mut Window, cx: &mut App) {
 98    let Some(copilot) = Copilot::global(cx) else {
 99        return;
100    };
101    if matches!(copilot.read(cx).status(), Status::Disabled) {
102        copilot.update(cx, |copilot, cx| copilot.start_copilot(false, true, cx));
103    }
104    match copilot.read(cx).status() {
105        Status::Starting { task } => {
106            copilot_toast(
107                Some(if is_reinstall {
108                    "Copilot is reinstalling…"
109                } else {
110                    "Copilot is starting…"
111                }),
112                window,
113                cx,
114            );
115
116            window
117                .spawn(cx, async move |cx| {
118                    task.await;
119                    cx.update(|window, cx| {
120                        let Some(copilot) = Copilot::global(cx) else {
121                            return;
122                        };
123                        match copilot.read(cx).status() {
124                            Status::Authorized => {
125                                copilot_toast(Some("Copilot has started."), window, cx)
126                            }
127                            _ => {
128                                copilot_toast(None, window, cx);
129                                copilot
130                                    .update(cx, |copilot, cx| copilot.sign_in(cx))
131                                    .detach_and_log_err(cx);
132                                open_copilot_code_verification_window(&copilot, window, cx);
133                            }
134                        }
135                    })
136                    .log_err();
137                })
138                .detach();
139        }
140        _ => {
141            copilot
142                .update(cx, |copilot, cx| copilot.sign_in(cx))
143                .detach();
144            open_copilot_code_verification_window(&copilot, window, cx);
145        }
146    }
147}
148
149pub struct CopilotCodeVerification {
150    status: Status,
151    connect_clicked: bool,
152    focus_handle: FocusHandle,
153    copilot: Entity<Copilot>,
154    _subscription: Subscription,
155}
156
157impl Focusable for CopilotCodeVerification {
158    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
159        self.focus_handle.clone()
160    }
161}
162
163impl EventEmitter<DismissEvent> for CopilotCodeVerification {}
164
165impl CopilotCodeVerification {
166    pub fn new(copilot: &Entity<Copilot>, window: &mut Window, cx: &mut Context<Self>) -> Self {
167        window.on_window_should_close(cx, |window, cx| {
168            if let Some(this) = window.root::<CopilotCodeVerification>().flatten() {
169                this.update(cx, |this, cx| {
170                    this.before_dismiss(cx);
171                });
172            }
173            true
174        });
175        cx.subscribe_in(
176            &cx.entity(),
177            window,
178            |this, _, _: &DismissEvent, window, cx| {
179                window.remove_window();
180                this.before_dismiss(cx);
181            },
182        )
183        .detach();
184
185        let status = copilot.read(cx).status();
186        Self {
187            status,
188            connect_clicked: false,
189            focus_handle: cx.focus_handle(),
190            copilot: copilot.clone(),
191            _subscription: cx.observe(copilot, |this, copilot, cx| {
192                let status = copilot.read(cx).status();
193                match status {
194                    Status::Authorized | Status::Unauthorized | Status::SigningIn { .. } => {
195                        this.set_status(status, cx)
196                    }
197                    _ => cx.emit(DismissEvent),
198                }
199            }),
200        }
201    }
202
203    pub fn set_status(&mut self, status: Status, cx: &mut Context<Self>) {
204        self.status = status;
205        cx.notify();
206    }
207
208    fn render_device_code(data: &PromptUserDeviceFlow, cx: &mut Context<Self>) -> impl IntoElement {
209        let copied = cx
210            .read_from_clipboard()
211            .map(|item| item.text().as_ref() == Some(&data.user_code))
212            .unwrap_or(false);
213
214        ButtonLike::new("copy-button")
215            .full_width()
216            .style(ButtonStyle::Tinted(ui::TintColor::Accent))
217            .size(ButtonSize::Medium)
218            .child(
219                h_flex()
220                    .w_full()
221                    .p_1()
222                    .justify_between()
223                    .child(Label::new(data.user_code.clone()))
224                    .child(Label::new(if copied { "Copied!" } else { "Copy" })),
225            )
226            .on_click({
227                let user_code = data.user_code.clone();
228                move |_, window, cx| {
229                    cx.write_to_clipboard(ClipboardItem::new_string(user_code.clone()));
230                    window.refresh();
231                }
232            })
233    }
234
235    fn render_prompting_modal(
236        connect_clicked: bool,
237        data: &PromptUserDeviceFlow,
238        cx: &mut Context<Self>,
239    ) -> impl Element {
240        let connect_button_label = if connect_clicked {
241            "Waiting for connection…"
242        } else {
243            "Connect to GitHub"
244        };
245
246        v_flex()
247            .flex_1()
248            .gap_2p5()
249            .items_center()
250            .text_center()
251            .child(Headline::new("Use GitHub Copilot in Zed").size(HeadlineSize::Large))
252            .child(
253                Label::new("Using Copilot requires an active subscription on GitHub.")
254                    .color(Color::Muted),
255            )
256            .child(Self::render_device_code(data, cx))
257            .child(
258                Label::new("Paste this code into GitHub after clicking the button below.")
259                    .color(Color::Muted),
260            )
261            .child(
262                v_flex()
263                    .w_full()
264                    .gap_1()
265                    .child(
266                        Button::new("connect-button", connect_button_label)
267                            .full_width()
268                            .style(ButtonStyle::Outlined)
269                            .size(ButtonSize::Medium)
270                            .on_click({
271                                let verification_uri = data.verification_uri.clone();
272                                cx.listener(move |this, _, _window, cx| {
273                                    cx.open_url(&verification_uri);
274                                    this.connect_clicked = true;
275                                })
276                            }),
277                    )
278                    .child(
279                        Button::new("copilot-enable-cancel-button", "Cancel")
280                            .full_width()
281                            .size(ButtonSize::Medium)
282                            .on_click(cx.listener(|_, _, _, cx| {
283                                cx.emit(DismissEvent);
284                            })),
285                    ),
286            )
287    }
288
289    fn render_enabled_modal(cx: &mut Context<Self>) -> impl Element {
290        v_flex()
291            .gap_2()
292            .text_center()
293            .justify_center()
294            .child(Headline::new("Copilot Enabled!").size(HeadlineSize::Large))
295            .child(Label::new("You're all set to use GitHub Copilot.").color(Color::Muted))
296            .child(
297                Button::new("copilot-enabled-done-button", "Done")
298                    .full_width()
299                    .style(ButtonStyle::Outlined)
300                    .size(ButtonSize::Medium)
301                    .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
302            )
303    }
304
305    fn render_unauthorized_modal(cx: &mut Context<Self>) -> impl Element {
306        let description = "Enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.";
307
308        v_flex()
309            .gap_2()
310            .text_center()
311            .justify_center()
312            .child(
313                Headline::new("You must have an active GitHub Copilot subscription.")
314                    .size(HeadlineSize::Large),
315            )
316            .child(Label::new(description).color(Color::Warning))
317            .child(
318                Button::new("copilot-subscribe-button", "Subscribe on GitHub")
319                    .full_width()
320                    .style(ButtonStyle::Outlined)
321                    .size(ButtonSize::Medium)
322                    .on_click(|_, _, cx| cx.open_url(COPILOT_SIGN_UP_URL)),
323            )
324            .child(
325                Button::new("copilot-subscribe-cancel-button", "Cancel")
326                    .full_width()
327                    .size(ButtonSize::Medium)
328                    .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
329            )
330    }
331
332    fn render_error_modal(_cx: &mut Context<Self>) -> impl Element {
333        v_flex()
334            .gap_2()
335            .text_center()
336            .justify_center()
337            .child(Headline::new("An Error Happened").size(HeadlineSize::Large))
338            .child(Label::new(ERROR_LABEL).color(Color::Muted))
339            .child(
340                Button::new("copilot-subscribe-button", "Reinstall Copilot and Sign In")
341                    .full_width()
342                    .style(ButtonStyle::Outlined)
343                    .size(ButtonSize::Medium)
344                    .icon(IconName::Download)
345                    .icon_color(Color::Muted)
346                    .icon_position(IconPosition::Start)
347                    .icon_size(IconSize::Small)
348                    .on_click(|_, window, cx| reinstall_and_sign_in(window, cx)),
349            )
350    }
351
352    fn before_dismiss(
353        &mut self,
354        cx: &mut Context<'_, CopilotCodeVerification>,
355    ) -> workspace::DismissDecision {
356        self.copilot.update(cx, |copilot, cx| {
357            if matches!(copilot.status(), Status::SigningIn { .. }) {
358                copilot.sign_out(cx).detach_and_log_err(cx);
359            }
360        });
361        workspace::DismissDecision::Dismiss(true)
362    }
363}
364
365impl Render for CopilotCodeVerification {
366    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
367        let prompt = match &self.status {
368            Status::SigningIn { prompt: None } => Icon::new(IconName::ArrowCircle)
369                .color(Color::Muted)
370                .with_rotate_animation(2)
371                .into_any_element(),
372            Status::SigningIn {
373                prompt: Some(prompt),
374            } => Self::render_prompting_modal(self.connect_clicked, prompt, cx).into_any_element(),
375            Status::Unauthorized => {
376                self.connect_clicked = false;
377                Self::render_unauthorized_modal(cx).into_any_element()
378            }
379            Status::Authorized => {
380                self.connect_clicked = false;
381                Self::render_enabled_modal(cx).into_any_element()
382            }
383            Status::Error(..) => Self::render_error_modal(cx).into_any_element(),
384            _ => div().into_any_element(),
385        };
386
387        v_flex()
388            .id("copilot_code_verification")
389            .track_focus(&self.focus_handle(cx))
390            .size_full()
391            .px_4()
392            .py_8()
393            .gap_2()
394            .items_center()
395            .justify_center()
396            .elevation_3(cx)
397            .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
398                cx.emit(DismissEvent);
399            }))
400            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _| {
401                window.focus(&this.focus_handle);
402            }))
403            .child(
404                Vector::new(VectorName::ZedXCopilot, rems(8.), rems(4.))
405                    .color(Color::Custom(cx.theme().colors().icon)),
406            )
407            .child(prompt)
408    }
409}
410
411pub struct ConfigurationView {
412    copilot_status: Option<Status>,
413    is_authenticated: fn(cx: &App) -> bool,
414    edit_prediction: bool,
415    _subscription: Option<Subscription>,
416}
417
418pub enum ConfigurationMode {
419    Chat,
420    EditPrediction,
421}
422
423impl ConfigurationView {
424    pub fn new(
425        is_authenticated: fn(cx: &App) -> bool,
426        mode: ConfigurationMode,
427        cx: &mut Context<Self>,
428    ) -> Self {
429        let copilot = Copilot::global(cx);
430
431        Self {
432            copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()),
433            is_authenticated,
434            edit_prediction: matches!(mode, ConfigurationMode::EditPrediction),
435            _subscription: copilot.as_ref().map(|copilot| {
436                cx.observe(copilot, |this, model, cx| {
437                    this.copilot_status = Some(model.read(cx).status());
438                    cx.notify();
439                })
440            }),
441        }
442    }
443}
444
445impl ConfigurationView {
446    fn is_starting(&self) -> bool {
447        matches!(&self.copilot_status, Some(Status::Starting { .. }))
448    }
449
450    fn is_signing_in(&self) -> bool {
451        matches!(
452            &self.copilot_status,
453            Some(Status::SigningIn { .. })
454                | Some(Status::SignedOut {
455                    awaiting_signing_in: true
456                })
457        )
458    }
459
460    fn is_error(&self) -> bool {
461        matches!(&self.copilot_status, Some(Status::Error(_)))
462    }
463
464    fn has_no_status(&self) -> bool {
465        self.copilot_status.is_none()
466    }
467
468    fn loading_message(&self) -> Option<SharedString> {
469        if self.is_starting() {
470            Some("Starting Copilot…".into())
471        } else if self.is_signing_in() {
472            Some("Signing into Copilot…".into())
473        } else {
474            None
475        }
476    }
477
478    fn render_loading_button(
479        &self,
480        label: impl Into<SharedString>,
481        edit_prediction: bool,
482    ) -> impl IntoElement {
483        ButtonLike::new("loading_button")
484            .disabled(true)
485            .style(ButtonStyle::Outlined)
486            .when(edit_prediction, |this| this.size(ButtonSize::Medium))
487            .child(
488                h_flex()
489                    .w_full()
490                    .gap_1()
491                    .justify_center()
492                    .child(
493                        Icon::new(IconName::ArrowCircle)
494                            .size(IconSize::Small)
495                            .color(Color::Muted)
496                            .with_rotate_animation(4),
497                    )
498                    .child(Label::new(label)),
499            )
500    }
501
502    fn render_sign_in_button(&self, edit_prediction: bool) -> impl IntoElement {
503        let label = if edit_prediction {
504            "Sign in to GitHub"
505        } else {
506            "Sign in to use GitHub Copilot"
507        };
508
509        Button::new("sign_in", label)
510            .map(|this| {
511                if edit_prediction {
512                    this.size(ButtonSize::Medium)
513                } else {
514                    this.full_width()
515                }
516            })
517            .style(ButtonStyle::Outlined)
518            .icon(IconName::Github)
519            .icon_color(Color::Muted)
520            .icon_position(IconPosition::Start)
521            .icon_size(IconSize::Small)
522            .on_click(|_, window, cx| initiate_sign_in(window, cx))
523    }
524
525    fn render_reinstall_button(&self, edit_prediction: bool) -> impl IntoElement {
526        let label = if edit_prediction {
527            "Reinstall and Sign in"
528        } else {
529            "Reinstall Copilot and Sign in"
530        };
531
532        Button::new("reinstall_and_sign_in", label)
533            .map(|this| {
534                if edit_prediction {
535                    this.size(ButtonSize::Medium)
536                } else {
537                    this.full_width()
538                }
539            })
540            .style(ButtonStyle::Outlined)
541            .icon(IconName::Download)
542            .icon_color(Color::Muted)
543            .icon_position(IconPosition::Start)
544            .icon_size(IconSize::Small)
545            .on_click(|_, window, cx| reinstall_and_sign_in(window, cx))
546    }
547
548    fn render_for_edit_prediction(&self) -> impl IntoElement {
549        let container = |description: SharedString, action: AnyElement| {
550            h_flex()
551                .pt_2p5()
552                .w_full()
553                .justify_between()
554                .child(
555                    v_flex()
556                        .w_full()
557                        .max_w_1_2()
558                        .child(Label::new("Authenticate To Use"))
559                        .child(
560                            Label::new(description)
561                                .color(Color::Muted)
562                                .size(LabelSize::Small),
563                        ),
564                )
565                .child(action)
566        };
567
568        let start_label = "To use Copilot for edit predictions, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot subscription.".into();
569        let no_status_label = "Copilot requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different edit predictions provider.".into();
570
571        if let Some(msg) = self.loading_message() {
572            container(
573                start_label,
574                self.render_loading_button(msg, true).into_any_element(),
575            )
576            .into_any_element()
577        } else if self.is_error() {
578            container(
579                ERROR_LABEL.into(),
580                self.render_reinstall_button(true).into_any_element(),
581            )
582            .into_any_element()
583        } else if self.has_no_status() {
584            container(
585                no_status_label,
586                self.render_sign_in_button(true).into_any_element(),
587            )
588            .into_any_element()
589        } else {
590            container(
591                start_label,
592                self.render_sign_in_button(true).into_any_element(),
593            )
594            .into_any_element()
595        }
596    }
597
598    fn render_for_chat(&self) -> impl IntoElement {
599        let start_label = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription.";
600        let no_status_label = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different LLM provider.";
601
602        if let Some(msg) = self.loading_message() {
603            v_flex()
604                .gap_2()
605                .child(Label::new(start_label))
606                .child(self.render_loading_button(msg, false))
607                .into_any_element()
608        } else if self.is_error() {
609            v_flex()
610                .gap_2()
611                .child(Label::new(ERROR_LABEL))
612                .child(self.render_reinstall_button(false))
613                .into_any_element()
614        } else if self.has_no_status() {
615            v_flex()
616                .gap_2()
617                .child(Label::new(no_status_label))
618                .child(self.render_sign_in_button(false))
619                .into_any_element()
620        } else {
621            v_flex()
622                .gap_2()
623                .child(Label::new(start_label))
624                .child(self.render_sign_in_button(false))
625                .into_any_element()
626        }
627    }
628}
629
630impl Render for ConfigurationView {
631    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
632        let is_authenticated = self.is_authenticated;
633
634        if is_authenticated(cx) {
635            return ConfiguredApiCard::new("Authorized")
636                .button_label("Sign Out")
637                .on_click(|_, window, cx| {
638                    initiate_sign_out(window, cx);
639                })
640                .into_any_element();
641        }
642
643        if self.edit_prediction {
644            self.render_for_edit_prediction().into_any_element()
645        } else {
646            self.render_for_chat().into_any_element()
647        }
648    }
649}