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