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" version = "0.1.0"
dependencies = [ dependencies = [
"client", "client",
"collections",
"component", "component",
"gpui", "gpui",
"languages", "languages",

View file

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

View file

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

View file

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