extensions_ui: Add design changes to expose the filters more (#29582)

Danilo Leal created

Closes https://github.com/zed-industries/zed/issues/28086

The main motivator for this change is to have the "MCP Servers" filter
more clearly visible. And because of this, all other filters end up more
visible, as they're not in a dropdown menu anymore. Ended up pushing
some other small changes here and there as well. This is our final
product:

<img
src="https://github.com/user-attachments/assets/16ac78b6-72d9-4a8a-801b-b4b992221331"
width="700"/>

Release Notes:

- N/A

Change summary

crates/extensions_ui/src/components/extension_card.rs |   2 
crates/extensions_ui/src/components/feature_upsell.rs |  21 
crates/extensions_ui/src/extensions_ui.rs             | 154 +++++-------
3 files changed, 75 insertions(+), 102 deletions(-)

Detailed changes

crates/extensions_ui/src/components/extension_card.rs 🔗

@@ -40,7 +40,7 @@ impl RenderOnce for ExtensionCard {
                 .bg(cx.theme().colors().elevated_surface_background)
                 .border_1()
                 .border_color(cx.theme().colors().border)
-                .rounded_sm()
+                .rounded_md()
                 .children(self.children)
                 .when(self.overridden_by_dev_extension, |card| {
                     card.child(

crates/extensions_ui/src/components/feature_upsell.rs 🔗

@@ -1,6 +1,6 @@
 use gpui::{AnyElement, Div, StyleRefinement};
 use smallvec::SmallVec;
-use ui::{ButtonLike, prelude::*};
+use ui::prelude::*;
 
 #[derive(IntoElement)]
 pub struct FeatureUpsell {
@@ -46,21 +46,20 @@ impl FeatureUpsell {
 impl RenderOnce for FeatureUpsell {
     fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
         self.base
-            .p_4()
+            .py_2()
+            .px_4()
             .justify_between()
-            .border_color(cx.theme().colors().border)
-            .child(v_flex().overflow_hidden().child(Label::new(self.text)))
+            .flex_wrap()
+            .border_color(cx.theme().colors().border_variant)
+            .child(Label::new(self.text))
             .child(h_flex().gap_2().children(self.children).when_some(
                 self.docs_url,
                 |el, docs_url| {
                     el.child(
-                        ButtonLike::new("open_docs")
-                            .child(
-                                h_flex()
-                                    .gap_2()
-                                    .child(Label::new("View docs"))
-                                    .child(Icon::new(IconName::ArrowUpRight)),
-                            )
+                        Button::new("open_docs", "View Documentation")
+                            .icon(IconName::ArrowUpRight)
+                            .icon_size(IconSize::XSmall)
+                            .icon_position(IconPosition::End)
                             .on_click({
                                 let docs_url = docs_url.clone();
                                 move |_event, _window, cx| {

crates/extensions_ui/src/extensions_ui.rs 🔗

@@ -165,7 +165,7 @@ fn extension_provides_label(provides: ExtensionProvides) -> &'static str {
         ExtensionProvides::Languages => "Languages",
         ExtensionProvides::Grammars => "Grammars",
         ExtensionProvides::LanguageServers => "Language Servers",
-        ExtensionProvides::ContextServers => "Context Servers",
+        ExtensionProvides::ContextServers => "MCP Servers",
         ExtensionProvides::SlashCommands => "Slash Commands",
         ExtensionProvides::IndexedDocsProviders => "Indexed Docs Providers",
         ExtensionProvides::Snippets => "Snippets",
@@ -573,6 +573,7 @@ impl ExtensionsPage {
                             extension.authors.join(", ")
                         ))
                         .size(LabelSize::Small)
+                        .color(Color::Muted)
                         .truncate(),
                     )
                     .child(Label::new("<>").size(LabelSize::Small)),
@@ -594,7 +595,6 @@ impl ExtensionsPage {
                         )
                         .icon_color(Color::Accent)
                         .icon_size(IconSize::Small)
-                        .style(ButtonStyle::Filled)
                         .on_click(cx.listener({
                             let repository_url = repository_url.clone();
                             move |_, _, _, cx| {
@@ -701,6 +701,7 @@ impl ExtensionsPage {
                             extension.manifest.authors.join(", ")
                         ))
                         .size(LabelSize::Small)
+                        .color(Color::Muted)
                         .truncate(),
                     )
                     .child(
@@ -731,7 +732,6 @@ impl ExtensionsPage {
                                 )
                                 .icon_color(Color::Accent)
                                 .icon_size(IconSize::Small)
-                                .style(ButtonStyle::Filled)
                                 .on_click(cx.listener({
                                     let repository_url = repository_url.clone();
                                     move |_, _, _, cx| {
@@ -751,8 +751,7 @@ impl ExtensionsPage {
                                         IconName::Ellipsis,
                                     )
                                     .icon_color(Color::Accent)
-                                    .icon_size(IconSize::Small)
-                                    .style(ButtonStyle::Filled),
+                                    .icon_size(IconSize::Small),
                                 )
                                 .menu(move |window, cx| {
                                     Some(Self::render_remote_extension_context_menu(
@@ -950,19 +949,20 @@ impl ExtensionsPage {
             cx.theme().colors().border
         };
 
-        h_flex().w_full().gap_2().key_context(key_context).child(
-            h_flex()
-                .flex_1()
-                .px_2()
-                .py_1()
-                .gap_2()
-                .border_1()
-                .border_color(editor_border)
-                .min_w(rems_from_px(384.))
-                .rounded_lg()
-                .child(Icon::new(IconName::MagnifyingGlass))
-                .child(self.render_text_input(&self.query_editor, cx)),
-        )
+        h_flex()
+            .key_context(key_context)
+            .h_8()
+            .flex_1()
+            .min_w(rems_from_px(384.))
+            .pl_1p5()
+            .pr_2()
+            .py_1()
+            .gap_2()
+            .border_1()
+            .border_color(editor_border)
+            .rounded_lg()
+            .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
+            .child(self.render_text_input(&self.query_editor, cx))
     }
 
     fn render_text_input(
@@ -1193,52 +1193,6 @@ 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.change_provides_filter(None, 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.change_provides_filter(Some(provides), cx);
-                                this.provides_filter = Some(provides);
-                            });
-                        }
-                    },
-                )
-            }
-
-            menu
-        })
-    }
 }
 
 impl Render for ExtensionsPage {
@@ -1249,9 +1203,8 @@ impl Render for ExtensionsPage {
             .child(
                 v_flex()
                     .gap_4()
-                    .p_4()
-                    .border_b_1()
-                    .border_color(cx.theme().colors().border)
+                    .pt_4()
+                    .px_4()
                     .bg(cx.theme().colors().editor_background)
                     .child(
                         h_flex()
@@ -1271,29 +1224,9 @@ impl Render for ExtensionsPage {
                     .child(
                         h_flex()
                             .w_full()
-                            .gap_2()
-                            .justify_between()
-                            .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)
-                            }))
+                            .gap_4()
+                            .flex_wrap()
+                            .child(self.render_search(cx))
                             .child(
                                 h_flex()
                                     .child(
@@ -1343,6 +1276,47 @@ impl Render for ExtensionsPage {
                             ),
                     ),
             )
+            .child(
+                h_flex()
+                    .id("filter-row")
+                    .gap_2()
+                    .py_2p5()
+                    .px_4()
+                    .border_b_1()
+                    .border_color(cx.theme().colors().border_variant)
+                    .overflow_x_scroll()
+                    .child(
+                        Button::new("filter-all-categories", "All")
+                            .when(self.provides_filter.is_none(), |button| {
+                                button.style(ButtonStyle::Filled)
+                            })
+                            .when(self.provides_filter.is_some(), |button| {
+                                button.style(ButtonStyle::Subtle)
+                            })
+                            .toggle_state(self.provides_filter.is_none())
+                            .on_click(cx.listener(|this, _event, _, cx| {
+                                this.change_provides_filter(None, cx);
+                            })),
+                    )
+                    .children(ExtensionProvides::iter().map(|provides| {
+                        let label = extension_provides_label(provides);
+                        Button::new(
+                            SharedString::from(format!("filter-category-{}", label)),
+                            label,
+                        )
+                        .style(if self.provides_filter == Some(provides) {
+                            ButtonStyle::Filled
+                        } else {
+                            ButtonStyle::Subtle
+                        })
+                        .toggle_state(self.provides_filter == Some(provides))
+                        .on_click({
+                            cx.listener(move |this, _event, _, cx| {
+                                this.change_provides_filter(Some(provides), cx);
+                            })
+                        })
+                    })),
+            )
             .child(self.render_feature_upsells(cx))
             .child(
                 v_flex()