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