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