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                .start_icon(
1060                    Icon::new(IconName::Download)
1061                        .size(IconSize::Small)
1062                        .color(Color::Muted),
1063                )
1064                .on_click({
1065                    let extension_id = extension.id.clone();
1066                    move |_, _, cx| {
1067                        telemetry::event!("Extension Installed");
1068                        ExtensionStore::global(cx).update(cx, |store, cx| {
1069                            store.install_latest_extension(extension_id.clone(), cx)
1070                        });
1071                    }
1072                }),
1073                configure: None,
1074                upgrade: None,
1075            },
1076            ExtensionStatus::Installing => ExtensionCardButtons {
1077                install_or_uninstall: Button::new(
1078                    extension_button_id(&extension.id, ExtensionOperation::Install),
1079                    "Install",
1080                )
1081                .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1082                .start_icon(
1083                    Icon::new(IconName::Download)
1084                        .size(IconSize::Small)
1085                        .color(Color::Muted),
1086                )
1087                .disabled(true),
1088                configure: None,
1089                upgrade: None,
1090            },
1091            ExtensionStatus::Upgrading => ExtensionCardButtons {
1092                install_or_uninstall: Button::new(
1093                    extension_button_id(&extension.id, ExtensionOperation::Remove),
1094                    "Uninstall",
1095                )
1096                .style(ButtonStyle::OutlinedGhost)
1097                .disabled(true),
1098                configure: is_configurable.then(|| {
1099                    Button::new(
1100                        SharedString::from(format!("configure-{}", extension.id)),
1101                        "Configure",
1102                    )
1103                    .disabled(true)
1104                }),
1105                upgrade: Some(
1106                    Button::new(
1107                        extension_button_id(&extension.id, ExtensionOperation::Upgrade),
1108                        "Upgrade",
1109                    )
1110                    .disabled(true),
1111                ),
1112            },
1113            ExtensionStatus::Installed(installed_version) => ExtensionCardButtons {
1114                install_or_uninstall: Button::new(
1115                    extension_button_id(&extension.id, ExtensionOperation::Remove),
1116                    "Uninstall",
1117                )
1118                .style(ButtonStyle::OutlinedGhost)
1119                .on_click({
1120                    let extension_id = extension.id.clone();
1121                    move |_, _, cx| {
1122                        telemetry::event!("Extension Uninstalled", extension_id);
1123                        ExtensionStore::global(cx).update(cx, |store, cx| {
1124                            store
1125                                .uninstall_extension(extension_id.clone(), cx)
1126                                .detach_and_log_err(cx);
1127                        });
1128                    }
1129                }),
1130                configure: is_configurable.then(|| {
1131                    Button::new(
1132                        SharedString::from(format!("configure-{}", extension.id)),
1133                        "Configure",
1134                    )
1135                    .style(ButtonStyle::OutlinedGhost)
1136                    .on_click({
1137                        let extension_id = extension.id.clone();
1138                        move |_, _, cx| {
1139                            if let Some(manifest) = ExtensionStore::global(cx)
1140                                .read(cx)
1141                                .extension_manifest_for_id(&extension_id)
1142                                .cloned()
1143                                && let Some(events) = extension::ExtensionEvents::try_global(cx)
1144                            {
1145                                events.update(cx, |this, cx| {
1146                                    this.emit(
1147                                        extension::Event::ConfigureExtensionRequested(manifest),
1148                                        cx,
1149                                    )
1150                                });
1151                            }
1152                        }
1153                    })
1154                }),
1155                upgrade: if installed_version == extension.manifest.version {
1156                    None
1157                } else {
1158                    Some(
1159                        Button::new(extension_button_id(&extension.id, ExtensionOperation::Upgrade), "Upgrade")
1160                          .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1161                            .when(!is_compatible, |upgrade_button| {
1162                                upgrade_button.disabled(true).tooltip({
1163                                    let version = extension.manifest.version.clone();
1164                                    move |_, cx| {
1165                                        Tooltip::simple(
1166                                            format!(
1167                                                "v{version} is not compatible with this version of Zed.",
1168                                            ),
1169                                             cx,
1170                                        )
1171                                    }
1172                                })
1173                            })
1174                            .disabled(!is_compatible)
1175                            .on_click({
1176                                let extension_id = extension.id.clone();
1177                                let version = extension.manifest.version.clone();
1178                                move |_, _, cx| {
1179                                    telemetry::event!("Extension Installed", extension_id, version);
1180                                    ExtensionStore::global(cx).update(cx, |store, cx| {
1181                                        store
1182                                            .upgrade_extension(
1183                                                extension_id.clone(),
1184                                                version.clone(),
1185                                                cx,
1186                                            )
1187                                            .detach_and_log_err(cx)
1188                                    });
1189                                }
1190                            }),
1191                    )
1192                },
1193            },
1194            ExtensionStatus::Removing => ExtensionCardButtons {
1195                install_or_uninstall: Button::new(
1196                    extension_button_id(&extension.id, ExtensionOperation::Remove),
1197                    "Uninstall",
1198                )
1199                .style(ButtonStyle::OutlinedGhost)
1200                .disabled(true),
1201                configure: is_configurable.then(|| {
1202                    Button::new(
1203                        SharedString::from(format!("configure-{}", extension.id)),
1204                        "Configure",
1205                    )
1206                    .disabled(true)
1207                }),
1208                upgrade: None,
1209            },
1210        }
1211    }
1212
1213    fn render_search(&self, cx: &mut Context<Self>) -> Div {
1214        let mut key_context = KeyContext::new_with_defaults();
1215        key_context.add("BufferSearchBar");
1216
1217        let editor_border = if self.query_contains_error {
1218            Color::Error.color(cx)
1219        } else {
1220            cx.theme().colors().border
1221        };
1222
1223        h_flex()
1224            .key_context(key_context)
1225            .h_8()
1226            .min_w(rems_from_px(384.))
1227            .flex_1()
1228            .pl_1p5()
1229            .pr_2()
1230            .gap_2()
1231            .border_1()
1232            .border_color(editor_border)
1233            .rounded_md()
1234            .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
1235            .child(self.render_text_input(&self.query_editor, cx))
1236    }
1237
1238    fn render_text_input(
1239        &self,
1240        editor: &Entity<Editor>,
1241        cx: &mut Context<Self>,
1242    ) -> impl IntoElement {
1243        let settings = ThemeSettings::get_global(cx);
1244        let text_style = TextStyle {
1245            color: if editor.read(cx).read_only(cx) {
1246                cx.theme().colors().text_disabled
1247            } else {
1248                cx.theme().colors().text
1249            },
1250            font_family: settings.ui_font.family.clone(),
1251            font_features: settings.ui_font.features.clone(),
1252            font_fallbacks: settings.ui_font.fallbacks.clone(),
1253            font_size: rems(0.875).into(),
1254            font_weight: settings.ui_font.weight,
1255            line_height: relative(1.3),
1256            ..Default::default()
1257        };
1258
1259        EditorElement::new(
1260            editor,
1261            EditorStyle {
1262                background: cx.theme().colors().editor_background,
1263                local_player: cx.theme().players().local(),
1264                text: text_style,
1265                ..Default::default()
1266            },
1267        )
1268    }
1269
1270    fn on_query_change(
1271        &mut self,
1272        _: Entity<Editor>,
1273        event: &editor::EditorEvent,
1274        cx: &mut Context<Self>,
1275    ) {
1276        if let editor::EditorEvent::Edited { .. } = event {
1277            self.query_contains_error = false;
1278            self.refresh_search(cx);
1279        }
1280    }
1281
1282    fn refresh_search(&mut self, cx: &mut Context<Self>) {
1283        self.fetch_extensions_debounced(
1284            Some(Box::new(|this, cx| {
1285                this.scroll_to_top(cx);
1286            })),
1287            cx,
1288        );
1289        self.refresh_feature_upsells(cx);
1290    }
1291
1292    pub fn focus_extension(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
1293        self.query_editor.update(cx, |editor, cx| {
1294            editor.set_text(format!("id:{id}"), window, cx)
1295        });
1296        self.refresh_search(cx);
1297    }
1298
1299    pub fn change_provides_filter(
1300        &mut self,
1301        provides_filter: Option<ExtensionProvides>,
1302        cx: &mut Context<Self>,
1303    ) {
1304        self.provides_filter = provides_filter;
1305        self.refresh_search(cx);
1306    }
1307
1308    fn fetch_extensions_debounced(
1309        &mut self,
1310        on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
1311        cx: &mut Context<ExtensionsPage>,
1312    ) {
1313        self.extension_fetch_task = Some(cx.spawn(async move |this, cx| {
1314            let search = this
1315                .update(cx, |this, cx| this.search_query(cx))
1316                .ok()
1317                .flatten();
1318
1319            // Only debounce the fetching of extensions if we have a search
1320            // query.
1321            //
1322            // If the search was just cleared then we can just reload the list
1323            // of extensions without a debounce, which allows us to avoid seeing
1324            // an intermittent flash of a "no extensions" state.
1325            if search.is_some() {
1326                cx.background_executor()
1327                    .timer(Duration::from_millis(250))
1328                    .await;
1329            };
1330
1331            this.update(cx, |this, cx| {
1332                this.fetch_extensions(
1333                    search,
1334                    Some(BTreeSet::from_iter(this.provides_filter)),
1335                    on_complete,
1336                    cx,
1337                );
1338            })
1339            .ok();
1340        }));
1341    }
1342
1343    pub fn search_query(&self, cx: &mut App) -> Option<String> {
1344        let search = self.query_editor.read(cx).text(cx);
1345        if search.trim().is_empty() {
1346            None
1347        } else {
1348            Some(search)
1349        }
1350    }
1351
1352    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1353        let has_search = self.search_query(cx).is_some();
1354
1355        let message = if self.is_fetching_extensions {
1356            "Loading extensions…"
1357        } else if self.fetch_failed {
1358            "Failed to load extensions. Please check your connection and try again."
1359        } else {
1360            match self.filter {
1361                ExtensionFilter::All => {
1362                    if has_search {
1363                        "No extensions that match your search."
1364                    } else {
1365                        "No extensions."
1366                    }
1367                }
1368                ExtensionFilter::Installed => {
1369                    if has_search {
1370                        "No installed extensions that match your search."
1371                    } else {
1372                        "No installed extensions."
1373                    }
1374                }
1375                ExtensionFilter::NotInstalled => {
1376                    if has_search {
1377                        "No not installed extensions that match your search."
1378                    } else {
1379                        "No not installed extensions."
1380                    }
1381                }
1382            }
1383        };
1384
1385        h_flex()
1386            .py_4()
1387            .gap_1p5()
1388            .when(self.fetch_failed, |this| {
1389                this.child(
1390                    Icon::new(IconName::Warning)
1391                        .size(IconSize::Small)
1392                        .color(Color::Warning),
1393                )
1394            })
1395            .child(Label::new(message))
1396    }
1397
1398    fn update_settings(
1399        &mut self,
1400        selection: &ToggleState,
1401
1402        cx: &mut Context<Self>,
1403        callback: impl 'static + Send + Fn(&mut SettingsContent, bool),
1404    ) {
1405        if let Some(workspace) = self.workspace.upgrade() {
1406            let fs = workspace.read(cx).app_state().fs.clone();
1407            let selection = *selection;
1408            settings::update_settings_file(fs, cx, move |settings, _| {
1409                let value = match selection {
1410                    ToggleState::Unselected => false,
1411                    ToggleState::Selected => true,
1412                    _ => return,
1413                };
1414
1415                callback(settings, value)
1416            });
1417        }
1418    }
1419
1420    fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1421        let Some(search) = self.search_query(cx) else {
1422            self.upsells.clear();
1423            self.show_acp_registry_upsell = false;
1424            return;
1425        };
1426
1427        if let Some(id) = search.strip_prefix("id:") {
1428            self.upsells.clear();
1429            self.show_acp_registry_upsell = false;
1430
1431            let upsell = match id.to_lowercase().as_str() {
1432                "ruff" => Some(Feature::ExtensionRuff),
1433                "basedpyright" => Some(Feature::ExtensionBasedpyright),
1434                "ty" => Some(Feature::ExtensionTy),
1435                _ => None,
1436            };
1437
1438            if let Some(upsell) = upsell {
1439                self.upsells.insert(upsell);
1440            }
1441
1442            return;
1443        }
1444
1445        let search = search.to_lowercase();
1446        let search_terms = search
1447            .split_whitespace()
1448            .map(|term| term.trim())
1449            .collect::<Vec<_>>();
1450
1451        for (feature, keywords) in keywords_by_feature() {
1452            if keywords
1453                .iter()
1454                .any(|keyword| search_terms.contains(keyword))
1455            {
1456                self.upsells.insert(*feature);
1457            } else {
1458                self.upsells.remove(feature);
1459            }
1460        }
1461
1462        self.show_acp_registry_upsell = acp_registry_upsell_keywords()
1463            .iter()
1464            .any(|keyword| search_terms.iter().any(|term| keyword.contains(term)));
1465    }
1466
1467    fn render_acp_registry_upsell(&self, cx: &mut Context<Self>) -> impl IntoElement {
1468        let registry_url = zed_urls::acp_registry_blog(cx);
1469
1470        let view_registry = Button::new("view_registry", "View Registry")
1471            .style(ButtonStyle::Tinted(ui::TintColor::Warning))
1472            .on_click({
1473                let registry_url = registry_url.clone();
1474                move |_, window, cx| {
1475                    telemetry::event!(
1476                        "ACP Registry Opened from Extensions",
1477                        source = "ACP Registry Upsell",
1478                        url = registry_url,
1479                    );
1480                    window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
1481                }
1482            });
1483        let open_registry_button = Button::new("open_registry", "Learn More")
1484            .end_icon(
1485                Icon::new(IconName::ArrowUpRight)
1486                    .size(IconSize::Small)
1487                    .color(Color::Muted),
1488            )
1489            .on_click({
1490                move |_event, _window, cx| {
1491                    telemetry::event!(
1492                        "ACP Registry Viewed",
1493                        source = "ACP Registry Upsell",
1494                        url = registry_url,
1495                    );
1496                    cx.open_url(&registry_url)
1497                }
1498            });
1499
1500        div().pt_4().px_4().child(
1501            Banner::new()
1502                .severity(Severity::Warning)
1503                .child(
1504                    Label::new(
1505                        "Agent Server extensions will be deprecated in favor of the ACP registry.",
1506                    )
1507                    .mt_0p5(),
1508                )
1509                .action_slot(
1510                    h_flex()
1511                        .gap_1()
1512                        .child(open_registry_button)
1513                        .child(view_registry),
1514                ),
1515        )
1516    }
1517
1518    fn render_feature_upsell_banner(
1519        &self,
1520        label: SharedString,
1521        docs_url: SharedString,
1522        vim: bool,
1523        cx: &mut Context<Self>,
1524    ) -> impl IntoElement {
1525        let docs_url_button = Button::new("open_docs", "View Documentation")
1526            .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small))
1527            .on_click({
1528                move |_event, _window, cx| {
1529                    telemetry::event!(
1530                        "Documentation Viewed",
1531                        source = "Feature Upsell",
1532                        url = docs_url,
1533                    );
1534                    cx.open_url(&docs_url)
1535                }
1536            });
1537
1538        div()
1539            .pt_4()
1540            .px_4()
1541            .child(
1542                Banner::new()
1543                    .severity(Severity::Success)
1544                    .child(Label::new(label).mt_0p5())
1545                    .map(|this| {
1546                        if vim {
1547                            this.action_slot(
1548                                h_flex()
1549                                    .gap_1()
1550                                    .child(docs_url_button)
1551                                    .child(Divider::vertical().color(ui::DividerColor::Border))
1552                                    .child(
1553                                        h_flex()
1554                                            .pl_1()
1555                                            .gap_1()
1556                                            .child(Label::new("Enable Vim mode"))
1557                                            .child(
1558                                                Switch::new(
1559                                                    "enable-vim",
1560                                                    if VimModeSetting::get_global(cx).0 {
1561                                                        ui::ToggleState::Selected
1562                                                    } else {
1563                                                        ui::ToggleState::Unselected
1564                                                    },
1565                                                )
1566                                                .on_click(cx.listener(
1567                                                    move |this, selection, _, cx| {
1568                                                        telemetry::event!(
1569                                                            "Vim Mode Toggled",
1570                                                            source = "Feature Upsell"
1571                                                        );
1572                                                        this.update_settings(
1573                                                            selection,
1574                                                            cx,
1575                                                            |setting, value| {
1576                                                                setting.vim_mode = Some(value)
1577                                                            },
1578                                                        );
1579                                                    },
1580                                                )),
1581                                            ),
1582                                    ),
1583                            )
1584                        } else {
1585                            this.action_slot(docs_url_button)
1586                        }
1587                    }),
1588            )
1589            .into_any_element()
1590    }
1591
1592    fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1593        let mut container = v_flex();
1594
1595        for feature in &self.upsells {
1596            let banner = match feature {
1597                Feature::AgentClaude => self.render_feature_upsell_banner(
1598                    "Claude Agent support is built-in to Zed!".into(),
1599                    "https://zed.dev/docs/ai/external-agents#claude-agent".into(),
1600                    false,
1601                    cx,
1602                ),
1603                Feature::AgentCodex => self.render_feature_upsell_banner(
1604                    "Codex CLI support is built-in to Zed!".into(),
1605                    "https://zed.dev/docs/ai/external-agents#codex-cli".into(),
1606                    false,
1607                    cx,
1608                ),
1609                Feature::AgentGemini => self.render_feature_upsell_banner(
1610                    "Gemini CLI support is built-in to Zed!".into(),
1611                    "https://zed.dev/docs/ai/external-agents#gemini-cli".into(),
1612                    false,
1613                    cx,
1614                ),
1615                Feature::ExtensionBasedpyright => self.render_feature_upsell_banner(
1616                    "Basedpyright (Python language server) support is built-in to Zed!".into(),
1617                    "https://zed.dev/docs/languages/python#basedpyright".into(),
1618                    false,
1619                    cx,
1620                ),
1621                Feature::ExtensionRuff => self.render_feature_upsell_banner(
1622                    "Ruff (linter for Python) support is built-in to Zed!".into(),
1623                    "https://zed.dev/docs/languages/python#code-formatting--linting".into(),
1624                    false,
1625                    cx,
1626                ),
1627                Feature::ExtensionTailwind => self.render_feature_upsell_banner(
1628                    "Tailwind CSS support is built-in to Zed!".into(),
1629                    "https://zed.dev/docs/languages/tailwindcss".into(),
1630                    false,
1631                    cx,
1632                ),
1633                Feature::ExtensionTy => self.render_feature_upsell_banner(
1634                    "Ty (Python language server) support is built-in to Zed!".into(),
1635                    "https://zed.dev/docs/languages/python".into(),
1636                    false,
1637                    cx,
1638                ),
1639                Feature::Git => self.render_feature_upsell_banner(
1640                    "Zed comes with basic Git support—more features are coming in the future."
1641                        .into(),
1642                    "https://zed.dev/docs/git".into(),
1643                    false,
1644                    cx,
1645                ),
1646                Feature::LanguageBash => self.render_feature_upsell_banner(
1647                    "Shell support is built-in to Zed!".into(),
1648                    "https://zed.dev/docs/languages/bash".into(),
1649                    false,
1650                    cx,
1651                ),
1652                Feature::LanguageC => self.render_feature_upsell_banner(
1653                    "C support is built-in to Zed!".into(),
1654                    "https://zed.dev/docs/languages/c".into(),
1655                    false,
1656                    cx,
1657                ),
1658                Feature::LanguageCpp => self.render_feature_upsell_banner(
1659                    "C++ support is built-in to Zed!".into(),
1660                    "https://zed.dev/docs/languages/cpp".into(),
1661                    false,
1662                    cx,
1663                ),
1664                Feature::LanguageGo => self.render_feature_upsell_banner(
1665                    "Go support is built-in to Zed!".into(),
1666                    "https://zed.dev/docs/languages/go".into(),
1667                    false,
1668                    cx,
1669                ),
1670                Feature::LanguagePython => self.render_feature_upsell_banner(
1671                    "Python support is built-in to Zed!".into(),
1672                    "https://zed.dev/docs/languages/python".into(),
1673                    false,
1674                    cx,
1675                ),
1676                Feature::LanguageReact => self.render_feature_upsell_banner(
1677                    "React support is built-in to Zed!".into(),
1678                    "https://zed.dev/docs/languages/typescript".into(),
1679                    false,
1680                    cx,
1681                ),
1682                Feature::LanguageRust => self.render_feature_upsell_banner(
1683                    "Rust support is built-in to Zed!".into(),
1684                    "https://zed.dev/docs/languages/rust".into(),
1685                    false,
1686                    cx,
1687                ),
1688                Feature::LanguageTypescript => self.render_feature_upsell_banner(
1689                    "Typescript support is built-in to Zed!".into(),
1690                    "https://zed.dev/docs/languages/typescript".into(),
1691                    false,
1692                    cx,
1693                ),
1694                Feature::OpenIn => self.render_feature_upsell_banner(
1695                    "Zed supports linking to a source line on GitHub and others.".into(),
1696                    "https://zed.dev/docs/git#git-integrations".into(),
1697                    false,
1698                    cx,
1699                ),
1700                Feature::Vim => self.render_feature_upsell_banner(
1701                    "Vim support is built-in to Zed!".into(),
1702                    "https://zed.dev/docs/vim".into(),
1703                    true,
1704                    cx,
1705                ),
1706            };
1707            container = container.child(banner);
1708        }
1709
1710        container
1711    }
1712}
1713
1714impl Render for ExtensionsPage {
1715    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1716        v_flex()
1717            .size_full()
1718            .bg(cx.theme().colors().editor_background)
1719            .child(
1720                v_flex()
1721                    .gap_4()
1722                    .pt_4()
1723                    .px_4()
1724                    .bg(cx.theme().colors().editor_background)
1725                    .child(
1726                        h_flex()
1727                            .w_full()
1728                            .gap_1p5()
1729                            .justify_between()
1730                            .child(Headline::new("Extensions").size(HeadlineSize::Large))
1731                            .child(
1732                                Button::new("install-dev-extension", "Install Dev Extension")
1733                                    .style(ButtonStyle::Outlined)
1734                                    .size(ButtonSize::Medium)
1735                                    .on_click(|_event, window, cx| {
1736                                        window.dispatch_action(Box::new(InstallDevExtension), cx)
1737                                    }),
1738                            ),
1739                    )
1740                    .child(
1741                        h_flex()
1742                            .w_full()
1743                            .flex_wrap()
1744                            .gap_2()
1745                            .child(self.render_search(cx))
1746                            .child(
1747                                div().child(
1748                                    ToggleButtonGroup::single_row(
1749                                        "filter-buttons",
1750                                        [
1751                                            ToggleButtonSimple::new(
1752                                                "All",
1753                                                cx.listener(|this, _event, _, cx| {
1754                                                    this.filter = ExtensionFilter::All;
1755                                                    this.filter_extension_entries(cx);
1756                                                    this.scroll_to_top(cx);
1757                                                }),
1758                                            ),
1759                                            ToggleButtonSimple::new(
1760                                                "Installed",
1761                                                cx.listener(|this, _event, _, cx| {
1762                                                    this.filter = ExtensionFilter::Installed;
1763                                                    this.filter_extension_entries(cx);
1764                                                    this.scroll_to_top(cx);
1765                                                }),
1766                                            ),
1767                                            ToggleButtonSimple::new(
1768                                                "Not Installed",
1769                                                cx.listener(|this, _event, _, cx| {
1770                                                    this.filter = ExtensionFilter::NotInstalled;
1771                                                    this.filter_extension_entries(cx);
1772                                                    this.scroll_to_top(cx);
1773                                                }),
1774                                            ),
1775                                        ],
1776                                    )
1777                                    .style(ToggleButtonGroupStyle::Outlined)
1778                                    .size(ToggleButtonGroupSize::Custom(rems_from_px(30.))) // Perfectly matches the input
1779                                    .label_size(LabelSize::Default)
1780                                    .auto_width()
1781                                    .selected_index(match self.filter {
1782                                        ExtensionFilter::All => 0,
1783                                        ExtensionFilter::Installed => 1,
1784                                        ExtensionFilter::NotInstalled => 2,
1785                                    })
1786                                    .into_any_element(),
1787                                ),
1788                            ),
1789                    ),
1790            )
1791            .child(
1792                h_flex()
1793                    .id("filter-row")
1794                    .gap_2()
1795                    .py_2p5()
1796                    .px_4()
1797                    .border_b_1()
1798                    .border_color(cx.theme().colors().border_variant)
1799                    .overflow_x_scroll()
1800                    .child(
1801                        Button::new("filter-all-categories", "All")
1802                            .when(self.provides_filter.is_none(), |button| {
1803                                button.style(ButtonStyle::Filled)
1804                            })
1805                            .when(self.provides_filter.is_some(), |button| {
1806                                button.style(ButtonStyle::Subtle)
1807                            })
1808                            .toggle_state(self.provides_filter.is_none())
1809                            .on_click(cx.listener(|this, _event, _, cx| {
1810                                this.change_provides_filter(None, cx);
1811                            })),
1812                    )
1813                    .children(ExtensionProvides::iter().filter_map(|provides| {
1814                        match provides {
1815                            ExtensionProvides::SlashCommands
1816                            | ExtensionProvides::IndexedDocsProviders => return None,
1817                            _ => {}
1818                        }
1819
1820                        let label = extension_provides_label(provides);
1821                        let button_id = SharedString::from(format!("filter-category-{}", label));
1822
1823                        Some(
1824                            Button::new(button_id, label)
1825                                .style(if self.provides_filter == Some(provides) {
1826                                    ButtonStyle::Filled
1827                                } else {
1828                                    ButtonStyle::Subtle
1829                                })
1830                                .toggle_state(self.provides_filter == Some(provides))
1831                                .on_click({
1832                                    cx.listener(move |this, _event, _, cx| {
1833                                        this.change_provides_filter(Some(provides), cx);
1834                                    })
1835                                }),
1836                        )
1837                    })),
1838            )
1839            .when(
1840                self.provides_filter == Some(ExtensionProvides::AgentServers)
1841                    || self.show_acp_registry_upsell,
1842                |this| this.child(self.render_acp_registry_upsell(cx)),
1843            )
1844            .child(self.render_feature_upsells(cx))
1845            .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
1846                let mut count = self.filtered_remote_extension_indices.len();
1847                if self.filter.include_dev_extensions() {
1848                    count += self.filtered_dev_extension_indices.len();
1849                }
1850
1851                if count == 0 {
1852                    this.child(self.render_empty_state(cx)).into_any_element()
1853                } else {
1854                    let scroll_handle = &self.list;
1855                    this.child(
1856                        uniform_list("entries", count, cx.processor(Self::render_extensions))
1857                            .flex_grow()
1858                            .pb_4()
1859                            .track_scroll(scroll_handle),
1860                    )
1861                    .vertical_scrollbar_for(scroll_handle, window, cx)
1862                    .into_any_element()
1863                }
1864            }))
1865    }
1866}
1867
1868impl EventEmitter<ItemEvent> for ExtensionsPage {}
1869
1870impl Focusable for ExtensionsPage {
1871    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1872        self.query_editor.read(cx).focus_handle(cx)
1873    }
1874}
1875
1876impl Item for ExtensionsPage {
1877    type Event = ItemEvent;
1878
1879    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1880        "Extensions".into()
1881    }
1882
1883    fn telemetry_event_text(&self) -> Option<&'static str> {
1884        Some("Extensions Page Opened")
1885    }
1886
1887    fn show_toolbar(&self) -> bool {
1888        false
1889    }
1890
1891    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
1892        f(*event)
1893    }
1894}