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