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