edit_prediction_button.rs

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