From 70b50f985237fe81a0bea73ecbbd67d78bce7489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 9 May 2026 19:30:17 -0400 Subject: [PATCH] Wire plugin marketplace search to filter the grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the static "Install plugin · N active" hero button with the design's pair: a glass search input + a primary Install action. The input is bound to a new InputState on ElyShell (plugin_search_input); on every render the catalog reads its current value and filters the plugin grid by case-insensitive contains on name, author, and description. An empty-needle render shows every installed plugin so the page still works at rest. When the search yields no matches, the grid swaps to an empty-state copy that quotes the user's needle. Install action moves into a trailing dark chip that calls choose_plugin_package, preserving the existing file-picker flow. --- .../shell/internal_pages/plugin_catalog.rs | 248 ++++++++++++------ crates/ely_app/src/shell/mod.rs | 4 + 2 files changed, 169 insertions(+), 83 deletions(-) diff --git a/crates/ely_app/src/shell/internal_pages/plugin_catalog.rs b/crates/ely_app/src/shell/internal_pages/plugin_catalog.rs index 93adf9a..4d81128 100644 --- a/crates/ely_app/src/shell/internal_pages/plugin_catalog.rs +++ b/crates/ely_app/src/shell/internal_pages/plugin_catalog.rs @@ -6,7 +6,7 @@ use gpui::{ StatefulInteractiveElement, Styled, div, hsla, linear_color_stop, linear_gradient, px, rgb, rgba, }; -use gpui_component::{IconName, scroll::ScrollableElement}; +use gpui_component::{IconName, input::Input, scroll::ScrollableElement}; use super::plugin_editors_pick::{action_button, render_editors_pick}; use super::{ElyShell, render_canvas_surface}; @@ -18,6 +18,9 @@ impl ElyShell { snapshot: &BrowserSnapshot, cx: &mut Context, ) -> AnyElement { + let needle = self.plugin_search_input.read(cx).value().to_string(); + let needle_lower = needle.trim().to_lowercase(); + render_canvas_surface( div() .size_full() @@ -27,88 +30,122 @@ impl ElyShell { .flex() .flex_col() .gap(px(24.0)) - .child(render_hero(snapshot, cx)) + .child(self.render_hero(snapshot, cx)) .child(render_summary(snapshot)) - .child(render_grid(snapshot, cx)), + .child(render_grid(snapshot, &needle_lower, cx)), ) } + + fn render_hero( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + div() + .grid() + .grid_cols(2) + .gap(px(28.0)) + .child(self.render_hero_left(snapshot, cx)) + .child(render_editors_pick(snapshot, cx)) + .into_any_element() + } + + fn render_hero_left( + &mut self, + _snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + div() + .flex() + .flex_col() + .gap(px(10.0)) + .child( + div() + .text_size(px(11.0)) + .text_color(rgb(colors::INK_4)) + .child("PLUGINS · NATIVE TO ELY"), + ) + .child( + div() + .font_family(SERIF_FAMILY) + .text_size(px(48.0)) + .font_weight(FontWeight(400.0)) + .text_color(rgb(colors::INK)) + .child("Quiet tools. Everyday magic."), + ) + .child( + div() + .max_w(px(520.0)) + .text_size(px(13.5)) + .text_color(rgb(colors::INK_2)) + .child( + "ELY plugins are sandboxed, theme-aware, and ship with their own \ + controls in your sidebar — no Chrome extension framework required.", + ), + ) + .child(self.render_search_row(cx)) + .into_any_element() + } + + fn render_search_row(&mut self, cx: &mut Context) -> AnyElement { + div() + .flex() + .items_center() + .gap(px(8.0)) + .max_w(px(520.0)) + .child( + div() + .flex_1() + .h(px(42.0)) + .px(px(16.0)) + .rounded(px(12.0)) + .bg(rgba(SEARCH_BG)) + .flex() + .items_center() + .gap(px(10.0)) + .child( + div() + .text_color(rgb(colors::INK_3)) + .child(IconName::Search), + ) + .child( + div().flex_1().child( + Input::new(&self.plugin_search_input) + .appearance(false) + .cleanable(true), + ), + ), + ) + .child( + div() + .id(SharedString::from("plugin-install-action")) + .h(px(42.0)) + .px(px(16.0)) + .rounded(px(12.0)) + .bg(rgba(INSTALL_BG)) + .flex() + .items_center() + .gap(px(8.0)) + .text_size(px(13.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(0xffffff)) + .cursor_pointer() + .hover(|style| style.opacity(0.92)) + .active(|style| style.opacity(0.78)) + .on_click(cx.listener(|shell, _, window, cx| { + shell.choose_plugin_package(window, cx); + })) + .child( + div() + .text_color(rgb(0xffffff)) + .child(IconName::Plus), + ) + .child("Install"), + ) + .into_any_element() + } } -fn render_hero(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyElement { - div() - .grid() - .grid_cols(2) - .gap(px(28.0)) - .child(render_hero_left(snapshot, cx)) - .child(render_editors_pick(snapshot, cx)) - .into_any_element() -} - -fn render_hero_left(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyElement { - let total = snapshot.installed_plugins.len(); - - div() - .flex() - .flex_col() - .gap(px(10.0)) - .child( - div() - .text_size(px(11.0)) - .text_color(rgb(colors::INK_4)) - .child("PLUGINS · NATIVE TO ELY"), - ) - .child( - div() - .font_family(SERIF_FAMILY) - .text_size(px(48.0)) - .font_weight(FontWeight(400.0)) - .text_color(rgb(colors::INK)) - .child("Quiet tools. Everyday magic."), - ) - .child( - div() - .max_w(px(520.0)) - .text_size(px(13.5)) - .text_color(rgb(colors::INK_2)) - .child( - "ELY plugins are sandboxed, theme-aware, and ship with their own controls in \ - your sidebar — no Chrome extension framework required.", - ), - ) - .child(render_install_button(total, cx)) - .into_any_element() -} - -fn render_install_button(total: usize, cx: &mut Context) -> AnyElement { - div().flex().child( - div() - .id(SharedString::from("plugin-install")) - .h(px(42.0)) - .px(px(16.0)) - .rounded(px(12.0)) - .bg(rgba(SEARCH_BG)) - .flex() - .items_center() - .gap(px(10.0)) - .text_size(px(13.0)) - .text_color(rgb(colors::INK_3)) - .cursor_pointer() - .hover(|style| style.bg(rgba(SEARCH_BG_HOVER))) - .active(|style| style.opacity(0.85)) - .on_click(cx.listener(|shell, _, window, cx| { - shell.choose_plugin_package(window, cx); - })) - .child( - div() - .text_color(rgb(colors::INK_3)) - .child(IconName::Plus), - ) - .child(format!( - "Install plugin · {} active", - total - ))) - .into_any_element() -} fn render_summary(snapshot: &BrowserSnapshot) -> AnyElement { div() @@ -151,11 +188,25 @@ fn category_chip(label: &'static str, active: bool) -> AnyElement { .into_any_element() } -fn render_grid(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyElement { +fn render_grid( + snapshot: &BrowserSnapshot, + needle: &str, + cx: &mut Context, +) -> AnyElement { if snapshot.installed_plugins.is_empty() { return render_empty_state(cx); } + let matches: Vec<&InstalledPlugin> = snapshot + .installed_plugins + .iter() + .filter(|plugin| matches_plugin(plugin, needle)) + .collect(); + + if matches.is_empty() { + return render_no_match_state(needle); + } + div() .flex_1() .min_h_0() @@ -164,9 +215,8 @@ fn render_grid(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyEle .grid_cols(4) .gap(px(14.0)) .children( - snapshot - .installed_plugins - .iter() + matches + .into_iter() .enumerate() .map(|(index, plugin)| { render_plugin_card(index, plugin, &snapshot.active_profile_kind, cx) @@ -175,6 +225,38 @@ fn render_grid(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyEle .into_any_element() } +fn matches_plugin(plugin: &InstalledPlugin, needle: &str) -> bool { + if needle.is_empty() { + return true; + } + let manifest = plugin.manifest(); + manifest.name().to_lowercase().contains(needle) + || manifest.author().to_lowercase().contains(needle) + || manifest.description().to_lowercase().contains(needle) +} + +fn render_no_match_state(needle: &str) -> AnyElement { + div() + .flex() + .flex_col() + .items_center() + .gap(px(8.0)) + .py(px(40.0)) + .child( + div() + .text_size(px(13.0)) + .text_color(rgb(colors::INK)) + .child(format!("No plugins match \"{needle}\".")), + ) + .child( + div() + .text_size(px(11.5)) + .text_color(rgb(colors::INK_3)) + .child("Try a different keyword."), + ) + .into_any_element() +} + fn render_empty_state(cx: &mut Context) -> AnyElement { div() .flex() @@ -350,7 +432,7 @@ fn sandbox_chip_label(snapshot: &BrowserSnapshot) -> &'static str { } const SEARCH_BG: u32 = 0xffffffd9; -const SEARCH_BG_HOVER: u32 = 0xffffffeb; +const INSTALL_BG: u32 = 0x1d1c1aff; const CARD_BG: u32 = 0xffffffc7; const CARD_BG_HOVER: u32 = 0xffffffeb; const STATUS_BG: u32 = 0xffffffd9; diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 1ef1681..95f0775 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -62,6 +62,7 @@ pub struct ElyShell { state: ShellState, focus_handle: FocusHandle, command_input: Entity, + pub(crate) plugin_search_input: Entity, download_action_error: Option, download_clear_confirmation: bool, download_security_confirmation: Option, @@ -104,6 +105,8 @@ impl ElyShell { ) -> Self { let command_input = cx.new(|cx| InputState::new(window, cx).placeholder("Search or enter address")); + let plugin_search_input = + cx.new(|cx| InputState::new(window, cx).placeholder("Search plugins…")); let command_subscription = cx.subscribe_in( &command_input, @@ -151,6 +154,7 @@ impl ElyShell { state, focus_handle: cx.focus_handle(), command_input, + plugin_search_input, download_action_error: None, download_clear_confirmation: false, download_security_confirmation: None,