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