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