component_preview: Add component pages (#26284)

This PR adds pages to component preview when clicking on a given
component in the sidebar.

This will let us create richer previews & better docs for using
components in the future.

Release Notes:

- N/A
This commit is contained in:
Nate Butler 2025-03-07 13:56:17 -05:00 committed by GitHub
parent 3ff2c8fc38
commit 1b34437839
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 132 additions and 57 deletions

1
Cargo.lock generated
View file

@ -3047,6 +3047,7 @@ name = "component_preview"
version = "0.1.0"
dependencies = [
"client",
"collections",
"component",
"gpui",
"languages",

View file

@ -78,6 +78,7 @@ pub struct ComponentId(pub &'static str);
#[derive(Clone)]
pub struct ComponentMetadata {
id: ComponentId,
name: SharedString,
scope: Option<ComponentScope>,
description: Option<SharedString>,
@ -85,6 +86,10 @@ pub struct ComponentMetadata {
}
impl ComponentMetadata {
pub fn id(&self) -> ComponentId {
self.id.clone()
}
pub fn name(&self) -> SharedString {
self.name.clone()
}
@ -156,9 +161,11 @@ pub fn components() -> AllComponents {
for (ref scope, name, description) in &data.components {
let preview = data.previews.get(name).cloned();
let component_name = SharedString::new_static(name);
let id = ComponentId(name);
all_components.insert(
ComponentId(name),
id.clone(),
ComponentMetadata {
id,
name: component_name,
scope: scope.clone(),
description: description.map(Into::into),

View file

@ -23,3 +23,4 @@ project.workspace = true
ui.workspace = true
workspace.workspace = true
notifications.workspace = true
collections.workspace = true

View file

@ -6,12 +6,14 @@ use std::iter::Iterator;
use std::sync::Arc;
use client::UserStore;
use component::{components, ComponentMetadata};
use component::{components, ComponentId, ComponentMetadata};
use gpui::{
list, prelude::*, uniform_list, App, Entity, EventEmitter, FocusHandle, Focusable, Task,
WeakEntity, Window,
};
use collections::HashMap;
use gpui::{ListState, ScrollHandle, UniformListScrollHandle};
use languages::LanguageRegistry;
use notifications::status_toast::{StatusToast, ToastIcon};
@ -59,6 +61,8 @@ pub fn init(app_state: Arc<AppState>, cx: &mut App) {
}
enum PreviewEntry {
AllComponents,
Separator,
Component(ComponentMetadata),
SectionHeader(SharedString),
}
@ -75,13 +79,22 @@ impl From<SharedString> for PreviewEntry {
}
}
#[derive(Default, Debug, Clone, PartialEq, Eq)]
enum PreviewPage {
#[default]
AllComponents,
Component(ComponentId),
}
struct ComponentPreview {
focus_handle: FocusHandle,
_view_scroll_handle: ScrollHandle,
nav_scroll_handle: UniformListScrollHandle,
component_map: HashMap<ComponentId, ComponentMetadata>,
active_page: PreviewPage,
components: Vec<ComponentMetadata>,
component_list: ListState,
selected_index: usize,
cursor_index: usize,
language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>,
user_store: Entity<UserStore>,
@ -95,22 +108,25 @@ impl ComponentPreview {
selected_index: impl Into<Option<usize>>,
cx: &mut Context<Self>,
) -> Self {
let components = components().all_sorted();
let initial_length = components.len();
let sorted_components = components().all_sorted();
let selected_index = selected_index.into().unwrap_or(0);
let component_list =
ListState::new(initial_length, gpui::ListAlignment::Top, px(1500.0), {
let component_list = ListState::new(
sorted_components.len(),
gpui::ListAlignment::Top,
px(1500.0),
{
let this = cx.entity().downgrade();
move |ix, window: &mut Window, cx: &mut App| {
this.update(cx, |this, cx| {
let component = this.get_component(ix);
this.render_preview(ix, &component, window, cx)
this.render_preview(&component, window, cx)
.into_any_element()
})
.unwrap()
}
});
},
);
let mut component_preview = Self {
focus_handle: cx.focus_handle(),
@ -119,13 +135,15 @@ impl ComponentPreview {
language_registry,
user_store,
workspace,
components,
active_page: PreviewPage::AllComponents,
component_map: components().0,
components: sorted_components,
component_list,
selected_index,
cursor_index: selected_index,
};
if component_preview.selected_index > 0 {
component_preview.scroll_to_preview(component_preview.selected_index, cx);
if component_preview.cursor_index > 0 {
component_preview.scroll_to_preview(component_preview.cursor_index, cx);
}
component_preview.update_component_list(cx);
@ -135,7 +153,12 @@ impl ComponentPreview {
fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
self.component_list.scroll_to_reveal_item(ix);
self.selected_index = ix;
self.cursor_index = ix;
cx.notify();
}
fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) {
self.active_page = page;
cx.notify();
}
@ -146,7 +169,6 @@ impl ComponentPreview {
fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
use std::collections::HashMap;
// Group components by scope
let mut scope_groups: HashMap<Option<ComponentScope>, Vec<ComponentMetadata>> =
HashMap::default();
@ -157,15 +179,12 @@ impl ComponentPreview {
.push(component.clone());
}
// Sort components within each scope by name
for components in scope_groups.values_mut() {
components.sort_by_key(|c| c.name().to_lowercase());
}
// Build entries with scopes in a defined order
let mut entries = Vec::new();
// Define scope order (we want Unknown at the end)
let known_scopes = [
ComponentScope::Layout,
ComponentScope::Input,
@ -175,15 +194,16 @@ impl ComponentPreview {
ComponentScope::VersionControl,
];
// First add components with known scopes
// Always show all components first
entries.push(PreviewEntry::AllComponents);
entries.push(PreviewEntry::Separator);
for scope in known_scopes.iter() {
let scope_key = Some(scope.clone());
if let Some(components) = scope_groups.remove(&scope_key) {
if !components.is_empty() {
// Add section header
entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
// Add all components under this scope
for component in components {
entries.push(PreviewEntry::Component(component));
}
@ -191,16 +211,13 @@ impl ComponentPreview {
}
}
// Handle components with Unknown scope
for (scope, components) in &scope_groups {
if let Some(ComponentScope::Unknown(_)) = scope {
if !components.is_empty() {
// Add the unknown scope header
if let Some(scope_value) = scope {
entries.push(PreviewEntry::SectionHeader(scope_value.to_string().into()));
}
// Add all components under this unknown scope
for component in components {
entries.push(PreviewEntry::Component(component.clone()));
}
@ -208,9 +225,9 @@ impl ComponentPreview {
}
}
// Handle components with no scope
if let Some(components) = scope_groups.get(&None) {
if !components.is_empty() {
entries.push(PreviewEntry::Separator);
entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
for component in components {
@ -226,22 +243,42 @@ impl ComponentPreview {
&self,
ix: usize,
entry: &PreviewEntry,
selected: bool,
cx: &Context<Self>,
) -> impl IntoElement {
match entry {
PreviewEntry::Component(component_metadata) => ListItem::new(ix)
.child(Label::new(component_metadata.name().clone()).color(Color::Default))
.selectable(true)
.toggle_state(selected)
.inset(true)
.on_click(cx.listener(move |this, _, _, cx| {
this.scroll_to_preview(ix, cx);
}))
.into_any_element(),
PreviewEntry::Component(component_metadata) => {
let id = component_metadata.id();
let selected = self.active_page == PreviewPage::Component(id.clone());
ListItem::new(ix)
.child(Label::new(component_metadata.name().clone()).color(Color::Default))
.selectable(true)
.toggle_state(selected)
.inset(true)
.on_click(cx.listener(move |this, _, _, cx| {
let id = id.clone();
this.set_active_page(PreviewPage::Component(id), cx);
}))
.into_any_element()
}
PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
.inset(true)
.into_any_element(),
PreviewEntry::AllComponents => {
let selected = self.active_page == PreviewPage::AllComponents;
ListItem::new(ix)
.child(Label::new("All Components").color(Color::Default))
.selectable(true)
.toggle_state(selected)
.inset(true)
.on_click(cx.listener(move |this, _, _, cx| {
this.set_active_page(PreviewPage::AllComponents, cx);
}))
.into_any_element()
}
PreviewEntry::Separator => ListItem::new(ix)
.child(h_flex().pt_3().child(Divider::horizontal_dashed()))
.into_any_element(),
}
}
@ -260,11 +297,13 @@ impl ComponentPreview {
weak_entity
.update(cx, |this, cx| match entry {
PreviewEntry::Component(component) => this
.render_preview(ix, component, window, cx)
.render_preview(component, window, cx)
.into_any_element(),
PreviewEntry::SectionHeader(shared_string) => this
.render_scope_header(ix, shared_string.clone(), window, cx)
.into_any_element(),
PreviewEntry::AllComponents => div().w_full().h_0().into_any_element(),
PreviewEntry::Separator => div().w_full().h_0().into_any_element(),
})
.unwrap()
},
@ -290,7 +329,6 @@ impl ComponentPreview {
fn render_preview(
&self,
_ix: usize,
component: &ComponentMetadata,
window: &mut Window,
cx: &mut App,
@ -341,6 +379,44 @@ impl ComponentPreview {
.into_any_element()
}
fn render_all_components(&self) -> impl IntoElement {
v_flex()
.id("component-list")
.px_8()
.pt_4()
.size_full()
.child(
list(self.component_list.clone())
.flex_grow()
.with_sizing_behavior(gpui::ListSizingBehavior::Auto),
)
}
fn render_component_page(
&mut self,
component_id: &ComponentId,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let component = self.component_map.get(&component_id);
if let Some(component) = component {
v_flex()
.w_full()
.flex_initial()
.min_h_full()
.child(self.render_preview(component, window, cx))
.into_any_element()
} else {
v_flex()
.size_full()
.items_center()
.justify_center()
.child("Component not found")
.into_any_element()
}
}
fn test_status_toast(&self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(workspace) = self.workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
@ -363,8 +439,9 @@ impl ComponentPreview {
}
impl Render for ComponentPreview {
fn render(&mut self, _window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let sidebar_entries = self.scope_ordered_entries();
let active_page = self.active_page.clone();
h_flex()
.id("component-preview")
@ -386,12 +463,7 @@ impl Render for ComponentPreview {
move |this, range, _window, cx| {
range
.map(|ix| {
this.render_sidebar_entry(
ix,
&sidebar_entries[ix],
ix == this.selected_index,
cx,
)
this.render_sidebar_entry(ix, &sidebar_entries[ix], cx)
})
.collect()
},
@ -415,18 +487,12 @@ impl Render for ComponentPreview {
),
),
)
.child(
v_flex()
.id("component-list")
.px_8()
.pt_4()
.size_full()
.child(
list(self.component_list.clone())
.flex_grow()
.with_sizing_behavior(gpui::ListSizingBehavior::Auto),
),
)
.child(match active_page {
PreviewPage::AllComponents => self.render_all_components().into_any_element(),
PreviewPage::Component(id) => self
.render_component_page(&id, window, cx)
.into_any_element(),
})
}
}
@ -465,7 +531,7 @@ impl Item for ComponentPreview {
let language_registry = self.language_registry.clone();
let user_store = self.user_store.clone();
let weak_workspace = self.workspace.clone();
let selected_index = self.selected_index;
let selected_index = self.cursor_index;
Some(cx.new(|cx| {
Self::new(