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