Wire plugin marketplace search to filter the grid

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.
This commit is contained in:
2026-05-09 19:30:17 -04:00
parent 6419609eba
commit 70b50f9852
2 changed files with 169 additions and 83 deletions
@@ -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<Self>,
) -> 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,26 +30,31 @@ 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(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -> AnyElement {
fn render_hero(
&mut self,
snapshot: &BrowserSnapshot,
cx: &mut Context<Self>,
) -> AnyElement {
div()
.grid()
.grid_cols(2)
.gap(px(28.0))
.child(render_hero_left(snapshot, cx))
.child(self.render_hero_left(snapshot, cx))
.child(render_editors_pick(snapshot, cx))
.into_any_element()
}
fn render_hero_left(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -> AnyElement {
let total = snapshot.installed_plugins.len();
}
fn render_hero_left(
&mut self,
_snapshot: &BrowserSnapshot,
cx: &mut Context<Self>,
) -> AnyElement {
div()
.flex()
.flex_col()
@@ -71,18 +79,23 @@ fn render_hero_left(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -> A
.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.",
"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))
.child(self.render_search_row(cx))
.into_any_element()
}
}
fn render_install_button(total: usize, cx: &mut Context<ElyShell>) -> AnyElement {
div().flex().child(
fn render_search_row(&mut self, cx: &mut Context<Self>) -> AnyElement {
div()
.id(SharedString::from("plugin-install"))
.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))
@@ -90,26 +103,50 @@ fn render_install_button(total: usize, cx: &mut Context<ElyShell>) -> AnyElement
.flex()
.items_center()
.gap(px(10.0))
.text_size(px(13.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.bg(rgba(SEARCH_BG_HOVER)))
.active(|style| style.opacity(0.85))
.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(colors::INK_3))
.text_color(rgb(0xffffff))
.child(IconName::Plus),
)
.child(format!(
"Install plugin · {} active",
total
)))
.child("Install"),
)
.into_any_element()
}
}
fn render_summary(snapshot: &BrowserSnapshot) -> AnyElement {
div()
.flex()
@@ -151,11 +188,25 @@ fn category_chip(label: &'static str, active: bool) -> AnyElement {
.into_any_element()
}
fn render_grid(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -> AnyElement {
fn render_grid(
snapshot: &BrowserSnapshot,
needle: &str,
cx: &mut Context<ElyShell>,
) -> 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<ElyShell>) -> 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<ElyShell>) -> 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<ElyShell>) -> 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;
+4
View File
@@ -62,6 +62,7 @@ pub struct ElyShell {
state: ShellState,
focus_handle: FocusHandle,
command_input: Entity<InputState>,
pub(crate) plugin_search_input: Entity<InputState>,
download_action_error: Option<String>,
download_clear_confirmation: bool,
download_security_confirmation: Option<PendingDownloadFileAction>,
@@ -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,