extensions_ui: Add ability to filter extensions by category (#27005)

Marshall Bowers created

This PR adds the ability to filter the list of extensions by category:


https://github.com/user-attachments/assets/ea7b518e-4769-4e2e-8bbe-e75f9f01edf9

Release Notes:

- Added the ability to filter the list of extensions by category.

Change summary

Cargo.lock                                |   2 
crates/extensions_ui/Cargo.toml           |   2 
crates/extensions_ui/src/extensions_ui.rs | 143 +++++++++++++++---------
crates/rpc/src/extension.rs               |   1 
4 files changed, 90 insertions(+), 58 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4821,7 +4821,6 @@ dependencies = [
  "db",
  "editor",
  "extension_host",
- "feature_flags",
  "fs",
  "fuzzy",
  "gpui",
@@ -4834,6 +4833,7 @@ dependencies = [
  "serde",
  "settings",
  "smallvec",
+ "strum",
  "telemetry",
  "theme",
  "ui",

crates/extensions_ui/Cargo.toml 🔗

@@ -18,7 +18,6 @@ collections.workspace = true
 db.workspace = true
 editor.workspace = true
 extension_host.workspace = true
-feature_flags.workspace = true
 fs.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
@@ -31,6 +30,7 @@ semantic_version.workspace = true
 serde.workspace = true
 settings.workspace = true
 smallvec.workspace = true
+strum.workspace = true
 telemetry.workspace = true
 theme.workspace = true
 ui.workspace = true

crates/extensions_ui/src/extensions_ui.rs 🔗

@@ -10,7 +10,6 @@ use client::{ExtensionMetadata, ExtensionProvides};
 use collections::{BTreeMap, BTreeSet};
 use editor::{Editor, EditorElement, EditorStyle};
 use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore};
-use feature_flags::FeatureFlagAppExt as _;
 use fuzzy::{match_strings, StringMatchCandidate};
 use gpui::{
     actions, uniform_list, Action, App, ClipboardItem, Context, Entity, EventEmitter, Flatten,
@@ -21,6 +20,7 @@ use num_format::{Locale, ToFormattedString};
 use project::DirectoryLister;
 use release_channel::ReleaseChannel;
 use settings::Settings;
+use strum::IntoEnumIterator as _;
 use theme::ThemeSettings;
 use ui::{prelude::*, CheckboxWithLabel, ContextMenu, PopoverMenu, ToggleButton, Tooltip};
 use vim_mode_setting::VimModeSetting;
@@ -127,6 +127,20 @@ pub fn init(cx: &mut App) {
     .detach();
 }
 
+fn extension_provides_label(provides: ExtensionProvides) -> &'static str {
+    match provides {
+        ExtensionProvides::Themes => "Themes",
+        ExtensionProvides::IconThemes => "Icon Themes",
+        ExtensionProvides::Languages => "Languages",
+        ExtensionProvides::Grammars => "Grammars",
+        ExtensionProvides::LanguageServers => "Language Servers",
+        ExtensionProvides::ContextServers => "Context Servers",
+        ExtensionProvides::SlashCommands => "Slash Commands",
+        ExtensionProvides::IndexedDocsProviders => "Indexed Docs Providers",
+        ExtensionProvides::Snippets => "Snippets",
+    }
+}
+
 #[derive(Clone)]
 pub enum ExtensionStatus {
     NotInstalled,
@@ -608,25 +622,6 @@ impl ExtensionsPage {
                                             .provides
                                             .iter()
                                             .map(|provides| {
-                                                let label = match provides {
-                                                    ExtensionProvides::Themes => "Themes",
-                                                    ExtensionProvides::IconThemes => "Icon Themes",
-                                                    ExtensionProvides::Languages => "Languages",
-                                                    ExtensionProvides::Grammars => "Grammars",
-                                                    ExtensionProvides::LanguageServers => {
-                                                        "Language Servers"
-                                                    }
-                                                    ExtensionProvides::ContextServers => {
-                                                        "Context Servers"
-                                                    }
-                                                    ExtensionProvides::SlashCommands => {
-                                                        "Slash Commands"
-                                                    }
-                                                    ExtensionProvides::IndexedDocsProviders => {
-                                                        "Indexed Docs Providers"
-                                                    }
-                                                    ExtensionProvides::Snippets => "Snippets",
-                                                };
                                                 div()
                                                     .bg(cx.theme().colors().element_background)
                                                     .px_0p5()
@@ -634,7 +629,10 @@ impl ExtensionsPage {
                                                     .border_color(cx.theme().colors().border)
                                                     .rounded_sm()
                                                     .child(
-                                                        Label::new(label).size(LabelSize::XSmall),
+                                                        Label::new(extension_provides_label(
+                                                            *provides,
+                                                        ))
+                                                        .size(LabelSize::XSmall),
                                                     )
                                             })
                                             .collect::<Vec<_>>(),
@@ -1140,6 +1138,53 @@ impl ExtensionsPage {
             upsell.when(ix < upsells_count, |upsell| upsell.border_b_1())
         }))
     }
+
+    fn build_extension_provides_filter_menu(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Entity<ContextMenu> {
+        let this = cx.entity();
+        ContextMenu::build(window, cx, |mut menu, _window, _cx| {
+            menu = menu.header("Extension Category").toggleable_entry(
+                "All",
+                self.provides_filter.is_none(),
+                IconPosition::End,
+                None,
+                {
+                    let this = this.clone();
+                    move |_window, cx| {
+                        this.update(cx, |this, cx| {
+                            this.provides_filter = None;
+                            this.refresh_search(cx);
+                        });
+                    }
+                },
+            );
+
+            for provides in ExtensionProvides::iter() {
+                let label = extension_provides_label(provides);
+
+                menu = menu.toggleable_entry(
+                    label,
+                    self.provides_filter == Some(provides),
+                    IconPosition::End,
+                    None,
+                    {
+                        let this = this.clone();
+                        move |_window, cx| {
+                            this.update(cx, |this, cx| {
+                                this.provides_filter = Some(provides);
+                                this.refresh_search(cx);
+                            });
+                        }
+                    },
+                )
+            }
+
+            menu
+        })
+    }
 }
 
 impl Render for ExtensionsPage {
@@ -1174,41 +1219,27 @@ impl Render for ExtensionsPage {
                             .w_full()
                             .gap_2()
                             .justify_between()
-                            .child(
-                                h_flex()
-                                    .gap_2()
-                                    .child(self.render_search(cx))
-                                    .map(|parent| {
-                                        // Note: Staff-only until this gets design input.
-                                        if !cx.is_staff() {
-                                            return parent;
-                                        }
-
-                                        parent.child(CheckboxWithLabel::new(
-                                            "icon-themes-filter",
-                                            Label::new("Icon themes"),
-                                            match self.provides_filter {
-                                                Some(ExtensionProvides::IconThemes) => {
-                                                    ToggleState::Selected
-                                                }
-                                                _ => ToggleState::Unselected,
-                                            },
-                                            cx.listener(|this, checked, _window, cx| {
-                                                match checked {
-                                                    ToggleState::Unselected
-                                                    | ToggleState::Indeterminate => {
-                                                        this.provides_filter = None
-                                                    }
-                                                    ToggleState::Selected => {
-                                                        this.provides_filter =
-                                                            Some(ExtensionProvides::IconThemes)
-                                                    }
-                                                };
-                                                this.refresh_search(cx);
-                                            }),
-                                        ))
-                                    }),
-                            )
+                            .child(h_flex().gap_2().child(self.render_search(cx)).child({
+                                let this = cx.entity().clone();
+                                PopoverMenu::new("extension-provides-filter")
+                                    .menu(move |window, cx| {
+                                        Some(this.update(cx, |this, cx| {
+                                            this.build_extension_provides_filter_menu(window, cx)
+                                        }))
+                                    })
+                                    .trigger_with_tooltip(
+                                        Button::new(
+                                            "extension-provides-filter-button",
+                                            self.provides_filter
+                                                .map(extension_provides_label)
+                                                .unwrap_or("All"),
+                                        )
+                                        .icon(IconName::Filter)
+                                        .icon_position(IconPosition::Start),
+                                        Tooltip::text("Filter extensions by category"),
+                                    )
+                                    .anchor(gpui::Corner::TopLeft)
+                            }))
                             .child(
                                 h_flex()
                                     .child(

crates/rpc/src/extension.rs 🔗

@@ -31,6 +31,7 @@ pub struct ExtensionApiManifest {
     Deserialize,
     EnumString,
     strum::Display,
+    strum::EnumIter,
 )]
 #[serde(rename_all = "kebab-case")]
 #[strum(serialize_all = "kebab-case")]