extensions_ui: Add general structure for filtering extensions by what they provide (#24325)

Marshall Bowers created

This PR adds the general structure for filtering the extensions list by
what the extensions provide.

Currently flagged for Zed staff until we get some design direction on
how best to present the filter.

Release Notes:

- N/A

Change summary

Cargo.lock                                  |  1 
crates/extension_host/src/extension_host.rs | 15 +++++
crates/extensions_ui/Cargo.toml             |  1 
crates/extensions_ui/src/extensions_ui.rs   | 60 ++++++++++++++++++++--
crates/rpc/src/extension.rs                 | 13 ++++
5 files changed, 81 insertions(+), 9 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4490,6 +4490,7 @@ dependencies = [
  "db",
  "editor",
  "extension_host",
+ "feature_flags",
  "fs",
  "fuzzy",
  "gpui",

crates/extension_host/src/extension_host.rs 🔗

@@ -8,8 +8,9 @@ mod extension_store_test;
 use anyhow::{anyhow, bail, Context as _, Result};
 use async_compression::futures::bufread::GzipDecoder;
 use async_tar::Archive;
+use client::ExtensionProvides;
 use client::{proto, telemetry::Telemetry, Client, ExtensionMetadata, GetExtensionsResponse};
-use collections::{btree_map, BTreeMap, HashMap, HashSet};
+use collections::{btree_map, BTreeMap, BTreeSet, HashMap, HashSet};
 use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder};
 pub use extension::ExtensionManifest;
 use extension::{
@@ -464,6 +465,7 @@ impl ExtensionStore {
     pub fn fetch_extensions(
         &self,
         search: Option<&str>,
+        provides_filter: Option<&BTreeSet<ExtensionProvides>>,
         cx: &mut Context<Self>,
     ) -> Task<Result<Vec<ExtensionMetadata>>> {
         let version = CURRENT_SCHEMA_VERSION.to_string();
@@ -472,6 +474,17 @@ impl ExtensionStore {
             query.push(("filter", search));
         }
 
+        let provides_filter = provides_filter.map(|provides_filter| {
+            provides_filter
+                .iter()
+                .map(|provides| provides.to_string())
+                .collect::<Vec<_>>()
+                .join(",")
+        });
+        if let Some(provides_filter) = provides_filter.as_deref() {
+            query.push(("provides", provides_filter));
+        }
+
         self.fetch_extensions_from_api("/extensions", &query, cx)
     }
 

crates/extensions_ui/Cargo.toml 🔗

@@ -18,6 +18,7 @@ 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

crates/extensions_ui/src/extensions_ui.rs 🔗

@@ -10,6 +10,7 @@ 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,
@@ -210,6 +211,7 @@ pub struct ExtensionsPage {
     filtered_remote_extension_indices: Vec<usize>,
     query_editor: Entity<Editor>,
     query_contains_error: bool,
+    provides_filter: Option<ExtensionProvides>,
     _subscriptions: [gpui::Subscription; 2],
     extension_fetch_task: Option<Task<()>>,
     upsells: BTreeSet<Feature>,
@@ -261,12 +263,13 @@ impl ExtensionsPage {
                 filtered_remote_extension_indices: Vec::new(),
                 remote_extension_entries: Vec::new(),
                 query_contains_error: false,
+                provides_filter: None,
                 extension_fetch_task: None,
                 _subscriptions: subscriptions,
                 query_editor,
                 upsells: BTreeSet::default(),
             };
-            this.fetch_extensions(None, cx);
+            this.fetch_extensions(None, None, cx);
             this
         })
     }
@@ -363,7 +366,12 @@ impl ExtensionsPage {
         cx.notify();
     }
 
-    fn fetch_extensions(&mut self, search: Option<String>, cx: &mut Context<Self>) {
+    fn fetch_extensions(
+        &mut self,
+        search: Option<String>,
+        provides_filter: Option<BTreeSet<ExtensionProvides>>,
+        cx: &mut Context<Self>,
+    ) {
         self.is_fetching_extensions = true;
         cx.notify();
 
@@ -374,7 +382,7 @@ impl ExtensionsPage {
         });
 
         let remote_extensions = extension_store.update(cx, |store, cx| {
-            store.fetch_extensions(search.as_deref(), cx)
+            store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx)
         });
 
         cx.spawn(move |this, mut cx| async move {
@@ -953,11 +961,15 @@ impl ExtensionsPage {
     ) {
         if let editor::EditorEvent::Edited { .. } = event {
             self.query_contains_error = false;
-            self.fetch_extensions_debounced(cx);
-            self.refresh_feature_upsells(cx);
+            self.refresh_search(cx);
         }
     }
 
+    fn refresh_search(&mut self, cx: &mut Context<Self>) {
+        self.fetch_extensions_debounced(cx);
+        self.refresh_feature_upsells(cx);
+    }
+
     fn fetch_extensions_debounced(&mut self, cx: &mut Context<ExtensionsPage>) {
         self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
             let search = this
@@ -978,7 +990,7 @@ impl ExtensionsPage {
             };
 
             this.update(&mut cx, |this, cx| {
-                this.fetch_extensions(search, cx);
+                this.fetch_extensions(search, Some(BTreeSet::from_iter(this.provides_filter)), cx);
             })
             .ok();
         }));
@@ -1162,7 +1174,41 @@ impl Render for ExtensionsPage {
                             .w_full()
                             .gap_2()
                             .justify_between()
-                            .child(h_flex().child(self.render_search(cx)))
+                            .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()
                                     .child(

crates/rpc/src/extension.rs 🔗

@@ -19,7 +19,18 @@ pub struct ExtensionApiManifest {
 }
 
 #[derive(
-    Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize, EnumString,
+    Debug,
+    PartialEq,
+    Eq,
+    PartialOrd,
+    Ord,
+    Hash,
+    Clone,
+    Copy,
+    Serialize,
+    Deserialize,
+    EnumString,
+    strum::Display,
 )]
 #[serde(rename_all = "kebab-case")]
 #[strum(serialize_all = "kebab-case")]