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