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 project::project_settings::ProjectSettings;
12use settings::Settings as _;
13use ui::{ButtonLike, CommonAnimationExt, ConfiguredApiCard, Vector, VectorName, prelude::*};
14use util::ResultExt as _;
15use workspace::{AppState, Toast, Workspace, notifications::NotificationId};
16
17const COPILOT_SIGN_UP_URL: &str = "https://github.com/features/copilot";
18const ERROR_LABEL: &str =
19 "Copilot had issues starting. You can try reinstalling it and signing in again.";
20
21struct CopilotStatusToast;
22
23pub fn initiate_sign_in(copilot: Entity<Copilot>, window: &mut Window, cx: &mut App) {
24 let is_reinstall = false;
25 initiate_sign_in_impl(copilot, is_reinstall, window, cx)
26}
27
28pub fn initiate_sign_out(copilot: Entity<Copilot>, window: &mut Window, cx: &mut App) {
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) = Workspace::for_window(window, cx) {
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(copilot: Entity<Copilot>, window: &mut Window, cx: &mut App) {
51 let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx));
52 let is_reinstall = true;
53 initiate_sign_in_impl(copilot, is_reinstall, window, cx);
54}
55
56fn open_copilot_code_verification_window(copilot: &Entity<Copilot>, window: &Window, cx: &mut App) {
57 let current_window_center = window.bounds().center();
58 let height = px(450.);
59 let width = px(350.);
60 let window_bounds = WindowBounds::Windowed(gpui::bounds(
61 current_window_center - point(height / 2.0, width / 2.0),
62 gpui::size(height, width),
63 ));
64 cx.open_window(
65 WindowOptions {
66 kind: gpui::WindowKind::PopUp,
67 window_bounds: Some(window_bounds),
68 is_resizable: false,
69 is_movable: true,
70 titlebar: Some(gpui::TitlebarOptions {
71 appears_transparent: true,
72 ..Default::default()
73 }),
74 ..Default::default()
75 },
76 |window, cx| cx.new(|cx| CopilotCodeVerification::new(&copilot, window, cx)),
77 )
78 .context("Failed to open Copilot code verification window")
79 .log_err();
80}
81
82fn copilot_toast(message: Option<&'static str>, window: &Window, cx: &mut App) {
83 const NOTIFICATION_ID: NotificationId = NotificationId::unique::<CopilotStatusToast>();
84
85 let Some(workspace) = Workspace::for_window(window, cx) else {
86 return;
87 };
88
89 cx.defer(move |cx| {
90 workspace.update(cx, |workspace, cx| match message {
91 Some(message) => workspace.show_toast(Toast::new(NOTIFICATION_ID, message), cx),
92 None => workspace.dismiss_toast(&NOTIFICATION_ID, cx),
93 });
94 })
95}
96
97pub fn initiate_sign_in_impl(
98 copilot: Entity<Copilot>,
99 is_reinstall: bool,
100 window: &mut Window,
101 cx: &mut App,
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| match copilot.read(cx).status() {
122 Status::Authorized => {
123 copilot_toast(Some("Copilot has started."), window, cx)
124 }
125 _ => {
126 copilot_toast(None, window, cx);
127 copilot
128 .update(cx, |copilot, cx| copilot.sign_in(cx))
129 .detach_and_log_err(cx);
130 open_copilot_code_verification_window(&copilot, window, cx);
131 }
132 })
133 .log_err();
134 })
135 .detach();
136 }
137 _ => {
138 copilot
139 .update(cx, |copilot, cx| copilot.sign_in(cx))
140 .detach();
141 open_copilot_code_verification_window(&copilot, window, cx);
142 }
143 }
144}
145
146pub struct CopilotCodeVerification {
147 status: Status,
148 connect_clicked: bool,
149 focus_handle: FocusHandle,
150 copilot: Entity<Copilot>,
151 _subscription: Subscription,
152 sign_up_url: Option<String>,
153}
154
155impl Focusable for CopilotCodeVerification {
156 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
157 self.focus_handle.clone()
158 }
159}
160
161impl EventEmitter<DismissEvent> for CopilotCodeVerification {}
162
163impl CopilotCodeVerification {
164 pub fn new(copilot: &Entity<Copilot>, window: &mut Window, cx: &mut Context<Self>) -> Self {
165 window.on_window_should_close(cx, |window, cx| {
166 if let Some(this) = window.root::<CopilotCodeVerification>().flatten() {
167 this.update(cx, |this, cx| {
168 this.before_dismiss(cx);
169 });
170 }
171 true
172 });
173 cx.subscribe_in(
174 &cx.entity(),
175 window,
176 |this, _, _: &DismissEvent, window, cx| {
177 window.remove_window();
178 this.before_dismiss(cx);
179 },
180 )
181 .detach();
182
183 let status = copilot.read(cx).status();
184 Self {
185 status,
186 connect_clicked: false,
187 focus_handle: cx.focus_handle(),
188 copilot: copilot.clone(),
189 sign_up_url: None,
190 _subscription: cx.observe(copilot, |this, copilot, cx| {
191 let status = copilot.read(cx).status();
192 match status {
193 Status::Authorized | Status::Unauthorized | Status::SigningIn { .. } => {
194 this.set_status(status, cx)
195 }
196 _ => cx.emit(DismissEvent),
197 }
198 }),
199 }
200 }
201
202 pub fn set_status(&mut self, status: Status, cx: &mut Context<Self>) {
203 self.status = status;
204 cx.notify();
205 }
206
207 fn render_device_code(data: &PromptUserDeviceFlow, cx: &mut Context<Self>) -> impl IntoElement {
208 let copied = cx
209 .read_from_clipboard()
210 .map(|item| item.text().as_ref() == Some(&data.user_code))
211 .unwrap_or(false);
212
213 ButtonLike::new("copy-button")
214 .full_width()
215 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
216 .size(ButtonSize::Medium)
217 .child(
218 h_flex()
219 .w_full()
220 .p_1()
221 .justify_between()
222 .child(Label::new(data.user_code.clone()))
223 .child(Label::new(if copied { "Copied!" } else { "Copy" })),
224 )
225 .on_click({
226 let user_code = data.user_code.clone();
227 move |_, window, cx| {
228 cx.write_to_clipboard(ClipboardItem::new_string(user_code.clone()));
229 window.refresh();
230 }
231 })
232 }
233
234 fn render_prompting_modal(
235 copilot: Entity<Copilot>,
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 command = data.command.clone();
272 cx.listener(move |this, _, _window, cx| {
273 let command = command.clone();
274 let copilot_clone = copilot.clone();
275 let request_timeout = ProjectSettings::get_global(cx)
276 .global_lsp_settings
277 .get_request_timeout();
278 copilot.update(cx, |copilot, cx| {
279 if let Some(server) = copilot.language_server() {
280 let server = server.clone();
281 cx.spawn(async move |_, cx| {
282 let result = server
283 .request::<lsp::request::ExecuteCommand>(
284 lsp::ExecuteCommandParams {
285 command: command.command.clone(),
286 arguments: command
287 .arguments
288 .clone()
289 .unwrap_or_default(),
290 ..Default::default()
291 },
292 request_timeout,
293 )
294 .await
295 .into_response()
296 .ok()
297 .flatten();
298 if let Some(value) = result {
299 if let Ok(status) = serde_json::from_value::<
300 request::SignInStatus,
301 >(
302 value
303 ) {
304 copilot_clone.update(cx, |copilot, cx| {
305 copilot
306 .update_sign_in_status(status, cx);
307 });
308 }
309 }
310 })
311 .detach();
312 }
313 });
314
315 this.connect_clicked = true;
316 })
317 }),
318 )
319 .child(
320 Button::new("copilot-enable-cancel-button", "Cancel")
321 .full_width()
322 .size(ButtonSize::Medium)
323 .on_click(cx.listener(|_, _, _, cx| {
324 cx.emit(DismissEvent);
325 })),
326 ),
327 )
328 }
329
330 fn render_enabled_modal(cx: &mut Context<Self>) -> impl Element {
331 v_flex()
332 .gap_2()
333 .text_center()
334 .justify_center()
335 .child(Headline::new("Copilot Enabled!").size(HeadlineSize::Large))
336 .child(Label::new("You're all set to use GitHub Copilot.").color(Color::Muted))
337 .child(
338 Button::new("copilot-enabled-done-button", "Done")
339 .full_width()
340 .style(ButtonStyle::Outlined)
341 .size(ButtonSize::Medium)
342 .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
343 )
344 }
345
346 fn render_unauthorized_modal(&self, cx: &mut Context<Self>) -> impl Element {
347 let sign_up_url = self
348 .sign_up_url
349 .as_deref()
350 .unwrap_or(COPILOT_SIGN_UP_URL)
351 .to_owned();
352 let description = "Enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.";
353
354 v_flex()
355 .gap_2()
356 .text_center()
357 .justify_center()
358 .child(
359 Headline::new("You must have an active GitHub Copilot subscription.")
360 .size(HeadlineSize::Large),
361 )
362 .child(Label::new(description).color(Color::Warning))
363 .child(
364 Button::new("copilot-subscribe-button", "Subscribe on GitHub")
365 .full_width()
366 .style(ButtonStyle::Outlined)
367 .size(ButtonSize::Medium)
368 .on_click(move |_, _, cx| cx.open_url(&sign_up_url)),
369 )
370 .child(
371 Button::new("copilot-subscribe-cancel-button", "Cancel")
372 .full_width()
373 .size(ButtonSize::Medium)
374 .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
375 )
376 }
377
378 fn render_error_modal(copilot: Entity<Copilot>, _cx: &mut Context<Self>) -> impl Element {
379 v_flex()
380 .gap_2()
381 .text_center()
382 .justify_center()
383 .child(Headline::new("An Error Happened").size(HeadlineSize::Large))
384 .child(Label::new(ERROR_LABEL).color(Color::Muted))
385 .child(
386 Button::new("copilot-subscribe-button", "Reinstall Copilot and Sign In")
387 .full_width()
388 .style(ButtonStyle::Outlined)
389 .size(ButtonSize::Medium)
390 .start_icon(
391 Icon::new(IconName::Download)
392 .size(IconSize::Small)
393 .color(Color::Muted),
394 )
395 .on_click(move |_, window, cx| {
396 reinstall_and_sign_in(copilot.clone(), window, cx)
397 }),
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 } => {
424 Self::render_prompting_modal(self.copilot.clone(), self.connect_clicked, prompt, cx)
425 .into_any_element()
426 }
427 Status::Unauthorized => {
428 self.connect_clicked = false;
429 self.render_unauthorized_modal(cx).into_any_element()
430 }
431 Status::Authorized => {
432 self.connect_clicked = false;
433 Self::render_enabled_modal(cx).into_any_element()
434 }
435 Status::Error(..) => {
436 Self::render_error_modal(self.copilot.clone(), cx).into_any_element()
437 }
438 _ => div().into_any_element(),
439 };
440
441 v_flex()
442 .id("copilot_code_verification")
443 .track_focus(&self.focus_handle(cx))
444 .size_full()
445 .px_4()
446 .py_8()
447 .gap_2()
448 .items_center()
449 .justify_center()
450 .elevation_3(cx)
451 .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
452 cx.emit(DismissEvent);
453 }))
454 .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
455 window.focus(&this.focus_handle, cx);
456 }))
457 .child(
458 Vector::new(VectorName::ZedXCopilot, rems(8.), rems(4.))
459 .color(Color::Custom(cx.theme().colors().icon)),
460 )
461 .child(prompt)
462 }
463}
464
465pub struct ConfigurationView {
466 copilot_status: Option<Status>,
467 is_authenticated: Box<dyn Fn(&mut App) -> bool + 'static>,
468 edit_prediction: bool,
469 _subscription: Option<Subscription>,
470}
471
472pub enum ConfigurationMode {
473 Chat,
474 EditPrediction,
475}
476
477impl ConfigurationView {
478 pub fn new(
479 is_authenticated: impl Fn(&mut App) -> bool + 'static,
480 mode: ConfigurationMode,
481 cx: &mut Context<Self>,
482 ) -> Self {
483 let copilot = AppState::try_global(cx)
484 .and_then(|state| state.upgrade())
485 .and_then(|state| GlobalCopilotAuth::try_get_or_init(state, cx));
486
487 Self {
488 copilot_status: copilot.as_ref().map(|copilot| copilot.0.read(cx).status()),
489 is_authenticated: Box::new(is_authenticated),
490 edit_prediction: matches!(mode, ConfigurationMode::EditPrediction),
491 _subscription: copilot.as_ref().map(|copilot| {
492 cx.observe(&copilot.0, |this, model, cx| {
493 this.copilot_status = Some(model.read(cx).status());
494 cx.notify();
495 })
496 }),
497 }
498 }
499}
500
501impl ConfigurationView {
502 fn is_starting(&self) -> bool {
503 matches!(&self.copilot_status, Some(Status::Starting { .. }))
504 }
505
506 fn is_signing_in(&self) -> bool {
507 matches!(
508 &self.copilot_status,
509 Some(Status::SigningIn { .. })
510 | Some(Status::SignedOut {
511 awaiting_signing_in: true
512 })
513 )
514 }
515
516 fn is_error(&self) -> bool {
517 matches!(&self.copilot_status, Some(Status::Error(_)))
518 }
519
520 fn has_no_status(&self) -> bool {
521 self.copilot_status.is_none()
522 }
523
524 fn loading_message(&self) -> Option<SharedString> {
525 if self.is_starting() {
526 Some("Starting Copilot…".into())
527 } else if self.is_signing_in() {
528 Some("Signing into Copilot…".into())
529 } else {
530 None
531 }
532 }
533
534 fn render_loading_button(
535 &self,
536 label: impl Into<SharedString>,
537 edit_prediction: bool,
538 ) -> impl IntoElement {
539 ButtonLike::new("loading_button")
540 .disabled(true)
541 .style(ButtonStyle::Outlined)
542 .when(edit_prediction, |this| this.size(ButtonSize::Medium))
543 .child(
544 h_flex()
545 .w_full()
546 .gap_1()
547 .justify_center()
548 .child(
549 Icon::new(IconName::ArrowCircle)
550 .size(IconSize::Small)
551 .color(Color::Muted)
552 .with_rotate_animation(4),
553 )
554 .child(Label::new(label)),
555 )
556 }
557
558 fn render_sign_in_button(&self, edit_prediction: bool) -> impl IntoElement {
559 let label = if edit_prediction {
560 "Sign in to GitHub"
561 } else {
562 "Sign in to use GitHub Copilot"
563 };
564
565 Button::new("sign_in", label)
566 .map(|this| {
567 if edit_prediction {
568 this.size(ButtonSize::Medium)
569 } else {
570 this.full_width()
571 }
572 })
573 .style(ButtonStyle::Outlined)
574 .start_icon(
575 Icon::new(IconName::Github)
576 .size(IconSize::Small)
577 .color(Color::Muted),
578 )
579 .when(edit_prediction, |this| this.tab_index(0isize))
580 .on_click(|_, window, cx| {
581 if let Some(app_state) = AppState::global(cx).upgrade()
582 && let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx)
583 {
584 initiate_sign_in(copilot.0, window, cx)
585 }
586 })
587 }
588
589 fn render_reinstall_button(&self, edit_prediction: bool) -> impl IntoElement {
590 let label = if edit_prediction {
591 "Reinstall and Sign in"
592 } else {
593 "Reinstall Copilot and Sign in"
594 };
595
596 Button::new("reinstall_and_sign_in", label)
597 .map(|this| {
598 if edit_prediction {
599 this.size(ButtonSize::Medium)
600 } else {
601 this.full_width()
602 }
603 })
604 .style(ButtonStyle::Outlined)
605 .start_icon(
606 Icon::new(IconName::Download)
607 .size(IconSize::Small)
608 .color(Color::Muted),
609 )
610 .on_click(|_, window, cx| {
611 if let Some(app_state) = AppState::global(cx).upgrade()
612 && let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx)
613 {
614 reinstall_and_sign_in(copilot.0, window, cx);
615 }
616 })
617 }
618
619 fn render_for_edit_prediction(&self) -> impl IntoElement {
620 let container = |description: SharedString, action: AnyElement| {
621 h_flex()
622 .pt_2p5()
623 .w_full()
624 .justify_between()
625 .child(
626 v_flex()
627 .w_full()
628 .max_w_1_2()
629 .child(Label::new("Authenticate To Use"))
630 .child(
631 Label::new(description)
632 .color(Color::Muted)
633 .size(LabelSize::Small),
634 ),
635 )
636 .child(action)
637 };
638
639 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();
640 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();
641
642 if let Some(msg) = self.loading_message() {
643 container(
644 start_label,
645 self.render_loading_button(msg, true).into_any_element(),
646 )
647 .into_any_element()
648 } else if self.is_error() {
649 container(
650 ERROR_LABEL.into(),
651 self.render_reinstall_button(true).into_any_element(),
652 )
653 .into_any_element()
654 } else if self.has_no_status() {
655 container(
656 no_status_label,
657 self.render_sign_in_button(true).into_any_element(),
658 )
659 .into_any_element()
660 } else {
661 container(
662 start_label,
663 self.render_sign_in_button(true).into_any_element(),
664 )
665 .into_any_element()
666 }
667 }
668
669 fn render_for_chat(&self) -> impl IntoElement {
670 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.";
671 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.";
672
673 if let Some(msg) = self.loading_message() {
674 v_flex()
675 .gap_2()
676 .child(Label::new(start_label))
677 .child(self.render_loading_button(msg, false))
678 .into_any_element()
679 } else if self.is_error() {
680 v_flex()
681 .gap_2()
682 .child(Label::new(ERROR_LABEL))
683 .child(self.render_reinstall_button(false))
684 .into_any_element()
685 } else if self.has_no_status() {
686 v_flex()
687 .gap_2()
688 .child(Label::new(no_status_label))
689 .child(self.render_sign_in_button(false))
690 .into_any_element()
691 } else {
692 v_flex()
693 .gap_2()
694 .child(Label::new(start_label))
695 .child(self.render_sign_in_button(false))
696 .into_any_element()
697 }
698 }
699}
700
701impl Render for ConfigurationView {
702 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
703 let is_authenticated = &self.is_authenticated;
704
705 if is_authenticated(cx) {
706 return ConfiguredApiCard::new("Authorized")
707 .button_label("Sign Out")
708 .on_click(|_, window, cx| {
709 if let Some(auth) = GlobalCopilotAuth::try_global(cx) {
710 initiate_sign_out(auth.0.clone(), window, cx);
711 }
712 })
713 .into_any_element();
714 }
715
716 if self.edit_prediction {
717 self.render_for_edit_prediction().into_any_element()
718 } else {
719 self.render_for_chat().into_any_element()
720 }
721 }
722}