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