extensions_ui.rs

   1mod components;
   2mod extension_suggest;
   3mod extension_version_selector;
   4
   5use std::sync::OnceLock;
   6use std::time::Duration;
   7use std::{ops::Range, sync::Arc};
   8
   9use anyhow::Context as _;
  10use client::zed_urls;
  11use cloud_api_types::{ExtensionMetadata, ExtensionProvides};
  12use collections::{BTreeMap, BTreeSet};
  13use editor::{Editor, EditorElement, EditorStyle};
  14use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore};
  15use fuzzy::{StringMatchCandidate, match_strings};
  16use gpui::{
  17    Action, App, ClipboardItem, Context, Corner, Entity, EventEmitter, Focusable,
  18    InteractiveElement, KeyContext, ParentElement, Point, Render, Styled, Task, TextStyle,
  19    UniformListScrollHandle, WeakEntity, Window, actions, point, uniform_list,
  20};
  21use num_format::{Locale, ToFormattedString};
  22use project::DirectoryLister;
  23use release_channel::ReleaseChannel;
  24use settings::{Settings, SettingsContent};
  25use strum::IntoEnumIterator as _;
  26use theme::ThemeSettings;
  27use ui::{
  28    Banner, Chip, ContextMenu, Divider, PopoverMenu, ScrollableHandle, Switch, ToggleButtonGroup,
  29    ToggleButtonGroupSize, ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip, WithScrollbar,
  30    prelude::*,
  31};
  32use vim_mode_setting::VimModeSetting;
  33use workspace::{
  34    Workspace,
  35    item::{Item, ItemEvent},
  36};
  37use zed_actions::ExtensionCategoryFilter;
  38
  39use crate::components::ExtensionCard;
  40use crate::extension_version_selector::{
  41    ExtensionVersionSelector, ExtensionVersionSelectorDelegate,
  42};
  43
  44actions!(
  45    zed,
  46    [
  47        /// Installs an extension from a local directory for development.
  48        InstallDevExtension
  49    ]
  50);
  51
  52pub fn init(cx: &mut App) {
  53    cx.observe_new(move |workspace: &mut Workspace, window, cx| {
  54        let Some(window) = window else {
  55            return;
  56        };
  57        workspace
  58            .register_action(
  59                move |workspace, action: &zed_actions::Extensions, window, cx| {
  60                    let provides_filter = action.category_filter.map(|category| match category {
  61                        ExtensionCategoryFilter::Themes => ExtensionProvides::Themes,
  62                        ExtensionCategoryFilter::IconThemes => ExtensionProvides::IconThemes,
  63                        ExtensionCategoryFilter::Languages => ExtensionProvides::Languages,
  64                        ExtensionCategoryFilter::Grammars => ExtensionProvides::Grammars,
  65                        ExtensionCategoryFilter::LanguageServers => {
  66                            ExtensionProvides::LanguageServers
  67                        }
  68                        ExtensionCategoryFilter::ContextServers => {
  69                            ExtensionProvides::ContextServers
  70                        }
  71                        ExtensionCategoryFilter::AgentServers => ExtensionProvides::AgentServers,
  72                        ExtensionCategoryFilter::SlashCommands => ExtensionProvides::SlashCommands,
  73                        ExtensionCategoryFilter::IndexedDocsProviders => {
  74                            ExtensionProvides::IndexedDocsProviders
  75                        }
  76                        ExtensionCategoryFilter::Snippets => ExtensionProvides::Snippets,
  77                        ExtensionCategoryFilter::DebugAdapters => ExtensionProvides::DebugAdapters,
  78                    });
  79
  80                    let existing = workspace
  81                        .active_pane()
  82                        .read(cx)
  83                        .items()
  84                        .find_map(|item| item.downcast::<ExtensionsPage>());
  85
  86                    if let Some(existing) = existing {
  87                        existing.update(cx, |extensions_page, cx| {
  88                            if provides_filter.is_some() {
  89                                extensions_page.change_provides_filter(provides_filter, cx);
  90                            }
  91                            if let Some(id) = action.id.as_ref() {
  92                                extensions_page.focus_extension(id, window, cx);
  93                            }
  94                        });
  95
  96                        workspace.activate_item(&existing, true, true, window, cx);
  97                    } else {
  98                        let extensions_page = ExtensionsPage::new(
  99                            workspace,
 100                            provides_filter,
 101                            action.id.as_deref(),
 102                            window,
 103                            cx,
 104                        );
 105                        workspace.add_item_to_active_pane(
 106                            Box::new(extensions_page),
 107                            None,
 108                            true,
 109                            window,
 110                            cx,
 111                        )
 112                    }
 113                },
 114            )
 115            .register_action(move |workspace, _: &InstallDevExtension, window, cx| {
 116                let store = ExtensionStore::global(cx);
 117                let prompt = workspace.prompt_for_open_path(
 118                    gpui::PathPromptOptions {
 119                        files: false,
 120                        directories: true,
 121                        multiple: false,
 122                        prompt: None,
 123                    },
 124                    DirectoryLister::Local(
 125                        workspace.project().clone(),
 126                        workspace.app_state().fs.clone(),
 127                    ),
 128                    window,
 129                    cx,
 130                );
 131
 132                let workspace_handle = cx.entity().downgrade();
 133                window
 134                    .spawn(cx, async move |cx| {
 135                        let extension_path = match prompt.await.map_err(anyhow::Error::from) {
 136                            Ok(Some(mut paths)) => paths.pop()?,
 137                            Ok(None) => return None,
 138                            Err(err) => {
 139                                workspace_handle
 140                                    .update(cx, |workspace, cx| {
 141                                        workspace.show_portal_error(err.to_string(), cx);
 142                                    })
 143                                    .ok();
 144                                return None;
 145                            }
 146                        };
 147
 148                        let install_task = store.update(cx, |store, cx| {
 149                            store.install_dev_extension(extension_path, cx)
 150                        });
 151
 152                        match install_task.await {
 153                            Ok(_) => {}
 154                            Err(err) => {
 155                                log::error!("Failed to install dev extension: {:?}", err);
 156                                workspace_handle
 157                                    .update(cx, |workspace, cx| {
 158                                        workspace.show_error(
 159                                            // NOTE: using `anyhow::context` here ends up not printing
 160                                            // the error
 161                                            &format!("Failed to install dev extension: {}", err),
 162                                            cx,
 163                                        );
 164                                    })
 165                                    .ok();
 166                            }
 167                        }
 168
 169                        Some(())
 170                    })
 171                    .detach();
 172            });
 173
 174        cx.subscribe_in(workspace.project(), window, |_, _, event, window, cx| {
 175            if let project::Event::LanguageNotFound(buffer) = event {
 176                extension_suggest::suggest(buffer.clone(), window, cx);
 177            }
 178        })
 179        .detach();
 180    })
 181    .detach();
 182}
 183
 184fn extension_provides_label(provides: ExtensionProvides) -> &'static str {
 185    match provides {
 186        ExtensionProvides::Themes => "Themes",
 187        ExtensionProvides::IconThemes => "Icon Themes",
 188        ExtensionProvides::Languages => "Languages",
 189        ExtensionProvides::Grammars => "Grammars",
 190        ExtensionProvides::LanguageServers => "Language Servers",
 191        ExtensionProvides::ContextServers => "MCP Servers",
 192        ExtensionProvides::AgentServers => "Agent Servers",
 193        ExtensionProvides::SlashCommands => "Slash Commands",
 194        ExtensionProvides::IndexedDocsProviders => "Indexed Docs Providers",
 195        ExtensionProvides::Snippets => "Snippets",
 196        ExtensionProvides::DebugAdapters => "Debug Adapters",
 197    }
 198}
 199
 200#[derive(Clone)]
 201pub enum ExtensionStatus {
 202    NotInstalled,
 203    Installing,
 204    Upgrading,
 205    Installed(Arc<str>),
 206    Removing,
 207}
 208
 209#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
 210enum ExtensionFilter {
 211    All,
 212    Installed,
 213    NotInstalled,
 214}
 215
 216impl ExtensionFilter {
 217    pub fn include_dev_extensions(&self) -> bool {
 218        match self {
 219            Self::All | Self::Installed => true,
 220            Self::NotInstalled => false,
 221        }
 222    }
 223}
 224
 225#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
 226enum Feature {
 227    AgentClaude,
 228    AgentCodex,
 229    AgentGemini,
 230    ExtensionBasedpyright,
 231    ExtensionRuff,
 232    ExtensionTailwind,
 233    ExtensionTy,
 234    Git,
 235    LanguageBash,
 236    LanguageC,
 237    LanguageCpp,
 238    LanguageGo,
 239    LanguagePython,
 240    LanguageReact,
 241    LanguageRust,
 242    LanguageTypescript,
 243    OpenIn,
 244    Vim,
 245}
 246
 247fn keywords_by_feature() -> &'static BTreeMap<Feature, Vec<&'static str>> {
 248    static KEYWORDS_BY_FEATURE: OnceLock<BTreeMap<Feature, Vec<&'static str>>> = OnceLock::new();
 249    KEYWORDS_BY_FEATURE.get_or_init(|| {
 250        BTreeMap::from_iter([
 251            (
 252                Feature::AgentClaude,
 253                vec!["claude", "claude code", "claude agent"],
 254            ),
 255            (Feature::AgentCodex, vec!["codex", "codex cli"]),
 256            (Feature::AgentGemini, vec!["gemini", "gemini cli"]),
 257            (
 258                Feature::ExtensionBasedpyright,
 259                vec!["basedpyright", "pyright"],
 260            ),
 261            (Feature::ExtensionRuff, vec!["ruff"]),
 262            (Feature::ExtensionTailwind, vec!["tail", "tailwind"]),
 263            (Feature::ExtensionTy, vec!["ty"]),
 264            (Feature::Git, vec!["git"]),
 265            (Feature::LanguageBash, vec!["sh", "bash"]),
 266            (Feature::LanguageC, vec!["c", "clang"]),
 267            (Feature::LanguageCpp, vec!["c++", "cpp", "clang"]),
 268            (Feature::LanguageGo, vec!["go", "golang"]),
 269            (Feature::LanguagePython, vec!["python", "py"]),
 270            (Feature::LanguageReact, vec!["react"]),
 271            (Feature::LanguageRust, vec!["rust", "rs"]),
 272            (
 273                Feature::LanguageTypescript,
 274                vec!["type", "typescript", "ts"],
 275            ),
 276            (
 277                Feature::OpenIn,
 278                vec![
 279                    "github",
 280                    "gitlab",
 281                    "bitbucket",
 282                    "codeberg",
 283                    "sourcehut",
 284                    "permalink",
 285                    "link",
 286                    "open in",
 287                ],
 288            ),
 289            (Feature::Vim, vec!["vim"]),
 290        ])
 291    })
 292}
 293
 294fn acp_registry_upsell_keywords() -> &'static [&'static str] {
 295    &[
 296        "opencode",
 297        "mistral",
 298        "auggie",
 299        "stakpak",
 300        "codebuddy",
 301        "autohand",
 302        "factory droid",
 303        "corust",
 304    ]
 305}
 306
 307fn extension_button_id(extension_id: &Arc<str>, operation: ExtensionOperation) -> ElementId {
 308    (SharedString::from(extension_id.clone()), operation as usize).into()
 309}
 310
 311struct ExtensionCardButtons {
 312    install_or_uninstall: Button,
 313    upgrade: Option<Button>,
 314    configure: Option<Button>,
 315}
 316
 317pub struct ExtensionsPage {
 318    workspace: WeakEntity<Workspace>,
 319    list: UniformListScrollHandle,
 320    is_fetching_extensions: bool,
 321    fetch_failed: bool,
 322    filter: ExtensionFilter,
 323    remote_extension_entries: Vec<ExtensionMetadata>,
 324    dev_extension_entries: Vec<Arc<ExtensionManifest>>,
 325    filtered_remote_extension_indices: Vec<usize>,
 326    filtered_dev_extension_indices: Vec<usize>,
 327    query_editor: Entity<Editor>,
 328    query_contains_error: bool,
 329    provides_filter: Option<ExtensionProvides>,
 330    _subscriptions: [gpui::Subscription; 2],
 331    extension_fetch_task: Option<Task<()>>,
 332    upsells: BTreeSet<Feature>,
 333    show_acp_registry_upsell: bool,
 334}
 335
 336impl ExtensionsPage {
 337    pub fn new(
 338        workspace: &Workspace,
 339        provides_filter: Option<ExtensionProvides>,
 340        focus_extension_id: Option<&str>,
 341        window: &mut Window,
 342        cx: &mut Context<Workspace>,
 343    ) -> Entity<Self> {
 344        cx.new(|cx| {
 345            let store = ExtensionStore::global(cx);
 346            let workspace_handle = workspace.weak_handle();
 347            let subscriptions = [
 348                cx.observe(&store, |_: &mut Self, _, cx| cx.notify()),
 349                cx.subscribe_in(
 350                    &store,
 351                    window,
 352                    move |this, _, event, window, cx| match event {
 353                        extension_host::Event::ExtensionsUpdated => {
 354                            this.fetch_extensions_debounced(None, cx)
 355                        }
 356                        extension_host::Event::ExtensionInstalled(extension_id) => this
 357                            .on_extension_installed(
 358                                workspace_handle.clone(),
 359                                extension_id,
 360                                window,
 361                                cx,
 362                            ),
 363                        _ => {}
 364                    },
 365                ),
 366            ];
 367
 368            let query_editor = cx.new(|cx| {
 369                let mut input = Editor::single_line(window, cx);
 370                input.set_placeholder_text("Search extensions...", window, cx);
 371                if let Some(id) = focus_extension_id {
 372                    input.set_text(format!("id:{id}"), window, cx);
 373                }
 374                input
 375            });
 376            cx.subscribe(&query_editor, Self::on_query_change).detach();
 377
 378            let scroll_handle = UniformListScrollHandle::new();
 379
 380            let mut this = Self {
 381                workspace: workspace.weak_handle(),
 382                list: scroll_handle,
 383                is_fetching_extensions: false,
 384                fetch_failed: false,
 385                filter: ExtensionFilter::All,
 386                dev_extension_entries: Vec::new(),
 387                filtered_remote_extension_indices: Vec::new(),
 388                filtered_dev_extension_indices: Vec::new(),
 389                remote_extension_entries: Vec::new(),
 390                query_contains_error: false,
 391                provides_filter,
 392                extension_fetch_task: None,
 393                _subscriptions: subscriptions,
 394                query_editor,
 395                upsells: BTreeSet::default(),
 396                show_acp_registry_upsell: false,
 397            };
 398            this.fetch_extensions(
 399                this.search_query(cx),
 400                Some(BTreeSet::from_iter(this.provides_filter)),
 401                None,
 402                cx,
 403            );
 404            this
 405        })
 406    }
 407
 408    fn on_extension_installed(
 409        &mut self,
 410        workspace: WeakEntity<Workspace>,
 411        extension_id: &str,
 412        window: &mut Window,
 413        cx: &mut Context<Self>,
 414    ) {
 415        let extension_store = ExtensionStore::global(cx).read(cx);
 416        let themes = extension_store
 417            .extension_themes(extension_id)
 418            .map(|name| name.to_string())
 419            .collect::<Vec<_>>();
 420        if !themes.is_empty() {
 421            workspace
 422                .update(cx, |_workspace, cx| {
 423                    window.dispatch_action(
 424                        zed_actions::theme_selector::Toggle {
 425                            themes_filter: Some(themes),
 426                        }
 427                        .boxed_clone(),
 428                        cx,
 429                    );
 430                })
 431                .ok();
 432            return;
 433        }
 434
 435        let icon_themes = extension_store
 436            .extension_icon_themes(extension_id)
 437            .map(|name| name.to_string())
 438            .collect::<Vec<_>>();
 439        if !icon_themes.is_empty() {
 440            workspace
 441                .update(cx, |_workspace, cx| {
 442                    window.dispatch_action(
 443                        zed_actions::icon_theme_selector::Toggle {
 444                            themes_filter: Some(icon_themes),
 445                        }
 446                        .boxed_clone(),
 447                        cx,
 448                    );
 449                })
 450                .ok();
 451        }
 452    }
 453
 454    /// Returns whether a dev extension currently exists for the extension with the given ID.
 455    fn dev_extension_exists(extension_id: &str, cx: &mut Context<Self>) -> bool {
 456        let extension_store = ExtensionStore::global(cx).read(cx);
 457
 458        extension_store
 459            .dev_extensions()
 460            .any(|dev_extension| dev_extension.id.as_ref() == extension_id)
 461    }
 462
 463    fn extension_status(extension_id: &str, cx: &mut Context<Self>) -> ExtensionStatus {
 464        let extension_store = ExtensionStore::global(cx).read(cx);
 465
 466        match extension_store.outstanding_operations().get(extension_id) {
 467            Some(ExtensionOperation::Install) => ExtensionStatus::Installing,
 468            Some(ExtensionOperation::Remove) => ExtensionStatus::Removing,
 469            Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading,
 470            None => match extension_store.installed_extensions().get(extension_id) {
 471                Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()),
 472                None => ExtensionStatus::NotInstalled,
 473            },
 474        }
 475    }
 476
 477    fn filter_extension_entries(&mut self, cx: &mut Context<Self>) {
 478        self.filtered_remote_extension_indices.clear();
 479        self.filtered_remote_extension_indices.extend(
 480            self.remote_extension_entries
 481                .iter()
 482                .enumerate()
 483                .filter(|(_, extension)| match self.filter {
 484                    ExtensionFilter::All => true,
 485                    ExtensionFilter::Installed => {
 486                        let status = Self::extension_status(&extension.id, cx);
 487                        matches!(status, ExtensionStatus::Installed(_))
 488                    }
 489                    ExtensionFilter::NotInstalled => {
 490                        let status = Self::extension_status(&extension.id, cx);
 491
 492                        matches!(status, ExtensionStatus::NotInstalled)
 493                    }
 494                })
 495                .filter(|(_, extension)| match self.provides_filter {
 496                    Some(provides) => extension.manifest.provides.contains(&provides),
 497                    None => true,
 498                })
 499                .map(|(ix, _)| ix),
 500        );
 501
 502        self.filtered_dev_extension_indices.clear();
 503        self.filtered_dev_extension_indices.extend(
 504            self.dev_extension_entries
 505                .iter()
 506                .enumerate()
 507                .filter(|(_, manifest)| match self.provides_filter {
 508                    Some(provides) => manifest.provides().contains(&provides),
 509                    None => true,
 510                })
 511                .map(|(ix, _)| ix),
 512        );
 513
 514        cx.notify();
 515    }
 516
 517    fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
 518        self.list.set_offset(point(px(0.), px(0.)));
 519        cx.notify();
 520    }
 521
 522    fn fetch_extensions(
 523        &mut self,
 524        search: Option<String>,
 525        provides_filter: Option<BTreeSet<ExtensionProvides>>,
 526        on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
 527        cx: &mut Context<Self>,
 528    ) {
 529        self.is_fetching_extensions = true;
 530        self.fetch_failed = false;
 531        cx.notify();
 532
 533        let extension_store = ExtensionStore::global(cx);
 534
 535        let dev_extensions = extension_store
 536            .read(cx)
 537            .dev_extensions()
 538            .cloned()
 539            .collect::<Vec<_>>();
 540
 541        let remote_extensions =
 542            if let Some(id) = search.as_ref().and_then(|s| s.strip_prefix("id:")) {
 543                let versions =
 544                    extension_store.update(cx, |store, cx| store.fetch_extension_versions(id, cx));
 545                cx.foreground_executor().spawn(async move {
 546                    let versions = versions.await?;
 547                    let latest = versions
 548                        .into_iter()
 549                        .max_by_key(|v| v.published_at)
 550                        .context("no extension found")?;
 551                    Ok(vec![latest])
 552                })
 553            } else {
 554                extension_store.update(cx, |store, cx| {
 555                    store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx)
 556                })
 557            };
 558
 559        cx.spawn(async move |this, cx| {
 560            let dev_extensions = if let Some(search) = search {
 561                let match_candidates = dev_extensions
 562                    .iter()
 563                    .enumerate()
 564                    .map(|(ix, manifest)| StringMatchCandidate::new(ix, &manifest.name))
 565                    .collect::<Vec<_>>();
 566
 567                let matches = match_strings(
 568                    &match_candidates,
 569                    &search,
 570                    false,
 571                    true,
 572                    match_candidates.len(),
 573                    &Default::default(),
 574                    cx.background_executor().clone(),
 575                )
 576                .await;
 577                matches
 578                    .into_iter()
 579                    .map(|mat| dev_extensions[mat.candidate_id].clone())
 580                    .collect()
 581            } else {
 582                dev_extensions
 583            };
 584
 585            let fetch_result = remote_extensions.await;
 586
 587            let result = this.update(cx, |this, cx| {
 588                cx.notify();
 589                this.dev_extension_entries = dev_extensions;
 590                this.is_fetching_extensions = false;
 591
 592                match fetch_result {
 593                    Ok(extensions) => {
 594                        this.fetch_failed = false;
 595                        this.remote_extension_entries = extensions;
 596                        this.filter_extension_entries(cx);
 597                        if let Some(callback) = on_complete {
 598                            callback(this, cx);
 599                        }
 600                        Ok(())
 601                    }
 602                    Err(err) => {
 603                        this.fetch_failed = true;
 604                        this.filter_extension_entries(cx);
 605                        Err(err)
 606                    }
 607                }
 608            });
 609
 610            result?
 611        })
 612        .detach_and_log_err(cx);
 613    }
 614
 615    fn render_extensions(
 616        &mut self,
 617        range: Range<usize>,
 618        _: &mut Window,
 619        cx: &mut Context<Self>,
 620    ) -> Vec<ExtensionCard> {
 621        let dev_extension_entries_len = if self.filter.include_dev_extensions() {
 622            self.filtered_dev_extension_indices.len()
 623        } else {
 624            0
 625        };
 626        range
 627            .map(|ix| {
 628                if ix < dev_extension_entries_len {
 629                    let dev_ix = self.filtered_dev_extension_indices[ix];
 630                    let extension = &self.dev_extension_entries[dev_ix];
 631                    self.render_dev_extension(extension, cx)
 632                } else {
 633                    let extension_ix =
 634                        self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
 635                    let extension = &self.remote_extension_entries[extension_ix];
 636                    self.render_remote_extension(extension, cx)
 637                }
 638            })
 639            .collect()
 640    }
 641
 642    fn render_dev_extension(
 643        &self,
 644        extension: &ExtensionManifest,
 645        cx: &mut Context<Self>,
 646    ) -> ExtensionCard {
 647        let status = Self::extension_status(&extension.id, cx);
 648
 649        let repository_url = extension.repository.clone();
 650
 651        let can_configure = !extension.context_servers.is_empty();
 652
 653        ExtensionCard::new()
 654            .child(
 655                h_flex()
 656                    .justify_between()
 657                    .child(
 658                        h_flex()
 659                            .gap_2()
 660                            .items_end()
 661                            .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
 662                            .child(
 663                                Headline::new(format!("v{}", extension.version))
 664                                    .size(HeadlineSize::XSmall),
 665                            ),
 666                    )
 667                    .child(
 668                        h_flex()
 669                            .gap_1()
 670                            .justify_between()
 671                            .child(
 672                                Button::new(
 673                                    SharedString::from(format!("rebuild-{}", extension.id)),
 674                                    "Rebuild",
 675                                )
 676                                .color(Color::Accent)
 677                                .disabled(matches!(status, ExtensionStatus::Upgrading))
 678                                .on_click({
 679                                    let extension_id = extension.id.clone();
 680                                    move |_, _, cx| {
 681                                        ExtensionStore::global(cx).update(cx, |store, cx| {
 682                                            store.rebuild_dev_extension(extension_id.clone(), cx)
 683                                        });
 684                                    }
 685                                }),
 686                            )
 687                            .child(
 688                                Button::new(extension_button_id(&extension.id, ExtensionOperation::Remove), "Uninstall")
 689                                    .color(Color::Accent)
 690                                    .disabled(matches!(status, ExtensionStatus::Removing))
 691                                    .on_click({
 692                                        let extension_id = extension.id.clone();
 693                                        move |_, _, cx| {
 694                                            ExtensionStore::global(cx).update(cx, |store, cx| {
 695                                                store.uninstall_extension(extension_id.clone(), cx).detach_and_log_err(cx);
 696                                            });
 697                                        }
 698                                    }),
 699                            )
 700                            .when(can_configure, |this| {
 701                                this.child(
 702                                    Button::new(
 703                                        SharedString::from(format!("configure-{}", extension.id)),
 704                                        "Configure",
 705                                    )
 706                                    .color(Color::Accent)
 707                                    .disabled(matches!(status, ExtensionStatus::Installing))
 708                                    .on_click({
 709                                        let manifest = Arc::new(extension.clone());
 710                                        move |_, _, cx| {
 711                                            if let Some(events) =
 712                                                extension::ExtensionEvents::try_global(cx)
 713                                            {
 714                                                events.update(cx, |this, cx| {
 715                                                    this.emit(
 716                                                        extension::Event::ConfigureExtensionRequested(
 717                                                            manifest.clone(),
 718                                                        ),
 719                                                        cx,
 720                                                    )
 721                                                });
 722                                            }
 723                                        }
 724                                    }),
 725                                )
 726                            }),
 727                    ),
 728            )
 729            .child(
 730                h_flex()
 731                    .gap_2()
 732                    .justify_between()
 733                    .child(
 734                        Label::new(format!(
 735                            "{}: {}",
 736                            if extension.authors.len() > 1 {
 737                                "Authors"
 738                            } else {
 739                                "Author"
 740                            },
 741                            extension.authors.join(", ")
 742                        ))
 743                        .size(LabelSize::Small)
 744                        .color(Color::Muted)
 745                        .truncate(),
 746                    )
 747                    .child(Label::new("<>").size(LabelSize::Small)),
 748            )
 749            .child(
 750                h_flex()
 751                    .gap_2()
 752                    .justify_between()
 753                    .children(extension.description.as_ref().map(|description| {
 754                        Label::new(description.clone())
 755                            .size(LabelSize::Small)
 756                            .color(Color::Default)
 757                            .truncate()
 758                    }))
 759                    .children(repository_url.map(|repository_url| {
 760                        IconButton::new(
 761                            SharedString::from(format!("repository-{}", extension.id)),
 762                            IconName::Github,
 763                        )
 764                        .icon_color(Color::Accent)
 765                        .icon_size(IconSize::Small)
 766                        .on_click(cx.listener({
 767                            let repository_url = repository_url.clone();
 768                            move |_, _, _, cx| {
 769                                cx.open_url(&repository_url);
 770                            }
 771                        }))
 772                        .tooltip(Tooltip::text(repository_url))
 773                    })),
 774            )
 775    }
 776
 777    fn render_remote_extension(
 778        &self,
 779        extension: &ExtensionMetadata,
 780        cx: &mut Context<Self>,
 781    ) -> ExtensionCard {
 782        let this = cx.weak_entity();
 783        let status = Self::extension_status(&extension.id, cx);
 784        let has_dev_extension = Self::dev_extension_exists(&extension.id, cx);
 785
 786        let extension_id = extension.id.clone();
 787        let buttons = self.buttons_for_entry(extension, &status, has_dev_extension, cx);
 788        let version = extension.manifest.version.clone();
 789        let repository_url = extension.manifest.repository.clone();
 790        let authors = extension.manifest.authors.clone();
 791
 792        let installed_version = match status {
 793            ExtensionStatus::Installed(installed_version) => Some(installed_version),
 794            _ => None,
 795        };
 796
 797        ExtensionCard::new()
 798            .overridden_by_dev_extension(has_dev_extension)
 799            .child(
 800                h_flex()
 801                    .justify_between()
 802                    .child(
 803                        h_flex()
 804                            .gap_2()
 805                            .child(
 806                                Headline::new(extension.manifest.name.clone())
 807                                    .size(HeadlineSize::Small),
 808                            )
 809                            .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
 810                            .children(
 811                                installed_version
 812                                    .filter(|installed_version| *installed_version != version)
 813                                    .map(|installed_version| {
 814                                        Headline::new(format!("(v{installed_version} installed)",))
 815                                            .size(HeadlineSize::XSmall)
 816                                    }),
 817                            )
 818                            .map(|parent| {
 819                                if extension.manifest.provides.is_empty() {
 820                                    return parent;
 821                                }
 822
 823                                parent.child(
 824                                    h_flex().gap_1().children(
 825                                        extension
 826                                            .manifest
 827                                            .provides
 828                                            .iter()
 829                                            .filter_map(|provides| {
 830                                                match provides {
 831                                                    ExtensionProvides::SlashCommands
 832                                                    | ExtensionProvides::IndexedDocsProviders => {
 833                                                        return None;
 834                                                    }
 835                                                    _ => {}
 836                                                }
 837
 838                                                Some(Chip::new(extension_provides_label(*provides)))
 839                                            })
 840                                            .collect::<Vec<_>>(),
 841                                    ),
 842                                )
 843                            }),
 844                    )
 845                    .child(
 846                        h_flex()
 847                            .gap_1()
 848                            .children(buttons.upgrade)
 849                            .children(buttons.configure)
 850                            .child(buttons.install_or_uninstall),
 851                    ),
 852            )
 853            .child(
 854                h_flex()
 855                    .gap_2()
 856                    .justify_between()
 857                    .children(extension.manifest.description.as_ref().map(|description| {
 858                        Label::new(description.clone())
 859                            .size(LabelSize::Small)
 860                            .color(Color::Default)
 861                            .truncate()
 862                    }))
 863                    .child(
 864                        Label::new(format!(
 865                            "Downloads: {}",
 866                            extension.download_count.to_formatted_string(&Locale::en)
 867                        ))
 868                        .size(LabelSize::Small),
 869                    ),
 870            )
 871            .child(
 872                h_flex()
 873                    .min_w_0()
 874                    .w_full()
 875                    .justify_between()
 876                    .child(
 877                        h_flex()
 878                            .min_w_0()
 879                            .gap_1()
 880                            .child(
 881                                Icon::new(IconName::Person)
 882                                    .size(IconSize::XSmall)
 883                                    .color(Color::Muted),
 884                            )
 885                            .child(
 886                                Label::new(extension.manifest.authors.join(", "))
 887                                    .size(LabelSize::Small)
 888                                    .color(Color::Muted)
 889                                    .truncate(),
 890                            ),
 891                    )
 892                    .child(
 893                        h_flex()
 894                            .gap_1()
 895                            .flex_shrink_0()
 896                            .child({
 897                                let repo_url_for_tooltip = repository_url.clone();
 898
 899                                IconButton::new(
 900                                    SharedString::from(format!("repository-{}", extension.id)),
 901                                    IconName::Github,
 902                                )
 903                                .icon_size(IconSize::Small)
 904                                .tooltip(move |_, cx| {
 905                                    Tooltip::with_meta(
 906                                        "Visit Extension Repository",
 907                                        None,
 908                                        repo_url_for_tooltip.clone(),
 909                                        cx,
 910                                    )
 911                                })
 912                                .on_click(cx.listener(
 913                                    move |_, _, _, cx| {
 914                                        cx.open_url(&repository_url);
 915                                    },
 916                                ))
 917                            })
 918                            .child(
 919                                PopoverMenu::new(SharedString::from(format!(
 920                                    "more-{}",
 921                                    extension.id
 922                                )))
 923                                .trigger(
 924                                    IconButton::new(
 925                                        SharedString::from(format!("more-{}", extension.id)),
 926                                        IconName::Ellipsis,
 927                                    )
 928                                    .icon_size(IconSize::Small),
 929                                )
 930                                .anchor(Corner::TopRight)
 931                                .offset(Point {
 932                                    x: px(0.0),
 933                                    y: px(2.0),
 934                                })
 935                                .menu(move |window, cx| {
 936                                    this.upgrade().map(|this| {
 937                                        Self::render_remote_extension_context_menu(
 938                                            &this,
 939                                            extension_id.clone(),
 940                                            authors.clone(),
 941                                            window,
 942                                            cx,
 943                                        )
 944                                    })
 945                                }),
 946                            ),
 947                    ),
 948            )
 949    }
 950
 951    fn render_remote_extension_context_menu(
 952        this: &Entity<Self>,
 953        extension_id: Arc<str>,
 954        authors: Vec<String>,
 955        window: &mut Window,
 956        cx: &mut App,
 957    ) -> Entity<ContextMenu> {
 958        ContextMenu::build(window, cx, |context_menu, window, _| {
 959            context_menu
 960                .entry(
 961                    "Install Another Version...",
 962                    None,
 963                    window.handler_for(this, {
 964                        let extension_id = extension_id.clone();
 965                        move |this, window, cx| {
 966                            this.show_extension_version_list(extension_id.clone(), window, cx)
 967                        }
 968                    }),
 969                )
 970                .entry("Copy Extension ID", None, {
 971                    let extension_id = extension_id.clone();
 972                    move |_, cx| {
 973                        cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
 974                    }
 975                })
 976                .entry("Copy Author Info", None, {
 977                    let authors = authors.clone();
 978                    move |_, cx| {
 979                        cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", ")));
 980                    }
 981                })
 982        })
 983    }
 984
 985    fn show_extension_version_list(
 986        &mut self,
 987        extension_id: Arc<str>,
 988        window: &mut Window,
 989        cx: &mut Context<Self>,
 990    ) {
 991        let Some(workspace) = self.workspace.upgrade() else {
 992            return;
 993        };
 994
 995        cx.spawn_in(window, async move |this, cx| {
 996            let extension_versions_task = this.update(cx, |_, cx| {
 997                let extension_store = ExtensionStore::global(cx);
 998
 999                extension_store.update(cx, |store, cx| {
1000                    store.fetch_extension_versions(&extension_id, cx)
1001                })
1002            })?;
1003
1004            let extension_versions = extension_versions_task.await?;
1005
1006            workspace.update_in(cx, |workspace, window, cx| {
1007                let fs = workspace.project().read(cx).fs().clone();
1008                workspace.toggle_modal(window, cx, |window, cx| {
1009                    let delegate = ExtensionVersionSelectorDelegate::new(
1010                        fs,
1011                        cx.entity().downgrade(),
1012                        extension_versions,
1013                    );
1014
1015                    ExtensionVersionSelector::new(delegate, window, cx)
1016                });
1017            })?;
1018
1019            anyhow::Ok(())
1020        })
1021        .detach_and_log_err(cx);
1022    }
1023
1024    fn buttons_for_entry(
1025        &self,
1026        extension: &ExtensionMetadata,
1027        status: &ExtensionStatus,
1028        has_dev_extension: bool,
1029        cx: &mut Context<Self>,
1030    ) -> ExtensionCardButtons {
1031        let is_compatible =
1032            extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
1033
1034        if has_dev_extension {
1035            // If we have a dev extension for the given extension, just treat it as uninstalled.
1036            // The button here is a placeholder, as it won't be interactable anyways.
1037            return ExtensionCardButtons {
1038                install_or_uninstall: Button::new(
1039                    extension_button_id(&extension.id, ExtensionOperation::Install),
1040                    "Install",
1041                ),
1042                configure: None,
1043                upgrade: None,
1044            };
1045        }
1046
1047        let is_configurable = extension
1048            .manifest
1049            .provides
1050            .contains(&ExtensionProvides::ContextServers);
1051
1052        match status.clone() {
1053            ExtensionStatus::NotInstalled => ExtensionCardButtons {
1054                install_or_uninstall: Button::new(
1055                    extension_button_id(&extension.id, ExtensionOperation::Install),
1056                    "Install",
1057                )
1058                .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1059                .icon(IconName::Download)
1060                .icon_size(IconSize::Small)
1061                .icon_color(Color::Muted)
1062                .icon_position(IconPosition::Start)
1063                .on_click({
1064                    let extension_id = extension.id.clone();
1065                    move |_, _, cx| {
1066                        telemetry::event!("Extension Installed");
1067                        ExtensionStore::global(cx).update(cx, |store, cx| {
1068                            store.install_latest_extension(extension_id.clone(), cx)
1069                        });
1070                    }
1071                }),
1072                configure: None,
1073                upgrade: None,
1074            },
1075            ExtensionStatus::Installing => ExtensionCardButtons {
1076                install_or_uninstall: Button::new(
1077                    extension_button_id(&extension.id, ExtensionOperation::Install),
1078                    "Install",
1079                )
1080                .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1081                .icon(IconName::Download)
1082                .icon_size(IconSize::Small)
1083                .icon_color(Color::Muted)
1084                .icon_position(IconPosition::Start)
1085                .disabled(true),
1086                configure: None,
1087                upgrade: None,
1088            },
1089            ExtensionStatus::Upgrading => ExtensionCardButtons {
1090                install_or_uninstall: Button::new(
1091                    extension_button_id(&extension.id, ExtensionOperation::Remove),
1092                    "Uninstall",
1093                )
1094                .style(ButtonStyle::OutlinedGhost)
1095                .disabled(true),
1096                configure: is_configurable.then(|| {
1097                    Button::new(
1098                        SharedString::from(format!("configure-{}", extension.id)),
1099                        "Configure",
1100                    )
1101                    .disabled(true)
1102                }),
1103                upgrade: Some(
1104                    Button::new(
1105                        extension_button_id(&extension.id, ExtensionOperation::Upgrade),
1106                        "Upgrade",
1107                    )
1108                    .disabled(true),
1109                ),
1110            },
1111            ExtensionStatus::Installed(installed_version) => ExtensionCardButtons {
1112                install_or_uninstall: Button::new(
1113                    extension_button_id(&extension.id, ExtensionOperation::Remove),
1114                    "Uninstall",
1115                )
1116                .style(ButtonStyle::OutlinedGhost)
1117                .on_click({
1118                    let extension_id = extension.id.clone();
1119                    move |_, _, cx| {
1120                        telemetry::event!("Extension Uninstalled", extension_id);
1121                        ExtensionStore::global(cx).update(cx, |store, cx| {
1122                            store
1123                                .uninstall_extension(extension_id.clone(), cx)
1124                                .detach_and_log_err(cx);
1125                        });
1126                    }
1127                }),
1128                configure: is_configurable.then(|| {
1129                    Button::new(
1130                        SharedString::from(format!("configure-{}", extension.id)),
1131                        "Configure",
1132                    )
1133                    .style(ButtonStyle::OutlinedGhost)
1134                    .on_click({
1135                        let extension_id = extension.id.clone();
1136                        move |_, _, cx| {
1137                            if let Some(manifest) = ExtensionStore::global(cx)
1138                                .read(cx)
1139                                .extension_manifest_for_id(&extension_id)
1140                                .cloned()
1141                                && let Some(events) = extension::ExtensionEvents::try_global(cx)
1142                            {
1143                                events.update(cx, |this, cx| {
1144                                    this.emit(
1145                                        extension::Event::ConfigureExtensionRequested(manifest),
1146                                        cx,
1147                                    )
1148                                });
1149                            }
1150                        }
1151                    })
1152                }),
1153                upgrade: if installed_version == extension.manifest.version {
1154                    None
1155                } else {
1156                    Some(
1157                        Button::new(extension_button_id(&extension.id, ExtensionOperation::Upgrade), "Upgrade")
1158                          .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1159                            .when(!is_compatible, |upgrade_button| {
1160                                upgrade_button.disabled(true).tooltip({
1161                                    let version = extension.manifest.version.clone();
1162                                    move |_, cx| {
1163                                        Tooltip::simple(
1164                                            format!(
1165                                                "v{version} is not compatible with this version of Zed.",
1166                                            ),
1167                                             cx,
1168                                        )
1169                                    }
1170                                })
1171                            })
1172                            .disabled(!is_compatible)
1173                            .on_click({
1174                                let extension_id = extension.id.clone();
1175                                let version = extension.manifest.version.clone();
1176                                move |_, _, cx| {
1177                                    telemetry::event!("Extension Installed", extension_id, version);
1178                                    ExtensionStore::global(cx).update(cx, |store, cx| {
1179                                        store
1180                                            .upgrade_extension(
1181                                                extension_id.clone(),
1182                                                version.clone(),
1183                                                cx,
1184                                            )
1185                                            .detach_and_log_err(cx)
1186                                    });
1187                                }
1188                            }),
1189                    )
1190                },
1191            },
1192            ExtensionStatus::Removing => ExtensionCardButtons {
1193                install_or_uninstall: Button::new(
1194                    extension_button_id(&extension.id, ExtensionOperation::Remove),
1195                    "Uninstall",
1196                )
1197                .style(ButtonStyle::OutlinedGhost)
1198                .disabled(true),
1199                configure: is_configurable.then(|| {
1200                    Button::new(
1201                        SharedString::from(format!("configure-{}", extension.id)),
1202                        "Configure",
1203                    )
1204                    .disabled(true)
1205                }),
1206                upgrade: None,
1207            },
1208        }
1209    }
1210
1211    fn render_search(&self, cx: &mut Context<Self>) -> Div {
1212        let mut key_context = KeyContext::new_with_defaults();
1213        key_context.add("BufferSearchBar");
1214
1215        let editor_border = if self.query_contains_error {
1216            Color::Error.color(cx)
1217        } else {
1218            cx.theme().colors().border
1219        };
1220
1221        h_flex()
1222            .key_context(key_context)
1223            .h_8()
1224            .min_w(rems_from_px(384.))
1225            .flex_1()
1226            .pl_1p5()
1227            .pr_2()
1228            .gap_2()
1229            .border_1()
1230            .border_color(editor_border)
1231            .rounded_md()
1232            .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
1233            .child(self.render_text_input(&self.query_editor, cx))
1234    }
1235
1236    fn render_text_input(
1237        &self,
1238        editor: &Entity<Editor>,
1239        cx: &mut Context<Self>,
1240    ) -> impl IntoElement {
1241        let settings = ThemeSettings::get_global(cx);
1242        let text_style = TextStyle {
1243            color: if editor.read(cx).read_only(cx) {
1244                cx.theme().colors().text_disabled
1245            } else {
1246                cx.theme().colors().text
1247            },
1248            font_family: settings.ui_font.family.clone(),
1249            font_features: settings.ui_font.features.clone(),
1250            font_fallbacks: settings.ui_font.fallbacks.clone(),
1251            font_size: rems(0.875).into(),
1252            font_weight: settings.ui_font.weight,
1253            line_height: relative(1.3),
1254            ..Default::default()
1255        };
1256
1257        EditorElement::new(
1258            editor,
1259            EditorStyle {
1260                background: cx.theme().colors().editor_background,
1261                local_player: cx.theme().players().local(),
1262                text: text_style,
1263                ..Default::default()
1264            },
1265        )
1266    }
1267
1268    fn on_query_change(
1269        &mut self,
1270        _: Entity<Editor>,
1271        event: &editor::EditorEvent,
1272        cx: &mut Context<Self>,
1273    ) {
1274        if let editor::EditorEvent::Edited { .. } = event {
1275            self.query_contains_error = false;
1276            self.refresh_search(cx);
1277        }
1278    }
1279
1280    fn refresh_search(&mut self, cx: &mut Context<Self>) {
1281        self.fetch_extensions_debounced(
1282            Some(Box::new(|this, cx| {
1283                this.scroll_to_top(cx);
1284            })),
1285            cx,
1286        );
1287        self.refresh_feature_upsells(cx);
1288    }
1289
1290    pub fn focus_extension(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
1291        self.query_editor.update(cx, |editor, cx| {
1292            editor.set_text(format!("id:{id}"), window, cx)
1293        });
1294        self.refresh_search(cx);
1295    }
1296
1297    pub fn change_provides_filter(
1298        &mut self,
1299        provides_filter: Option<ExtensionProvides>,
1300        cx: &mut Context<Self>,
1301    ) {
1302        self.provides_filter = provides_filter;
1303        self.refresh_search(cx);
1304    }
1305
1306    fn fetch_extensions_debounced(
1307        &mut self,
1308        on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
1309        cx: &mut Context<ExtensionsPage>,
1310    ) {
1311        self.extension_fetch_task = Some(cx.spawn(async move |this, cx| {
1312            let search = this
1313                .update(cx, |this, cx| this.search_query(cx))
1314                .ok()
1315                .flatten();
1316
1317            // Only debounce the fetching of extensions if we have a search
1318            // query.
1319            //
1320            // If the search was just cleared then we can just reload the list
1321            // of extensions without a debounce, which allows us to avoid seeing
1322            // an intermittent flash of a "no extensions" state.
1323            if search.is_some() {
1324                cx.background_executor()
1325                    .timer(Duration::from_millis(250))
1326                    .await;
1327            };
1328
1329            this.update(cx, |this, cx| {
1330                this.fetch_extensions(
1331                    search,
1332                    Some(BTreeSet::from_iter(this.provides_filter)),
1333                    on_complete,
1334                    cx,
1335                );
1336            })
1337            .ok();
1338        }));
1339    }
1340
1341    pub fn search_query(&self, cx: &mut App) -> Option<String> {
1342        let search = self.query_editor.read(cx).text(cx);
1343        if search.trim().is_empty() {
1344            None
1345        } else {
1346            Some(search)
1347        }
1348    }
1349
1350    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1351        let has_search = self.search_query(cx).is_some();
1352
1353        let message = if self.is_fetching_extensions {
1354            "Loading extensions…"
1355        } else if self.fetch_failed {
1356            "Failed to load extensions. Please check your connection and try again."
1357        } else {
1358            match self.filter {
1359                ExtensionFilter::All => {
1360                    if has_search {
1361                        "No extensions that match your search."
1362                    } else {
1363                        "No extensions."
1364                    }
1365                }
1366                ExtensionFilter::Installed => {
1367                    if has_search {
1368                        "No installed extensions that match your search."
1369                    } else {
1370                        "No installed extensions."
1371                    }
1372                }
1373                ExtensionFilter::NotInstalled => {
1374                    if has_search {
1375                        "No not installed extensions that match your search."
1376                    } else {
1377                        "No not installed extensions."
1378                    }
1379                }
1380            }
1381        };
1382
1383        h_flex()
1384            .py_4()
1385            .gap_1p5()
1386            .when(self.fetch_failed, |this| {
1387                this.child(
1388                    Icon::new(IconName::Warning)
1389                        .size(IconSize::Small)
1390                        .color(Color::Warning),
1391                )
1392            })
1393            .child(Label::new(message))
1394    }
1395
1396    fn update_settings(
1397        &mut self,
1398        selection: &ToggleState,
1399
1400        cx: &mut Context<Self>,
1401        callback: impl 'static + Send + Fn(&mut SettingsContent, bool),
1402    ) {
1403        if let Some(workspace) = self.workspace.upgrade() {
1404            let fs = workspace.read(cx).app_state().fs.clone();
1405            let selection = *selection;
1406            settings::update_settings_file(fs, cx, move |settings, _| {
1407                let value = match selection {
1408                    ToggleState::Unselected => false,
1409                    ToggleState::Selected => true,
1410                    _ => return,
1411                };
1412
1413                callback(settings, value)
1414            });
1415        }
1416    }
1417
1418    fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1419        let Some(search) = self.search_query(cx) else {
1420            self.upsells.clear();
1421            self.show_acp_registry_upsell = false;
1422            return;
1423        };
1424
1425        if let Some(id) = search.strip_prefix("id:") {
1426            self.upsells.clear();
1427            self.show_acp_registry_upsell = false;
1428
1429            let upsell = match id.to_lowercase().as_str() {
1430                "ruff" => Some(Feature::ExtensionRuff),
1431                "basedpyright" => Some(Feature::ExtensionBasedpyright),
1432                "ty" => Some(Feature::ExtensionTy),
1433                _ => None,
1434            };
1435
1436            if let Some(upsell) = upsell {
1437                self.upsells.insert(upsell);
1438            }
1439
1440            return;
1441        }
1442
1443        let search = search.to_lowercase();
1444        let search_terms = search
1445            .split_whitespace()
1446            .map(|term| term.trim())
1447            .collect::<Vec<_>>();
1448
1449        for (feature, keywords) in keywords_by_feature() {
1450            if keywords
1451                .iter()
1452                .any(|keyword| search_terms.contains(keyword))
1453            {
1454                self.upsells.insert(*feature);
1455            } else {
1456                self.upsells.remove(feature);
1457            }
1458        }
1459
1460        self.show_acp_registry_upsell = acp_registry_upsell_keywords()
1461            .iter()
1462            .any(|keyword| search_terms.iter().any(|term| keyword.contains(term)));
1463    }
1464
1465    fn render_acp_registry_upsell(&self, cx: &mut Context<Self>) -> impl IntoElement {
1466        let registry_url = zed_urls::acp_registry_blog(cx);
1467
1468        let view_registry = Button::new("view_registry", "View Registry")
1469            .style(ButtonStyle::Tinted(ui::TintColor::Warning))
1470            .on_click({
1471                let registry_url = registry_url.clone();
1472                move |_, window, cx| {
1473                    telemetry::event!(
1474                        "ACP Registry Opened from Extensions",
1475                        source = "ACP Registry Upsell",
1476                        url = registry_url,
1477                    );
1478                    window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
1479                }
1480            });
1481        let open_registry_button = Button::new("open_registry", "Learn More")
1482            .icon(IconName::ArrowUpRight)
1483            .icon_size(IconSize::Small)
1484            .icon_position(IconPosition::End)
1485            .icon_color(Color::Muted)
1486            .on_click({
1487                move |_event, _window, cx| {
1488                    telemetry::event!(
1489                        "ACP Registry Viewed",
1490                        source = "ACP Registry Upsell",
1491                        url = registry_url,
1492                    );
1493                    cx.open_url(&registry_url)
1494                }
1495            });
1496
1497        div().pt_4().px_4().child(
1498            Banner::new()
1499                .severity(Severity::Warning)
1500                .child(
1501                    Label::new(
1502                        "Agent Server extensions will be deprecated in favor of the ACP registry.",
1503                    )
1504                    .mt_0p5(),
1505                )
1506                .action_slot(
1507                    h_flex()
1508                        .gap_1()
1509                        .child(open_registry_button)
1510                        .child(view_registry),
1511                ),
1512        )
1513    }
1514
1515    fn render_feature_upsell_banner(
1516        &self,
1517        label: SharedString,
1518        docs_url: SharedString,
1519        vim: bool,
1520        cx: &mut Context<Self>,
1521    ) -> impl IntoElement {
1522        let docs_url_button = Button::new("open_docs", "View Documentation")
1523            .icon(IconName::ArrowUpRight)
1524            .icon_size(IconSize::Small)
1525            .icon_position(IconPosition::End)
1526            .on_click({
1527                move |_event, _window, cx| {
1528                    telemetry::event!(
1529                        "Documentation Viewed",
1530                        source = "Feature Upsell",
1531                        url = docs_url,
1532                    );
1533                    cx.open_url(&docs_url)
1534                }
1535            });
1536
1537        div()
1538            .pt_4()
1539            .px_4()
1540            .child(
1541                Banner::new()
1542                    .severity(Severity::Success)
1543                    .child(Label::new(label).mt_0p5())
1544                    .map(|this| {
1545                        if vim {
1546                            this.action_slot(
1547                                h_flex()
1548                                    .gap_1()
1549                                    .child(docs_url_button)
1550                                    .child(Divider::vertical().color(ui::DividerColor::Border))
1551                                    .child(
1552                                        h_flex()
1553                                            .pl_1()
1554                                            .gap_1()
1555                                            .child(Label::new("Enable Vim mode"))
1556                                            .child(
1557                                                Switch::new(
1558                                                    "enable-vim",
1559                                                    if VimModeSetting::get_global(cx).0 {
1560                                                        ui::ToggleState::Selected
1561                                                    } else {
1562                                                        ui::ToggleState::Unselected
1563                                                    },
1564                                                )
1565                                                .on_click(cx.listener(
1566                                                    move |this, selection, _, cx| {
1567                                                        telemetry::event!(
1568                                                            "Vim Mode Toggled",
1569                                                            source = "Feature Upsell"
1570                                                        );
1571                                                        this.update_settings(
1572                                                            selection,
1573                                                            cx,
1574                                                            |setting, value| {
1575                                                                setting.vim_mode = Some(value)
1576                                                            },
1577                                                        );
1578                                                    },
1579                                                )),
1580                                            ),
1581                                    ),
1582                            )
1583                        } else {
1584                            this.action_slot(docs_url_button)
1585                        }
1586                    }),
1587            )
1588            .into_any_element()
1589    }
1590
1591    fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1592        let mut container = v_flex();
1593
1594        for feature in &self.upsells {
1595            let banner = match feature {
1596                Feature::AgentClaude => self.render_feature_upsell_banner(
1597                    "Claude Agent support is built-in to Zed!".into(),
1598                    "https://zed.dev/docs/ai/external-agents#claude-agent".into(),
1599                    false,
1600                    cx,
1601                ),
1602                Feature::AgentCodex => self.render_feature_upsell_banner(
1603                    "Codex CLI support is built-in to Zed!".into(),
1604                    "https://zed.dev/docs/ai/external-agents#codex-cli".into(),
1605                    false,
1606                    cx,
1607                ),
1608                Feature::AgentGemini => self.render_feature_upsell_banner(
1609                    "Gemini CLI support is built-in to Zed!".into(),
1610                    "https://zed.dev/docs/ai/external-agents#gemini-cli".into(),
1611                    false,
1612                    cx,
1613                ),
1614                Feature::ExtensionBasedpyright => self.render_feature_upsell_banner(
1615                    "Basedpyright (Python language server) support is built-in to Zed!".into(),
1616                    "https://zed.dev/docs/languages/python#basedpyright".into(),
1617                    false,
1618                    cx,
1619                ),
1620                Feature::ExtensionRuff => self.render_feature_upsell_banner(
1621                    "Ruff (linter for Python) support is built-in to Zed!".into(),
1622                    "https://zed.dev/docs/languages/python#code-formatting--linting".into(),
1623                    false,
1624                    cx,
1625                ),
1626                Feature::ExtensionTailwind => self.render_feature_upsell_banner(
1627                    "Tailwind CSS support is built-in to Zed!".into(),
1628                    "https://zed.dev/docs/languages/tailwindcss".into(),
1629                    false,
1630                    cx,
1631                ),
1632                Feature::ExtensionTy => self.render_feature_upsell_banner(
1633                    "Ty (Python language server) support is built-in to Zed!".into(),
1634                    "https://zed.dev/docs/languages/python".into(),
1635                    false,
1636                    cx,
1637                ),
1638                Feature::Git => self.render_feature_upsell_banner(
1639                    "Zed comes with basic Git support—more features are coming in the future."
1640                        .into(),
1641                    "https://zed.dev/docs/git".into(),
1642                    false,
1643                    cx,
1644                ),
1645                Feature::LanguageBash => self.render_feature_upsell_banner(
1646                    "Shell support is built-in to Zed!".into(),
1647                    "https://zed.dev/docs/languages/bash".into(),
1648                    false,
1649                    cx,
1650                ),
1651                Feature::LanguageC => self.render_feature_upsell_banner(
1652                    "C support is built-in to Zed!".into(),
1653                    "https://zed.dev/docs/languages/c".into(),
1654                    false,
1655                    cx,
1656                ),
1657                Feature::LanguageCpp => self.render_feature_upsell_banner(
1658                    "C++ support is built-in to Zed!".into(),
1659                    "https://zed.dev/docs/languages/cpp".into(),
1660                    false,
1661                    cx,
1662                ),
1663                Feature::LanguageGo => self.render_feature_upsell_banner(
1664                    "Go support is built-in to Zed!".into(),
1665                    "https://zed.dev/docs/languages/go".into(),
1666                    false,
1667                    cx,
1668                ),
1669                Feature::LanguagePython => self.render_feature_upsell_banner(
1670                    "Python support is built-in to Zed!".into(),
1671                    "https://zed.dev/docs/languages/python".into(),
1672                    false,
1673                    cx,
1674                ),
1675                Feature::LanguageReact => self.render_feature_upsell_banner(
1676                    "React support is built-in to Zed!".into(),
1677                    "https://zed.dev/docs/languages/typescript".into(),
1678                    false,
1679                    cx,
1680                ),
1681                Feature::LanguageRust => self.render_feature_upsell_banner(
1682                    "Rust support is built-in to Zed!".into(),
1683                    "https://zed.dev/docs/languages/rust".into(),
1684                    false,
1685                    cx,
1686                ),
1687                Feature::LanguageTypescript => self.render_feature_upsell_banner(
1688                    "Typescript support is built-in to Zed!".into(),
1689                    "https://zed.dev/docs/languages/typescript".into(),
1690                    false,
1691                    cx,
1692                ),
1693                Feature::OpenIn => self.render_feature_upsell_banner(
1694                    "Zed supports linking to a source line on GitHub and others.".into(),
1695                    "https://zed.dev/docs/git#git-integrations".into(),
1696                    false,
1697                    cx,
1698                ),
1699                Feature::Vim => self.render_feature_upsell_banner(
1700                    "Vim support is built-in to Zed!".into(),
1701                    "https://zed.dev/docs/vim".into(),
1702                    true,
1703                    cx,
1704                ),
1705            };
1706            container = container.child(banner);
1707        }
1708
1709        container
1710    }
1711}
1712
1713impl Render for ExtensionsPage {
1714    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1715        v_flex()
1716            .size_full()
1717            .bg(cx.theme().colors().editor_background)
1718            .child(
1719                v_flex()
1720                    .gap_4()
1721                    .pt_4()
1722                    .px_4()
1723                    .bg(cx.theme().colors().editor_background)
1724                    .child(
1725                        h_flex()
1726                            .w_full()
1727                            .gap_1p5()
1728                            .justify_between()
1729                            .child(Headline::new("Extensions").size(HeadlineSize::Large))
1730                            .child(
1731                                Button::new("install-dev-extension", "Install Dev Extension")
1732                                    .style(ButtonStyle::Outlined)
1733                                    .size(ButtonSize::Medium)
1734                                    .on_click(|_event, window, cx| {
1735                                        window.dispatch_action(Box::new(InstallDevExtension), cx)
1736                                    }),
1737                            ),
1738                    )
1739                    .child(
1740                        h_flex()
1741                            .w_full()
1742                            .flex_wrap()
1743                            .gap_2()
1744                            .child(self.render_search(cx))
1745                            .child(
1746                                div().child(
1747                                    ToggleButtonGroup::single_row(
1748                                        "filter-buttons",
1749                                        [
1750                                            ToggleButtonSimple::new(
1751                                                "All",
1752                                                cx.listener(|this, _event, _, cx| {
1753                                                    this.filter = ExtensionFilter::All;
1754                                                    this.filter_extension_entries(cx);
1755                                                    this.scroll_to_top(cx);
1756                                                }),
1757                                            ),
1758                                            ToggleButtonSimple::new(
1759                                                "Installed",
1760                                                cx.listener(|this, _event, _, cx| {
1761                                                    this.filter = ExtensionFilter::Installed;
1762                                                    this.filter_extension_entries(cx);
1763                                                    this.scroll_to_top(cx);
1764                                                }),
1765                                            ),
1766                                            ToggleButtonSimple::new(
1767                                                "Not Installed",
1768                                                cx.listener(|this, _event, _, cx| {
1769                                                    this.filter = ExtensionFilter::NotInstalled;
1770                                                    this.filter_extension_entries(cx);
1771                                                    this.scroll_to_top(cx);
1772                                                }),
1773                                            ),
1774                                        ],
1775                                    )
1776                                    .style(ToggleButtonGroupStyle::Outlined)
1777                                    .size(ToggleButtonGroupSize::Custom(rems_from_px(30.))) // Perfectly matches the input
1778                                    .label_size(LabelSize::Default)
1779                                    .auto_width()
1780                                    .selected_index(match self.filter {
1781                                        ExtensionFilter::All => 0,
1782                                        ExtensionFilter::Installed => 1,
1783                                        ExtensionFilter::NotInstalled => 2,
1784                                    })
1785                                    .into_any_element(),
1786                                ),
1787                            ),
1788                    ),
1789            )
1790            .child(
1791                h_flex()
1792                    .id("filter-row")
1793                    .gap_2()
1794                    .py_2p5()
1795                    .px_4()
1796                    .border_b_1()
1797                    .border_color(cx.theme().colors().border_variant)
1798                    .overflow_x_scroll()
1799                    .child(
1800                        Button::new("filter-all-categories", "All")
1801                            .when(self.provides_filter.is_none(), |button| {
1802                                button.style(ButtonStyle::Filled)
1803                            })
1804                            .when(self.provides_filter.is_some(), |button| {
1805                                button.style(ButtonStyle::Subtle)
1806                            })
1807                            .toggle_state(self.provides_filter.is_none())
1808                            .on_click(cx.listener(|this, _event, _, cx| {
1809                                this.change_provides_filter(None, cx);
1810                            })),
1811                    )
1812                    .children(ExtensionProvides::iter().filter_map(|provides| {
1813                        match provides {
1814                            ExtensionProvides::SlashCommands
1815                            | ExtensionProvides::IndexedDocsProviders => return None,
1816                            _ => {}
1817                        }
1818
1819                        let label = extension_provides_label(provides);
1820                        let button_id = SharedString::from(format!("filter-category-{}", label));
1821
1822                        Some(
1823                            Button::new(button_id, label)
1824                                .style(if self.provides_filter == Some(provides) {
1825                                    ButtonStyle::Filled
1826                                } else {
1827                                    ButtonStyle::Subtle
1828                                })
1829                                .toggle_state(self.provides_filter == Some(provides))
1830                                .on_click({
1831                                    cx.listener(move |this, _event, _, cx| {
1832                                        this.change_provides_filter(Some(provides), cx);
1833                                    })
1834                                }),
1835                        )
1836                    })),
1837            )
1838            .when(
1839                self.provides_filter == Some(ExtensionProvides::AgentServers)
1840                    || self.show_acp_registry_upsell,
1841                |this| this.child(self.render_acp_registry_upsell(cx)),
1842            )
1843            .child(self.render_feature_upsells(cx))
1844            .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
1845                let mut count = self.filtered_remote_extension_indices.len();
1846                if self.filter.include_dev_extensions() {
1847                    count += self.filtered_dev_extension_indices.len();
1848                }
1849
1850                if count == 0 {
1851                    this.child(self.render_empty_state(cx)).into_any_element()
1852                } else {
1853                    let scroll_handle = &self.list;
1854                    this.child(
1855                        uniform_list("entries", count, cx.processor(Self::render_extensions))
1856                            .flex_grow()
1857                            .pb_4()
1858                            .track_scroll(scroll_handle),
1859                    )
1860                    .vertical_scrollbar_for(scroll_handle, window, cx)
1861                    .into_any_element()
1862                }
1863            }))
1864    }
1865}
1866
1867impl EventEmitter<ItemEvent> for ExtensionsPage {}
1868
1869impl Focusable for ExtensionsPage {
1870    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1871        self.query_editor.read(cx).focus_handle(cx)
1872    }
1873}
1874
1875impl Item for ExtensionsPage {
1876    type Event = ItemEvent;
1877
1878    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1879        "Extensions".into()
1880    }
1881
1882    fn telemetry_event_text(&self) -> Option<&'static str> {
1883        Some("Extensions Page Opened")
1884    }
1885
1886    fn show_toolbar(&self) -> bool {
1887        false
1888    }
1889
1890    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
1891        f(*event)
1892    }
1893}