edit_prediction_button.rs

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