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