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