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 cx.observe_global::<EditPredictionStore>(move |_, cx| cx.notify())
491 .detach();
492
493 let sweep_api_token_task = edit_prediction::sweep_ai::load_sweep_api_token(cx);
494 let mercury_api_token_task = edit_prediction::mercury::load_mercury_api_token(cx);
495
496 cx.spawn(async move |this, cx| {
497 _ = futures::join!(sweep_api_token_task, mercury_api_token_task);
498 this.update(cx, |_, cx| {
499 cx.notify();
500 })
501 .ok();
502 })
503 .detach();
504
505 CodestralEditPredictionDelegate::ensure_api_key_loaded(client.http_client(), cx);
506
507 Self {
508 editor_subscription: None,
509 editor_enabled: None,
510 editor_show_predictions: true,
511 editor_focus_handle: None,
512 language: None,
513 file: None,
514 edit_prediction_provider: None,
515 user_store,
516 popover_menu_handle,
517 fs,
518 }
519 }
520
521 fn get_available_providers(&self, cx: &mut App) -> Vec<EditPredictionProvider> {
522 let mut providers = Vec::new();
523
524 providers.push(EditPredictionProvider::Zed);
525
526 if cx.has_flag::<Zeta2FeatureFlag>() {
527 providers.push(EditPredictionProvider::Experimental(
528 EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
529 ));
530 }
531
532 if let Some(copilot) = Copilot::global(cx) {
533 if matches!(copilot.read(cx).status(), Status::Authorized) {
534 providers.push(EditPredictionProvider::Copilot);
535 }
536 }
537
538 if let Some(supermaven) = Supermaven::global(cx) {
539 if let Supermaven::Spawned(agent) = supermaven.read(cx) {
540 if matches!(agent.account_status, AccountStatus::Ready) {
541 providers.push(EditPredictionProvider::Supermaven);
542 }
543 }
544 }
545
546 if CodestralEditPredictionDelegate::has_api_key(cx) {
547 providers.push(EditPredictionProvider::Codestral);
548 }
549
550 if cx.has_flag::<SweepFeatureFlag>()
551 && edit_prediction::sweep_ai::sweep_api_token(cx)
552 .read(cx)
553 .has_key()
554 {
555 providers.push(EditPredictionProvider::Experimental(
556 EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
557 ));
558 }
559
560 if cx.has_flag::<MercuryFeatureFlag>()
561 && edit_prediction::mercury::mercury_api_token(cx)
562 .read(cx)
563 .has_key()
564 {
565 providers.push(EditPredictionProvider::Experimental(
566 EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
567 ));
568 }
569
570 providers
571 }
572
573 fn add_provider_switching_section(
574 &self,
575 mut menu: ContextMenu,
576 current_provider: EditPredictionProvider,
577 cx: &mut App,
578 ) -> ContextMenu {
579 let available_providers = self.get_available_providers(cx);
580
581 let providers: Vec<_> = available_providers
582 .into_iter()
583 .filter(|p| *p != EditPredictionProvider::None)
584 .collect();
585
586 if !providers.is_empty() {
587 menu = menu.separator().header("Providers");
588
589 for provider in providers {
590 let is_current = provider == current_provider;
591 let fs = self.fs.clone();
592
593 let name = match provider {
594 EditPredictionProvider::Zed => "Zed AI",
595 EditPredictionProvider::Copilot => "GitHub Copilot",
596 EditPredictionProvider::Supermaven => "Supermaven",
597 EditPredictionProvider::Codestral => "Codestral",
598 EditPredictionProvider::Experimental(
599 EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
600 ) => "Sweep",
601 EditPredictionProvider::Experimental(
602 EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
603 ) => "Mercury",
604 EditPredictionProvider::Experimental(
605 EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
606 ) => "Zeta2",
607 EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => {
608 continue;
609 }
610 };
611
612 menu = menu.item(
613 ContextMenuEntry::new(name)
614 .toggleable(IconPosition::Start, is_current)
615 .handler(move |_, cx| {
616 set_completion_provider(fs.clone(), cx, provider);
617 }),
618 )
619 }
620 }
621
622 menu
623 }
624
625 pub fn build_copilot_start_menu(
626 &mut self,
627 window: &mut Window,
628 cx: &mut Context<Self>,
629 ) -> Entity<ContextMenu> {
630 let fs = self.fs.clone();
631 ContextMenu::build(window, cx, |menu, _, _| {
632 menu.entry("Sign In to Copilot", None, copilot::initiate_sign_in)
633 .entry("Disable Copilot", None, {
634 let fs = fs.clone();
635 move |_window, cx| hide_copilot(fs.clone(), cx)
636 })
637 .separator()
638 .entry("Use Zed AI", None, {
639 let fs = fs.clone();
640 move |_window, cx| {
641 set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed)
642 }
643 })
644 })
645 }
646
647 pub fn build_language_settings_menu(
648 &self,
649 mut menu: ContextMenu,
650 window: &Window,
651 cx: &mut App,
652 ) -> ContextMenu {
653 let fs = self.fs.clone();
654 let line_height = window.line_height();
655
656 menu = menu.header("Show Edit Predictions For");
657
658 let language_state = self.language.as_ref().map(|language| {
659 (
660 language.clone(),
661 language_settings::language_settings(Some(language.name()), None, cx)
662 .show_edit_predictions,
663 )
664 });
665
666 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
667 let entry = ContextMenuEntry::new("This Buffer")
668 .toggleable(IconPosition::Start, self.editor_show_predictions)
669 .action(Box::new(editor::actions::ToggleEditPrediction))
670 .handler(move |window, cx| {
671 editor_focus_handle.dispatch_action(
672 &editor::actions::ToggleEditPrediction,
673 window,
674 cx,
675 );
676 });
677
678 match language_state.clone() {
679 Some((language, false)) => {
680 menu = menu.item(
681 entry
682 .disabled(true)
683 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_cx| {
684 Label::new(format!("Edit predictions cannot be toggled for this buffer because they are disabled for {}", language.name()))
685 .into_any_element()
686 })
687 );
688 }
689 Some(_) | None => menu = menu.item(entry),
690 }
691 }
692
693 if let Some((language, language_enabled)) = language_state {
694 let fs = fs.clone();
695
696 menu = menu.toggleable_entry(
697 language.name(),
698 language_enabled,
699 IconPosition::Start,
700 None,
701 move |_, cx| {
702 toggle_show_edit_predictions_for_language(language.clone(), fs.clone(), cx)
703 },
704 );
705 }
706
707 let settings = AllLanguageSettings::get_global(cx);
708
709 let globally_enabled = settings.show_edit_predictions(None, cx);
710 let entry = ContextMenuEntry::new("All Files")
711 .toggleable(IconPosition::Start, globally_enabled)
712 .action(workspace::ToggleEditPrediction.boxed_clone())
713 .handler(|window, cx| {
714 window.dispatch_action(workspace::ToggleEditPrediction.boxed_clone(), cx)
715 });
716 menu = menu.item(entry);
717
718 let provider = settings.edit_predictions.provider;
719 let current_mode = settings.edit_predictions_mode();
720 let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle);
721 let eager_mode = matches!(current_mode, EditPredictionsMode::Eager);
722
723 menu = menu
724 .separator()
725 .header("Display Modes")
726 .item(
727 ContextMenuEntry::new("Eager")
728 .toggleable(IconPosition::Start, eager_mode)
729 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| {
730 Label::new("Display predictions inline when there are no language server completions available.").into_any_element()
731 })
732 .handler({
733 let fs = fs.clone();
734 move |_, cx| {
735 toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Eager, cx)
736 }
737 }),
738 )
739 .item(
740 ContextMenuEntry::new("Subtle")
741 .toggleable(IconPosition::Start, subtle_mode)
742 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| {
743 Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element()
744 })
745 .handler({
746 let fs = fs.clone();
747 move |_, cx| {
748 toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Subtle, cx)
749 }
750 }),
751 );
752
753 menu = menu.separator().header("Privacy");
754
755 if matches!(
756 provider,
757 EditPredictionProvider::Zed
758 | EditPredictionProvider::Experimental(
759 EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
760 )
761 ) {
762 if let Some(provider) = &self.edit_prediction_provider {
763 let data_collection = provider.data_collection_state(cx);
764
765 if data_collection.is_supported() {
766 let provider = provider.clone();
767 let enabled = data_collection.is_enabled();
768 let is_open_source = data_collection.is_project_open_source();
769 let is_collecting = data_collection.is_enabled();
770 let (icon_name, icon_color) = if is_open_source && is_collecting {
771 (IconName::Check, Color::Success)
772 } else {
773 (IconName::Check, Color::Accent)
774 };
775
776 menu = menu.item(
777 ContextMenuEntry::new("Training Data Collection")
778 .toggleable(IconPosition::Start, data_collection.is_enabled())
779 .icon(icon_name)
780 .icon_color(icon_color)
781 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| {
782 let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
783 (true, true) => (
784 "Project identified as open source, and you're sharing data.",
785 Color::Default,
786 IconName::Check,
787 Color::Success,
788 ),
789 (true, false) => (
790 "Project identified as open source, but you're not sharing data.",
791 Color::Muted,
792 IconName::Close,
793 Color::Muted,
794 ),
795 (false, true) => (
796 "Project not identified as open source. No data captured.",
797 Color::Muted,
798 IconName::Close,
799 Color::Muted,
800 ),
801 (false, false) => (
802 "Project not identified as open source, and setting turned off.",
803 Color::Muted,
804 IconName::Close,
805 Color::Muted,
806 ),
807 };
808 v_flex()
809 .gap_2()
810 .child(
811 Label::new(indoc!{
812 "Help us improve our open dataset model by sharing data from open source repositories. \
813 Zed must detect a license file in your repo for this setting to take effect. \
814 Files with sensitive data and secrets are excluded by default."
815 })
816 )
817 .child(
818 h_flex()
819 .items_start()
820 .pt_2()
821 .pr_1()
822 .flex_1()
823 .gap_1p5()
824 .border_t_1()
825 .border_color(cx.theme().colors().border_variant)
826 .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color)))
827 .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx)))
828 )
829 .into_any_element()
830 })
831 .handler(move |_, cx| {
832 provider.toggle_data_collection(cx);
833
834 if !enabled {
835 telemetry::event!(
836 "Data Collection Enabled",
837 source = "Edit Prediction Status Menu"
838 );
839 } else {
840 telemetry::event!(
841 "Data Collection Disabled",
842 source = "Edit Prediction Status Menu"
843 );
844 }
845 })
846 );
847
848 if is_collecting && !is_open_source {
849 menu = menu.item(
850 ContextMenuEntry::new("No data captured.")
851 .disabled(true)
852 .icon(IconName::Close)
853 .icon_color(Color::Error)
854 .icon_size(IconSize::Small),
855 );
856 }
857 }
858 }
859 }
860
861 menu = menu.item(
862 ContextMenuEntry::new("Configure Excluded Files")
863 .icon(IconName::LockOutlined)
864 .icon_color(Color::Muted)
865 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, |_| {
866 Label::new(indoc!{"
867 Open your settings to add sensitive paths for which Zed will never predict edits."}).into_any_element()
868 })
869 .handler(move |window, cx| {
870 if let Some(workspace) = window.root().flatten() {
871 let workspace = workspace.downgrade();
872 window
873 .spawn(cx, async |cx| {
874 open_disabled_globs_setting_in_editor(
875 workspace,
876 cx,
877 ).await
878 })
879 .detach_and_log_err(cx);
880 }
881 }),
882 ).item(
883 ContextMenuEntry::new("View Docs")
884 .icon(IconName::FileGeneric)
885 .icon_color(Color::Muted)
886 .handler(move |_, cx| {
887 cx.open_url(PRIVACY_DOCS);
888 })
889 );
890
891 if !self.editor_enabled.unwrap_or(true) {
892 menu = menu.item(
893 ContextMenuEntry::new("This file is excluded.")
894 .disabled(true)
895 .icon(IconName::ZedPredictDisabled)
896 .icon_size(IconSize::Small),
897 );
898 }
899
900 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
901 menu = menu
902 .separator()
903 .header("Actions")
904 .entry(
905 "Predict Edit at Cursor",
906 Some(Box::new(ShowEditPrediction)),
907 {
908 let editor_focus_handle = editor_focus_handle.clone();
909 move |window, cx| {
910 editor_focus_handle.dispatch_action(&ShowEditPrediction, window, cx);
911 }
912 },
913 )
914 .context(editor_focus_handle)
915 .when(
916 cx.has_flag::<PredictEditsRatePredictionsFeatureFlag>(),
917 |this| {
918 this.action(
919 "Capture Edit Prediction Example",
920 CaptureExample.boxed_clone(),
921 )
922 .action("Rate Predictions", RatePredictions.boxed_clone())
923 },
924 );
925 }
926
927 menu
928 }
929
930 fn build_copilot_context_menu(
931 &self,
932 window: &mut Window,
933 cx: &mut Context<Self>,
934 ) -> Entity<ContextMenu> {
935 let all_language_settings = all_language_settings(None, cx);
936 let copilot_config = copilot::copilot_chat::CopilotChatConfiguration {
937 enterprise_uri: all_language_settings
938 .edit_predictions
939 .copilot
940 .enterprise_uri
941 .clone(),
942 };
943 let settings_url = copilot_settings_url(copilot_config.enterprise_uri.as_deref());
944
945 ContextMenu::build(window, cx, |menu, window, cx| {
946 let menu = self.build_language_settings_menu(menu, window, cx);
947 let menu =
948 self.add_provider_switching_section(menu, EditPredictionProvider::Copilot, cx);
949
950 menu.separator()
951 .link(
952 "Go to Copilot Settings",
953 OpenBrowser { url: settings_url }.boxed_clone(),
954 )
955 .action("Sign Out", copilot::SignOut.boxed_clone())
956 })
957 }
958
959 fn build_supermaven_context_menu(
960 &self,
961 window: &mut Window,
962 cx: &mut Context<Self>,
963 ) -> Entity<ContextMenu> {
964 ContextMenu::build(window, cx, |menu, window, cx| {
965 let menu = self.build_language_settings_menu(menu, window, cx);
966 let menu =
967 self.add_provider_switching_section(menu, EditPredictionProvider::Supermaven, cx);
968
969 menu.separator()
970 .action("Sign Out", supermaven::SignOut.boxed_clone())
971 })
972 }
973
974 fn build_codestral_context_menu(
975 &self,
976 window: &mut Window,
977 cx: &mut Context<Self>,
978 ) -> Entity<ContextMenu> {
979 ContextMenu::build(window, cx, |menu, window, cx| {
980 let menu = self.build_language_settings_menu(menu, window, cx);
981 let menu =
982 self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx);
983
984 menu
985 })
986 }
987
988 fn build_edit_prediction_context_menu(
989 &self,
990 provider: EditPredictionProvider,
991 window: &mut Window,
992 cx: &mut Context<Self>,
993 ) -> Entity<ContextMenu> {
994 ContextMenu::build(window, cx, |mut menu, window, cx| {
995 if let Some(usage) = self
996 .edit_prediction_provider
997 .as_ref()
998 .and_then(|provider| provider.usage(cx))
999 {
1000 menu = menu.header("Usage");
1001 menu = menu
1002 .custom_entry(
1003 move |_window, cx| {
1004 let used_percentage = match usage.limit {
1005 UsageLimit::Limited(limit) => {
1006 Some((usage.amount as f32 / limit as f32) * 100.)
1007 }
1008 UsageLimit::Unlimited => None,
1009 };
1010
1011 h_flex()
1012 .flex_1()
1013 .gap_1p5()
1014 .children(
1015 used_percentage.map(|percent| {
1016 ProgressBar::new("usage", percent, 100., cx)
1017 }),
1018 )
1019 .child(
1020 Label::new(match usage.limit {
1021 UsageLimit::Limited(limit) => {
1022 format!("{} / {limit}", usage.amount)
1023 }
1024 UsageLimit::Unlimited => format!("{} / ∞", usage.amount),
1025 })
1026 .size(LabelSize::Small)
1027 .color(Color::Muted),
1028 )
1029 .into_any_element()
1030 },
1031 move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
1032 )
1033 .when(usage.over_limit(), |menu| -> ContextMenu {
1034 menu.entry("Subscribe to increase your limit", None, |_window, cx| {
1035 cx.open_url(&zed_urls::account_url(cx))
1036 })
1037 })
1038 .separator();
1039 } else if self.user_store.read(cx).account_too_young() {
1040 menu = menu
1041 .custom_entry(
1042 |_window, _cx| {
1043 Label::new("Your GitHub account is less than 30 days old.")
1044 .size(LabelSize::Small)
1045 .color(Color::Warning)
1046 .into_any_element()
1047 },
1048 |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
1049 )
1050 .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
1051 cx.open_url(&zed_urls::account_url(cx))
1052 })
1053 .separator();
1054 } else if self.user_store.read(cx).has_overdue_invoices() {
1055 menu = menu
1056 .custom_entry(
1057 |_window, _cx| {
1058 Label::new("You have an outstanding invoice")
1059 .size(LabelSize::Small)
1060 .color(Color::Warning)
1061 .into_any_element()
1062 },
1063 |_window, cx| {
1064 cx.open_url(&zed_urls::account_url(cx))
1065 },
1066 )
1067 .entry(
1068 "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
1069 None,
1070 |_window, cx| {
1071 cx.open_url(&zed_urls::account_url(cx))
1072 },
1073 )
1074 .separator();
1075 }
1076
1077 menu = self.build_language_settings_menu(menu, window, cx);
1078
1079 if cx.has_flag::<Zeta2FeatureFlag>() {
1080 let settings = all_language_settings(None, cx);
1081 let context_retrieval = settings.edit_predictions.use_context;
1082 menu = menu.separator().header("Context Retrieval").item(
1083 ContextMenuEntry::new("Enable Context Retrieval")
1084 .toggleable(IconPosition::Start, context_retrieval)
1085 .action(workspace::ToggleEditPrediction.boxed_clone())
1086 .handler({
1087 let fs = self.fs.clone();
1088 move |_, cx| {
1089 update_settings_file(fs.clone(), cx, move |settings, _| {
1090 settings
1091 .project
1092 .all_languages
1093 .features
1094 .get_or_insert_default()
1095 .experimental_edit_prediction_context_retrieval =
1096 Some(!context_retrieval)
1097 });
1098 }
1099 }),
1100 );
1101 }
1102
1103 menu = self.add_provider_switching_section(menu, provider, cx);
1104 menu = menu.separator().item(
1105 ContextMenuEntry::new("Configure Providers")
1106 .icon(IconName::Settings)
1107 .icon_position(IconPosition::Start)
1108 .icon_color(Color::Muted)
1109 .handler(move |window, cx| {
1110 window.dispatch_action(
1111 OpenSettingsAt {
1112 path: "edit_predictions.providers".to_string(),
1113 }
1114 .boxed_clone(),
1115 cx,
1116 );
1117 }),
1118 );
1119
1120 menu
1121 })
1122 }
1123
1124 fn build_zeta_upsell_context_menu(
1125 &self,
1126 window: &mut Window,
1127 cx: &mut Context<Self>,
1128 ) -> Entity<ContextMenu> {
1129 ContextMenu::build(window, cx, |mut menu, _window, cx| {
1130 menu = menu
1131 .custom_row(move |_window, cx| {
1132 let description = indoc! {
1133 "You get 2,000 accepted suggestions at every keystroke for free, \
1134 powered by Zeta, our open-source, open-data model"
1135 };
1136
1137 v_flex()
1138 .max_w_64()
1139 .h(rems_from_px(148.))
1140 .child(render_zeta_tab_animation(cx))
1141 .child(Label::new("Edit Prediction"))
1142 .child(
1143 Label::new(description)
1144 .color(Color::Muted)
1145 .size(LabelSize::Small),
1146 )
1147 .into_any_element()
1148 })
1149 .separator()
1150 .entry("Sign In & Start Using", None, |window, cx| {
1151 let client = Client::global(cx);
1152 window
1153 .spawn(cx, async move |cx| {
1154 client
1155 .sign_in_with_optional_connect(true, &cx)
1156 .await
1157 .log_err();
1158 })
1159 .detach();
1160 })
1161 .link(
1162 "Learn More",
1163 OpenBrowser {
1164 url: zed_urls::edit_prediction_docs(cx),
1165 }
1166 .boxed_clone(),
1167 );
1168
1169 menu
1170 })
1171 }
1172
1173 pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
1174 let editor = editor.read(cx);
1175 let snapshot = editor.buffer().read(cx).snapshot(cx);
1176 let suggestion_anchor = editor.selections.newest_anchor().start;
1177 let language = snapshot.language_at(suggestion_anchor);
1178 let file = snapshot.file_at(suggestion_anchor).cloned();
1179 self.editor_enabled = {
1180 let file = file.as_ref();
1181 Some(
1182 file.map(|file| {
1183 all_language_settings(Some(file), cx)
1184 .edit_predictions_enabled_for_file(file, cx)
1185 })
1186 .unwrap_or(true),
1187 )
1188 };
1189 self.editor_show_predictions = editor.edit_predictions_enabled();
1190 self.edit_prediction_provider = editor.edit_prediction_provider();
1191 self.language = language.cloned();
1192 self.file = file;
1193 self.editor_focus_handle = Some(editor.focus_handle(cx));
1194
1195 cx.notify();
1196 }
1197}
1198
1199impl StatusItemView for EditPredictionButton {
1200 fn set_active_pane_item(
1201 &mut self,
1202 item: Option<&dyn ItemHandle>,
1203 _: &mut Window,
1204 cx: &mut Context<Self>,
1205 ) {
1206 if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
1207 self.editor_subscription = Some((
1208 cx.observe(&editor, Self::update_enabled),
1209 editor.entity_id().as_u64() as usize,
1210 ));
1211 self.update_enabled(editor, cx);
1212 } else {
1213 self.language = None;
1214 self.editor_subscription = None;
1215 self.editor_enabled = None;
1216 }
1217 cx.notify();
1218 }
1219}
1220
1221impl SupermavenButtonStatus {
1222 fn to_icon(&self) -> IconName {
1223 match self {
1224 SupermavenButtonStatus::Ready => IconName::Supermaven,
1225 SupermavenButtonStatus::Errored(_) => IconName::SupermavenError,
1226 SupermavenButtonStatus::NeedsActivation(_) => IconName::SupermavenInit,
1227 SupermavenButtonStatus::Initializing => IconName::SupermavenInit,
1228 }
1229 }
1230
1231 fn to_tooltip(&self) -> String {
1232 match self {
1233 SupermavenButtonStatus::Ready => "Supermaven is ready".to_string(),
1234 SupermavenButtonStatus::Errored(error) => format!("Supermaven error: {}", error),
1235 SupermavenButtonStatus::NeedsActivation(_) => "Supermaven needs activation".to_string(),
1236 SupermavenButtonStatus::Initializing => "Supermaven initializing".to_string(),
1237 }
1238 }
1239
1240 fn has_menu(&self) -> bool {
1241 match self {
1242 SupermavenButtonStatus::Ready | SupermavenButtonStatus::NeedsActivation(_) => true,
1243 SupermavenButtonStatus::Errored(_) | SupermavenButtonStatus::Initializing => false,
1244 }
1245 }
1246}
1247
1248async fn open_disabled_globs_setting_in_editor(
1249 workspace: WeakEntity<Workspace>,
1250 cx: &mut AsyncWindowContext,
1251) -> Result<()> {
1252 let settings_editor = workspace
1253 .update_in(cx, |_, window, cx| {
1254 create_and_open_local_file(paths::settings_file(), window, cx, || {
1255 settings::initial_user_settings_content().as_ref().into()
1256 })
1257 })?
1258 .await?
1259 .downcast::<Editor>()
1260 .unwrap();
1261
1262 settings_editor
1263 .downgrade()
1264 .update_in(cx, |item, window, cx| {
1265 let text = item.buffer().read(cx).snapshot(cx).text();
1266
1267 let settings = cx.global::<SettingsStore>();
1268
1269 // Ensure that we always have "edit_predictions { "disabled_globs": [] }"
1270 let edits = settings.edits_for_update(&text, |file| {
1271 file.project
1272 .all_languages
1273 .edit_predictions
1274 .get_or_insert_with(Default::default)
1275 .disabled_globs
1276 .get_or_insert_with(Vec::new);
1277 });
1278
1279 if !edits.is_empty() {
1280 item.edit(
1281 edits
1282 .into_iter()
1283 .map(|(r, s)| (MultiBufferOffset(r.start)..MultiBufferOffset(r.end), s)),
1284 cx,
1285 );
1286 }
1287
1288 let text = item.buffer().read(cx).snapshot(cx).text();
1289
1290 static DISABLED_GLOBS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1291 Regex::new(r#""disabled_globs":\s*\[\s*(?P<content>(?:.|\n)*?)\s*\]"#).unwrap()
1292 });
1293 // Only capture [...]
1294 let range = DISABLED_GLOBS_REGEX.captures(&text).and_then(|captures| {
1295 captures
1296 .name("content")
1297 .map(|inner_match| inner_match.start()..inner_match.end())
1298 });
1299 if let Some(range) = range {
1300 let range = MultiBufferOffset(range.start)..MultiBufferOffset(range.end);
1301 item.change_selections(
1302 SelectionEffects::scroll(Autoscroll::newest()),
1303 window,
1304 cx,
1305 |selections| {
1306 selections.select_ranges(vec![range]);
1307 },
1308 );
1309 }
1310 })?;
1311
1312 anyhow::Ok(())
1313}
1314
1315fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: EditPredictionProvider) {
1316 update_settings_file(fs, cx, move |settings, _| {
1317 settings
1318 .project
1319 .all_languages
1320 .features
1321 .get_or_insert_default()
1322 .edit_prediction_provider = Some(provider);
1323 });
1324}
1325
1326fn toggle_show_edit_predictions_for_language(
1327 language: Arc<Language>,
1328 fs: Arc<dyn Fs>,
1329 cx: &mut App,
1330) {
1331 let show_edit_predictions =
1332 all_language_settings(None, cx).show_edit_predictions(Some(&language), cx);
1333 update_settings_file(fs, cx, move |settings, _| {
1334 settings
1335 .project
1336 .all_languages
1337 .languages
1338 .0
1339 .entry(language.name().0)
1340 .or_default()
1341 .show_edit_predictions = Some(!show_edit_predictions);
1342 });
1343}
1344
1345fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut App) {
1346 update_settings_file(fs, cx, move |settings, _| {
1347 settings
1348 .project
1349 .all_languages
1350 .features
1351 .get_or_insert(Default::default())
1352 .edit_prediction_provider = Some(EditPredictionProvider::None);
1353 });
1354}
1355
1356fn toggle_edit_prediction_mode(fs: Arc<dyn Fs>, mode: EditPredictionsMode, cx: &mut App) {
1357 let settings = AllLanguageSettings::get_global(cx);
1358 let current_mode = settings.edit_predictions_mode();
1359
1360 if current_mode != mode {
1361 update_settings_file(fs, cx, move |settings, _cx| {
1362 if let Some(edit_predictions) = settings.project.all_languages.edit_predictions.as_mut()
1363 {
1364 edit_predictions.mode = Some(mode);
1365 } else {
1366 settings.project.all_languages.edit_predictions =
1367 Some(settings::EditPredictionSettingsContent {
1368 mode: Some(mode),
1369 ..Default::default()
1370 });
1371 }
1372 });
1373 }
1374}
1375
1376fn render_zeta_tab_animation(cx: &App) -> impl IntoElement {
1377 let tab = |n: u64, inverted: bool| {
1378 let text_color = cx.theme().colors().text;
1379
1380 h_flex().child(
1381 h_flex()
1382 .text_size(TextSize::XSmall.rems(cx))
1383 .text_color(text_color)
1384 .child("tab")
1385 .with_animation(
1386 ElementId::Integer(n),
1387 Animation::new(Duration::from_secs(3)).repeat(),
1388 move |tab, delta| {
1389 let n_f32 = n as f32;
1390
1391 let offset = if inverted {
1392 0.2 * (4.0 - n_f32)
1393 } else {
1394 0.2 * n_f32
1395 };
1396
1397 let phase = (delta - offset + 1.0) % 1.0;
1398 let pulse = if phase < 0.6 {
1399 let t = phase / 0.6;
1400 1.0 - (0.5 - t).abs() * 2.0
1401 } else {
1402 0.0
1403 };
1404
1405 let eased = ease_in_out(pulse);
1406 let opacity = 0.1 + 0.5 * eased;
1407
1408 tab.text_color(text_color.opacity(opacity))
1409 },
1410 ),
1411 )
1412 };
1413
1414 let tab_sequence = |inverted: bool| {
1415 h_flex()
1416 .gap_1()
1417 .child(tab(0, inverted))
1418 .child(tab(1, inverted))
1419 .child(tab(2, inverted))
1420 .child(tab(3, inverted))
1421 .child(tab(4, inverted))
1422 };
1423
1424 h_flex()
1425 .my_1p5()
1426 .p_4()
1427 .justify_center()
1428 .gap_2()
1429 .rounded_xs()
1430 .border_1()
1431 .border_dashed()
1432 .border_color(cx.theme().colors().border)
1433 .bg(gpui::pattern_slash(
1434 cx.theme().colors().border.opacity(0.5),
1435 1.,
1436 8.,
1437 ))
1438 .child(tab_sequence(true))
1439 .child(Icon::new(IconName::ZedPredict))
1440 .child(tab_sequence(false))
1441}
1442
1443fn copilot_settings_url(enterprise_uri: Option<&str>) -> String {
1444 match enterprise_uri {
1445 Some(uri) => {
1446 format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH)
1447 }
1448 None => COPILOT_SETTINGS_URL.to_string(),
1449 }
1450}
1451
1452#[cfg(test)]
1453mod tests {
1454 use super::*;
1455 use gpui::TestAppContext;
1456
1457 #[gpui::test]
1458 async fn test_copilot_settings_url_with_enterprise_uri(cx: &mut TestAppContext) {
1459 cx.update(|cx| {
1460 let settings_store = SettingsStore::test(cx);
1461 cx.set_global(settings_store);
1462 });
1463
1464 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1465 settings_store
1466 .set_user_settings(
1467 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com"}}}"#,
1468 cx,
1469 )
1470 .unwrap();
1471 });
1472
1473 let url = cx.update(|cx| {
1474 let all_language_settings = all_language_settings(None, cx);
1475 copilot_settings_url(
1476 all_language_settings
1477 .edit_predictions
1478 .copilot
1479 .enterprise_uri
1480 .as_deref(),
1481 )
1482 });
1483
1484 assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1485 }
1486
1487 #[gpui::test]
1488 async fn test_copilot_settings_url_with_enterprise_uri_trailing_slash(cx: &mut TestAppContext) {
1489 cx.update(|cx| {
1490 let settings_store = SettingsStore::test(cx);
1491 cx.set_global(settings_store);
1492 });
1493
1494 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1495 settings_store
1496 .set_user_settings(
1497 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com/"}}}"#,
1498 cx,
1499 )
1500 .unwrap();
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://my-company.ghe.com/settings/copilot");
1515 }
1516
1517 #[gpui::test]
1518 async fn test_copilot_settings_url_without_enterprise_uri(cx: &mut TestAppContext) {
1519 cx.update(|cx| {
1520 let settings_store = SettingsStore::test(cx);
1521 cx.set_global(settings_store);
1522 });
1523
1524 let url = cx.update(|cx| {
1525 let all_language_settings = all_language_settings(None, cx);
1526 copilot_settings_url(
1527 all_language_settings
1528 .edit_predictions
1529 .copilot
1530 .enterprise_uri
1531 .as_deref(),
1532 )
1533 });
1534
1535 assert_eq!(url, "https://github.com/settings/copilot");
1536 }
1537}