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 missing_token = edit_prediction::EditPredictionStore::try_global(cx)
363 .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token(cx));
364 tooltip_meta = if missing_token {
365 "Missing API key for Mercury"
366 } else {
367 "Powered by Mercury"
368 };
369 }
370 _ => {
371 ep_icon = if enabled { icons.base } else { icons.disabled };
372 tooltip_meta = "Powered by Zeta"
373 }
374 };
375
376 if edit_prediction::should_show_upsell_modal() {
377 let tooltip_meta = if self.user_store.read(cx).current_user().is_some() {
378 "Choose a Plan"
379 } else {
380 "Sign In To Use"
381 };
382
383 return div().child(
384 IconButton::new("zed-predict-pending-button", ep_icon)
385 .shape(IconButtonShape::Square)
386 .indicator(Indicator::dot().color(Color::Muted))
387 .indicator_border_color(Some(cx.theme().colors().status_bar_background))
388 .tooltip(move |_window, cx| {
389 Tooltip::with_meta("Edit Predictions", None, tooltip_meta, cx)
390 })
391 .on_click(cx.listener(move |_, _, window, cx| {
392 telemetry::event!(
393 "Pending ToS Clicked",
394 source = "Edit Prediction Status Button"
395 );
396 window.dispatch_action(
397 zed_actions::OpenZedPredictOnboarding.boxed_clone(),
398 cx,
399 );
400 })),
401 );
402 }
403
404 let mut over_limit = false;
405
406 if let Some(usage) = self
407 .edit_prediction_provider
408 .as_ref()
409 .and_then(|provider| provider.usage(cx))
410 {
411 over_limit = usage.over_limit()
412 }
413
414 let show_editor_predictions = self.editor_show_predictions;
415 let user = self.user_store.read(cx).current_user();
416
417 let indicator_color = if missing_token {
418 Some(Color::Error)
419 } else if enabled && (!show_editor_predictions || over_limit) {
420 Some(if over_limit {
421 Color::Error
422 } else {
423 Color::Muted
424 })
425 } else {
426 None
427 };
428
429 let icon_button = IconButton::new("zed-predict-pending-button", ep_icon)
430 .shape(IconButtonShape::Square)
431 .when_some(indicator_color, |this, color| {
432 this.indicator(Indicator::dot().color(color))
433 .indicator_border_color(Some(cx.theme().colors().status_bar_background))
434 })
435 .when(!self.popover_menu_handle.is_deployed(), |element| {
436 let user = user.clone();
437
438 element.tooltip(move |_window, cx| {
439 let description = if enabled {
440 if show_editor_predictions {
441 tooltip_meta
442 } else if user.is_none() {
443 "Sign In To Use"
444 } else {
445 "Hidden For This File"
446 }
447 } else {
448 "Disabled For This File"
449 };
450
451 Tooltip::with_meta(
452 "Edit Prediction",
453 Some(&ToggleMenu),
454 description,
455 cx,
456 )
457 })
458 });
459
460 let this = cx.weak_entity();
461
462 let mut popover_menu = PopoverMenu::new("edit-prediction")
463 .on_open({
464 let file = file.clone();
465 let language = language;
466 let project = project;
467 Rc::new(move |_window, cx| {
468 emit_edit_prediction_menu_opened(
469 provider_name,
470 &file,
471 &language,
472 &project,
473 cx,
474 );
475 })
476 })
477 .map(|popover_menu| {
478 let this = this.clone();
479 popover_menu.menu(move |window, cx| {
480 this.update(cx, |this, cx| {
481 this.build_edit_prediction_context_menu(provider, window, cx)
482 })
483 .ok()
484 })
485 })
486 .anchor(Corner::BottomRight)
487 .with_handle(self.popover_menu_handle.clone());
488
489 let is_refreshing = self
490 .edit_prediction_provider
491 .as_ref()
492 .is_some_and(|provider| provider.is_refreshing(cx));
493
494 if is_refreshing {
495 popover_menu = popover_menu.trigger(
496 icon_button.with_animation(
497 "pulsating-label",
498 Animation::new(Duration::from_secs(2))
499 .repeat()
500 .with_easing(pulsating_between(0.2, 1.0)),
501 |icon_button, delta| icon_button.alpha(delta),
502 ),
503 );
504 } else {
505 popover_menu = popover_menu.trigger(icon_button);
506 }
507
508 div().child(popover_menu.into_any_element())
509 }
510
511 EditPredictionProvider::None => div().hidden(),
512 }
513 }
514}
515
516impl EditPredictionButton {
517 pub fn new(
518 fs: Arc<dyn Fs>,
519 user_store: Entity<UserStore>,
520 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
521 project: Entity<Project>,
522 cx: &mut Context<Self>,
523 ) -> Self {
524 let copilot = EditPredictionStore::try_global(cx).and_then(|store| {
525 store.update(cx, |this, cx| this.start_copilot_for_project(&project, cx))
526 });
527 if let Some(copilot) = copilot {
528 cx.observe(&copilot, |_, _, cx| cx.notify()).detach()
529 }
530
531 cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
532 .detach();
533
534 cx.observe_global::<EditPredictionStore>(move |_, cx| cx.notify())
535 .detach();
536
537 edit_prediction::ollama::ensure_authenticated(cx);
538 let sweep_api_token_task = edit_prediction::sweep_ai::load_sweep_api_token(cx);
539 let mercury_api_token_task = edit_prediction::mercury::load_mercury_api_token(cx);
540 let open_ai_compatible_api_token_task =
541 edit_prediction::open_ai_compatible::load_open_ai_compatible_api_token(cx);
542
543 cx.spawn(async move |this, cx| {
544 _ = futures::join!(
545 sweep_api_token_task,
546 mercury_api_token_task,
547 open_ai_compatible_api_token_task
548 );
549 this.update(cx, |_, cx| {
550 cx.notify();
551 })
552 .ok();
553 })
554 .detach();
555
556 CodestralEditPredictionDelegate::ensure_api_key_loaded(cx);
557
558 Self {
559 editor_subscription: None,
560 editor_enabled: None,
561 editor_show_predictions: true,
562 editor_focus_handle: None,
563 language: None,
564 file: None,
565 edit_prediction_provider: None,
566 user_store,
567 popover_menu_handle,
568 project: project.downgrade(),
569 fs,
570 }
571 }
572
573 fn add_provider_switching_section(
574 &self,
575 mut menu: ContextMenu,
576 current_provider: EditPredictionProvider,
577 cx: &mut App,
578 ) -> ContextMenu {
579 let available_providers = get_available_providers(cx);
580
581 let providers: Vec<_> = available_providers
582 .into_iter()
583 .filter(|p| *p != EditPredictionProvider::None)
584 .collect();
585
586 if !providers.is_empty() {
587 menu = menu.separator().header("Providers");
588
589 for provider in providers {
590 let Some(name) = provider.display_name() else {
591 continue;
592 };
593 let is_current = provider == current_provider;
594 let fs = self.fs.clone();
595
596 menu = menu.item(
597 ContextMenuEntry::new(name)
598 .toggleable(IconPosition::Start, is_current)
599 .handler(move |_, cx| {
600 set_completion_provider(fs.clone(), cx, provider);
601 }),
602 )
603 }
604 }
605
606 menu
607 }
608
609 pub fn build_copilot_start_menu(
610 &mut self,
611 window: &mut Window,
612 cx: &mut Context<Self>,
613 ) -> Entity<ContextMenu> {
614 let fs = self.fs.clone();
615 let project = self.project.clone();
616 ContextMenu::build(window, cx, |menu, _, _| {
617 menu.entry("Sign In to Copilot", None, move |window, cx| {
618 telemetry::event!(
619 "Edit Prediction Menu Action",
620 action = "sign_in",
621 provider = "copilot",
622 );
623 if let Some(copilot) = EditPredictionStore::try_global(cx).and_then(|store| {
624 store.update(cx, |this, cx| {
625 this.start_copilot_for_project(&project.upgrade()?, cx)
626 })
627 }) {
628 copilot_ui::initiate_sign_in(copilot, window, cx);
629 }
630 })
631 .entry("Disable Copilot", None, {
632 let fs = fs.clone();
633 move |_window, cx| {
634 telemetry::event!(
635 "Edit Prediction Menu Action",
636 action = "disable_provider",
637 provider = "copilot",
638 );
639 hide_copilot(fs.clone(), cx)
640 }
641 })
642 .separator()
643 .entry("Use Zed AI", None, {
644 let fs = fs.clone();
645 move |_window, cx| {
646 set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed)
647 }
648 })
649 })
650 }
651
652 pub fn build_language_settings_menu(
653 &self,
654 mut menu: ContextMenu,
655 window: &Window,
656 cx: &mut App,
657 ) -> ContextMenu {
658 let fs = self.fs.clone();
659 let line_height = window.line_height();
660
661 menu = menu.header("Show Edit Predictions For");
662
663 let language_state = self.language.as_ref().map(|language| {
664 (
665 language.clone(),
666 language_settings::language_settings(Some(language.name()), None, cx)
667 .show_edit_predictions,
668 )
669 });
670
671 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
672 let entry = ContextMenuEntry::new("This Buffer")
673 .toggleable(IconPosition::Start, self.editor_show_predictions)
674 .action(Box::new(editor::actions::ToggleEditPrediction))
675 .handler(move |window, cx| {
676 editor_focus_handle.dispatch_action(
677 &editor::actions::ToggleEditPrediction,
678 window,
679 cx,
680 );
681 });
682
683 match language_state.clone() {
684 Some((language, false)) => {
685 menu = menu.item(
686 entry
687 .disabled(true)
688 .documentation_aside(DocumentationSide::Left, move |_cx| {
689 Label::new(format!("Edit predictions cannot be toggled for this buffer because they are disabled for {}", language.name()))
690 .into_any_element()
691 })
692 );
693 }
694 Some(_) | None => menu = menu.item(entry),
695 }
696 }
697
698 if let Some((language, language_enabled)) = language_state {
699 let fs = fs.clone();
700 let language_name = language.name();
701
702 menu = menu.toggleable_entry(
703 language_name.clone(),
704 language_enabled,
705 IconPosition::Start,
706 None,
707 move |_, cx| {
708 telemetry::event!(
709 "Edit Prediction Setting Changed",
710 setting = "language",
711 language = language_name.to_string(),
712 enabled = !language_enabled,
713 );
714 toggle_show_edit_predictions_for_language(language.clone(), fs.clone(), cx)
715 },
716 );
717 }
718
719 let settings = AllLanguageSettings::get_global(cx);
720
721 let globally_enabled = settings.show_edit_predictions(None, cx);
722 let entry = ContextMenuEntry::new("All Files")
723 .toggleable(IconPosition::Start, globally_enabled)
724 .action(workspace::ToggleEditPrediction.boxed_clone())
725 .handler(|window, cx| {
726 window.dispatch_action(workspace::ToggleEditPrediction.boxed_clone(), cx)
727 });
728 menu = menu.item(entry);
729
730 let provider = settings.edit_predictions.provider;
731 let current_mode = settings.edit_predictions_mode();
732 let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle);
733 let eager_mode = matches!(current_mode, EditPredictionsMode::Eager);
734
735 menu = menu
736 .separator()
737 .header("Display Modes")
738 .item(
739 ContextMenuEntry::new("Eager")
740 .toggleable(IconPosition::Start, eager_mode)
741 .documentation_aside(DocumentationSide::Left, move |_| {
742 Label::new("Display predictions inline when there are no language server completions available.").into_any_element()
743 })
744 .handler({
745 let fs = fs.clone();
746 move |_, cx| {
747 telemetry::event!(
748 "Edit Prediction Setting Changed",
749 setting = "mode",
750 value = "eager",
751 );
752 toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Eager, cx)
753 }
754 }),
755 )
756 .item(
757 ContextMenuEntry::new("Subtle")
758 .toggleable(IconPosition::Start, subtle_mode)
759 .documentation_aside(DocumentationSide::Left, move |_| {
760 Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element()
761 })
762 .handler({
763 let fs = fs.clone();
764 move |_, cx| {
765 telemetry::event!(
766 "Edit Prediction Setting Changed",
767 setting = "mode",
768 value = "subtle",
769 );
770 toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Subtle, cx)
771 }
772 }),
773 );
774
775 menu = menu.separator().header("Privacy");
776
777 if matches!(provider, EditPredictionProvider::Zed) {
778 if let Some(provider) = &self.edit_prediction_provider {
779 let data_collection = provider.data_collection_state(cx);
780
781 if data_collection.is_supported() {
782 let provider = provider.clone();
783 let enabled = data_collection.is_enabled();
784 let is_open_source = data_collection.is_project_open_source();
785 let is_collecting = data_collection.is_enabled();
786 let (icon_name, icon_color) = if is_open_source && is_collecting {
787 (IconName::Check, Color::Success)
788 } else {
789 (IconName::Check, Color::Accent)
790 };
791
792 menu = menu.item(
793 ContextMenuEntry::new("Training Data Collection")
794 .toggleable(IconPosition::Start, data_collection.is_enabled())
795 .icon(icon_name)
796 .icon_color(icon_color)
797 .disabled(cx.is_staff())
798 .documentation_aside(DocumentationSide::Left, move |cx| {
799 let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
800 (true, true) => (
801 "Project identified as open source, and you're sharing data.",
802 Color::Default,
803 IconName::Check,
804 Color::Success,
805 ),
806 (true, false) => (
807 "Project identified as open source, but you're not sharing data.",
808 Color::Muted,
809 IconName::Close,
810 Color::Muted,
811 ),
812 (false, true) => (
813 "Project not identified as open source. No data captured.",
814 Color::Muted,
815 IconName::Close,
816 Color::Muted,
817 ),
818 (false, false) => (
819 "Project not identified as open source, and setting turned off.",
820 Color::Muted,
821 IconName::Close,
822 Color::Muted,
823 ),
824 };
825 v_flex()
826 .gap_2()
827 .child(
828 Label::new(indoc!{
829 "Help us improve our open dataset model by sharing data from open source repositories. \
830 Zed must detect a license file in your repo for this setting to take effect. \
831 Files with sensitive data and secrets are excluded by default."
832 })
833 )
834 .child(
835 h_flex()
836 .items_start()
837 .pt_2()
838 .pr_1()
839 .flex_1()
840 .gap_1p5()
841 .border_t_1()
842 .border_color(cx.theme().colors().border_variant)
843 .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color)))
844 .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx)))
845 )
846 .into_any_element()
847 })
848 .handler(move |_, cx| {
849 provider.toggle_data_collection(cx);
850
851 if !enabled {
852 telemetry::event!(
853 "Data Collection Enabled",
854 source = "Edit Prediction Status Menu"
855 );
856 } else {
857 telemetry::event!(
858 "Data Collection Disabled",
859 source = "Edit Prediction Status Menu"
860 );
861 }
862 })
863 );
864
865 if is_collecting && !is_open_source {
866 menu = menu.item(
867 ContextMenuEntry::new("No data captured.")
868 .disabled(true)
869 .icon(IconName::Close)
870 .icon_color(Color::Error)
871 .icon_size(IconSize::Small),
872 );
873 }
874 }
875 }
876 }
877
878 menu = menu.item(
879 ContextMenuEntry::new("Configure Excluded Files")
880 .icon(IconName::LockOutlined)
881 .icon_color(Color::Muted)
882 .documentation_aside(DocumentationSide::Left, |_| {
883 Label::new(indoc!{"
884 Open your settings to add sensitive paths for which Zed will never predict edits."}).into_any_element()
885 })
886 .handler(move |window, cx| {
887 telemetry::event!(
888 "Edit Prediction Menu Action",
889 action = "configure_excluded_files",
890 );
891 if let Some(workspace) = Workspace::for_window(window, cx) {
892 let workspace = workspace.downgrade();
893 window
894 .spawn(cx, async |cx| {
895 open_disabled_globs_setting_in_editor(
896 workspace,
897 cx,
898 ).await
899 })
900 .detach_and_log_err(cx);
901 }
902 }),
903 ).item(
904 ContextMenuEntry::new("View Docs")
905 .icon(IconName::FileGeneric)
906 .icon_color(Color::Muted)
907 .handler(move |_, cx| {
908 telemetry::event!(
909 "Edit Prediction Menu Action",
910 action = "view_docs",
911 );
912 cx.open_url(PRIVACY_DOCS);
913 })
914 );
915
916 if !self.editor_enabled.unwrap_or(true) {
917 let icons = self
918 .edit_prediction_provider
919 .as_ref()
920 .map(|p| p.icons(cx))
921 .unwrap_or_else(|| {
922 edit_prediction_types::EditPredictionIconSet::new(IconName::ZedPredict)
923 });
924 menu = menu.item(
925 ContextMenuEntry::new("This file is excluded.")
926 .disabled(true)
927 .icon(icons.disabled)
928 .icon_size(IconSize::Small),
929 );
930 }
931
932 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
933 menu = menu
934 .separator()
935 .header("Actions")
936 .entry(
937 "Predict Edit at Cursor",
938 Some(Box::new(ShowEditPrediction)),
939 {
940 let editor_focus_handle = editor_focus_handle.clone();
941 move |window, cx| {
942 telemetry::event!(
943 "Edit Prediction Menu Action",
944 action = "predict_at_cursor",
945 );
946 editor_focus_handle.dispatch_action(&ShowEditPrediction, window, cx);
947 }
948 },
949 )
950 .context(editor_focus_handle)
951 .when(
952 cx.has_flag::<PredictEditsRatePredictionsFeatureFlag>(),
953 |this| {
954 this.action("Capture Prediction Example", CaptureExample.boxed_clone())
955 .action("Rate Predictions", RatePredictions.boxed_clone())
956 },
957 );
958 }
959
960 menu
961 }
962
963 fn build_copilot_context_menu(
964 &self,
965 window: &mut Window,
966 cx: &mut Context<Self>,
967 ) -> Entity<ContextMenu> {
968 let all_language_settings = all_language_settings(None, cx);
969 let next_edit_suggestions = all_language_settings
970 .edit_predictions
971 .copilot
972 .enable_next_edit_suggestions
973 .unwrap_or(true);
974 let copilot_config = copilot_chat::CopilotChatConfiguration {
975 enterprise_uri: all_language_settings
976 .edit_predictions
977 .copilot
978 .enterprise_uri
979 .clone(),
980 };
981 let settings_url = copilot_settings_url(copilot_config.enterprise_uri.as_deref());
982
983 ContextMenu::build(window, cx, |menu, window, cx| {
984 let menu = self.build_language_settings_menu(menu, window, cx);
985 let menu =
986 self.add_provider_switching_section(menu, EditPredictionProvider::Copilot, cx);
987
988 menu.separator()
989 .item(
990 ContextMenuEntry::new("Copilot: Next Edit Suggestions")
991 .toggleable(IconPosition::Start, next_edit_suggestions)
992 .handler({
993 let fs = self.fs.clone();
994 move |_, cx| {
995 update_settings_file(fs.clone(), cx, move |settings, _| {
996 settings
997 .project
998 .all_languages
999 .edit_predictions
1000 .get_or_insert_default()
1001 .copilot
1002 .get_or_insert_default()
1003 .enable_next_edit_suggestions =
1004 Some(!next_edit_suggestions);
1005 });
1006 }
1007 }),
1008 )
1009 .separator()
1010 .link(
1011 "Go to Copilot Settings",
1012 OpenBrowser { url: settings_url }.boxed_clone(),
1013 )
1014 .action("Sign Out", copilot::SignOut.boxed_clone())
1015 })
1016 }
1017
1018 fn build_codestral_context_menu(
1019 &self,
1020 window: &mut Window,
1021 cx: &mut Context<Self>,
1022 ) -> Entity<ContextMenu> {
1023 ContextMenu::build(window, cx, |menu, window, cx| {
1024 let menu = self.build_language_settings_menu(menu, window, cx);
1025 let menu =
1026 self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx);
1027
1028 menu
1029 })
1030 }
1031
1032 fn build_edit_prediction_context_menu(
1033 &self,
1034 provider: EditPredictionProvider,
1035 window: &mut Window,
1036 cx: &mut Context<Self>,
1037 ) -> Entity<ContextMenu> {
1038 ContextMenu::build(window, cx, |mut menu, window, cx| {
1039 let user = self.user_store.read(cx).current_user();
1040
1041 let needs_sign_in = user.is_none()
1042 && matches!(
1043 provider,
1044 EditPredictionProvider::None | EditPredictionProvider::Zed
1045 );
1046
1047 if needs_sign_in {
1048 menu = menu
1049 .custom_row(move |_window, cx| {
1050 let description = indoc! {
1051 "You get 2,000 accepted suggestions at every keystroke for free, \
1052 powered by Zeta, our open-source, open-data model"
1053 };
1054
1055 v_flex()
1056 .max_w_64()
1057 .h(rems_from_px(148.))
1058 .child(render_zeta_tab_animation(cx))
1059 .child(Label::new("Edit Prediction"))
1060 .child(
1061 Label::new(description)
1062 .color(Color::Muted)
1063 .size(LabelSize::Small),
1064 )
1065 .into_any_element()
1066 })
1067 .separator()
1068 .entry("Sign In & Start Using", None, |window, cx| {
1069 telemetry::event!(
1070 "Edit Prediction Menu Action",
1071 action = "sign_in",
1072 provider = "zed",
1073 );
1074 let client = Client::global(cx);
1075 window
1076 .spawn(cx, async move |cx| {
1077 client
1078 .sign_in_with_optional_connect(true, &cx)
1079 .await
1080 .log_err();
1081 })
1082 .detach();
1083 })
1084 .link_with_handler(
1085 "Learn More",
1086 OpenBrowser {
1087 url: zed_urls::edit_prediction_docs(cx),
1088 }
1089 .boxed_clone(),
1090 |_window, _cx| {
1091 telemetry::event!(
1092 "Edit Prediction Menu Action",
1093 action = "view_docs",
1094 source = "upsell",
1095 );
1096 },
1097 )
1098 .separator();
1099 } else if let Some(usage) = self
1100 .edit_prediction_provider
1101 .as_ref()
1102 .and_then(|provider| provider.usage(cx))
1103 {
1104 menu = menu.header("Usage");
1105 menu = menu
1106 .custom_entry(
1107 move |_window, cx| {
1108 let used_percentage = match usage.limit {
1109 UsageLimit::Limited(limit) => {
1110 Some((usage.amount as f32 / limit as f32) * 100.)
1111 }
1112 UsageLimit::Unlimited => None,
1113 };
1114
1115 h_flex()
1116 .flex_1()
1117 .gap_1p5()
1118 .children(
1119 used_percentage.map(|percent| {
1120 ProgressBar::new("usage", percent, 100., cx)
1121 }),
1122 )
1123 .child(
1124 Label::new(match usage.limit {
1125 UsageLimit::Limited(limit) => {
1126 format!("{} / {limit}", usage.amount)
1127 }
1128 UsageLimit::Unlimited => format!("{} / ∞", usage.amount),
1129 })
1130 .size(LabelSize::Small)
1131 .color(Color::Muted),
1132 )
1133 .into_any_element()
1134 },
1135 move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
1136 )
1137 .when(usage.over_limit(), |menu| -> ContextMenu {
1138 menu.entry("Subscribe to increase your limit", None, |_window, cx| {
1139 telemetry::event!(
1140 "Edit Prediction Menu Action",
1141 action = "upsell_clicked",
1142 reason = "usage_limit",
1143 );
1144 cx.open_url(&zed_urls::account_url(cx))
1145 })
1146 })
1147 .separator();
1148 } else if self.user_store.read(cx).account_too_young() {
1149 menu = menu
1150 .custom_entry(
1151 |_window, _cx| {
1152 Label::new("Your GitHub account is less than 30 days old.")
1153 .size(LabelSize::Small)
1154 .color(Color::Warning)
1155 .into_any_element()
1156 },
1157 |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
1158 )
1159 .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
1160 telemetry::event!(
1161 "Edit Prediction Menu Action",
1162 action = "upsell_clicked",
1163 reason = "account_age",
1164 );
1165 cx.open_url(&zed_urls::account_url(cx))
1166 })
1167 .separator();
1168 } else if self.user_store.read(cx).has_overdue_invoices() {
1169 menu = menu
1170 .custom_entry(
1171 |_window, _cx| {
1172 Label::new("You have an outstanding invoice")
1173 .size(LabelSize::Small)
1174 .color(Color::Warning)
1175 .into_any_element()
1176 },
1177 |_window, cx| {
1178 cx.open_url(&zed_urls::account_url(cx))
1179 },
1180 )
1181 .entry(
1182 "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
1183 None,
1184 |_window, cx| {
1185 cx.open_url(&zed_urls::account_url(cx))
1186 },
1187 )
1188 .separator();
1189 }
1190
1191 if !needs_sign_in {
1192 menu = self.build_language_settings_menu(menu, window, cx);
1193 }
1194 menu = self.add_provider_switching_section(menu, provider, cx);
1195
1196 if cx.is_staff() {
1197 if let Some(store) = EditPredictionStore::try_global(cx) {
1198 store.update(cx, |store, cx| {
1199 store.refresh_available_experiments(cx);
1200 });
1201 let store = store.read(cx);
1202 let experiments = store.available_experiments().to_vec();
1203 let preferred = store.preferred_experiment().map(|s| s.to_owned());
1204 let active = store.active_experiment().map(|s| s.to_owned());
1205
1206 let preferred_for_submenu = preferred.clone();
1207 menu = menu
1208 .separator()
1209 .submenu("Experiment", move |menu, _window, _cx| {
1210 let mut menu = menu.toggleable_entry(
1211 "Default",
1212 preferred_for_submenu.is_none(),
1213 IconPosition::Start,
1214 None,
1215 {
1216 move |_window, cx| {
1217 if let Some(store) = EditPredictionStore::try_global(cx) {
1218 store.update(cx, |store, _cx| {
1219 store.set_preferred_experiment(None);
1220 });
1221 }
1222 }
1223 },
1224 );
1225 for experiment in &experiments {
1226 let is_selected = active.as_deref() == Some(experiment.as_str())
1227 || preferred.as_deref() == Some(experiment.as_str());
1228 let experiment_name = experiment.clone();
1229 menu = menu.toggleable_entry(
1230 experiment.clone(),
1231 is_selected,
1232 IconPosition::Start,
1233 None,
1234 move |_window, cx| {
1235 if let Some(store) = EditPredictionStore::try_global(cx) {
1236 store.update(cx, |store, _cx| {
1237 store.set_preferred_experiment(Some(
1238 experiment_name.clone(),
1239 ));
1240 });
1241 }
1242 },
1243 );
1244 }
1245 menu
1246 });
1247 }
1248 }
1249
1250 menu = menu.separator().item(
1251 ContextMenuEntry::new("Configure Providers")
1252 .icon(IconName::Settings)
1253 .icon_position(IconPosition::Start)
1254 .icon_color(Color::Muted)
1255 .handler(move |window, cx| {
1256 telemetry::event!(
1257 "Edit Prediction Menu Action",
1258 action = "configure_providers",
1259 );
1260 window.dispatch_action(
1261 OpenSettingsAt {
1262 path: "edit_predictions.providers".to_string(),
1263 }
1264 .boxed_clone(),
1265 cx,
1266 );
1267 }),
1268 );
1269
1270 menu
1271 })
1272 }
1273
1274 pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
1275 let editor = editor.read(cx);
1276 let snapshot = editor.buffer().read(cx).snapshot(cx);
1277 let suggestion_anchor = editor.selections.newest_anchor().start;
1278 let language = snapshot.language_at(suggestion_anchor);
1279 let file = snapshot.file_at(suggestion_anchor).cloned();
1280 self.editor_enabled = {
1281 let file = file.as_ref();
1282 Some(
1283 file.map(|file| {
1284 all_language_settings(Some(file), cx)
1285 .edit_predictions_enabled_for_file(file, cx)
1286 })
1287 .unwrap_or(true),
1288 )
1289 };
1290 self.editor_show_predictions = editor.edit_predictions_enabled();
1291 self.edit_prediction_provider = editor.edit_prediction_provider();
1292 self.language = language.cloned();
1293 self.file = file;
1294 self.editor_focus_handle = Some(editor.focus_handle(cx));
1295
1296 cx.notify();
1297 }
1298}
1299
1300impl StatusItemView for EditPredictionButton {
1301 fn set_active_pane_item(
1302 &mut self,
1303 item: Option<&dyn ItemHandle>,
1304 _: &mut Window,
1305 cx: &mut Context<Self>,
1306 ) {
1307 if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
1308 self.editor_subscription = Some((
1309 cx.observe(&editor, Self::update_enabled),
1310 editor.entity_id().as_u64() as usize,
1311 ));
1312 self.update_enabled(editor, cx);
1313 } else {
1314 self.language = None;
1315 self.editor_subscription = None;
1316 self.editor_enabled = None;
1317 }
1318 cx.notify();
1319 }
1320}
1321
1322async fn open_disabled_globs_setting_in_editor(
1323 workspace: WeakEntity<Workspace>,
1324 cx: &mut AsyncWindowContext,
1325) -> Result<()> {
1326 let settings_editor = workspace
1327 .update_in(cx, |_, window, cx| {
1328 create_and_open_local_file(paths::settings_file(), window, cx, || {
1329 settings::initial_user_settings_content().as_ref().into()
1330 })
1331 })?
1332 .await?
1333 .downcast::<Editor>()
1334 .unwrap();
1335
1336 settings_editor
1337 .downgrade()
1338 .update_in(cx, |item, window, cx| {
1339 let text = item.buffer().read(cx).snapshot(cx).text();
1340
1341 let settings = cx.global::<SettingsStore>();
1342
1343 // Ensure that we always have "edit_predictions { "disabled_globs": [] }"
1344 let edits = settings.edits_for_update(&text, |file| {
1345 file.project
1346 .all_languages
1347 .edit_predictions
1348 .get_or_insert_with(Default::default)
1349 .disabled_globs
1350 .get_or_insert_with(Vec::new);
1351 });
1352
1353 if !edits.is_empty() {
1354 item.edit(
1355 edits
1356 .into_iter()
1357 .map(|(r, s)| (MultiBufferOffset(r.start)..MultiBufferOffset(r.end), s)),
1358 cx,
1359 );
1360 }
1361
1362 let text = item.buffer().read(cx).snapshot(cx).text();
1363
1364 static DISABLED_GLOBS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1365 Regex::new(r#""disabled_globs":\s*\[\s*(?P<content>(?:.|\n)*?)\s*\]"#).unwrap()
1366 });
1367 // Only capture [...]
1368 let range = DISABLED_GLOBS_REGEX.captures(&text).and_then(|captures| {
1369 captures
1370 .name("content")
1371 .map(|inner_match| inner_match.start()..inner_match.end())
1372 });
1373 if let Some(range) = range {
1374 let range = MultiBufferOffset(range.start)..MultiBufferOffset(range.end);
1375 item.change_selections(
1376 SelectionEffects::scroll(Autoscroll::newest()),
1377 window,
1378 cx,
1379 |selections| {
1380 selections.select_ranges(vec![range]);
1381 },
1382 );
1383 }
1384 })?;
1385
1386 anyhow::Ok(())
1387}
1388
1389pub fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: EditPredictionProvider) {
1390 update_settings_file(fs, cx, move |settings, _| {
1391 settings
1392 .project
1393 .all_languages
1394 .edit_predictions
1395 .get_or_insert_default()
1396 .provider = Some(provider);
1397 });
1398}
1399
1400pub fn get_available_providers(cx: &mut App) -> Vec<EditPredictionProvider> {
1401 let mut providers = Vec::new();
1402
1403 providers.push(EditPredictionProvider::Zed);
1404
1405 if let Some(app_state) = workspace::AppState::global(cx).upgrade()
1406 && copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx)
1407 .is_some_and(|copilot| copilot.0.read(cx).is_authenticated())
1408 {
1409 providers.push(EditPredictionProvider::Copilot);
1410 };
1411
1412 if codestral::codestral_api_key(cx).is_some() {
1413 providers.push(EditPredictionProvider::Codestral);
1414 }
1415
1416 if edit_prediction::ollama::is_available(cx) {
1417 providers.push(EditPredictionProvider::Ollama);
1418 }
1419
1420 if all_language_settings(None, cx)
1421 .edit_predictions
1422 .open_ai_compatible_api
1423 .is_some()
1424 {
1425 providers.push(EditPredictionProvider::OpenAiCompatibleApi);
1426 }
1427
1428 if edit_prediction::sweep_ai::sweep_api_token(cx)
1429 .read(cx)
1430 .has_key()
1431 {
1432 providers.push(EditPredictionProvider::Sweep);
1433 }
1434
1435 if edit_prediction::mercury::mercury_api_token(cx)
1436 .read(cx)
1437 .has_key()
1438 {
1439 providers.push(EditPredictionProvider::Mercury);
1440 }
1441
1442 providers
1443}
1444
1445fn toggle_show_edit_predictions_for_language(
1446 language: Arc<Language>,
1447 fs: Arc<dyn Fs>,
1448 cx: &mut App,
1449) {
1450 let show_edit_predictions =
1451 all_language_settings(None, cx).show_edit_predictions(Some(&language), cx);
1452 update_settings_file(fs, cx, move |settings, _| {
1453 settings
1454 .project
1455 .all_languages
1456 .languages
1457 .0
1458 .entry(language.name().0.to_string())
1459 .or_default()
1460 .show_edit_predictions = Some(!show_edit_predictions);
1461 });
1462}
1463
1464fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut App) {
1465 update_settings_file(fs, cx, move |settings, _| {
1466 settings
1467 .project
1468 .all_languages
1469 .edit_predictions
1470 .get_or_insert(Default::default())
1471 .provider = Some(EditPredictionProvider::None);
1472 });
1473}
1474
1475fn toggle_edit_prediction_mode(fs: Arc<dyn Fs>, mode: EditPredictionsMode, cx: &mut App) {
1476 let settings = AllLanguageSettings::get_global(cx);
1477 let current_mode = settings.edit_predictions_mode();
1478
1479 if current_mode != mode {
1480 update_settings_file(fs, cx, move |settings, _cx| {
1481 if let Some(edit_predictions) = settings.project.all_languages.edit_predictions.as_mut()
1482 {
1483 edit_predictions.mode = Some(mode);
1484 } else {
1485 settings.project.all_languages.edit_predictions =
1486 Some(settings::EditPredictionSettingsContent {
1487 mode: Some(mode),
1488 ..Default::default()
1489 });
1490 }
1491 });
1492 }
1493}
1494
1495fn render_zeta_tab_animation(cx: &App) -> impl IntoElement {
1496 let tab = |n: u64, inverted: bool| {
1497 let text_color = cx.theme().colors().text;
1498
1499 h_flex().child(
1500 h_flex()
1501 .text_size(TextSize::XSmall.rems(cx))
1502 .text_color(text_color)
1503 .child("tab")
1504 .with_animation(
1505 ElementId::Integer(n),
1506 Animation::new(Duration::from_secs(3)).repeat(),
1507 move |tab, delta| {
1508 let n_f32 = n as f32;
1509
1510 let offset = if inverted {
1511 0.2 * (4.0 - n_f32)
1512 } else {
1513 0.2 * n_f32
1514 };
1515
1516 let phase = (delta - offset + 1.0) % 1.0;
1517 let pulse = if phase < 0.6 {
1518 let t = phase / 0.6;
1519 1.0 - (0.5 - t).abs() * 2.0
1520 } else {
1521 0.0
1522 };
1523
1524 let eased = ease_in_out(pulse);
1525 let opacity = 0.1 + 0.5 * eased;
1526
1527 tab.text_color(text_color.opacity(opacity))
1528 },
1529 ),
1530 )
1531 };
1532
1533 let tab_sequence = |inverted: bool| {
1534 h_flex()
1535 .gap_1()
1536 .child(tab(0, inverted))
1537 .child(tab(1, inverted))
1538 .child(tab(2, inverted))
1539 .child(tab(3, inverted))
1540 .child(tab(4, inverted))
1541 };
1542
1543 h_flex()
1544 .my_1p5()
1545 .p_4()
1546 .justify_center()
1547 .gap_2()
1548 .rounded_xs()
1549 .border_1()
1550 .border_dashed()
1551 .border_color(cx.theme().colors().border)
1552 .bg(gpui::pattern_slash(
1553 cx.theme().colors().border.opacity(0.5),
1554 1.,
1555 8.,
1556 ))
1557 .child(tab_sequence(true))
1558 .child(Icon::new(IconName::ZedPredict))
1559 .child(tab_sequence(false))
1560}
1561
1562fn emit_edit_prediction_menu_opened(
1563 provider: &str,
1564 file: &Option<Arc<dyn File>>,
1565 language: &Option<Arc<Language>>,
1566 project: &WeakEntity<Project>,
1567 cx: &App,
1568) {
1569 let language_name = language.as_ref().map(|l| l.name());
1570 let edit_predictions_enabled_for_language =
1571 language_settings::language_settings(language_name, file.as_ref(), cx)
1572 .show_edit_predictions;
1573 let file_extension = file
1574 .as_ref()
1575 .and_then(|f| {
1576 std::path::Path::new(f.file_name(cx))
1577 .extension()
1578 .and_then(|e| e.to_str())
1579 })
1580 .map(|s| s.to_string());
1581 let is_via_ssh = project
1582 .upgrade()
1583 .map(|p| p.read(cx).is_via_remote_server())
1584 .unwrap_or(false);
1585 telemetry::event!(
1586 "Toolbar Menu Opened",
1587 name = "Edit Predictions",
1588 provider,
1589 file_extension,
1590 edit_predictions_enabled_for_language,
1591 is_via_ssh,
1592 );
1593}
1594
1595fn copilot_settings_url(enterprise_uri: Option<&str>) -> String {
1596 match enterprise_uri {
1597 Some(uri) => {
1598 format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH)
1599 }
1600 None => COPILOT_SETTINGS_URL.to_string(),
1601 }
1602}
1603
1604#[cfg(test)]
1605mod tests {
1606 use super::*;
1607 use gpui::TestAppContext;
1608
1609 #[gpui::test]
1610 async fn test_copilot_settings_url_with_enterprise_uri(cx: &mut TestAppContext) {
1611 cx.update(|cx| {
1612 let settings_store = SettingsStore::test(cx);
1613 cx.set_global(settings_store);
1614 });
1615
1616 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1617 settings_store
1618 .set_user_settings(
1619 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com"}}}"#,
1620 cx,
1621 )
1622 .unwrap();
1623 });
1624
1625 let url = cx.update(|cx| {
1626 let all_language_settings = all_language_settings(None, cx);
1627 copilot_settings_url(
1628 all_language_settings
1629 .edit_predictions
1630 .copilot
1631 .enterprise_uri
1632 .as_deref(),
1633 )
1634 });
1635
1636 assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1637 }
1638
1639 #[gpui::test]
1640 async fn test_copilot_settings_url_with_enterprise_uri_trailing_slash(cx: &mut TestAppContext) {
1641 cx.update(|cx| {
1642 let settings_store = SettingsStore::test(cx);
1643 cx.set_global(settings_store);
1644 });
1645
1646 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1647 settings_store
1648 .set_user_settings(
1649 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com/"}}}"#,
1650 cx,
1651 )
1652 .unwrap();
1653 });
1654
1655 let url = cx.update(|cx| {
1656 let all_language_settings = all_language_settings(None, cx);
1657 copilot_settings_url(
1658 all_language_settings
1659 .edit_predictions
1660 .copilot
1661 .enterprise_uri
1662 .as_deref(),
1663 )
1664 });
1665
1666 assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1667 }
1668
1669 #[gpui::test]
1670 async fn test_copilot_settings_url_without_enterprise_uri(cx: &mut TestAppContext) {
1671 cx.update(|cx| {
1672 let settings_store = SettingsStore::test(cx);
1673 cx.set_global(settings_store);
1674 });
1675
1676 let url = cx.update(|cx| {
1677 let all_language_settings = all_language_settings(None, cx);
1678 copilot_settings_url(
1679 all_language_settings
1680 .edit_predictions
1681 .copilot
1682 .enterprise_uri
1683 .as_deref(),
1684 )
1685 });
1686
1687 assert_eq!(url, "https://github.com/settings/copilot");
1688 }
1689}