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