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 menu = self.build_language_settings_menu(menu, window, cx);
1109
1110 if cx.has_flag::<Zeta2FeatureFlag>() {
1111 let settings = all_language_settings(None, cx);
1112 let context_retrieval = settings.edit_predictions.use_context;
1113 menu = menu.separator().header("Context Retrieval").item(
1114 ContextMenuEntry::new("Enable Context Retrieval")
1115 .toggleable(IconPosition::Start, context_retrieval)
1116 .action(workspace::ToggleEditPrediction.boxed_clone())
1117 .handler({
1118 let fs = self.fs.clone();
1119 move |_, cx| {
1120 update_settings_file(fs.clone(), cx, move |settings, _| {
1121 settings
1122 .project
1123 .all_languages
1124 .features
1125 .get_or_insert_default()
1126 .experimental_edit_prediction_context_retrieval =
1127 Some(!context_retrieval)
1128 });
1129 }
1130 }),
1131 );
1132 }
1133
1134 menu = self.add_provider_switching_section(menu, provider, cx);
1135 menu
1136 })
1137 }
1138
1139 fn build_zeta_upsell_context_menu(
1140 &self,
1141 window: &mut Window,
1142 cx: &mut Context<Self>,
1143 ) -> Entity<ContextMenu> {
1144 ContextMenu::build(window, cx, |mut menu, _window, cx| {
1145 menu = menu
1146 .custom_row(move |_window, cx| {
1147 let description = indoc! {
1148 "You get 2,000 accepted suggestions at every keystroke for free, \
1149 powered by Zeta, our open-source, open-data model"
1150 };
1151
1152 v_flex()
1153 .max_w_64()
1154 .h(rems_from_px(148.))
1155 .child(render_zeta_tab_animation(cx))
1156 .child(Label::new("Edit Prediction"))
1157 .child(
1158 Label::new(description)
1159 .color(Color::Muted)
1160 .size(LabelSize::Small),
1161 )
1162 .into_any_element()
1163 })
1164 .separator()
1165 .entry("Sign In & Start Using", None, |window, cx| {
1166 let client = Client::global(cx);
1167 window
1168 .spawn(cx, async move |cx| {
1169 client
1170 .sign_in_with_optional_connect(true, &cx)
1171 .await
1172 .log_err();
1173 })
1174 .detach();
1175 })
1176 .link(
1177 "Learn More",
1178 OpenBrowser {
1179 url: zed_urls::edit_prediction_docs(cx),
1180 }
1181 .boxed_clone(),
1182 );
1183
1184 menu
1185 })
1186 }
1187
1188 pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
1189 let editor = editor.read(cx);
1190 let snapshot = editor.buffer().read(cx).snapshot(cx);
1191 let suggestion_anchor = editor.selections.newest_anchor().start;
1192 let language = snapshot.language_at(suggestion_anchor);
1193 let file = snapshot.file_at(suggestion_anchor).cloned();
1194 self.editor_enabled = {
1195 let file = file.as_ref();
1196 Some(
1197 file.map(|file| {
1198 all_language_settings(Some(file), cx)
1199 .edit_predictions_enabled_for_file(file, cx)
1200 })
1201 .unwrap_or(true),
1202 )
1203 };
1204 self.editor_show_predictions = editor.edit_predictions_enabled();
1205 self.edit_prediction_provider = editor.edit_prediction_provider();
1206 self.language = language.cloned();
1207 self.file = file;
1208 self.editor_focus_handle = Some(editor.focus_handle(cx));
1209
1210 cx.notify();
1211 }
1212}
1213
1214impl StatusItemView for EditPredictionButton {
1215 fn set_active_pane_item(
1216 &mut self,
1217 item: Option<&dyn ItemHandle>,
1218 _: &mut Window,
1219 cx: &mut Context<Self>,
1220 ) {
1221 if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
1222 self.editor_subscription = Some((
1223 cx.observe(&editor, Self::update_enabled),
1224 editor.entity_id().as_u64() as usize,
1225 ));
1226 self.update_enabled(editor, cx);
1227 } else {
1228 self.language = None;
1229 self.editor_subscription = None;
1230 self.editor_enabled = None;
1231 }
1232 cx.notify();
1233 }
1234}
1235
1236impl SupermavenButtonStatus {
1237 fn to_icon(&self) -> IconName {
1238 match self {
1239 SupermavenButtonStatus::Ready => IconName::Supermaven,
1240 SupermavenButtonStatus::Errored(_) => IconName::SupermavenError,
1241 SupermavenButtonStatus::NeedsActivation(_) => IconName::SupermavenInit,
1242 SupermavenButtonStatus::Initializing => IconName::SupermavenInit,
1243 }
1244 }
1245
1246 fn to_tooltip(&self) -> String {
1247 match self {
1248 SupermavenButtonStatus::Ready => "Supermaven is ready".to_string(),
1249 SupermavenButtonStatus::Errored(error) => format!("Supermaven error: {}", error),
1250 SupermavenButtonStatus::NeedsActivation(_) => "Supermaven needs activation".to_string(),
1251 SupermavenButtonStatus::Initializing => "Supermaven initializing".to_string(),
1252 }
1253 }
1254
1255 fn has_menu(&self) -> bool {
1256 match self {
1257 SupermavenButtonStatus::Ready | SupermavenButtonStatus::NeedsActivation(_) => true,
1258 SupermavenButtonStatus::Errored(_) | SupermavenButtonStatus::Initializing => false,
1259 }
1260 }
1261}
1262
1263async fn open_disabled_globs_setting_in_editor(
1264 workspace: WeakEntity<Workspace>,
1265 cx: &mut AsyncWindowContext,
1266) -> Result<()> {
1267 let settings_editor = workspace
1268 .update_in(cx, |_, window, cx| {
1269 create_and_open_local_file(paths::settings_file(), window, cx, || {
1270 settings::initial_user_settings_content().as_ref().into()
1271 })
1272 })?
1273 .await?
1274 .downcast::<Editor>()
1275 .unwrap();
1276
1277 settings_editor
1278 .downgrade()
1279 .update_in(cx, |item, window, cx| {
1280 let text = item.buffer().read(cx).snapshot(cx).text();
1281
1282 let settings = cx.global::<SettingsStore>();
1283
1284 // Ensure that we always have "edit_predictions { "disabled_globs": [] }"
1285 let edits = settings.edits_for_update(&text, |file| {
1286 file.project
1287 .all_languages
1288 .edit_predictions
1289 .get_or_insert_with(Default::default)
1290 .disabled_globs
1291 .get_or_insert_with(Vec::new);
1292 });
1293
1294 if !edits.is_empty() {
1295 item.edit(
1296 edits
1297 .into_iter()
1298 .map(|(r, s)| (MultiBufferOffset(r.start)..MultiBufferOffset(r.end), s)),
1299 cx,
1300 );
1301 }
1302
1303 let text = item.buffer().read(cx).snapshot(cx).text();
1304
1305 static DISABLED_GLOBS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1306 Regex::new(r#""disabled_globs":\s*\[\s*(?P<content>(?:.|\n)*?)\s*\]"#).unwrap()
1307 });
1308 // Only capture [...]
1309 let range = DISABLED_GLOBS_REGEX.captures(&text).and_then(|captures| {
1310 captures
1311 .name("content")
1312 .map(|inner_match| inner_match.start()..inner_match.end())
1313 });
1314 if let Some(range) = range {
1315 let range = MultiBufferOffset(range.start)..MultiBufferOffset(range.end);
1316 item.change_selections(
1317 SelectionEffects::scroll(Autoscroll::newest()),
1318 window,
1319 cx,
1320 |selections| {
1321 selections.select_ranges(vec![range]);
1322 },
1323 );
1324 }
1325 })?;
1326
1327 anyhow::Ok(())
1328}
1329
1330fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: EditPredictionProvider) {
1331 update_settings_file(fs, cx, move |settings, _| {
1332 settings
1333 .project
1334 .all_languages
1335 .features
1336 .get_or_insert_default()
1337 .edit_prediction_provider = Some(provider);
1338 });
1339}
1340
1341fn toggle_show_edit_predictions_for_language(
1342 language: Arc<Language>,
1343 fs: Arc<dyn Fs>,
1344 cx: &mut App,
1345) {
1346 let show_edit_predictions =
1347 all_language_settings(None, cx).show_edit_predictions(Some(&language), cx);
1348 update_settings_file(fs, cx, move |settings, _| {
1349 settings
1350 .project
1351 .all_languages
1352 .languages
1353 .0
1354 .entry(language.name().0)
1355 .or_default()
1356 .show_edit_predictions = Some(!show_edit_predictions);
1357 });
1358}
1359
1360fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut App) {
1361 update_settings_file(fs, cx, move |settings, _| {
1362 settings
1363 .project
1364 .all_languages
1365 .features
1366 .get_or_insert(Default::default())
1367 .edit_prediction_provider = Some(EditPredictionProvider::None);
1368 });
1369}
1370
1371fn toggle_edit_prediction_mode(fs: Arc<dyn Fs>, mode: EditPredictionsMode, cx: &mut App) {
1372 let settings = AllLanguageSettings::get_global(cx);
1373 let current_mode = settings.edit_predictions_mode();
1374
1375 if current_mode != mode {
1376 update_settings_file(fs, cx, move |settings, _cx| {
1377 if let Some(edit_predictions) = settings.project.all_languages.edit_predictions.as_mut()
1378 {
1379 edit_predictions.mode = Some(mode);
1380 } else {
1381 settings.project.all_languages.edit_predictions =
1382 Some(settings::EditPredictionSettingsContent {
1383 mode: Some(mode),
1384 ..Default::default()
1385 });
1386 }
1387 });
1388 }
1389}
1390
1391fn render_zeta_tab_animation(cx: &App) -> impl IntoElement {
1392 let tab = |n: u64, inverted: bool| {
1393 let text_color = cx.theme().colors().text;
1394
1395 h_flex().child(
1396 h_flex()
1397 .text_size(TextSize::XSmall.rems(cx))
1398 .text_color(text_color)
1399 .child("tab")
1400 .with_animation(
1401 ElementId::Integer(n),
1402 Animation::new(Duration::from_secs(3)).repeat(),
1403 move |tab, delta| {
1404 let n_f32 = n as f32;
1405
1406 let offset = if inverted {
1407 0.2 * (4.0 - n_f32)
1408 } else {
1409 0.2 * n_f32
1410 };
1411
1412 let phase = (delta - offset + 1.0) % 1.0;
1413 let pulse = if phase < 0.6 {
1414 let t = phase / 0.6;
1415 1.0 - (0.5 - t).abs() * 2.0
1416 } else {
1417 0.0
1418 };
1419
1420 let eased = ease_in_out(pulse);
1421 let opacity = 0.1 + 0.5 * eased;
1422
1423 tab.text_color(text_color.opacity(opacity))
1424 },
1425 ),
1426 )
1427 };
1428
1429 let tab_sequence = |inverted: bool| {
1430 h_flex()
1431 .gap_1()
1432 .child(tab(0, inverted))
1433 .child(tab(1, inverted))
1434 .child(tab(2, inverted))
1435 .child(tab(3, inverted))
1436 .child(tab(4, inverted))
1437 };
1438
1439 h_flex()
1440 .my_1p5()
1441 .p_4()
1442 .justify_center()
1443 .gap_2()
1444 .rounded_xs()
1445 .border_1()
1446 .border_dashed()
1447 .border_color(cx.theme().colors().border)
1448 .bg(gpui::pattern_slash(
1449 cx.theme().colors().border.opacity(0.5),
1450 1.,
1451 8.,
1452 ))
1453 .child(tab_sequence(true))
1454 .child(Icon::new(IconName::ZedPredict))
1455 .child(tab_sequence(false))
1456}
1457
1458fn copilot_settings_url(enterprise_uri: Option<&str>) -> String {
1459 match enterprise_uri {
1460 Some(uri) => {
1461 format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH)
1462 }
1463 None => COPILOT_SETTINGS_URL.to_string(),
1464 }
1465}
1466
1467#[cfg(test)]
1468mod tests {
1469 use super::*;
1470 use gpui::TestAppContext;
1471
1472 #[gpui::test]
1473 async fn test_copilot_settings_url_with_enterprise_uri(cx: &mut TestAppContext) {
1474 cx.update(|cx| {
1475 let settings_store = SettingsStore::test(cx);
1476 cx.set_global(settings_store);
1477 });
1478
1479 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1480 settings_store
1481 .set_user_settings(
1482 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com"}}}"#,
1483 cx,
1484 )
1485 .unwrap();
1486 });
1487
1488 let url = cx.update(|cx| {
1489 let all_language_settings = all_language_settings(None, cx);
1490 copilot_settings_url(
1491 all_language_settings
1492 .edit_predictions
1493 .copilot
1494 .enterprise_uri
1495 .as_deref(),
1496 )
1497 });
1498
1499 assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1500 }
1501
1502 #[gpui::test]
1503 async fn test_copilot_settings_url_with_enterprise_uri_trailing_slash(cx: &mut TestAppContext) {
1504 cx.update(|cx| {
1505 let settings_store = SettingsStore::test(cx);
1506 cx.set_global(settings_store);
1507 });
1508
1509 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1510 settings_store
1511 .set_user_settings(
1512 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com/"}}}"#,
1513 cx,
1514 )
1515 .unwrap();
1516 });
1517
1518 let url = cx.update(|cx| {
1519 let all_language_settings = all_language_settings(None, cx);
1520 copilot_settings_url(
1521 all_language_settings
1522 .edit_predictions
1523 .copilot
1524 .enterprise_uri
1525 .as_deref(),
1526 )
1527 });
1528
1529 assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1530 }
1531
1532 #[gpui::test]
1533 async fn test_copilot_settings_url_without_enterprise_uri(cx: &mut TestAppContext) {
1534 cx.update(|cx| {
1535 let settings_store = SettingsStore::test(cx);
1536 cx.set_global(settings_store);
1537 });
1538
1539 let url = cx.update(|cx| {
1540 let all_language_settings = all_language_settings(None, cx);
1541 copilot_settings_url(
1542 all_language_settings
1543 .edit_predictions
1544 .copilot
1545 .enterprise_uri
1546 .as_deref(),
1547 )
1548 });
1549
1550 assert_eq!(url, "https://github.com/settings/copilot");
1551 }
1552}