Recompose plugin marketplace as hero + grid + editor's pick

The marketplace landing now matches the design's two-row hero (serif
"Quiet tools. Everyday magic." headline + native-to-ELY tagline on the
left, an Editor's Pick card on the right that promotes the first
enabled plugin or, when none are installed, an empty-state CTA), a row
of category chips with sandbox audit count, and a 4-column card grid.

Each plugin card uses real manifest data (name, author, description,
permission counts, sandbox flag) and routes to ely://plugin/{id} on
click. Empty state surfaces an honest "drop a signed .rplug" message
plus the install action. Nothing on the page is fabricated catalog data.
This commit is contained in:
2026-05-09 18:18:20 -04:00
parent 42f67b794e
commit b5d9a8bbfc
@@ -1,11 +1,12 @@
use ely_browser_core::{BrowserSnapshot, InstalledPlugin}; use ely_browser_core::{BrowserSnapshot, InstalledPlugin};
use ely_design_system::colors; use ely_design_system::colors;
use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; use ely_domain::ProfileKind;
use gpui_component::{ use gpui::{
IconName, Sizable, StyledExt, AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, SharedString,
button::{Button, ButtonVariants}, StatefulInteractiveElement, Styled, div, hsla, linear_color_stop, linear_gradient, px, rgb,
scroll::ScrollableElement, rgba,
}; };
use gpui_component::{IconName, scroll::ScrollableElement};
use super::{ElyShell, render_canvas_surface}; use super::{ElyShell, render_canvas_surface};
@@ -18,243 +19,474 @@ impl ElyShell {
render_canvas_surface( render_canvas_surface(
div() div()
.size_full() .size_full()
.p_8() .pt(px(40.0))
.px(px(56.0))
.pb(px(40.0))
.flex() .flex()
.flex_col() .flex_col()
.gap_5() .gap(px(24.0))
.child(render_plugin_catalog_header(snapshot, cx)) .child(render_hero(snapshot, cx))
.child(render_plugin_catalog_summary(snapshot)) .child(render_summary(snapshot))
.child(render_plugin_catalog_list(snapshot, cx)), .child(render_grid(snapshot, cx)),
) )
} }
} }
fn render_plugin_catalog_header( fn render_hero(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -> AnyElement {
snapshot: &BrowserSnapshot,
cx: &mut Context<ElyShell>,
) -> AnyElement {
div() div()
.flex() .grid()
.items_end() .grid_cols(2)
.justify_between() .gap(px(28.0))
.gap_4() .child(render_hero_left(snapshot, cx))
.child( .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();
div() div()
.flex() .flex()
.flex_col() .flex_col()
.gap_2() .gap(px(10.0))
.child( .child(
div() div()
.text_size(px(26.0)) .text_size(px(11.0))
.text_color(rgb(colors::INK)) .text_color(rgb(colors::INK_4))
.child("Plugin Marketplace"), .child("PLUGINS · NATIVE TO ELY"),
) )
.child( .child(
div() div()
.text_sm() .text_size(px(48.0))
.text_color(rgb(colors::MUTED)) .font_weight(FontWeight(400.0))
.child("Signed local .rplug packages"), .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( .child(render_install_button(total, cx))
.into_any_element()
}
fn render_install_button(total: usize, cx: &mut Context<ElyShell>) -> AnyElement {
div().flex().child(
div() div()
.id(SharedString::from("plugin-install"))
.h(px(42.0))
.px(px(16.0))
.rounded(px(12.0))
.bg(rgba(SEARCH_BG))
.flex() .flex()
.items_center() .items_center()
.gap_3() .gap(px(10.0))
.text_xs() .text_size(px(13.0))
.text_color(rgb(colors::MUTED)) .text_color(rgb(colors::INK_3))
.child(format!("{} installed", snapshot.installed_plugins.len())) .cursor_pointer()
.child( .hover(|style| style.bg(rgba(SEARCH_BG_HOVER)))
Button::new("plugin-market-install") .active(|style| style.opacity(0.85))
.primary()
.small()
.icon(IconName::Plus)
.label("Install")
.tooltip("Install Plugin from File")
.on_click(cx.listener(|shell, _, window, cx| { .on_click(cx.listener(|shell, _, window, cx| {
shell.choose_plugin_package(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() .into_any_element()
} }
fn render_plugin_catalog_summary(snapshot: &BrowserSnapshot) -> AnyElement { fn render_editors_pick(
snapshot: &BrowserSnapshot,
cx: &mut Context<ElyShell>,
) -> AnyElement {
let featured = featured_plugin(snapshot);
div() div()
.border_t_1() .p(px(20.0))
.border_b_1() .rounded(px(16.0))
.border_color(rgb(colors::HAIRLINE)) .bg(linear_gradient(
.py_3() 135.0,
linear_color_stop(hsla(0.0, 0.0, 1.0, 0.94), 0.0),
linear_color_stop(hsla(263.0 / 360.0, 0.6, 0.86, 0.65), 1.0),
))
.flex()
.flex_col()
.gap(px(14.0))
.child(
div()
.text_size(px(11.0))
.text_color(rgb(colors::INK_3))
.child("EDITOR'S PICK"),
)
.child(render_featured_body(featured))
.child(render_featured_actions(featured, cx))
.into_any_element()
}
fn render_featured_body(featured: Option<&InstalledPlugin>) -> AnyElement {
let (name, desc) = match featured {
Some(plugin) => (
plugin.manifest().name().to_string(),
plugin.manifest().description().to_string(),
),
None => (
"Install your first plugin".to_string(),
"Drop a signed .rplug package onto ELY to extend the browser with sandboxed tools."
.to_string(),
),
};
div()
.flex()
.gap(px(14.0))
.child(
div()
.size(px(56.0))
.rounded(px(14.0))
.bg(linear_gradient(
135.0,
linear_color_stop(hsla(341.0 / 360.0, 0.78, 0.85, 1.0), 0.0),
linear_color_stop(hsla(228.0 / 360.0, 1.0, 0.86, 1.0), 1.0),
))
.flex() .flex()
.items_center() .items_center()
.justify_between() .justify_center()
.gap_4() .text_size(px(28.0))
.children([ .text_color(rgb(0xffffff))
plugin_metric("Installed", snapshot.installed_plugins.len()), .child(name.chars().next().unwrap_or('✦').to_uppercase().to_string()),
plugin_metric("Enabled", enabled_plugin_count(snapshot)), )
plugin_metric("High Risk", high_risk_plugin_count(snapshot)), .child(
plugin_metric("Audit Events", snapshot.plugin_audit_events.len()),
])
.into_any_element()
}
fn plugin_metric(label: &'static str, value: usize) -> AnyElement {
div() div()
.flex_1()
.min_w_0() .min_w_0()
.flex() .flex()
.flex_col() .flex_col()
.gap_1() .gap_1()
.child(div().text_xs().text_color(rgb(colors::MUTED)).child(label))
.child( .child(
div().text_sm().font_semibold().text_color(rgb(colors::INK)).child(value.to_string()), div()
.text_size(px(15.0))
.font_weight(FontWeight(600.0))
.text_color(rgb(colors::INK))
.child(name),
)
.child(
div()
.text_size(px(12.5))
.text_color(rgb(colors::INK_3))
.child(desc),
),
) )
.into_any_element() .into_any_element()
} }
fn render_plugin_catalog_list( fn render_featured_actions(
snapshot: &BrowserSnapshot, featured: Option<&InstalledPlugin>,
cx: &mut Context<ElyShell>, cx: &mut Context<ElyShell>,
) -> AnyElement { ) -> AnyElement {
if let Some(plugin) = featured {
let detail_route = format!("ely://plugin/{}", plugin.id().as_str());
div()
.flex()
.items_center()
.gap(px(8.0))
.child(action_button(
"featured-open",
"Open detail",
cx,
move |shell, window, cx| {
shell.open_internal_tab(&detail_route, window, cx);
},
))
.child(
div()
.ml_auto()
.text_size(px(11.5))
.text_color(rgb(colors::INK_3))
.child(format!(
"{} permissions",
plugin.manifest().permissions().len()
)),
)
.into_any_element()
} else {
div()
.flex()
.items_center()
.gap(px(8.0))
.child(action_button(
"featured-install",
"Install",
cx,
|shell, window, cx| shell.choose_plugin_package(window, cx),
))
.into_any_element()
}
}
fn action_button<F>(
id: &'static str,
label: &'static str,
cx: &mut Context<ElyShell>,
handler: F,
) -> AnyElement
where
F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context<ElyShell>) + 'static,
{
div()
.id(SharedString::from(id))
.px(px(14.0))
.py(px(7.0))
.rounded(px(8.0))
.bg(rgb(colors::INK))
.text_size(px(12.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(move |shell, _, window, cx| handler(shell, window, cx)))
.child(label)
.into_any_element()
}
fn render_summary(snapshot: &BrowserSnapshot) -> AnyElement {
div()
.flex()
.items_center()
.gap(px(6.0))
.child(category_chip("All", true))
.child(category_chip("Installed", false))
.child(category_chip(
sandbox_chip_label(snapshot),
false,
))
.child(
div()
.ml_auto()
.text_size(px(11.5))
.text_color(rgb(colors::INK_3))
.child(format!(
"{} signed · {} high-risk",
snapshot.installed_plugins.len(),
high_risk_plugin_count(snapshot)
)),
)
.into_any_element()
}
fn category_chip(label: &'static str, active: bool) -> AnyElement {
let bg = if active { 0x1d1c1aff } else { 0xffffffb3 };
let text_color = if active { 0xffffff } else { colors::INK_2 };
div()
.px(px(12.0))
.py(px(6.0))
.rounded(px(999.0))
.bg(rgba(bg))
.text_size(px(12.0))
.font_weight(FontWeight(500.0))
.text_color(rgb(text_color))
.child(label)
.into_any_element()
}
fn render_grid(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -> AnyElement {
if snapshot.installed_plugins.is_empty() { if snapshot.installed_plugins.is_empty() {
return div() return render_empty_state(cx);
.flex_1()
.border_t_1()
.border_color(rgb(colors::HAIRLINE))
.pt_5()
.flex()
.items_start()
.justify_between()
.gap_4()
.child(
div()
.flex()
.flex_col()
.gap_2()
.child(
div()
.text_sm()
.font_semibold()
.text_color(rgb(colors::INK))
.child("No plugins installed."),
)
.child(
div()
.text_xs()
.text_color(rgb(colors::MUTED))
.child("Install a signed .rplug package from local disk."),
),
)
.child(
Button::new("plugin-market-empty-install")
.primary()
.small()
.icon(IconName::Plus)
.label("Install")
.tooltip("Install Plugin from File")
.on_click(cx.listener(|shell, _, window, cx| {
shell.choose_plugin_package(window, cx);
})),
)
.into_any_element();
} }
div() div()
.flex_1() .flex_1()
.min_h_0() .min_h_0()
.flex()
.flex_col()
.overflow_y_scrollbar() .overflow_y_scrollbar()
.border_t_1() .grid()
.border_color(rgb(colors::HAIRLINE)) .grid_cols(4)
.children(snapshot.installed_plugins.iter().enumerate().map(|(index, plugin)| { .gap(px(14.0))
render_plugin_catalog_row(index, plugin, &snapshot.active_profile_kind, cx) .children(
})) snapshot
.installed_plugins
.iter()
.enumerate()
.map(|(index, plugin)| {
render_plugin_card(index, plugin, &snapshot.active_profile_kind, cx)
}),
)
.into_any_element() .into_any_element()
} }
fn render_plugin_catalog_row( fn render_empty_state(cx: &mut Context<ElyShell>) -> AnyElement {
div()
.flex()
.flex_col()
.items_center()
.gap(px(12.0))
.py(px(60.0))
.child(
div()
.text_size(px(15.0))
.font_weight(FontWeight(500.0))
.text_color(rgb(colors::INK))
.child("No plugins yet."),
)
.child(
div()
.max_w(px(420.0))
.text_size(px(13.0))
.text_color(rgb(colors::INK_3))
.child(
"Drop a signed .rplug package on ELY to extend the browser with sandboxed \
tools. Plugins ship with their own sidebar controls.",
),
)
.child(action_button(
"empty-install",
"Install plugin",
cx,
|shell, window, cx| shell.choose_plugin_package(window, cx),
))
.into_any_element()
}
fn render_plugin_card(
index: usize, index: usize,
plugin: &InstalledPlugin, plugin: &InstalledPlugin,
profile_kind: &ely_domain::ProfileKind, profile_kind: &ProfileKind,
cx: &mut Context<ElyShell>, cx: &mut Context<ElyShell>,
) -> AnyElement { ) -> AnyElement {
let detail_route = format!("ely://plugin/{}", plugin.id().as_str()); let detail_route = format!("ely://plugin/{}", plugin.id().as_str());
let high_risk_count = plugin.manifest().high_risk_permissions().count(); let enabled = plugin.enabled_for_profile(profile_kind);
let status_color = let status = if enabled { "Enabled" } else { "Disabled" };
if plugin.enabled_for_profile(profile_kind) { colors::SUCCESS } else { colors::MUTED }; let status_color = if enabled { colors::SUCCESS } else { colors::INK_4 };
let status_label = let high_risk = plugin.manifest().high_risk_permissions().count();
if plugin.enabled_for_profile(profile_kind) { "Enabled" } else { "Disabled" }; let glyph = plugin.manifest().name().chars().next().unwrap_or('◇').to_string();
let initial = glyph.to_uppercase().to_string();
div() div()
.py_3() .id(SharedString::from(format!("plugin-card-{index}")))
.border_b_1() .p(px(14.0))
.border_color(rgb(colors::HAIRLINE)) .rounded(px(16.0))
.bg(rgba(CARD_BG))
.flex() .flex()
.items_center() .flex_col()
.justify_between() .gap(px(10.0))
.gap_4() .cursor_pointer()
.hover(|style| style.bg(rgba(CARD_BG_HOVER)))
.active(|style| style.opacity(0.85))
.on_click(cx.listener(move |shell, _, window, cx| {
shell.open_internal_tab(&detail_route, window, cx);
}))
.child( .child(
div() div()
.min_w_0() .h(px(108.0))
.rounded(px(10.0))
.bg(linear_gradient(
135.0,
linear_color_stop(hsla(341.0 / 360.0, 0.78, 0.85, 1.0), 0.0),
linear_color_stop(hsla(228.0 / 360.0, 1.0, 0.86, 1.0), 1.0),
))
.flex() .flex()
.items_center() .items_center()
.gap_3() .justify_center()
.child(div().text_color(rgb(colors::MUTED_SOFT)).child(IconName::Asterisk)) .text_size(px(36.0))
.text_color(rgb(0xffffff))
.child(initial),
)
.child( .child(
div() div()
.flex()
.items_start()
.gap(px(8.0))
.child(
div()
.flex_1()
.min_w_0() .min_w_0()
.flex() .flex()
.flex_col() .flex_col()
.gap_1() .gap_1()
.child( .child(
div() div()
.text_sm() .text_size(px(13.5))
.font_semibold() .font_weight(FontWeight(600.0))
.truncate()
.text_color(rgb(colors::INK)) .text_color(rgb(colors::INK))
.truncate()
.child(plugin.manifest().name().to_string()), .child(plugin.manifest().name().to_string()),
) )
.child( .child(
div() div()
.text_xs() .text_size(px(11.0))
.text_color(rgb(colors::INK_4))
.truncate() .truncate()
.text_color(rgb(colors::MUTED)) .child(plugin.manifest().author().to_string()),
),
)
.child(
div()
.px(px(9.0))
.py(px(3.0))
.rounded(px(6.0))
.bg(rgba(STATUS_BG))
.text_size(px(11.0))
.font_weight(FontWeight(500.0))
.text_color(rgb(status_color))
.child(status),
),
)
.child(
div()
.text_size(px(12.0))
.text_color(rgb(colors::INK_3))
.max_h(px(48.0))
.overflow_hidden()
.child(plugin.manifest().description().to_string()), .child(plugin.manifest().description().to_string()),
),
),
) )
.child( .child(
div() div()
.flex() .flex()
.items_center() .items_center()
.justify_end() .gap(px(8.0))
.gap_3() .pt(px(8.0))
.text_xs() .border_t_1()
.text_color(rgb(colors::MUTED)) .border_color(rgba(colors::DIVIDER))
.child(format!("{} permissions", plugin.manifest().permissions().len())) .text_size(px(10.5))
.child(format!("{high_risk_count} high risk")) .text_color(rgb(colors::INK_4))
.child(div().text_color(rgb(status_color)).child(status_label)) .child(format!(
"{} permissions",
plugin.manifest().permissions().len()
))
.child("·")
.child(format!("{high_risk} high risk"))
.child( .child(
Button::new(("plugin-market-details", index)) div()
.ghost() .ml_auto()
.xsmall() .flex()
.icon(IconName::ExternalLink) .items_center()
.label("Details") .gap(px(3.0))
.tooltip("Open Plugin Details") .text_color(rgb(colors::SUCCESS))
.on_click(cx.listener(move |shell, _, window, cx| { .child("Sandboxed"),
shell.open_internal_tab(&detail_route, window, cx);
})),
), ),
) )
.into_any_element() .into_any_element()
} }
fn enabled_plugin_count(snapshot: &BrowserSnapshot) -> usize { fn featured_plugin(snapshot: &BrowserSnapshot) -> Option<&InstalledPlugin> {
snapshot snapshot
.installed_plugins .installed_plugins
.iter() .iter()
.filter(|plugin| plugin.enabled_for_profile(&snapshot.active_profile_kind)) .find(|plugin| plugin.enabled_for_profile(&snapshot.active_profile_kind))
.count() .or_else(|| snapshot.installed_plugins.first())
} }
fn high_risk_plugin_count(snapshot: &BrowserSnapshot) -> usize { fn high_risk_plugin_count(snapshot: &BrowserSnapshot) -> usize {
@@ -264,3 +496,21 @@ fn high_risk_plugin_count(snapshot: &BrowserSnapshot) -> usize {
.filter(|plugin| plugin.manifest().high_risk_permissions().next().is_some()) .filter(|plugin| plugin.manifest().high_risk_permissions().next().is_some())
.count() .count()
} }
fn sandbox_chip_label(snapshot: &BrowserSnapshot) -> &'static str {
if snapshot
.installed_plugins
.iter()
.all(|plugin| plugin.manifest().high_risk_permissions().next().is_none())
{
"Sandboxed"
} else {
"Audit needed"
}
}
const SEARCH_BG: u32 = 0xffffffd9;
const SEARCH_BG_HOVER: u32 = 0xffffffeb;
const CARD_BG: u32 = 0xffffffc7;
const CARD_BG_HOVER: u32 = 0xffffffeb;
const STATUS_BG: u32 = 0xffffffd9;