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