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