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}