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