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}