edit_prediction_button.rs

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