sign_in.rs

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