1use anyhow::Context as _;
2use copilot::{
3 Copilot, GlobalCopilotAuth, Status,
4 request::{self, PromptUserDeviceFlow},
5};
6use gpui::{
7 App, ClipboardItem, Context, DismissEvent, Element, Entity, EventEmitter, FocusHandle,
8 Focusable, InteractiveElement, IntoElement, MouseDownEvent, ParentElement, Render, Styled,
9 Subscription, Window, WindowBounds, WindowOptions, div, point,
10};
11use ui::{ButtonLike, CommonAnimationExt, ConfiguredApiCard, Vector, VectorName, prelude::*};
12use util::ResultExt as _;
13use workspace::{Toast, Workspace, notifications::NotificationId};
14
15const COPILOT_SIGN_UP_URL: &str = "https://github.com/features/copilot";
16const ERROR_LABEL: &str =
17 "Copilot had issues starting. You can try reinstalling it and signing in again.";
18
19struct CopilotStatusToast;
20
21pub fn initiate_sign_in(copilot: Entity<Copilot>, window: &mut Window, cx: &mut App) {
22 let is_reinstall = false;
23 initiate_sign_in_impl(copilot, is_reinstall, window, cx)
24}
25
26pub fn initiate_sign_out(copilot: Entity<Copilot>, window: &mut Window, cx: &mut App) {
27 copilot_toast(Some("Signing out of Copilot…"), window, cx);
28
29 let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx));
30 window
31 .spawn(cx, async move |cx| match sign_out_task.await {
32 Ok(()) => {
33 cx.update(|window, cx| copilot_toast(Some("Signed out of Copilot"), window, cx))
34 }
35 Err(err) => cx.update(|window, cx| {
36 if let Some(workspace) = window.root::<Workspace>().flatten() {
37 workspace.update(cx, |workspace, cx| {
38 workspace.show_error(&err, cx);
39 })
40 } else {
41 log::error!("{:?}", err);
42 }
43 }),
44 })
45 .detach();
46}
47
48pub fn reinstall_and_sign_in(copilot: Entity<Copilot>, window: &mut Window, cx: &mut App) {
49 let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx));
50 let is_reinstall = true;
51 initiate_sign_in_impl(copilot, is_reinstall, window, cx);
52}
53
54fn open_copilot_code_verification_window(copilot: &Entity<Copilot>, window: &Window, cx: &mut App) {
55 let current_window_center = window.bounds().center();
56 let height = px(450.);
57 let width = px(350.);
58 let window_bounds = WindowBounds::Windowed(gpui::bounds(
59 current_window_center - point(height / 2.0, width / 2.0),
60 gpui::size(height, width),
61 ));
62 cx.open_window(
63 WindowOptions {
64 kind: gpui::WindowKind::PopUp,
65 window_bounds: Some(window_bounds),
66 is_resizable: false,
67 is_movable: true,
68 titlebar: Some(gpui::TitlebarOptions {
69 appears_transparent: true,
70 ..Default::default()
71 }),
72 ..Default::default()
73 },
74 |window, cx| cx.new(|cx| CopilotCodeVerification::new(&copilot, window, cx)),
75 )
76 .context("Failed to open Copilot code verification window")
77 .log_err();
78}
79
80fn copilot_toast(message: Option<&'static str>, window: &Window, cx: &mut App) {
81 const NOTIFICATION_ID: NotificationId = NotificationId::unique::<CopilotStatusToast>();
82
83 let Some(workspace) = window.root::<Workspace>().flatten() else {
84 return;
85 };
86
87 cx.defer(move |cx| {
88 workspace.update(cx, |workspace, cx| match message {
89 Some(message) => workspace.show_toast(Toast::new(NOTIFICATION_ID, message), cx),
90 None => workspace.dismiss_toast(&NOTIFICATION_ID, cx),
91 });
92 })
93}
94
95pub fn initiate_sign_in_impl(
96 copilot: Entity<Copilot>,
97 is_reinstall: bool,
98 window: &mut Window,
99 cx: &mut App,
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| match copilot.read(cx).status() {
120 Status::Authorized => {
121 copilot_toast(Some("Copilot has started."), window, cx)
122 }
123 _ => {
124 copilot_toast(None, window, cx);
125 copilot
126 .update(cx, |copilot, cx| copilot.sign_in(cx))
127 .detach_and_log_err(cx);
128 open_copilot_code_verification_window(&copilot, window, cx);
129 }
130 })
131 .log_err();
132 })
133 .detach();
134 }
135 _ => {
136 copilot
137 .update(cx, |copilot, cx| copilot.sign_in(cx))
138 .detach();
139 open_copilot_code_verification_window(&copilot, window, cx);
140 }
141 }
142}
143
144pub struct CopilotCodeVerification {
145 status: Status,
146 connect_clicked: bool,
147 focus_handle: FocusHandle,
148 copilot: Entity<Copilot>,
149 _subscription: Subscription,
150 sign_up_url: Option<String>,
151}
152
153impl Focusable for CopilotCodeVerification {
154 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
155 self.focus_handle.clone()
156 }
157}
158
159impl EventEmitter<DismissEvent> for CopilotCodeVerification {}
160
161impl CopilotCodeVerification {
162 pub fn new(copilot: &Entity<Copilot>, window: &mut Window, cx: &mut Context<Self>) -> Self {
163 window.on_window_should_close(cx, |window, cx| {
164 if let Some(this) = window.root::<CopilotCodeVerification>().flatten() {
165 this.update(cx, |this, cx| {
166 this.before_dismiss(cx);
167 });
168 }
169 true
170 });
171 cx.subscribe_in(
172 &cx.entity(),
173 window,
174 |this, _, _: &DismissEvent, window, cx| {
175 window.remove_window();
176 this.before_dismiss(cx);
177 },
178 )
179 .detach();
180
181 let status = copilot.read(cx).status();
182 Self {
183 status,
184 connect_clicked: false,
185 focus_handle: cx.focus_handle(),
186 copilot: copilot.clone(),
187 sign_up_url: None,
188 _subscription: cx.observe(copilot, |this, copilot, cx| {
189 let status = copilot.read(cx).status();
190 match status {
191 Status::Authorized | Status::Unauthorized | Status::SigningIn { .. } => {
192 this.set_status(status, cx)
193 }
194 _ => cx.emit(DismissEvent),
195 }
196 }),
197 }
198 }
199
200 pub fn set_status(&mut self, status: Status, cx: &mut Context<Self>) {
201 self.status = status;
202 cx.notify();
203 }
204
205 fn render_device_code(data: &PromptUserDeviceFlow, cx: &mut Context<Self>) -> impl IntoElement {
206 let copied = cx
207 .read_from_clipboard()
208 .map(|item| item.text().as_ref() == Some(&data.user_code))
209 .unwrap_or(false);
210
211 ButtonLike::new("copy-button")
212 .full_width()
213 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
214 .size(ButtonSize::Medium)
215 .child(
216 h_flex()
217 .w_full()
218 .p_1()
219 .justify_between()
220 .child(Label::new(data.user_code.clone()))
221 .child(Label::new(if copied { "Copied!" } else { "Copy" })),
222 )
223 .on_click({
224 let user_code = data.user_code.clone();
225 move |_, window, cx| {
226 cx.write_to_clipboard(ClipboardItem::new_string(user_code.clone()));
227 window.refresh();
228 }
229 })
230 }
231
232 fn render_prompting_modal(
233 copilot: Entity<Copilot>,
234 connect_clicked: bool,
235 data: &PromptUserDeviceFlow,
236 cx: &mut Context<Self>,
237 ) -> impl Element {
238 let connect_button_label = if connect_clicked {
239 "Waiting for connection…"
240 } else {
241 "Connect to GitHub"
242 };
243
244 v_flex()
245 .flex_1()
246 .gap_2p5()
247 .items_center()
248 .text_center()
249 .child(Headline::new("Use GitHub Copilot in Zed").size(HeadlineSize::Large))
250 .child(
251 Label::new("Using Copilot requires an active subscription on GitHub.")
252 .color(Color::Muted),
253 )
254 .child(Self::render_device_code(data, cx))
255 .child(
256 Label::new("Paste this code into GitHub after clicking the button below.")
257 .color(Color::Muted),
258 )
259 .child(
260 v_flex()
261 .w_full()
262 .gap_1()
263 .child(
264 Button::new("connect-button", connect_button_label)
265 .full_width()
266 .style(ButtonStyle::Outlined)
267 .size(ButtonSize::Medium)
268 .on_click({
269 let command = data.command.clone();
270 cx.listener(move |this, _, _window, cx| {
271 let command = command.clone();
272 let copilot_clone = copilot.clone();
273 copilot.update(cx, |copilot, cx| {
274 if let Some(server) = copilot.language_server() {
275 let server = server.clone();
276 cx.spawn(async move |_, cx| {
277 let result = server
278 .request::<lsp::request::ExecuteCommand>(
279 lsp::ExecuteCommandParams {
280 command: command.command.clone(),
281 arguments: command
282 .arguments
283 .clone()
284 .unwrap_or_default(),
285 ..Default::default()
286 },
287 )
288 .await
289 .into_response()
290 .ok()
291 .flatten();
292 if let Some(value) = result {
293 if let Ok(status) = serde_json::from_value::<
294 request::SignInStatus,
295 >(
296 value
297 ) {
298 copilot_clone.update(cx, |copilot, cx| {
299 copilot
300 .update_sign_in_status(status, cx);
301 });
302 }
303 }
304 })
305 .detach();
306 }
307 });
308
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(copilot: Entity<Copilot>, _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(move |_, window, cx| {
389 reinstall_and_sign_in(copilot.clone(), window, cx)
390 }),
391 )
392 }
393
394 fn before_dismiss(
395 &mut self,
396 cx: &mut Context<'_, CopilotCodeVerification>,
397 ) -> workspace::DismissDecision {
398 self.copilot.update(cx, |copilot, cx| {
399 if matches!(copilot.status(), Status::SigningIn { .. }) {
400 copilot.sign_out(cx).detach_and_log_err(cx);
401 }
402 });
403 workspace::DismissDecision::Dismiss(true)
404 }
405}
406
407impl Render for CopilotCodeVerification {
408 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
409 let prompt = match &self.status {
410 Status::SigningIn { prompt: None } => Icon::new(IconName::ArrowCircle)
411 .color(Color::Muted)
412 .with_rotate_animation(2)
413 .into_any_element(),
414 Status::SigningIn {
415 prompt: Some(prompt),
416 } => {
417 Self::render_prompting_modal(self.copilot.clone(), self.connect_clicked, prompt, cx)
418 .into_any_element()
419 }
420 Status::Unauthorized => {
421 self.connect_clicked = false;
422 self.render_unauthorized_modal(cx).into_any_element()
423 }
424 Status::Authorized => {
425 self.connect_clicked = false;
426 Self::render_enabled_modal(cx).into_any_element()
427 }
428 Status::Error(..) => {
429 Self::render_error_modal(self.copilot.clone(), cx).into_any_element()
430 }
431 _ => div().into_any_element(),
432 };
433
434 v_flex()
435 .id("copilot_code_verification")
436 .track_focus(&self.focus_handle(cx))
437 .size_full()
438 .px_4()
439 .py_8()
440 .gap_2()
441 .items_center()
442 .justify_center()
443 .elevation_3(cx)
444 .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
445 cx.emit(DismissEvent);
446 }))
447 .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
448 window.focus(&this.focus_handle, cx);
449 }))
450 .child(
451 Vector::new(VectorName::ZedXCopilot, rems(8.), rems(4.))
452 .color(Color::Custom(cx.theme().colors().icon)),
453 )
454 .child(prompt)
455 }
456}
457
458pub struct ConfigurationView {
459 copilot_status: Option<Status>,
460 is_authenticated: Box<dyn Fn(&App) -> bool + 'static>,
461 edit_prediction: bool,
462 _subscription: Option<Subscription>,
463}
464
465pub enum ConfigurationMode {
466 Chat,
467 EditPrediction,
468}
469
470impl ConfigurationView {
471 pub fn new(
472 is_authenticated: impl Fn(&App) -> bool + 'static,
473 mode: ConfigurationMode,
474 cx: &mut Context<Self>,
475 ) -> Self {
476 let copilot = GlobalCopilotAuth::try_global(cx).cloned();
477
478 Self {
479 copilot_status: copilot.as_ref().map(|copilot| copilot.0.read(cx).status()),
480 is_authenticated: Box::new(is_authenticated),
481 edit_prediction: matches!(mode, ConfigurationMode::EditPrediction),
482 _subscription: copilot.as_ref().map(|copilot| {
483 cx.observe(&copilot.0, |this, model, cx| {
484 this.copilot_status = Some(model.read(cx).status());
485 cx.notify();
486 })
487 }),
488 }
489 }
490}
491
492impl ConfigurationView {
493 fn is_starting(&self) -> bool {
494 matches!(&self.copilot_status, Some(Status::Starting { .. }))
495 }
496
497 fn is_signing_in(&self) -> bool {
498 matches!(
499 &self.copilot_status,
500 Some(Status::SigningIn { .. })
501 | Some(Status::SignedOut {
502 awaiting_signing_in: true
503 })
504 )
505 }
506
507 fn is_error(&self) -> bool {
508 matches!(&self.copilot_status, Some(Status::Error(_)))
509 }
510
511 fn has_no_status(&self) -> bool {
512 self.copilot_status.is_none()
513 }
514
515 fn loading_message(&self) -> Option<SharedString> {
516 if self.is_starting() {
517 Some("Starting Copilot…".into())
518 } else if self.is_signing_in() {
519 Some("Signing into Copilot…".into())
520 } else {
521 None
522 }
523 }
524
525 fn render_loading_button(
526 &self,
527 label: impl Into<SharedString>,
528 edit_prediction: bool,
529 ) -> impl IntoElement {
530 ButtonLike::new("loading_button")
531 .disabled(true)
532 .style(ButtonStyle::Outlined)
533 .when(edit_prediction, |this| this.size(ButtonSize::Medium))
534 .child(
535 h_flex()
536 .w_full()
537 .gap_1()
538 .justify_center()
539 .child(
540 Icon::new(IconName::ArrowCircle)
541 .size(IconSize::Small)
542 .color(Color::Muted)
543 .with_rotate_animation(4),
544 )
545 .child(Label::new(label)),
546 )
547 }
548
549 fn render_sign_in_button(&self, edit_prediction: bool) -> impl IntoElement {
550 let label = if edit_prediction {
551 "Sign in to GitHub"
552 } else {
553 "Sign in to use GitHub Copilot"
554 };
555
556 Button::new("sign_in", label)
557 .map(|this| {
558 if edit_prediction {
559 this.size(ButtonSize::Medium)
560 } else {
561 this.full_width()
562 }
563 })
564 .style(ButtonStyle::Outlined)
565 .icon(IconName::Github)
566 .icon_color(Color::Muted)
567 .icon_position(IconPosition::Start)
568 .icon_size(IconSize::Small)
569 .on_click(|_, window, cx| {
570 if let Some(copilot) = GlobalCopilotAuth::get_or_init(cx) {
571 initiate_sign_in(copilot.0, window, cx)
572 }
573 })
574 }
575
576 fn render_reinstall_button(&self, edit_prediction: bool) -> impl IntoElement {
577 let label = if edit_prediction {
578 "Reinstall and Sign in"
579 } else {
580 "Reinstall Copilot and Sign in"
581 };
582
583 Button::new("reinstall_and_sign_in", label)
584 .map(|this| {
585 if edit_prediction {
586 this.size(ButtonSize::Medium)
587 } else {
588 this.full_width()
589 }
590 })
591 .style(ButtonStyle::Outlined)
592 .icon(IconName::Download)
593 .icon_color(Color::Muted)
594 .icon_position(IconPosition::Start)
595 .icon_size(IconSize::Small)
596 .on_click(|_, window, cx| {
597 if let Some(copilot) = GlobalCopilotAuth::get_or_init(cx) {
598 reinstall_and_sign_in(copilot.0, window, cx)
599 }
600 })
601 }
602
603 fn render_for_edit_prediction(&self) -> impl IntoElement {
604 let container = |description: SharedString, action: AnyElement| {
605 h_flex()
606 .pt_2p5()
607 .w_full()
608 .justify_between()
609 .child(
610 v_flex()
611 .w_full()
612 .max_w_1_2()
613 .child(Label::new("Authenticate To Use"))
614 .child(
615 Label::new(description)
616 .color(Color::Muted)
617 .size(LabelSize::Small),
618 ),
619 )
620 .child(action)
621 };
622
623 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();
624 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();
625
626 if let Some(msg) = self.loading_message() {
627 container(
628 start_label,
629 self.render_loading_button(msg, true).into_any_element(),
630 )
631 .into_any_element()
632 } else if self.is_error() {
633 container(
634 ERROR_LABEL.into(),
635 self.render_reinstall_button(true).into_any_element(),
636 )
637 .into_any_element()
638 } else if self.has_no_status() {
639 container(
640 no_status_label,
641 self.render_sign_in_button(true).into_any_element(),
642 )
643 .into_any_element()
644 } else {
645 container(
646 start_label,
647 self.render_sign_in_button(true).into_any_element(),
648 )
649 .into_any_element()
650 }
651 }
652
653 fn render_for_chat(&self) -> impl IntoElement {
654 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.";
655 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.";
656
657 if let Some(msg) = self.loading_message() {
658 v_flex()
659 .gap_2()
660 .child(Label::new(start_label))
661 .child(self.render_loading_button(msg, false))
662 .into_any_element()
663 } else if self.is_error() {
664 v_flex()
665 .gap_2()
666 .child(Label::new(ERROR_LABEL))
667 .child(self.render_reinstall_button(false))
668 .into_any_element()
669 } else if self.has_no_status() {
670 v_flex()
671 .gap_2()
672 .child(Label::new(no_status_label))
673 .child(self.render_sign_in_button(false))
674 .into_any_element()
675 } else {
676 v_flex()
677 .gap_2()
678 .child(Label::new(start_label))
679 .child(self.render_sign_in_button(false))
680 .into_any_element()
681 }
682 }
683}
684
685impl Render for ConfigurationView {
686 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
687 let is_authenticated = &self.is_authenticated;
688
689 if is_authenticated(cx) {
690 return ConfiguredApiCard::new("Authorized")
691 .button_label("Sign Out")
692 .on_click(|_, window, cx| {
693 if let Some(auth) = GlobalCopilotAuth::try_global(cx) {
694 initiate_sign_out(auth.0.clone(), window, cx);
695 }
696 })
697 .into_any_element();
698 }
699
700 if self.edit_prediction {
701 self.render_for_edit_prediction().into_any_element()
702 } else {
703 self.render_for_chat().into_any_element()
704 }
705 }
706}