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