1use anyhow::Result;
2use client::UserStore;
3use copilot::{Copilot, Status};
4use editor::{actions::ShowInlineCompletion, scroll::Autoscroll, Editor};
5use feature_flags::{
6 FeatureFlagAppExt, PredictEditsFeatureFlag, PredictEditsRateCompletionsFeatureFlag,
7};
8use fs::Fs;
9use gpui::{
10 actions, div, pulsating_between, Action, Animation, AnimationExt, App, AsyncWindowContext,
11 Corner, Entity, FocusHandle, Focusable, IntoElement, ParentElement, Render, Subscription,
12 WeakEntity,
13};
14use language::{
15 language_settings::{
16 self, all_language_settings, AllLanguageSettings, InlineCompletionProvider,
17 },
18 File, Language,
19};
20use regex::Regex;
21use settings::{update_settings_file, Settings, SettingsStore};
22use std::{
23 sync::{Arc, LazyLock},
24 time::Duration,
25};
26use supermaven::{AccountStatus, Supermaven};
27use ui::{
28 prelude::*, Clickable, ContextMenu, ContextMenuEntry, IconButton, IconButtonShape, PopoverMenu,
29 PopoverMenuHandle, Tooltip,
30};
31use workspace::{
32 create_and_open_local_file, item::ItemHandle, notifications::NotificationId, StatusItemView,
33 Toast, Workspace,
34};
35use zed_actions::OpenBrowser;
36use zeta::RateCompletionModal;
37
38actions!(zeta, [RateCompletions]);
39actions!(inline_completion, [ToggleMenu]);
40
41const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
42
43struct CopilotErrorToast;
44
45pub struct InlineCompletionButton {
46 editor_subscription: Option<(Subscription, usize)>,
47 editor_enabled: Option<bool>,
48 editor_focus_handle: Option<FocusHandle>,
49 language: Option<Arc<Language>>,
50 file: Option<Arc<dyn File>>,
51 inline_completion_provider: Option<Arc<dyn inline_completion::InlineCompletionProviderHandle>>,
52 fs: Arc<dyn Fs>,
53 workspace: WeakEntity<Workspace>,
54 user_store: Entity<UserStore>,
55 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
56}
57
58enum SupermavenButtonStatus {
59 Ready,
60 Errored(String),
61 NeedsActivation(String),
62 Initializing,
63}
64
65impl Render for InlineCompletionButton {
66 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
67 let all_language_settings = all_language_settings(None, cx);
68
69 match all_language_settings.inline_completions.provider {
70 InlineCompletionProvider::None => div(),
71
72 InlineCompletionProvider::Copilot => {
73 let Some(copilot) = Copilot::global(cx) else {
74 return div();
75 };
76 let status = copilot.read(cx).status();
77
78 let enabled = self.editor_enabled.unwrap_or(false);
79
80 let icon = match status {
81 Status::Error(_) => IconName::CopilotError,
82 Status::Authorized => {
83 if enabled {
84 IconName::Copilot
85 } else {
86 IconName::CopilotDisabled
87 }
88 }
89 _ => IconName::CopilotInit,
90 };
91
92 if let Status::Error(e) = status {
93 return div().child(
94 IconButton::new("copilot-error", icon)
95 .icon_size(IconSize::Small)
96 .on_click(cx.listener(move |_, _, window, cx| {
97 if let Some(workspace) = window.root::<Workspace>().flatten() {
98 workspace.update(cx, |workspace, cx| {
99 workspace.show_toast(
100 Toast::new(
101 NotificationId::unique::<CopilotErrorToast>(),
102 format!("Copilot can't be started: {}", e),
103 )
104 .on_click(
105 "Reinstall Copilot",
106 |_, cx| {
107 if let Some(copilot) = Copilot::global(cx) {
108 copilot
109 .update(cx, |copilot, cx| {
110 copilot.reinstall(cx)
111 })
112 .detach();
113 }
114 },
115 ),
116 cx,
117 );
118 });
119 }
120 }))
121 .tooltip(|window, cx| {
122 Tooltip::for_action("GitHub Copilot", &ToggleMenu, window, cx)
123 }),
124 );
125 }
126 let this = cx.entity().clone();
127
128 div().child(
129 PopoverMenu::new("copilot")
130 .menu(move |window, cx| {
131 Some(match status {
132 Status::Authorized => this.update(cx, |this, cx| {
133 this.build_copilot_context_menu(window, cx)
134 }),
135 _ => this.update(cx, |this, cx| {
136 this.build_copilot_start_menu(window, cx)
137 }),
138 })
139 })
140 .anchor(Corner::BottomRight)
141 .trigger(IconButton::new("copilot-icon", icon).tooltip(|window, cx| {
142 Tooltip::for_action("GitHub Copilot", &ToggleMenu, window, cx)
143 }))
144 .with_handle(self.popover_menu_handle.clone()),
145 )
146 }
147
148 InlineCompletionProvider::Supermaven => {
149 let Some(supermaven) = Supermaven::global(cx) else {
150 return div();
151 };
152
153 let supermaven = supermaven.read(cx);
154
155 let status = match supermaven {
156 Supermaven::Starting => SupermavenButtonStatus::Initializing,
157 Supermaven::FailedDownload { error } => {
158 SupermavenButtonStatus::Errored(error.to_string())
159 }
160 Supermaven::Spawned(agent) => {
161 let account_status = agent.account_status.clone();
162 match account_status {
163 AccountStatus::NeedsActivation { activate_url } => {
164 SupermavenButtonStatus::NeedsActivation(activate_url.clone())
165 }
166 AccountStatus::Unknown => SupermavenButtonStatus::Initializing,
167 AccountStatus::Ready => SupermavenButtonStatus::Ready,
168 }
169 }
170 Supermaven::Error { error } => {
171 SupermavenButtonStatus::Errored(error.to_string())
172 }
173 };
174
175 let icon = status.to_icon();
176 let tooltip_text = status.to_tooltip();
177 let has_menu = status.has_menu();
178 let this = cx.entity().clone();
179 let fs = self.fs.clone();
180
181 return div().child(
182 PopoverMenu::new("supermaven")
183 .menu(move |window, cx| match &status {
184 SupermavenButtonStatus::NeedsActivation(activate_url) => {
185 Some(ContextMenu::build(window, cx, |menu, _, _| {
186 let fs = fs.clone();
187 let activate_url = activate_url.clone();
188 menu.entry("Sign In", None, move |_, cx| {
189 cx.open_url(activate_url.as_str())
190 })
191 .entry(
192 "Use Copilot",
193 None,
194 move |_, cx| {
195 set_completion_provider(
196 fs.clone(),
197 cx,
198 InlineCompletionProvider::Copilot,
199 )
200 },
201 )
202 }))
203 }
204 SupermavenButtonStatus::Ready => Some(this.update(cx, |this, cx| {
205 this.build_supermaven_context_menu(window, cx)
206 })),
207 _ => None,
208 })
209 .anchor(Corner::BottomRight)
210 .trigger(IconButton::new("supermaven-icon", icon).tooltip(
211 move |window, cx| {
212 if has_menu {
213 Tooltip::for_action(
214 tooltip_text.clone(),
215 &ToggleMenu,
216 window,
217 cx,
218 )
219 } else {
220 Tooltip::text(tooltip_text.clone())(window, cx)
221 }
222 },
223 ))
224 .with_handle(self.popover_menu_handle.clone()),
225 );
226 }
227
228 InlineCompletionProvider::Zed => {
229 if !cx.has_flag::<PredictEditsFeatureFlag>() {
230 return div();
231 }
232
233 let enabled = self.editor_enabled.unwrap_or(false);
234
235 let zeta_icon = if enabled {
236 IconName::ZedPredict
237 } else {
238 IconName::ZedPredictDisabled
239 };
240
241 let current_user_terms_accepted =
242 self.user_store.read(cx).current_user_has_accepted_terms();
243
244 let icon_button = || {
245 let base = IconButton::new("zed-predict-pending-button", zeta_icon)
246 .shape(IconButtonShape::Square);
247
248 match (
249 current_user_terms_accepted,
250 self.popover_menu_handle.is_deployed(),
251 enabled,
252 ) {
253 (Some(false) | None, _, _) => {
254 let signed_in = current_user_terms_accepted.is_some();
255 let tooltip_meta = if signed_in {
256 "Read Terms of Service"
257 } else {
258 "Sign in to use"
259 };
260
261 base.tooltip(move |window, cx| {
262 Tooltip::with_meta(
263 "Edit Predictions",
264 None,
265 tooltip_meta,
266 window,
267 cx,
268 )
269 })
270 .on_click(cx.listener(
271 move |_, _, window, cx| {
272 telemetry::event!(
273 "Pending ToS Clicked",
274 source = "Edit Prediction Status Button"
275 );
276 window.dispatch_action(
277 zed_actions::OpenZedPredictOnboarding.boxed_clone(),
278 cx,
279 );
280 },
281 ))
282 }
283 (Some(true), true, _) => base,
284 (Some(true), false, true) => base.tooltip(|window, cx| {
285 Tooltip::for_action("Edit Prediction", &ToggleMenu, window, cx)
286 }),
287 (Some(true), false, false) => base.tooltip(|window, cx| {
288 Tooltip::with_meta(
289 "Edit Prediction",
290 Some(&ToggleMenu),
291 "Disabled For This File",
292 window,
293 cx,
294 )
295 }),
296 }
297 };
298
299 let this = cx.entity().clone();
300
301 let mut popover_menu = PopoverMenu::new("zeta")
302 .menu(move |window, cx| {
303 Some(this.update(cx, |this, cx| this.build_zeta_context_menu(window, cx)))
304 })
305 .anchor(Corner::BottomRight)
306 .with_handle(self.popover_menu_handle.clone());
307
308 let is_refreshing = self
309 .inline_completion_provider
310 .as_ref()
311 .map_or(false, |provider| provider.is_refreshing(cx));
312
313 if is_refreshing {
314 popover_menu = popover_menu.trigger(
315 icon_button().with_animation(
316 "pulsating-label",
317 Animation::new(Duration::from_secs(2))
318 .repeat()
319 .with_easing(pulsating_between(0.2, 1.0)),
320 |icon_button, delta| icon_button.alpha(delta),
321 ),
322 );
323 } else {
324 popover_menu = popover_menu.trigger(icon_button());
325 }
326
327 div().child(popover_menu.into_any_element())
328 }
329 }
330 }
331}
332
333impl InlineCompletionButton {
334 pub fn new(
335 workspace: WeakEntity<Workspace>,
336 fs: Arc<dyn Fs>,
337 user_store: Entity<UserStore>,
338 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
339 cx: &mut Context<Self>,
340 ) -> Self {
341 if let Some(copilot) = Copilot::global(cx) {
342 cx.observe(&copilot, |_, _, cx| cx.notify()).detach()
343 }
344
345 cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
346 .detach();
347
348 Self {
349 editor_subscription: None,
350 editor_enabled: None,
351 editor_focus_handle: None,
352 language: None,
353 file: None,
354 inline_completion_provider: None,
355 popover_menu_handle,
356 workspace,
357 fs,
358 user_store,
359 }
360 }
361
362 pub fn build_copilot_start_menu(
363 &mut self,
364 window: &mut Window,
365 cx: &mut Context<Self>,
366 ) -> Entity<ContextMenu> {
367 let fs = self.fs.clone();
368 ContextMenu::build(window, cx, |menu, _, _| {
369 menu.entry("Sign In", None, copilot::initiate_sign_in)
370 .entry("Disable Copilot", None, {
371 let fs = fs.clone();
372 move |_window, cx| hide_copilot(fs.clone(), cx)
373 })
374 .entry("Use Supermaven", None, {
375 let fs = fs.clone();
376 move |_window, cx| {
377 set_completion_provider(
378 fs.clone(),
379 cx,
380 InlineCompletionProvider::Supermaven,
381 )
382 }
383 })
384 })
385 }
386
387 pub fn build_language_settings_menu(&self, mut menu: ContextMenu, cx: &mut App) -> ContextMenu {
388 let fs = self.fs.clone();
389
390 menu = menu.header("Show Edit Predictions For");
391
392 if let Some(language) = self.language.clone() {
393 let fs = fs.clone();
394 let language_enabled =
395 language_settings::language_settings(Some(language.name()), None, cx)
396 .show_inline_completions;
397
398 menu = menu.toggleable_entry(
399 language.name(),
400 language_enabled,
401 IconPosition::End,
402 None,
403 move |_, cx| {
404 toggle_show_inline_completions_for_language(language.clone(), fs.clone(), cx)
405 },
406 );
407 }
408
409 let settings = AllLanguageSettings::get_global(cx);
410 let globally_enabled = settings.show_inline_completions(None, cx);
411 menu = menu.toggleable_entry(
412 "All Files",
413 globally_enabled,
414 IconPosition::End,
415 None,
416 move |_, cx| toggle_inline_completions_globally(fs.clone(), cx),
417 );
418 menu = menu.separator().header("Privacy Settings");
419
420 if let Some(provider) = &self.inline_completion_provider {
421 let data_collection = provider.data_collection_state(cx);
422 if data_collection.is_supported() {
423 let provider = provider.clone();
424 let enabled = data_collection.is_enabled();
425
426 menu = menu.item(
427 // TODO: We want to add something later that communicates whether
428 // the current project is open-source.
429 ContextMenuEntry::new("Share Training Data")
430 .toggleable(IconPosition::End, data_collection.is_enabled())
431 .documentation_aside(|_| {
432 Label::new("Zed automatically detects if your project is open-source. This setting is only applicable in such cases.").into_any_element()
433 })
434 .handler(move |_, cx| {
435 provider.toggle_data_collection(cx);
436
437 if !enabled {
438 telemetry::event!(
439 "Data Collection Enabled",
440 source = "Edit Prediction Status Menu"
441 );
442 } else {
443 telemetry::event!(
444 "Data Collection Disabled",
445 source = "Edit Prediction Status Menu"
446 );
447 }
448 })
449 )
450 }
451 }
452
453 menu = menu.item(
454 ContextMenuEntry::new("Configure Excluded Files")
455 .documentation_aside(|_| {
456 Label::new("This item takes you to the settings where you can specify files that will never be captured by any edit prediction model. List both specific file extensions and individual file names.").into_any_element()
457 })
458 .handler(move |window, cx| {
459 if let Some(workspace) = window.root().flatten() {
460 let workspace = workspace.downgrade();
461 window
462 .spawn(cx, |cx| {
463 open_disabled_globs_setting_in_editor(
464 workspace,
465 cx,
466 )
467 })
468 .detach_and_log_err(cx);
469 }
470 }),
471 );
472
473 if self.file.as_ref().map_or(false, |file| {
474 !all_language_settings(Some(file), cx).inline_completions_enabled_for_path(file.path())
475 }) {
476 menu = menu.item(
477 ContextMenuEntry::new("This file is excluded.")
478 .disabled(true)
479 .icon(IconName::ZedPredictDisabled)
480 .icon_size(IconSize::Small),
481 );
482 }
483
484 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
485 menu = menu
486 .separator()
487 .entry(
488 "Predict Edit at Cursor",
489 Some(Box::new(ShowInlineCompletion)),
490 {
491 let editor_focus_handle = editor_focus_handle.clone();
492
493 move |window, cx| {
494 editor_focus_handle.dispatch_action(&ShowInlineCompletion, window, cx);
495 }
496 },
497 )
498 .context(editor_focus_handle);
499 }
500
501 menu
502 }
503
504 fn build_copilot_context_menu(
505 &self,
506 window: &mut Window,
507 cx: &mut Context<Self>,
508 ) -> Entity<ContextMenu> {
509 ContextMenu::build(window, cx, |menu, _, cx| {
510 self.build_language_settings_menu(menu, cx)
511 .separator()
512 .link(
513 "Go to Copilot Settings",
514 OpenBrowser {
515 url: COPILOT_SETTINGS_URL.to_string(),
516 }
517 .boxed_clone(),
518 )
519 .action("Sign Out", copilot::SignOut.boxed_clone())
520 })
521 }
522
523 fn build_supermaven_context_menu(
524 &self,
525 window: &mut Window,
526 cx: &mut Context<Self>,
527 ) -> Entity<ContextMenu> {
528 ContextMenu::build(window, cx, |menu, _, cx| {
529 self.build_language_settings_menu(menu, cx)
530 .separator()
531 .action("Sign Out", supermaven::SignOut.boxed_clone())
532 })
533 }
534
535 fn build_zeta_context_menu(
536 &self,
537 window: &mut Window,
538 cx: &mut Context<Self>,
539 ) -> Entity<ContextMenu> {
540 let workspace = self.workspace.clone();
541 ContextMenu::build(window, cx, |menu, _window, cx| {
542 self.build_language_settings_menu(menu, cx).when(
543 cx.has_flag::<PredictEditsRateCompletionsFeatureFlag>(),
544 |this| {
545 this.entry(
546 "Rate Completions",
547 Some(RateCompletions.boxed_clone()),
548 move |window, cx| {
549 workspace
550 .update(cx, |workspace, cx| {
551 RateCompletionModal::toggle(workspace, window, cx)
552 })
553 .ok();
554 },
555 )
556 },
557 )
558 })
559 }
560
561 pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
562 let editor = editor.read(cx);
563 let snapshot = editor.buffer().read(cx).snapshot(cx);
564 let suggestion_anchor = editor.selections.newest_anchor().start;
565 let language = snapshot.language_at(suggestion_anchor);
566 let file = snapshot.file_at(suggestion_anchor).cloned();
567 self.editor_enabled = {
568 let file = file.as_ref();
569 Some(
570 file.map(|file| {
571 all_language_settings(Some(file), cx)
572 .inline_completions_enabled_for_path(file.path())
573 })
574 .unwrap_or(true),
575 )
576 };
577 self.inline_completion_provider = editor.inline_completion_provider();
578 self.language = language.cloned();
579 self.file = file;
580 self.editor_focus_handle = Some(editor.focus_handle(cx));
581
582 cx.notify();
583 }
584
585 pub fn toggle_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
586 self.popover_menu_handle.toggle(window, cx);
587 }
588}
589
590impl StatusItemView for InlineCompletionButton {
591 fn set_active_pane_item(
592 &mut self,
593 item: Option<&dyn ItemHandle>,
594 _: &mut Window,
595 cx: &mut Context<Self>,
596 ) {
597 if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
598 self.editor_subscription = Some((
599 cx.observe(&editor, Self::update_enabled),
600 editor.entity_id().as_u64() as usize,
601 ));
602 self.update_enabled(editor, cx);
603 } else {
604 self.language = None;
605 self.editor_subscription = None;
606 self.editor_enabled = None;
607 }
608 cx.notify();
609 }
610}
611
612impl SupermavenButtonStatus {
613 fn to_icon(&self) -> IconName {
614 match self {
615 SupermavenButtonStatus::Ready => IconName::Supermaven,
616 SupermavenButtonStatus::Errored(_) => IconName::SupermavenError,
617 SupermavenButtonStatus::NeedsActivation(_) => IconName::SupermavenInit,
618 SupermavenButtonStatus::Initializing => IconName::SupermavenInit,
619 }
620 }
621
622 fn to_tooltip(&self) -> String {
623 match self {
624 SupermavenButtonStatus::Ready => "Supermaven is ready".to_string(),
625 SupermavenButtonStatus::Errored(error) => format!("Supermaven error: {}", error),
626 SupermavenButtonStatus::NeedsActivation(_) => "Supermaven needs activation".to_string(),
627 SupermavenButtonStatus::Initializing => "Supermaven initializing".to_string(),
628 }
629 }
630
631 fn has_menu(&self) -> bool {
632 match self {
633 SupermavenButtonStatus::Ready | SupermavenButtonStatus::NeedsActivation(_) => true,
634 SupermavenButtonStatus::Errored(_) | SupermavenButtonStatus::Initializing => false,
635 }
636 }
637}
638
639async fn open_disabled_globs_setting_in_editor(
640 workspace: WeakEntity<Workspace>,
641 mut cx: AsyncWindowContext,
642) -> Result<()> {
643 let settings_editor = workspace
644 .update_in(&mut cx, |_, window, cx| {
645 create_and_open_local_file(paths::settings_file(), window, cx, || {
646 settings::initial_user_settings_content().as_ref().into()
647 })
648 })?
649 .await?
650 .downcast::<Editor>()
651 .unwrap();
652
653 settings_editor
654 .downgrade()
655 .update_in(&mut cx, |item, window, cx| {
656 let text = item.buffer().read(cx).snapshot(cx).text();
657
658 let settings = cx.global::<SettingsStore>();
659
660 // Ensure that we always have "inline_completions { "disabled_globs": [] }"
661 let edits = settings.edits_for_update::<AllLanguageSettings>(&text, |file| {
662 file.inline_completions
663 .get_or_insert_with(Default::default)
664 .disabled_globs
665 .get_or_insert_with(Vec::new);
666 });
667
668 if !edits.is_empty() {
669 item.edit(edits.iter().cloned(), cx);
670 }
671
672 let text = item.buffer().read(cx).snapshot(cx).text();
673
674 static DISABLED_GLOBS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
675 Regex::new(r#""disabled_globs":\s*\[\s*(?P<content>(?:.|\n)*?)\s*\]"#).unwrap()
676 });
677 // Only capture [...]
678 let range = DISABLED_GLOBS_REGEX.captures(&text).and_then(|captures| {
679 captures
680 .name("content")
681 .map(|inner_match| inner_match.start()..inner_match.end())
682 });
683 if let Some(range) = range {
684 item.change_selections(Some(Autoscroll::newest()), window, cx, |selections| {
685 selections.select_ranges(vec![range]);
686 });
687 }
688 })?;
689
690 anyhow::Ok(())
691}
692
693fn toggle_inline_completions_globally(fs: Arc<dyn Fs>, cx: &mut App) {
694 let show_inline_completions = all_language_settings(None, cx).show_inline_completions(None, cx);
695 update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
696 file.defaults.show_inline_completions = Some(!show_inline_completions)
697 });
698}
699
700fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: InlineCompletionProvider) {
701 update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
702 file.features
703 .get_or_insert(Default::default())
704 .inline_completion_provider = Some(provider);
705 });
706}
707
708fn toggle_show_inline_completions_for_language(
709 language: Arc<Language>,
710 fs: Arc<dyn Fs>,
711 cx: &mut App,
712) {
713 let show_inline_completions =
714 all_language_settings(None, cx).show_inline_completions(Some(&language), cx);
715 update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
716 file.languages
717 .entry(language.name())
718 .or_default()
719 .show_inline_completions = Some(!show_inline_completions);
720 });
721}
722
723fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut App) {
724 update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
725 file.features
726 .get_or_insert(Default::default())
727 .inline_completion_provider = Some(InlineCompletionProvider::None);
728 });
729}