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