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}