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