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