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