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