edit_prediction_button.rs

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