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 .icon(IconName::Download)
391 .icon_color(Color::Muted)
392 .icon_position(IconPosition::Start)
393 .icon_size(IconSize::Small)
394 .on_click(move |_, window, cx| {
395 reinstall_and_sign_in(copilot.clone(), window, cx)
396 }),
397 )
398 }
399
400 fn before_dismiss(
401 &mut self,
402 cx: &mut Context<'_, CopilotCodeVerification>,
403 ) -> workspace::DismissDecision {
404 self.copilot.update(cx, |copilot, cx| {
405 if matches!(copilot.status(), Status::SigningIn { .. }) {
406 copilot.sign_out(cx).detach_and_log_err(cx);
407 }
408 });
409 workspace::DismissDecision::Dismiss(true)
410 }
411}
412
413impl Render for CopilotCodeVerification {
414 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
415 let prompt = match &self.status {
416 Status::SigningIn { prompt: None } => Icon::new(IconName::ArrowCircle)
417 .color(Color::Muted)
418 .with_rotate_animation(2)
419 .into_any_element(),
420 Status::SigningIn {
421 prompt: Some(prompt),
422 } => {
423 Self::render_prompting_modal(self.copilot.clone(), self.connect_clicked, prompt, cx)
424 .into_any_element()
425 }
426 Status::Unauthorized => {
427 self.connect_clicked = false;
428 self.render_unauthorized_modal(cx).into_any_element()
429 }
430 Status::Authorized => {
431 self.connect_clicked = false;
432 Self::render_enabled_modal(cx).into_any_element()
433 }
434 Status::Error(..) => {
435 Self::render_error_modal(self.copilot.clone(), cx).into_any_element()
436 }
437 _ => div().into_any_element(),
438 };
439
440 v_flex()
441 .id("copilot_code_verification")
442 .track_focus(&self.focus_handle(cx))
443 .size_full()
444 .px_4()
445 .py_8()
446 .gap_2()
447 .items_center()
448 .justify_center()
449 .elevation_3(cx)
450 .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
451 cx.emit(DismissEvent);
452 }))
453 .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
454 window.focus(&this.focus_handle, cx);
455 }))
456 .child(
457 Vector::new(VectorName::ZedXCopilot, rems(8.), rems(4.))
458 .color(Color::Custom(cx.theme().colors().icon)),
459 )
460 .child(prompt)
461 }
462}
463
464pub struct ConfigurationView {
465 copilot_status: Option<Status>,
466 is_authenticated: Box<dyn Fn(&mut App) -> bool + 'static>,
467 edit_prediction: bool,
468 _subscription: Option<Subscription>,
469}
470
471pub enum ConfigurationMode {
472 Chat,
473 EditPrediction,
474}
475
476impl ConfigurationView {
477 pub fn new(
478 is_authenticated: impl Fn(&mut App) -> bool + 'static,
479 mode: ConfigurationMode,
480 cx: &mut Context<Self>,
481 ) -> Self {
482 let copilot = AppState::try_global(cx)
483 .and_then(|state| state.upgrade())
484 .and_then(|state| GlobalCopilotAuth::try_get_or_init(state, cx));
485
486 Self {
487 copilot_status: copilot.as_ref().map(|copilot| copilot.0.read(cx).status()),
488 is_authenticated: Box::new(is_authenticated),
489 edit_prediction: matches!(mode, ConfigurationMode::EditPrediction),
490 _subscription: copilot.as_ref().map(|copilot| {
491 cx.observe(&copilot.0, |this, model, cx| {
492 this.copilot_status = Some(model.read(cx).status());
493 cx.notify();
494 })
495 }),
496 }
497 }
498}
499
500impl ConfigurationView {
501 fn is_starting(&self) -> bool {
502 matches!(&self.copilot_status, Some(Status::Starting { .. }))
503 }
504
505 fn is_signing_in(&self) -> bool {
506 matches!(
507 &self.copilot_status,
508 Some(Status::SigningIn { .. })
509 | Some(Status::SignedOut {
510 awaiting_signing_in: true
511 })
512 )
513 }
514
515 fn is_error(&self) -> bool {
516 matches!(&self.copilot_status, Some(Status::Error(_)))
517 }
518
519 fn has_no_status(&self) -> bool {
520 self.copilot_status.is_none()
521 }
522
523 fn loading_message(&self) -> Option<SharedString> {
524 if self.is_starting() {
525 Some("Starting Copilot…".into())
526 } else if self.is_signing_in() {
527 Some("Signing into Copilot…".into())
528 } else {
529 None
530 }
531 }
532
533 fn render_loading_button(
534 &self,
535 label: impl Into<SharedString>,
536 edit_prediction: bool,
537 ) -> impl IntoElement {
538 ButtonLike::new("loading_button")
539 .disabled(true)
540 .style(ButtonStyle::Outlined)
541 .when(edit_prediction, |this| this.size(ButtonSize::Medium))
542 .child(
543 h_flex()
544 .w_full()
545 .gap_1()
546 .justify_center()
547 .child(
548 Icon::new(IconName::ArrowCircle)
549 .size(IconSize::Small)
550 .color(Color::Muted)
551 .with_rotate_animation(4),
552 )
553 .child(Label::new(label)),
554 )
555 }
556
557 fn render_sign_in_button(&self, edit_prediction: bool) -> impl IntoElement {
558 let label = if edit_prediction {
559 "Sign in to GitHub"
560 } else {
561 "Sign in to use GitHub Copilot"
562 };
563
564 Button::new("sign_in", label)
565 .map(|this| {
566 if edit_prediction {
567 this.size(ButtonSize::Medium)
568 } else {
569 this.full_width()
570 }
571 })
572 .style(ButtonStyle::Outlined)
573 .icon(IconName::Github)
574 .icon_color(Color::Muted)
575 .icon_position(IconPosition::Start)
576 .icon_size(IconSize::Small)
577 .when(edit_prediction, |this| this.tab_index(0isize))
578 .on_click(|_, window, cx| {
579 if let Some(app_state) = AppState::global(cx).upgrade()
580 && let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx)
581 {
582 initiate_sign_in(copilot.0, window, cx)
583 }
584 })
585 }
586
587 fn render_reinstall_button(&self, edit_prediction: bool) -> impl IntoElement {
588 let label = if edit_prediction {
589 "Reinstall and Sign in"
590 } else {
591 "Reinstall Copilot and Sign in"
592 };
593
594 Button::new("reinstall_and_sign_in", label)
595 .map(|this| {
596 if edit_prediction {
597 this.size(ButtonSize::Medium)
598 } else {
599 this.full_width()
600 }
601 })
602 .style(ButtonStyle::Outlined)
603 .icon(IconName::Download)
604 .icon_color(Color::Muted)
605 .icon_position(IconPosition::Start)
606 .icon_size(IconSize::Small)
607 .on_click(|_, window, cx| {
608 if let Some(app_state) = AppState::global(cx).upgrade()
609 && let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx)
610 {
611 reinstall_and_sign_in(copilot.0, window, cx);
612 }
613 })
614 }
615
616 fn render_for_edit_prediction(&self) -> impl IntoElement {
617 let container = |description: SharedString, action: AnyElement| {
618 h_flex()
619 .pt_2p5()
620 .w_full()
621 .justify_between()
622 .child(
623 v_flex()
624 .w_full()
625 .max_w_1_2()
626 .child(Label::new("Authenticate To Use"))
627 .child(
628 Label::new(description)
629 .color(Color::Muted)
630 .size(LabelSize::Small),
631 ),
632 )
633 .child(action)
634 };
635
636 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();
637 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();
638
639 if let Some(msg) = self.loading_message() {
640 container(
641 start_label,
642 self.render_loading_button(msg, true).into_any_element(),
643 )
644 .into_any_element()
645 } else if self.is_error() {
646 container(
647 ERROR_LABEL.into(),
648 self.render_reinstall_button(true).into_any_element(),
649 )
650 .into_any_element()
651 } else if self.has_no_status() {
652 container(
653 no_status_label,
654 self.render_sign_in_button(true).into_any_element(),
655 )
656 .into_any_element()
657 } else {
658 container(
659 start_label,
660 self.render_sign_in_button(true).into_any_element(),
661 )
662 .into_any_element()
663 }
664 }
665
666 fn render_for_chat(&self) -> impl IntoElement {
667 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.";
668 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.";
669
670 if let Some(msg) = self.loading_message() {
671 v_flex()
672 .gap_2()
673 .child(Label::new(start_label))
674 .child(self.render_loading_button(msg, false))
675 .into_any_element()
676 } else if self.is_error() {
677 v_flex()
678 .gap_2()
679 .child(Label::new(ERROR_LABEL))
680 .child(self.render_reinstall_button(false))
681 .into_any_element()
682 } else if self.has_no_status() {
683 v_flex()
684 .gap_2()
685 .child(Label::new(no_status_label))
686 .child(self.render_sign_in_button(false))
687 .into_any_element()
688 } else {
689 v_flex()
690 .gap_2()
691 .child(Label::new(start_label))
692 .child(self.render_sign_in_button(false))
693 .into_any_element()
694 }
695 }
696}
697
698impl Render for ConfigurationView {
699 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
700 let is_authenticated = &self.is_authenticated;
701
702 if is_authenticated(cx) {
703 return ConfiguredApiCard::new("Authorized")
704 .button_label("Sign Out")
705 .on_click(|_, window, cx| {
706 if let Some(auth) = GlobalCopilotAuth::try_global(cx) {
707 initiate_sign_out(auth.0.clone(), window, cx);
708 }
709 })
710 .into_any_element();
711 }
712
713 if self.edit_prediction {
714 self.render_for_edit_prediction().into_any_element()
715 } else {
716 self.render_for_chat().into_any_element()
717 }
718 }
719}