edit_prediction_button.rs

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