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