1use anyhow::Result;
2use client::{Client, UserStore, zed_urls};
3use cloud_llm_client::UsageLimit;
4use codestral::CodestralEditPredictionDelegate;
5use copilot::{Copilot, Status};
6use edit_prediction::{
7 EditPredictionStore, MercuryFeatureFlag, SweepFeatureFlag, Zeta2FeatureFlag,
8};
9use edit_prediction_types::EditPredictionDelegateHandle;
10use editor::{
11 Editor, MultiBufferOffset, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll,
12};
13use feature_flags::FeatureFlagAppExt;
14use fs::Fs;
15use gpui::{
16 Action, Animation, AnimationExt, App, AsyncWindowContext, Corner, Entity, FocusHandle,
17 Focusable, IntoElement, ParentElement, Render, Subscription, WeakEntity, actions, div,
18 ease_in_out, pulsating_between,
19};
20use indoc::indoc;
21use language::{
22 EditPredictionsMode, File, Language,
23 language_settings::{self, AllLanguageSettings, EditPredictionProvider, all_language_settings},
24};
25use project::DisableAiSettings;
26use regex::Regex;
27use settings::{
28 EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
29 EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
30 EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, Settings, SettingsStore,
31 update_settings_file,
32};
33use std::{
34 sync::{Arc, LazyLock},
35 time::Duration,
36};
37use supermaven::{AccountStatus, Supermaven};
38use ui::{
39 Clickable, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, IconButton,
40 IconButtonShape, Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*,
41};
42use util::ResultExt as _;
43use workspace::{
44 StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle,
45 notifications::NotificationId,
46};
47use zed_actions::{OpenBrowser, OpenSettingsAt};
48
49use crate::{
50 CaptureExample, RatePredictions, rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag,
51};
52
53actions!(
54 edit_prediction,
55 [
56 /// Toggles the edit prediction menu.
57 ToggleMenu
58 ]
59);
60
61const COPILOT_SETTINGS_PATH: &str = "/settings/copilot";
62const COPILOT_SETTINGS_URL: &str = concat!("https://github.com", "/settings/copilot");
63const PRIVACY_DOCS: &str = "https://zed.dev/docs/ai/privacy-and-security";
64
65struct CopilotErrorToast;
66
67pub struct EditPredictionButton {
68 editor_subscription: Option<(Subscription, usize)>,
69 editor_enabled: Option<bool>,
70 editor_show_predictions: bool,
71 editor_focus_handle: Option<FocusHandle>,
72 language: Option<Arc<Language>>,
73 file: Option<Arc<dyn File>>,
74 edit_prediction_provider: Option<Arc<dyn EditPredictionDelegateHandle>>,
75 fs: Arc<dyn Fs>,
76 user_store: Entity<UserStore>,
77 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
78}
79
80enum SupermavenButtonStatus {
81 Ready,
82 Errored(String),
83 NeedsActivation(String),
84 Initializing,
85}
86
87impl Render for EditPredictionButton {
88 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
89 // Return empty div if AI is disabled
90 if DisableAiSettings::get_global(cx).disable_ai {
91 return div().hidden();
92 }
93
94 let all_language_settings = all_language_settings(None, cx);
95
96 match all_language_settings.edit_predictions.provider {
97 EditPredictionProvider::Copilot => {
98 let Some(copilot) = Copilot::global(cx) else {
99 return div().hidden();
100 };
101 let status = copilot.read(cx).status();
102
103 let enabled = self.editor_enabled.unwrap_or(false);
104
105 let icon = match status {
106 Status::Error(_) => IconName::CopilotError,
107 Status::Authorized => {
108 if enabled {
109 IconName::Copilot
110 } else {
111 IconName::CopilotDisabled
112 }
113 }
114 _ => IconName::CopilotInit,
115 };
116
117 if let Status::Error(e) = status {
118 return div().child(
119 IconButton::new("copilot-error", icon)
120 .icon_size(IconSize::Small)
121 .on_click(cx.listener(move |_, _, window, cx| {
122 if let Some(workspace) = window.root::<Workspace>().flatten() {
123 workspace.update(cx, |workspace, cx| {
124 workspace.show_toast(
125 Toast::new(
126 NotificationId::unique::<CopilotErrorToast>(),
127 format!("Copilot can't be started: {}", e),
128 )
129 .on_click(
130 "Reinstall Copilot",
131 |window, cx| {
132 copilot::reinstall_and_sign_in(window, cx)
133 },
134 ),
135 cx,
136 );
137 });
138 }
139 }))
140 .tooltip(|_window, cx| {
141 Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx)
142 }),
143 );
144 }
145 let this = cx.weak_entity();
146
147 div().child(
148 PopoverMenu::new("copilot")
149 .menu(move |window, cx| {
150 let current_status = Copilot::global(cx)?.read(cx).status();
151 match current_status {
152 Status::Authorized => this.update(cx, |this, cx| {
153 this.build_copilot_context_menu(window, cx)
154 }),
155 _ => this.update(cx, |this, cx| {
156 this.build_copilot_start_menu(window, cx)
157 }),
158 }
159 .ok()
160 })
161 .anchor(Corner::BottomRight)
162 .trigger_with_tooltip(
163 IconButton::new("copilot-icon", icon),
164 |_window, cx| Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx),
165 )
166 .with_handle(self.popover_menu_handle.clone()),
167 )
168 }
169
170 EditPredictionProvider::Supermaven => {
171 let Some(supermaven) = Supermaven::global(cx) else {
172 return div();
173 };
174
175 let supermaven = supermaven.read(cx);
176
177 let status = match supermaven {
178 Supermaven::Starting => SupermavenButtonStatus::Initializing,
179 Supermaven::FailedDownload { error } => {
180 SupermavenButtonStatus::Errored(error.to_string())
181 }
182 Supermaven::Spawned(agent) => {
183 let account_status = agent.account_status.clone();
184 match account_status {
185 AccountStatus::NeedsActivation { activate_url } => {
186 SupermavenButtonStatus::NeedsActivation(activate_url)
187 }
188 AccountStatus::Unknown => SupermavenButtonStatus::Initializing,
189 AccountStatus::Ready => SupermavenButtonStatus::Ready,
190 }
191 }
192 Supermaven::Error { error } => {
193 SupermavenButtonStatus::Errored(error.to_string())
194 }
195 };
196
197 let icon = status.to_icon();
198 let tooltip_text = status.to_tooltip();
199 let has_menu = status.has_menu();
200 let this = cx.weak_entity();
201 let fs = self.fs.clone();
202
203 div().child(
204 PopoverMenu::new("supermaven")
205 .menu(move |window, cx| match &status {
206 SupermavenButtonStatus::NeedsActivation(activate_url) => {
207 Some(ContextMenu::build(window, cx, |menu, _, _| {
208 let fs = fs.clone();
209 let activate_url = activate_url.clone();
210
211 menu.entry("Sign In", None, move |_, cx| {
212 cx.open_url(activate_url.as_str())
213 })
214 .entry(
215 "Use Zed AI",
216 None,
217 move |_, cx| {
218 set_completion_provider(
219 fs.clone(),
220 cx,
221 EditPredictionProvider::Zed,
222 )
223 },
224 )
225 }))
226 }
227 SupermavenButtonStatus::Ready => this
228 .update(cx, |this, cx| {
229 this.build_supermaven_context_menu(window, cx)
230 })
231 .ok(),
232 _ => None,
233 })
234 .anchor(Corner::BottomRight)
235 .trigger_with_tooltip(
236 IconButton::new("supermaven-icon", icon),
237 move |window, cx| {
238 if has_menu {
239 Tooltip::for_action(tooltip_text.clone(), &ToggleMenu, cx)
240 } else {
241 Tooltip::text(tooltip_text.clone())(window, cx)
242 }
243 },
244 )
245 .with_handle(self.popover_menu_handle.clone()),
246 )
247 }
248
249 EditPredictionProvider::Codestral => {
250 let enabled = self.editor_enabled.unwrap_or(true);
251 let has_api_key = CodestralEditPredictionDelegate::has_api_key(cx);
252 let this = cx.weak_entity();
253
254 let tooltip_meta = if has_api_key {
255 "Powered by Codestral"
256 } else {
257 "Missing API key for Codestral"
258 };
259
260 div().child(
261 PopoverMenu::new("codestral")
262 .menu(move |window, cx| {
263 this.update(cx, |this, cx| {
264 this.build_codestral_context_menu(window, cx)
265 })
266 .ok()
267 })
268 .anchor(Corner::BottomRight)
269 .trigger_with_tooltip(
270 IconButton::new("codestral-icon", IconName::AiMistral)
271 .shape(IconButtonShape::Square)
272 .when(!has_api_key, |this| {
273 this.indicator(Indicator::dot().color(Color::Error))
274 .indicator_border_color(Some(
275 cx.theme().colors().status_bar_background,
276 ))
277 })
278 .when(has_api_key && !enabled, |this| {
279 this.indicator(Indicator::dot().color(Color::Ignored))
280 .indicator_border_color(Some(
281 cx.theme().colors().status_bar_background,
282 ))
283 }),
284 move |_window, cx| {
285 Tooltip::with_meta(
286 "Edit Prediction",
287 Some(&ToggleMenu),
288 tooltip_meta,
289 cx,
290 )
291 },
292 )
293 .with_handle(self.popover_menu_handle.clone()),
294 )
295 }
296 provider @ (EditPredictionProvider::Experimental(_) | EditPredictionProvider::Zed) => {
297 let enabled = self.editor_enabled.unwrap_or(true);
298
299 let ep_icon;
300 let tooltip_meta;
301 let mut missing_token = false;
302
303 match provider {
304 EditPredictionProvider::Experimental(
305 EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
306 ) => {
307 ep_icon = IconName::SweepAi;
308 tooltip_meta = if missing_token {
309 "Missing API key for Sweep"
310 } else {
311 "Powered by Sweep"
312 };
313 missing_token = edit_prediction::EditPredictionStore::try_global(cx)
314 .is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token(cx));
315 }
316 EditPredictionProvider::Experimental(
317 EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
318 ) => {
319 ep_icon = IconName::Inception;
320 missing_token = edit_prediction::EditPredictionStore::try_global(cx)
321 .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token(cx));
322 tooltip_meta = if missing_token {
323 "Missing API key for Mercury"
324 } else {
325 "Powered by Mercury"
326 };
327 }
328 _ => {
329 ep_icon = if enabled {
330 IconName::ZedPredict
331 } else {
332 IconName::ZedPredictDisabled
333 };
334 tooltip_meta = "Powered by Zeta"
335 }
336 };
337
338 if edit_prediction::should_show_upsell_modal() {
339 let tooltip_meta = if self.user_store.read(cx).current_user().is_some() {
340 "Choose a Plan"
341 } else {
342 "Sign In To Use"
343 };
344
345 return div().child(
346 IconButton::new("zed-predict-pending-button", ep_icon)
347 .shape(IconButtonShape::Square)
348 .indicator(Indicator::dot().color(Color::Muted))
349 .indicator_border_color(Some(cx.theme().colors().status_bar_background))
350 .tooltip(move |_window, cx| {
351 Tooltip::with_meta("Edit Predictions", None, tooltip_meta, cx)
352 })
353 .on_click(cx.listener(move |_, _, window, cx| {
354 telemetry::event!(
355 "Pending ToS Clicked",
356 source = "Edit Prediction Status Button"
357 );
358 window.dispatch_action(
359 zed_actions::OpenZedPredictOnboarding.boxed_clone(),
360 cx,
361 );
362 })),
363 );
364 }
365
366 let mut over_limit = false;
367
368 if let Some(usage) = self
369 .edit_prediction_provider
370 .as_ref()
371 .and_then(|provider| provider.usage(cx))
372 {
373 over_limit = usage.over_limit()
374 }
375
376 let show_editor_predictions = self.editor_show_predictions;
377 let user = self.user_store.read(cx).current_user();
378
379 let indicator_color = if missing_token {
380 Some(Color::Error)
381 } else if enabled && (!show_editor_predictions || over_limit) {
382 Some(if over_limit {
383 Color::Error
384 } else {
385 Color::Muted
386 })
387 } else {
388 None
389 };
390
391 let icon_button = IconButton::new("zed-predict-pending-button", ep_icon)
392 .shape(IconButtonShape::Square)
393 .when_some(indicator_color, |this, color| {
394 this.indicator(Indicator::dot().color(color))
395 .indicator_border_color(Some(cx.theme().colors().status_bar_background))
396 })
397 .when(!self.popover_menu_handle.is_deployed(), |element| {
398 let user = user.clone();
399
400 element.tooltip(move |_window, cx| {
401 let description = if enabled {
402 if show_editor_predictions {
403 tooltip_meta
404 } else if user.is_none() {
405 "Sign In To Use"
406 } else {
407 "Hidden For This File"
408 }
409 } else {
410 "Disabled For This File"
411 };
412
413 Tooltip::with_meta(
414 "Edit Prediction",
415 Some(&ToggleMenu),
416 description,
417 cx,
418 )
419 })
420 });
421
422 let this = cx.weak_entity();
423
424 let mut popover_menu = PopoverMenu::new("edit-prediction")
425 .when(user.is_some(), |popover_menu| {
426 let this = this.clone();
427
428 popover_menu.menu(move |window, cx| {
429 this.update(cx, |this, cx| {
430 this.build_edit_prediction_context_menu(provider, window, cx)
431 })
432 .ok()
433 })
434 })
435 .when(user.is_none(), |popover_menu| {
436 let this = this.clone();
437
438 popover_menu.menu(move |window, cx| {
439 this.update(cx, |this, cx| {
440 this.build_zeta_upsell_context_menu(window, cx)
441 })
442 .ok()
443 })
444 })
445 .anchor(Corner::BottomRight)
446 .with_handle(self.popover_menu_handle.clone());
447
448 let is_refreshing = self
449 .edit_prediction_provider
450 .as_ref()
451 .is_some_and(|provider| provider.is_refreshing(cx));
452
453 if is_refreshing {
454 popover_menu = popover_menu.trigger(
455 icon_button.with_animation(
456 "pulsating-label",
457 Animation::new(Duration::from_secs(2))
458 .repeat()
459 .with_easing(pulsating_between(0.2, 1.0)),
460 |icon_button, delta| icon_button.alpha(delta),
461 ),
462 );
463 } else {
464 popover_menu = popover_menu.trigger(icon_button);
465 }
466
467 div().child(popover_menu.into_any_element())
468 }
469
470 EditPredictionProvider::None => div().hidden(),
471 }
472 }
473}
474
475impl EditPredictionButton {
476 pub fn new(
477 fs: Arc<dyn Fs>,
478 user_store: Entity<UserStore>,
479 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
480 client: Arc<Client>,
481 cx: &mut Context<Self>,
482 ) -> Self {
483 if let Some(copilot) = Copilot::global(cx) {
484 cx.observe(&copilot, |_, _, cx| cx.notify()).detach()
485 }
486
487 cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
488 .detach();
489
490 CodestralEditPredictionDelegate::ensure_api_key_loaded(client.http_client(), cx);
491
492 Self {
493 editor_subscription: None,
494 editor_enabled: None,
495 editor_show_predictions: true,
496 editor_focus_handle: None,
497 language: None,
498 file: None,
499 edit_prediction_provider: None,
500 user_store,
501 popover_menu_handle,
502 fs,
503 }
504 }
505
506 fn get_available_providers(&self, cx: &App) -> Vec<EditPredictionProvider> {
507 let mut providers = Vec::new();
508
509 providers.push(EditPredictionProvider::Zed);
510
511 if cx.has_flag::<Zeta2FeatureFlag>() {
512 providers.push(EditPredictionProvider::Experimental(
513 EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
514 ));
515 }
516
517 if let Some(copilot) = Copilot::global(cx) {
518 if matches!(copilot.read(cx).status(), Status::Authorized) {
519 providers.push(EditPredictionProvider::Copilot);
520 }
521 }
522
523 if let Some(supermaven) = Supermaven::global(cx) {
524 if let Supermaven::Spawned(agent) = supermaven.read(cx) {
525 if matches!(agent.account_status, AccountStatus::Ready) {
526 providers.push(EditPredictionProvider::Supermaven);
527 }
528 }
529 }
530
531 if CodestralEditPredictionDelegate::has_api_key(cx) {
532 providers.push(EditPredictionProvider::Codestral);
533 }
534
535 let ep_store = EditPredictionStore::try_global(cx);
536
537 if cx.has_flag::<SweepFeatureFlag>()
538 && ep_store
539 .as_ref()
540 .is_some_and(|ep_store| ep_store.read(cx).has_sweep_api_token(cx))
541 {
542 providers.push(EditPredictionProvider::Experimental(
543 EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
544 ));
545 }
546
547 if cx.has_flag::<MercuryFeatureFlag>()
548 && ep_store
549 .as_ref()
550 .is_some_and(|ep_store| ep_store.read(cx).has_mercury_api_token(cx))
551 {
552 providers.push(EditPredictionProvider::Experimental(
553 EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
554 ));
555 }
556
557 providers
558 }
559
560 fn add_provider_switching_section(
561 &self,
562 mut menu: ContextMenu,
563 current_provider: EditPredictionProvider,
564 cx: &mut App,
565 ) -> ContextMenu {
566 let available_providers = self.get_available_providers(cx);
567
568 let providers: Vec<_> = available_providers
569 .into_iter()
570 .filter(|p| *p != EditPredictionProvider::None)
571 .collect();
572
573 if !providers.is_empty() {
574 menu = menu.separator().header("Providers");
575
576 for provider in providers {
577 let is_current = provider == current_provider;
578 let fs = self.fs.clone();
579
580 let name = match provider {
581 EditPredictionProvider::Zed => "Zed AI",
582 EditPredictionProvider::Copilot => "GitHub Copilot",
583 EditPredictionProvider::Supermaven => "Supermaven",
584 EditPredictionProvider::Codestral => "Codestral",
585 EditPredictionProvider::Experimental(
586 EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
587 ) => "Sweep",
588 EditPredictionProvider::Experimental(
589 EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
590 ) => "Mercury",
591 EditPredictionProvider::Experimental(
592 EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
593 ) => "Zeta2",
594 EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => {
595 continue;
596 }
597 };
598
599 menu = menu.item(
600 ContextMenuEntry::new(name)
601 .toggleable(IconPosition::Start, is_current)
602 .handler(move |_, cx| {
603 set_completion_provider(fs.clone(), cx, provider);
604 }),
605 )
606 }
607 }
608
609 menu
610 }
611
612 pub fn build_copilot_start_menu(
613 &mut self,
614 window: &mut Window,
615 cx: &mut Context<Self>,
616 ) -> Entity<ContextMenu> {
617 let fs = self.fs.clone();
618 ContextMenu::build(window, cx, |menu, _, _| {
619 menu.entry("Sign In to Copilot", None, copilot::initiate_sign_in)
620 .entry("Disable Copilot", None, {
621 let fs = fs.clone();
622 move |_window, cx| hide_copilot(fs.clone(), cx)
623 })
624 .separator()
625 .entry("Use Zed AI", None, {
626 let fs = fs.clone();
627 move |_window, cx| {
628 set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed)
629 }
630 })
631 })
632 }
633
634 pub fn build_language_settings_menu(
635 &self,
636 mut menu: ContextMenu,
637 window: &Window,
638 cx: &mut App,
639 ) -> ContextMenu {
640 let fs = self.fs.clone();
641 let line_height = window.line_height();
642
643 menu = menu.header("Show Edit Predictions For");
644
645 let language_state = self.language.as_ref().map(|language| {
646 (
647 language.clone(),
648 language_settings::language_settings(Some(language.name()), None, cx)
649 .show_edit_predictions,
650 )
651 });
652
653 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
654 let entry = ContextMenuEntry::new("This Buffer")
655 .toggleable(IconPosition::Start, self.editor_show_predictions)
656 .action(Box::new(editor::actions::ToggleEditPrediction))
657 .handler(move |window, cx| {
658 editor_focus_handle.dispatch_action(
659 &editor::actions::ToggleEditPrediction,
660 window,
661 cx,
662 );
663 });
664
665 match language_state.clone() {
666 Some((language, false)) => {
667 menu = menu.item(
668 entry
669 .disabled(true)
670 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_cx| {
671 Label::new(format!("Edit predictions cannot be toggled for this buffer because they are disabled for {}", language.name()))
672 .into_any_element()
673 })
674 );
675 }
676 Some(_) | None => menu = menu.item(entry),
677 }
678 }
679
680 if let Some((language, language_enabled)) = language_state {
681 let fs = fs.clone();
682
683 menu = menu.toggleable_entry(
684 language.name(),
685 language_enabled,
686 IconPosition::Start,
687 None,
688 move |_, cx| {
689 toggle_show_edit_predictions_for_language(language.clone(), fs.clone(), cx)
690 },
691 );
692 }
693
694 let settings = AllLanguageSettings::get_global(cx);
695
696 let globally_enabled = settings.show_edit_predictions(None, cx);
697 let entry = ContextMenuEntry::new("All Files")
698 .toggleable(IconPosition::Start, globally_enabled)
699 .action(workspace::ToggleEditPrediction.boxed_clone())
700 .handler(|window, cx| {
701 window.dispatch_action(workspace::ToggleEditPrediction.boxed_clone(), cx)
702 });
703 menu = menu.item(entry);
704
705 let provider = settings.edit_predictions.provider;
706 let current_mode = settings.edit_predictions_mode();
707 let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle);
708 let eager_mode = matches!(current_mode, EditPredictionsMode::Eager);
709
710 menu = menu
711 .separator()
712 .header("Display Modes")
713 .item(
714 ContextMenuEntry::new("Eager")
715 .toggleable(IconPosition::Start, eager_mode)
716 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| {
717 Label::new("Display predictions inline when there are no language server completions available.").into_any_element()
718 })
719 .handler({
720 let fs = fs.clone();
721 move |_, cx| {
722 toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Eager, cx)
723 }
724 }),
725 )
726 .item(
727 ContextMenuEntry::new("Subtle")
728 .toggleable(IconPosition::Start, subtle_mode)
729 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| {
730 Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element()
731 })
732 .handler({
733 let fs = fs.clone();
734 move |_, cx| {
735 toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Subtle, cx)
736 }
737 }),
738 );
739
740 menu = menu.separator().header("Privacy");
741
742 if matches!(
743 provider,
744 EditPredictionProvider::Zed
745 | EditPredictionProvider::Experimental(
746 EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
747 )
748 ) {
749 if let Some(provider) = &self.edit_prediction_provider {
750 let data_collection = provider.data_collection_state(cx);
751
752 if data_collection.is_supported() {
753 let provider = provider.clone();
754 let enabled = data_collection.is_enabled();
755 let is_open_source = data_collection.is_project_open_source();
756 let is_collecting = data_collection.is_enabled();
757 let (icon_name, icon_color) = if is_open_source && is_collecting {
758 (IconName::Check, Color::Success)
759 } else {
760 (IconName::Check, Color::Accent)
761 };
762
763 menu = menu.item(
764 ContextMenuEntry::new("Training Data Collection")
765 .toggleable(IconPosition::Start, data_collection.is_enabled())
766 .icon(icon_name)
767 .icon_color(icon_color)
768 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| {
769 let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
770 (true, true) => (
771 "Project identified as open source, and you're sharing data.",
772 Color::Default,
773 IconName::Check,
774 Color::Success,
775 ),
776 (true, false) => (
777 "Project identified as open source, but you're not sharing data.",
778 Color::Muted,
779 IconName::Close,
780 Color::Muted,
781 ),
782 (false, true) => (
783 "Project not identified as open source. No data captured.",
784 Color::Muted,
785 IconName::Close,
786 Color::Muted,
787 ),
788 (false, false) => (
789 "Project not identified as open source, and setting turned off.",
790 Color::Muted,
791 IconName::Close,
792 Color::Muted,
793 ),
794 };
795 v_flex()
796 .gap_2()
797 .child(
798 Label::new(indoc!{
799 "Help us improve our open dataset model by sharing data from open source repositories. \
800 Zed must detect a license file in your repo for this setting to take effect. \
801 Files with sensitive data and secrets are excluded by default."
802 })
803 )
804 .child(
805 h_flex()
806 .items_start()
807 .pt_2()
808 .pr_1()
809 .flex_1()
810 .gap_1p5()
811 .border_t_1()
812 .border_color(cx.theme().colors().border_variant)
813 .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color)))
814 .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx)))
815 )
816 .into_any_element()
817 })
818 .handler(move |_, cx| {
819 provider.toggle_data_collection(cx);
820
821 if !enabled {
822 telemetry::event!(
823 "Data Collection Enabled",
824 source = "Edit Prediction Status Menu"
825 );
826 } else {
827 telemetry::event!(
828 "Data Collection Disabled",
829 source = "Edit Prediction Status Menu"
830 );
831 }
832 })
833 );
834
835 if is_collecting && !is_open_source {
836 menu = menu.item(
837 ContextMenuEntry::new("No data captured.")
838 .disabled(true)
839 .icon(IconName::Close)
840 .icon_color(Color::Error)
841 .icon_size(IconSize::Small),
842 );
843 }
844 }
845 }
846 }
847
848 menu = menu.item(
849 ContextMenuEntry::new("Configure Excluded Files")
850 .icon(IconName::LockOutlined)
851 .icon_color(Color::Muted)
852 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, |_| {
853 Label::new(indoc!{"
854 Open your settings to add sensitive paths for which Zed will never predict edits."}).into_any_element()
855 })
856 .handler(move |window, cx| {
857 if let Some(workspace) = window.root().flatten() {
858 let workspace = workspace.downgrade();
859 window
860 .spawn(cx, async |cx| {
861 open_disabled_globs_setting_in_editor(
862 workspace,
863 cx,
864 ).await
865 })
866 .detach_and_log_err(cx);
867 }
868 }),
869 ).item(
870 ContextMenuEntry::new("View Docs")
871 .icon(IconName::FileGeneric)
872 .icon_color(Color::Muted)
873 .handler(move |_, cx| {
874 cx.open_url(PRIVACY_DOCS);
875 })
876 );
877
878 if !self.editor_enabled.unwrap_or(true) {
879 menu = menu.item(
880 ContextMenuEntry::new("This file is excluded.")
881 .disabled(true)
882 .icon(IconName::ZedPredictDisabled)
883 .icon_size(IconSize::Small),
884 );
885 }
886
887 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
888 menu = menu
889 .separator()
890 .header("Actions")
891 .entry(
892 "Predict Edit at Cursor",
893 Some(Box::new(ShowEditPrediction)),
894 {
895 let editor_focus_handle = editor_focus_handle.clone();
896 move |window, cx| {
897 editor_focus_handle.dispatch_action(&ShowEditPrediction, window, cx);
898 }
899 },
900 )
901 .context(editor_focus_handle)
902 .when(
903 cx.has_flag::<PredictEditsRatePredictionsFeatureFlag>(),
904 |this| {
905 this.action(
906 "Capture Edit Prediction Example",
907 CaptureExample.boxed_clone(),
908 )
909 .action("Rate Predictions", RatePredictions.boxed_clone())
910 },
911 );
912 }
913
914 menu
915 }
916
917 fn build_copilot_context_menu(
918 &self,
919 window: &mut Window,
920 cx: &mut Context<Self>,
921 ) -> Entity<ContextMenu> {
922 let all_language_settings = all_language_settings(None, cx);
923 let copilot_config = copilot::copilot_chat::CopilotChatConfiguration {
924 enterprise_uri: all_language_settings
925 .edit_predictions
926 .copilot
927 .enterprise_uri
928 .clone(),
929 };
930 let settings_url = copilot_settings_url(copilot_config.enterprise_uri.as_deref());
931
932 ContextMenu::build(window, cx, |menu, window, cx| {
933 let menu = self.build_language_settings_menu(menu, window, cx);
934 let menu =
935 self.add_provider_switching_section(menu, EditPredictionProvider::Copilot, cx);
936
937 menu.separator()
938 .link(
939 "Go to Copilot Settings",
940 OpenBrowser { url: settings_url }.boxed_clone(),
941 )
942 .action("Sign Out", copilot::SignOut.boxed_clone())
943 })
944 }
945
946 fn build_supermaven_context_menu(
947 &self,
948 window: &mut Window,
949 cx: &mut Context<Self>,
950 ) -> Entity<ContextMenu> {
951 ContextMenu::build(window, cx, |menu, window, cx| {
952 let menu = self.build_language_settings_menu(menu, window, cx);
953 let menu =
954 self.add_provider_switching_section(menu, EditPredictionProvider::Supermaven, cx);
955
956 menu.separator()
957 .action("Sign Out", supermaven::SignOut.boxed_clone())
958 })
959 }
960
961 fn build_codestral_context_menu(
962 &self,
963 window: &mut Window,
964 cx: &mut Context<Self>,
965 ) -> Entity<ContextMenu> {
966 ContextMenu::build(window, cx, |menu, window, cx| {
967 let menu = self.build_language_settings_menu(menu, window, cx);
968 let menu =
969 self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx);
970
971 menu
972 })
973 }
974
975 fn build_edit_prediction_context_menu(
976 &self,
977 provider: EditPredictionProvider,
978 window: &mut Window,
979 cx: &mut Context<Self>,
980 ) -> Entity<ContextMenu> {
981 ContextMenu::build(window, cx, |mut menu, window, cx| {
982 if let Some(usage) = self
983 .edit_prediction_provider
984 .as_ref()
985 .and_then(|provider| provider.usage(cx))
986 {
987 menu = menu.header("Usage");
988 menu = menu
989 .custom_entry(
990 move |_window, cx| {
991 let used_percentage = match usage.limit {
992 UsageLimit::Limited(limit) => {
993 Some((usage.amount as f32 / limit as f32) * 100.)
994 }
995 UsageLimit::Unlimited => None,
996 };
997
998 h_flex()
999 .flex_1()
1000 .gap_1p5()
1001 .children(
1002 used_percentage.map(|percent| {
1003 ProgressBar::new("usage", percent, 100., cx)
1004 }),
1005 )
1006 .child(
1007 Label::new(match usage.limit {
1008 UsageLimit::Limited(limit) => {
1009 format!("{} / {limit}", usage.amount)
1010 }
1011 UsageLimit::Unlimited => format!("{} / ∞", usage.amount),
1012 })
1013 .size(LabelSize::Small)
1014 .color(Color::Muted),
1015 )
1016 .into_any_element()
1017 },
1018 move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
1019 )
1020 .when(usage.over_limit(), |menu| -> ContextMenu {
1021 menu.entry("Subscribe to increase your limit", None, |_window, cx| {
1022 cx.open_url(&zed_urls::account_url(cx))
1023 })
1024 })
1025 .separator();
1026 } else if self.user_store.read(cx).account_too_young() {
1027 menu = menu
1028 .custom_entry(
1029 |_window, _cx| {
1030 Label::new("Your GitHub account is less than 30 days old.")
1031 .size(LabelSize::Small)
1032 .color(Color::Warning)
1033 .into_any_element()
1034 },
1035 |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
1036 )
1037 .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
1038 cx.open_url(&zed_urls::account_url(cx))
1039 })
1040 .separator();
1041 } else if self.user_store.read(cx).has_overdue_invoices() {
1042 menu = menu
1043 .custom_entry(
1044 |_window, _cx| {
1045 Label::new("You have an outstanding invoice")
1046 .size(LabelSize::Small)
1047 .color(Color::Warning)
1048 .into_any_element()
1049 },
1050 |_window, cx| {
1051 cx.open_url(&zed_urls::account_url(cx))
1052 },
1053 )
1054 .entry(
1055 "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
1056 None,
1057 |_window, cx| {
1058 cx.open_url(&zed_urls::account_url(cx))
1059 },
1060 )
1061 .separator();
1062 }
1063
1064 menu = self.build_language_settings_menu(menu, window, cx);
1065
1066 if cx.has_flag::<Zeta2FeatureFlag>() {
1067 let settings = all_language_settings(None, cx);
1068 let context_retrieval = settings.edit_predictions.use_context;
1069 menu = menu.separator().header("Context Retrieval").item(
1070 ContextMenuEntry::new("Enable Context Retrieval")
1071 .toggleable(IconPosition::Start, context_retrieval)
1072 .action(workspace::ToggleEditPrediction.boxed_clone())
1073 .handler({
1074 let fs = self.fs.clone();
1075 move |_, cx| {
1076 update_settings_file(fs.clone(), cx, move |settings, _| {
1077 settings
1078 .project
1079 .all_languages
1080 .features
1081 .get_or_insert_default()
1082 .experimental_edit_prediction_context_retrieval =
1083 Some(!context_retrieval)
1084 });
1085 }
1086 }),
1087 );
1088 }
1089
1090 menu = self.add_provider_switching_section(menu, provider, cx);
1091 menu = menu.separator().item(
1092 ContextMenuEntry::new("Configure Providers")
1093 .icon(IconName::Settings)
1094 .icon_position(IconPosition::Start)
1095 .icon_color(Color::Muted)
1096 .handler(move |window, cx| {
1097 window.dispatch_action(
1098 OpenSettingsAt {
1099 path: "edit_predictions.providers".to_string(),
1100 }
1101 .boxed_clone(),
1102 cx,
1103 );
1104 }),
1105 );
1106
1107 menu
1108 })
1109 }
1110
1111 fn build_zeta_upsell_context_menu(
1112 &self,
1113 window: &mut Window,
1114 cx: &mut Context<Self>,
1115 ) -> Entity<ContextMenu> {
1116 ContextMenu::build(window, cx, |mut menu, _window, cx| {
1117 menu = menu
1118 .custom_row(move |_window, cx| {
1119 let description = indoc! {
1120 "You get 2,000 accepted suggestions at every keystroke for free, \
1121 powered by Zeta, our open-source, open-data model"
1122 };
1123
1124 v_flex()
1125 .max_w_64()
1126 .h(rems_from_px(148.))
1127 .child(render_zeta_tab_animation(cx))
1128 .child(Label::new("Edit Prediction"))
1129 .child(
1130 Label::new(description)
1131 .color(Color::Muted)
1132 .size(LabelSize::Small),
1133 )
1134 .into_any_element()
1135 })
1136 .separator()
1137 .entry("Sign In & Start Using", None, |window, cx| {
1138 let client = Client::global(cx);
1139 window
1140 .spawn(cx, async move |cx| {
1141 client
1142 .sign_in_with_optional_connect(true, &cx)
1143 .await
1144 .log_err();
1145 })
1146 .detach();
1147 })
1148 .link(
1149 "Learn More",
1150 OpenBrowser {
1151 url: zed_urls::edit_prediction_docs(cx),
1152 }
1153 .boxed_clone(),
1154 );
1155
1156 menu
1157 })
1158 }
1159
1160 pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
1161 let editor = editor.read(cx);
1162 let snapshot = editor.buffer().read(cx).snapshot(cx);
1163 let suggestion_anchor = editor.selections.newest_anchor().start;
1164 let language = snapshot.language_at(suggestion_anchor);
1165 let file = snapshot.file_at(suggestion_anchor).cloned();
1166 self.editor_enabled = {
1167 let file = file.as_ref();
1168 Some(
1169 file.map(|file| {
1170 all_language_settings(Some(file), cx)
1171 .edit_predictions_enabled_for_file(file, cx)
1172 })
1173 .unwrap_or(true),
1174 )
1175 };
1176 self.editor_show_predictions = editor.edit_predictions_enabled();
1177 self.edit_prediction_provider = editor.edit_prediction_provider();
1178 self.language = language.cloned();
1179 self.file = file;
1180 self.editor_focus_handle = Some(editor.focus_handle(cx));
1181
1182 cx.notify();
1183 }
1184}
1185
1186impl StatusItemView for EditPredictionButton {
1187 fn set_active_pane_item(
1188 &mut self,
1189 item: Option<&dyn ItemHandle>,
1190 _: &mut Window,
1191 cx: &mut Context<Self>,
1192 ) {
1193 if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
1194 self.editor_subscription = Some((
1195 cx.observe(&editor, Self::update_enabled),
1196 editor.entity_id().as_u64() as usize,
1197 ));
1198 self.update_enabled(editor, cx);
1199 } else {
1200 self.language = None;
1201 self.editor_subscription = None;
1202 self.editor_enabled = None;
1203 }
1204 cx.notify();
1205 }
1206}
1207
1208impl SupermavenButtonStatus {
1209 fn to_icon(&self) -> IconName {
1210 match self {
1211 SupermavenButtonStatus::Ready => IconName::Supermaven,
1212 SupermavenButtonStatus::Errored(_) => IconName::SupermavenError,
1213 SupermavenButtonStatus::NeedsActivation(_) => IconName::SupermavenInit,
1214 SupermavenButtonStatus::Initializing => IconName::SupermavenInit,
1215 }
1216 }
1217
1218 fn to_tooltip(&self) -> String {
1219 match self {
1220 SupermavenButtonStatus::Ready => "Supermaven is ready".to_string(),
1221 SupermavenButtonStatus::Errored(error) => format!("Supermaven error: {}", error),
1222 SupermavenButtonStatus::NeedsActivation(_) => "Supermaven needs activation".to_string(),
1223 SupermavenButtonStatus::Initializing => "Supermaven initializing".to_string(),
1224 }
1225 }
1226
1227 fn has_menu(&self) -> bool {
1228 match self {
1229 SupermavenButtonStatus::Ready | SupermavenButtonStatus::NeedsActivation(_) => true,
1230 SupermavenButtonStatus::Errored(_) | SupermavenButtonStatus::Initializing => false,
1231 }
1232 }
1233}
1234
1235async fn open_disabled_globs_setting_in_editor(
1236 workspace: WeakEntity<Workspace>,
1237 cx: &mut AsyncWindowContext,
1238) -> Result<()> {
1239 let settings_editor = workspace
1240 .update_in(cx, |_, window, cx| {
1241 create_and_open_local_file(paths::settings_file(), window, cx, || {
1242 settings::initial_user_settings_content().as_ref().into()
1243 })
1244 })?
1245 .await?
1246 .downcast::<Editor>()
1247 .unwrap();
1248
1249 settings_editor
1250 .downgrade()
1251 .update_in(cx, |item, window, cx| {
1252 let text = item.buffer().read(cx).snapshot(cx).text();
1253
1254 let settings = cx.global::<SettingsStore>();
1255
1256 // Ensure that we always have "edit_predictions { "disabled_globs": [] }"
1257 let edits = settings.edits_for_update(&text, |file| {
1258 file.project
1259 .all_languages
1260 .edit_predictions
1261 .get_or_insert_with(Default::default)
1262 .disabled_globs
1263 .get_or_insert_with(Vec::new);
1264 });
1265
1266 if !edits.is_empty() {
1267 item.edit(
1268 edits
1269 .into_iter()
1270 .map(|(r, s)| (MultiBufferOffset(r.start)..MultiBufferOffset(r.end), s)),
1271 cx,
1272 );
1273 }
1274
1275 let text = item.buffer().read(cx).snapshot(cx).text();
1276
1277 static DISABLED_GLOBS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1278 Regex::new(r#""disabled_globs":\s*\[\s*(?P<content>(?:.|\n)*?)\s*\]"#).unwrap()
1279 });
1280 // Only capture [...]
1281 let range = DISABLED_GLOBS_REGEX.captures(&text).and_then(|captures| {
1282 captures
1283 .name("content")
1284 .map(|inner_match| inner_match.start()..inner_match.end())
1285 });
1286 if let Some(range) = range {
1287 let range = MultiBufferOffset(range.start)..MultiBufferOffset(range.end);
1288 item.change_selections(
1289 SelectionEffects::scroll(Autoscroll::newest()),
1290 window,
1291 cx,
1292 |selections| {
1293 selections.select_ranges(vec![range]);
1294 },
1295 );
1296 }
1297 })?;
1298
1299 anyhow::Ok(())
1300}
1301
1302fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: EditPredictionProvider) {
1303 update_settings_file(fs, cx, move |settings, _| {
1304 settings
1305 .project
1306 .all_languages
1307 .features
1308 .get_or_insert_default()
1309 .edit_prediction_provider = Some(provider);
1310 });
1311}
1312
1313fn toggle_show_edit_predictions_for_language(
1314 language: Arc<Language>,
1315 fs: Arc<dyn Fs>,
1316 cx: &mut App,
1317) {
1318 let show_edit_predictions =
1319 all_language_settings(None, cx).show_edit_predictions(Some(&language), cx);
1320 update_settings_file(fs, cx, move |settings, _| {
1321 settings
1322 .project
1323 .all_languages
1324 .languages
1325 .0
1326 .entry(language.name().0)
1327 .or_default()
1328 .show_edit_predictions = Some(!show_edit_predictions);
1329 });
1330}
1331
1332fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut App) {
1333 update_settings_file(fs, cx, move |settings, _| {
1334 settings
1335 .project
1336 .all_languages
1337 .features
1338 .get_or_insert(Default::default())
1339 .edit_prediction_provider = Some(EditPredictionProvider::None);
1340 });
1341}
1342
1343fn toggle_edit_prediction_mode(fs: Arc<dyn Fs>, mode: EditPredictionsMode, cx: &mut App) {
1344 let settings = AllLanguageSettings::get_global(cx);
1345 let current_mode = settings.edit_predictions_mode();
1346
1347 if current_mode != mode {
1348 update_settings_file(fs, cx, move |settings, _cx| {
1349 if let Some(edit_predictions) = settings.project.all_languages.edit_predictions.as_mut()
1350 {
1351 edit_predictions.mode = Some(mode);
1352 } else {
1353 settings.project.all_languages.edit_predictions =
1354 Some(settings::EditPredictionSettingsContent {
1355 mode: Some(mode),
1356 ..Default::default()
1357 });
1358 }
1359 });
1360 }
1361}
1362
1363fn render_zeta_tab_animation(cx: &App) -> impl IntoElement {
1364 let tab = |n: u64, inverted: bool| {
1365 let text_color = cx.theme().colors().text;
1366
1367 h_flex().child(
1368 h_flex()
1369 .text_size(TextSize::XSmall.rems(cx))
1370 .text_color(text_color)
1371 .child("tab")
1372 .with_animation(
1373 ElementId::Integer(n),
1374 Animation::new(Duration::from_secs(3)).repeat(),
1375 move |tab, delta| {
1376 let n_f32 = n as f32;
1377
1378 let offset = if inverted {
1379 0.2 * (4.0 - n_f32)
1380 } else {
1381 0.2 * n_f32
1382 };
1383
1384 let phase = (delta - offset + 1.0) % 1.0;
1385 let pulse = if phase < 0.6 {
1386 let t = phase / 0.6;
1387 1.0 - (0.5 - t).abs() * 2.0
1388 } else {
1389 0.0
1390 };
1391
1392 let eased = ease_in_out(pulse);
1393 let opacity = 0.1 + 0.5 * eased;
1394
1395 tab.text_color(text_color.opacity(opacity))
1396 },
1397 ),
1398 )
1399 };
1400
1401 let tab_sequence = |inverted: bool| {
1402 h_flex()
1403 .gap_1()
1404 .child(tab(0, inverted))
1405 .child(tab(1, inverted))
1406 .child(tab(2, inverted))
1407 .child(tab(3, inverted))
1408 .child(tab(4, inverted))
1409 };
1410
1411 h_flex()
1412 .my_1p5()
1413 .p_4()
1414 .justify_center()
1415 .gap_2()
1416 .rounded_xs()
1417 .border_1()
1418 .border_dashed()
1419 .border_color(cx.theme().colors().border)
1420 .bg(gpui::pattern_slash(
1421 cx.theme().colors().border.opacity(0.5),
1422 1.,
1423 8.,
1424 ))
1425 .child(tab_sequence(true))
1426 .child(Icon::new(IconName::ZedPredict))
1427 .child(tab_sequence(false))
1428}
1429
1430fn copilot_settings_url(enterprise_uri: Option<&str>) -> String {
1431 match enterprise_uri {
1432 Some(uri) => {
1433 format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH)
1434 }
1435 None => COPILOT_SETTINGS_URL.to_string(),
1436 }
1437}
1438
1439#[cfg(test)]
1440mod tests {
1441 use super::*;
1442 use gpui::TestAppContext;
1443
1444 #[gpui::test]
1445 async fn test_copilot_settings_url_with_enterprise_uri(cx: &mut TestAppContext) {
1446 cx.update(|cx| {
1447 let settings_store = SettingsStore::test(cx);
1448 cx.set_global(settings_store);
1449 });
1450
1451 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1452 settings_store
1453 .set_user_settings(
1454 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com"}}}"#,
1455 cx,
1456 )
1457 .unwrap();
1458 });
1459
1460 let url = cx.update(|cx| {
1461 let all_language_settings = all_language_settings(None, cx);
1462 copilot_settings_url(
1463 all_language_settings
1464 .edit_predictions
1465 .copilot
1466 .enterprise_uri
1467 .as_deref(),
1468 )
1469 });
1470
1471 assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1472 }
1473
1474 #[gpui::test]
1475 async fn test_copilot_settings_url_with_enterprise_uri_trailing_slash(cx: &mut TestAppContext) {
1476 cx.update(|cx| {
1477 let settings_store = SettingsStore::test(cx);
1478 cx.set_global(settings_store);
1479 });
1480
1481 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1482 settings_store
1483 .set_user_settings(
1484 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com/"}}}"#,
1485 cx,
1486 )
1487 .unwrap();
1488 });
1489
1490 let url = cx.update(|cx| {
1491 let all_language_settings = all_language_settings(None, cx);
1492 copilot_settings_url(
1493 all_language_settings
1494 .edit_predictions
1495 .copilot
1496 .enterprise_uri
1497 .as_deref(),
1498 )
1499 });
1500
1501 assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1502 }
1503
1504 #[gpui::test]
1505 async fn test_copilot_settings_url_without_enterprise_uri(cx: &mut TestAppContext) {
1506 cx.update(|cx| {
1507 let settings_store = SettingsStore::test(cx);
1508 cx.set_global(settings_store);
1509 });
1510
1511 let url = cx.update(|cx| {
1512 let all_language_settings = all_language_settings(None, cx);
1513 copilot_settings_url(
1514 all_language_settings
1515 .edit_predictions
1516 .copilot
1517 .enterprise_uri
1518 .as_deref(),
1519 )
1520 });
1521
1522 assert_eq!(url, "https://github.com/settings/copilot");
1523 }
1524}