Refine component preview & add serialization (#28545)

Nate Butler and Max Brunsfeld created

https://github.com/user-attachments/assets/0be12a9a-f6ce-4eca-90de-6ef01eb41ff9

- Allows the active ComponentPreview page to be restored via
serialization
- Allows filtering components using a filter input
- Updates component example rendering
- Updates some components

Release Notes:

- N/A

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>

Change summary

Cargo.lock                                        |   6 
crates/component/src/component.rs                 |  42 +
crates/component_preview/Cargo.toml               |  10 
crates/component_preview/src/component_preview.rs | 387 ++++++++++++++--
crates/component_preview/src/persistence.rs       |  73 +++
crates/ui/src/components/avatar.rs                |  72 --
crates/ui/src/components/keybinding.rs            |   2 
7 files changed, 468 insertions(+), 124 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -3175,14 +3175,20 @@ dependencies = [
 name = "component_preview"
 version = "0.1.0"
 dependencies = [
+ "anyhow",
  "client",
  "collections",
  "component",
+ "db",
+ "futures 0.3.31",
  "gpui",
  "languages",
  "notifications",
  "project",
+ "serde",
  "ui",
+ "ui_input",
+ "util",
  "workspace",
  "workspace-hack",
 ]

crates/component/src/component.rs πŸ”—

@@ -191,6 +191,14 @@ pub fn components() -> AllComponents {
     all_components
 }
 
+// #[derive(Debug, Clone, PartialEq, Eq, Hash)]
+// pub enum ComponentStatus {
+//     WorkInProgress,
+//     EngineeringReady,
+//     Live,
+//     Deprecated,
+// }
+
 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
 pub enum ComponentScope {
     Collaboration,
@@ -241,24 +249,30 @@ pub struct ComponentExample {
 impl RenderOnce for ComponentExample {
     fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
         div()
+            .pt_2()
             .w_full()
             .flex()
             .flex_col()
             .gap_3()
             .child(
                 div()
-                    .child(self.variant_name.clone())
-                    .text_size(rems(1.25))
-                    .text_color(cx.theme().colors().text),
+                    .flex()
+                    .flex_col()
+                    .child(
+                        div()
+                            .child(self.variant_name.clone())
+                            .text_size(rems(1.0))
+                            .text_color(cx.theme().colors().text),
+                    )
+                    .when_some(self.description, |this, description| {
+                        this.child(
+                            div()
+                                .text_size(rems(0.875))
+                                .text_color(cx.theme().colors().text_muted)
+                                .child(description.clone()),
+                        )
+                    }),
             )
-            .when_some(self.description, |this, description| {
-                this.child(
-                    div()
-                        .text_size(rems(0.9375))
-                        .text_color(cx.theme().colors().text_muted)
-                        .child(description.clone()),
-                )
-            })
             .child(
                 div()
                     .flex()
@@ -268,11 +282,11 @@ impl RenderOnce for ComponentExample {
                     .justify_center()
                     .p_8()
                     .border_1()
-                    .border_color(cx.theme().colors().border)
+                    .border_color(cx.theme().colors().border.opacity(0.5))
                     .bg(pattern_slash(
                         cx.theme().colors().surface_background.opacity(0.5),
-                        24.0,
-                        24.0,
+                        12.0,
+                        12.0,
                     ))
                     .shadow_sm()
                     .child(self.element),

crates/component_preview/Cargo.toml πŸ”—

@@ -16,12 +16,16 @@ default = []
 
 [dependencies]
 client.workspace = true
+collections.workspace = true
 component.workspace = true
 gpui.workspace = true
 languages.workspace = true
+notifications.workspace = true
 project.workspace = true
 ui.workspace = true
-workspace.workspace = true
-notifications.workspace = true
-collections.workspace = true
+ui_input.workspace = true
 workspace-hack.workspace = true
+workspace.workspace = true
+db.workspace = true
+anyhow.workspace = true
+serde.workspace = true

crates/component_preview/src/component_preview.rs πŸ”—

@@ -2,6 +2,8 @@
 //!
 //! A view for exploring Zed components.
 
+mod persistence;
+
 use std::iter::Iterator;
 use std::sync::Arc;
 
@@ -9,24 +11,27 @@ use client::UserStore;
 use component::{ComponentId, ComponentMetadata, components};
 use gpui::{
     App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,
-    uniform_list,
 };
 
 use collections::HashMap;
 
-use gpui::{ListState, ScrollHandle, UniformListScrollHandle};
+use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};
 use languages::LanguageRegistry;
 use notifications::status_toast::{StatusToast, ToastIcon};
+use persistence::COMPONENT_PREVIEW_DB;
 use project::Project;
-use ui::{Divider, ListItem, ListSubHeader, prelude::*};
+use ui::{Divider, HighlightedLabel, ListItem, ListSubHeader, prelude::*};
 
+use ui_input::SingleLineInput;
 use workspace::{AppState, ItemId, SerializableItem};
 use workspace::{Item, Workspace, WorkspaceId, item::ItemEvent};
 
 pub fn init(app_state: Arc<AppState>, cx: &mut App) {
+    workspace::register_serializable_item::<ComponentPreview>(cx);
+
     let app_state = app_state.clone();
 
-    cx.observe_new(move |workspace: &mut Workspace, _, cx| {
+    cx.observe_new(move |workspace: &mut Workspace, _window, cx| {
         let app_state = app_state.clone();
         let weak_workspace = cx.entity().downgrade();
 
@@ -44,6 +49,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut App) {
                         user_store,
                         None,
                         None,
+                        window,
                         cx,
                     )
                 });
@@ -64,13 +70,13 @@ pub fn init(app_state: Arc<AppState>, cx: &mut App) {
 enum PreviewEntry {
     AllComponents,
     Separator,
-    Component(ComponentMetadata),
+    Component(ComponentMetadata, Option<Vec<usize>>),
     SectionHeader(SharedString),
 }
 
 impl From<ComponentMetadata> for PreviewEntry {
     fn from(component: ComponentMetadata) -> Self {
-        PreviewEntry::Component(component)
+        PreviewEntry::Component(component, None)
     }
 }
 
@@ -88,6 +94,7 @@ enum PreviewPage {
 }
 
 struct ComponentPreview {
+    workspace_id: Option<WorkspaceId>,
     focus_handle: FocusHandle,
     _view_scroll_handle: ScrollHandle,
     nav_scroll_handle: UniformListScrollHandle,
@@ -99,6 +106,8 @@ struct ComponentPreview {
     language_registry: Arc<LanguageRegistry>,
     workspace: WeakEntity<Workspace>,
     user_store: Entity<UserStore>,
+    filter_editor: Entity<SingleLineInput>,
+    filter_text: String,
 }
 
 impl ComponentPreview {
@@ -108,11 +117,14 @@ impl ComponentPreview {
         user_store: Entity<UserStore>,
         selected_index: impl Into<Option<usize>>,
         active_page: Option<PreviewPage>,
+        window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
         let sorted_components = components().all_sorted();
         let selected_index = selected_index.into().unwrap_or(0);
         let active_page = active_page.unwrap_or(PreviewPage::AllComponents);
+        let filter_editor =
+            cx.new(|cx| SingleLineInput::new(window, cx, "Find components or usages…"));
 
         let component_list = ListState::new(
             sorted_components.len(),
@@ -132,6 +144,7 @@ impl ComponentPreview {
         );
 
         let mut component_preview = Self {
+            workspace_id: None,
             focus_handle: cx.focus_handle(),
             _view_scroll_handle: ScrollHandle::new(),
             nav_scroll_handle: UniformListScrollHandle::new(),
@@ -143,6 +156,8 @@ impl ComponentPreview {
             components: sorted_components,
             component_list,
             cursor_index: selected_index,
+            filter_editor,
+            filter_text: String::new(),
         };
 
         if component_preview.cursor_index > 0 {
@@ -154,6 +169,13 @@ impl ComponentPreview {
         component_preview
     }
 
+    pub fn active_page_id(&self, _cx: &App) -> ActivePageId {
+        match &self.active_page {
+            PreviewPage::AllComponents => ActivePageId::default(),
+            PreviewPage::Component(component_id) => ActivePageId(component_id.0.to_string()),
+        }
+    }
+
     fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
         self.component_list.scroll_to_reveal_item(ix);
         self.cursor_index = ix;
@@ -162,6 +184,7 @@ impl ComponentPreview {
 
     fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) {
         self.active_page = page;
+        cx.emit(ItemEvent::UpdateTab);
         cx.notify();
     }
 
@@ -169,20 +192,94 @@ impl ComponentPreview {
         self.components[ix].clone()
     }
 
+    fn filtered_components(&self) -> Vec<ComponentMetadata> {
+        if self.filter_text.is_empty() {
+            return self.components.clone();
+        }
+
+        let filter = self.filter_text.to_lowercase();
+        self.components
+            .iter()
+            .filter(|component| {
+                let component_name = component.name().to_lowercase();
+                let scope_name = component.scope().to_string().to_lowercase();
+                let description = component
+                    .description()
+                    .map(|d| d.to_lowercase())
+                    .unwrap_or_default();
+
+                component_name.contains(&filter)
+                    || scope_name.contains(&filter)
+                    || description.contains(&filter)
+            })
+            .cloned()
+            .collect()
+    }
+
     fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
         use std::collections::HashMap;
 
-        let mut scope_groups: HashMap<ComponentScope, Vec<ComponentMetadata>> = HashMap::default();
+        let mut scope_groups: HashMap<
+            ComponentScope,
+            Vec<(ComponentMetadata, Option<Vec<usize>>)>,
+        > = HashMap::default();
+        let lowercase_filter = self.filter_text.to_lowercase();
 
         for component in &self.components {
-            scope_groups
-                .entry(component.scope())
-                .or_insert_with(Vec::new)
-                .push(component.clone());
+            if self.filter_text.is_empty() {
+                scope_groups
+                    .entry(component.scope())
+                    .or_insert_with(Vec::new)
+                    .push((component.clone(), None));
+                continue;
+            }
+
+            // let full_component_name = component.name();
+            let scopeless_name = component.scopeless_name();
+            let scope_name = component.scope().to_string();
+            let description = component.description().unwrap_or_default();
+
+            let lowercase_scopeless = scopeless_name.to_lowercase();
+            let lowercase_scope = scope_name.to_lowercase();
+            let lowercase_desc = description.to_lowercase();
+
+            if lowercase_scopeless.contains(&lowercase_filter) {
+                if let Some(index) = lowercase_scopeless.find(&lowercase_filter) {
+                    let end = index + lowercase_filter.len();
+
+                    if end <= scopeless_name.len() {
+                        let mut positions = Vec::new();
+                        for i in index..end {
+                            if scopeless_name.is_char_boundary(i) {
+                                positions.push(i);
+                            }
+                        }
+
+                        if !positions.is_empty() {
+                            scope_groups
+                                .entry(component.scope())
+                                .or_insert_with(Vec::new)
+                                .push((component.clone(), Some(positions)));
+                            continue;
+                        }
+                    }
+                }
+            }
+
+            if lowercase_scopeless.contains(&lowercase_filter)
+                || lowercase_scope.contains(&lowercase_filter)
+                || lowercase_desc.contains(&lowercase_filter)
+            {
+                scope_groups
+                    .entry(component.scope())
+                    .or_insert_with(Vec::new)
+                    .push((component.clone(), None));
+            }
         }
 
+        // Sort the components in each group
         for components in scope_groups.values_mut() {
-            components.sort_by_key(|c| c.name().to_lowercase());
+            components.sort_by_key(|(c, _)| c.sort_name());
         }
 
         let mut entries = Vec::new();
@@ -204,10 +301,10 @@ impl ComponentPreview {
                 if !components.is_empty() {
                     entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
                     let mut sorted_components = components;
-                    sorted_components.sort_by_key(|component| component.sort_name());
+                    sorted_components.sort_by_key(|(component, _)| component.sort_name());
 
-                    for component in sorted_components {
-                        entries.push(PreviewEntry::Component(component));
+                    for (component, positions) in sorted_components {
+                        entries.push(PreviewEntry::Component(component, positions));
                     }
                 }
             }
@@ -219,10 +316,10 @@ impl ComponentPreview {
                 entries.push(PreviewEntry::Separator);
                 entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
                 let mut sorted_components = components.clone();
-                sorted_components.sort_by_key(|c| c.sort_name());
+                sorted_components.sort_by_key(|(c, _)| c.sort_name());
 
-                for component in sorted_components {
-                    entries.push(PreviewEntry::Component(component.clone()));
+                for (component, positions) in sorted_components {
+                    entries.push(PreviewEntry::Component(component, positions));
                 }
             }
         }
@@ -237,14 +334,33 @@ impl ComponentPreview {
         cx: &Context<Self>,
     ) -> impl IntoElement + use<> {
         match entry {
-            PreviewEntry::Component(component_metadata) => {
+            PreviewEntry::Component(component_metadata, highlight_positions) => {
                 let id = component_metadata.id();
                 let selected = self.active_page == PreviewPage::Component(id.clone());
+                let name = component_metadata.scopeless_name();
+
                 ListItem::new(ix)
-                    .child(
-                        Label::new(component_metadata.scopeless_name().clone())
-                            .color(Color::Default),
-                    )
+                    .child(if let Some(_positions) = highlight_positions {
+                        let name_lower = name.to_lowercase();
+                        let filter_lower = self.filter_text.to_lowercase();
+                        let valid_positions = if let Some(start) = name_lower.find(&filter_lower) {
+                            let end = start + filter_lower.len();
+                            (start..end).collect()
+                        } else {
+                            Vec::new()
+                        };
+                        if valid_positions.is_empty() {
+                            Label::new(name.clone())
+                                .color(Color::Default)
+                                .into_any_element()
+                        } else {
+                            HighlightedLabel::new(name.clone(), valid_positions).into_any_element()
+                        }
+                    } else {
+                        Label::new(name.clone())
+                            .color(Color::Default)
+                            .into_any_element()
+                    })
                     .selectable(true)
                     .toggle_state(selected)
                     .inset(true)
@@ -282,20 +398,70 @@ impl ComponentPreview {
     }
 
     fn update_component_list(&mut self, cx: &mut Context<Self>) {
-        let new_len = self.scope_ordered_entries().len();
         let entries = self.scope_ordered_entries();
+        let new_len = entries.len();
         let weak_entity = cx.entity().downgrade();
 
+        if new_len > 0 {
+            self.nav_scroll_handle
+                .scroll_to_item(0, ScrollStrategy::Top);
+        }
+
+        let filtered_components = self.filtered_components();
+
+        if !self.filter_text.is_empty() && !matches!(self.active_page, PreviewPage::AllComponents) {
+            if let PreviewPage::Component(ref component_id) = self.active_page {
+                let component_still_visible = filtered_components
+                    .iter()
+                    .any(|component| component.id() == *component_id);
+
+                if !component_still_visible {
+                    if !filtered_components.is_empty() {
+                        let first_component = &filtered_components[0];
+                        self.set_active_page(PreviewPage::Component(first_component.id()), cx);
+                    } else {
+                        self.set_active_page(PreviewPage::AllComponents, cx);
+                    }
+                }
+            }
+        }
+
+        self.component_list = ListState::new(
+            filtered_components.len(),
+            gpui::ListAlignment::Top,
+            px(1500.0),
+            {
+                let components = filtered_components.clone();
+                let this = cx.entity().downgrade();
+                move |ix, window: &mut Window, cx: &mut App| {
+                    if ix >= components.len() {
+                        return div().w_full().h_0().into_any_element();
+                    }
+
+                    this.update(cx, |this, cx| {
+                        let component = &components[ix];
+                        this.render_preview(component, window, cx)
+                            .into_any_element()
+                    })
+                    .unwrap()
+                }
+            },
+        );
+
         let new_list = ListState::new(
             new_len,
             gpui::ListAlignment::Top,
             px(1500.0),
             move |ix, window, cx| {
+                if ix >= entries.len() {
+                    return div().w_full().h_0().into_any_element();
+                }
+
                 let entry = &entries[ix];
 
                 weak_entity
                     .update(cx, |this, cx| match entry {
-                        PreviewEntry::Component(component) => this
+                        PreviewEntry::Component(component, _) => this
                             .render_preview(component, window, cx)
                             .into_any_element(),
                         PreviewEntry::SectionHeader(shared_string) => this
@@ -309,6 +475,7 @@ impl ComponentPreview {
         );
 
         self.component_list = new_list;
+        cx.emit(ItemEvent::UpdateTab);
     }
 
     fn render_scope_header(
@@ -377,16 +544,27 @@ impl ComponentPreview {
             .into_any_element()
     }
 
-    fn render_all_components(&self) -> impl IntoElement {
+    fn render_all_components(&self, cx: &Context<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),
+                if self.filtered_components().is_empty() && !self.filter_text.is_empty() {
+                    div()
+                        .size_full()
+                        .items_center()
+                        .justify_center()
+                        .text_color(cx.theme().colors().text_muted)
+                        .child(format!("No components matching '{}'.", self.filter_text))
+                        .into_any_element()
+                } else {
+                    list(self.component_list.clone())
+                        .flex_grow()
+                        .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
+                        .into_any_element()
+                },
             )
     }
 
@@ -432,6 +610,19 @@ impl ComponentPreview {
 
 impl Render for ComponentPreview {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        // TODO: move this into the struct
+        let current_filter = self.filter_editor.update(cx, |input, cx| {
+            if input.is_empty(cx) {
+                String::new()
+            } else {
+                input.editor().read(cx).text(cx).to_string()
+            }
+        });
+
+        if current_filter != self.filter_text {
+            self.filter_text = current_filter;
+            self.update_component_list(cx);
+        }
         let sidebar_entries = self.scope_ordered_entries();
         let active_page = self.active_page.clone();
 
@@ -449,14 +640,22 @@ impl Render for ComponentPreview {
                     .border_color(cx.theme().colors().border)
                     .h_full()
                     .child(
-                        uniform_list(
+                        gpui::uniform_list(
                             cx.entity().clone(),
                             "component-nav",
                             sidebar_entries.len(),
                             move |this, range, _window, cx| {
                                 range
-                                    .map(|ix| {
-                                        this.render_sidebar_entry(ix, &sidebar_entries[ix], cx)
+                                    .filter_map(|ix| {
+                                        if ix < sidebar_entries.len() {
+                                            Some(this.render_sidebar_entry(
+                                                ix,
+                                                &sidebar_entries[ix],
+                                                cx,
+                                            ))
+                                        } else {
+                                            None
+                                        }
                                     })
                                     .collect()
                             },
@@ -481,12 +680,29 @@ impl Render for ComponentPreview {
                         ),
                     ),
             )
-            .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(),
-            })
+            .child(
+                v_flex()
+                    .id("content-area")
+                    .flex_1()
+                    .size_full()
+                    .overflow_hidden()
+                    .child(
+                        div()
+                            .p_2()
+                            .w_full()
+                            .border_b_1()
+                            .border_color(cx.theme().colors().border)
+                            .child(self.filter_editor.clone()),
+                    )
+                    .child(match active_page {
+                        PreviewPage::AllComponents => {
+                            self.render_all_components(cx).into_any_element()
+                        }
+                        PreviewPage::Component(id) => self
+                            .render_component_page(&id, window, cx)
+                            .into_any_element(),
+                    }),
+            )
     }
 }
 
@@ -498,6 +714,21 @@ impl Focusable for ComponentPreview {
     }
 }
 
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct ActivePageId(pub String);
+
+impl Default for ActivePageId {
+    fn default() -> Self {
+        ActivePageId("AllComponents".to_string())
+    }
+}
+
+impl From<ComponentId> for ActivePageId {
+    fn from(id: ComponentId) -> Self {
+        ActivePageId(id.0.to_string())
+    }
+}
+
 impl Item for ComponentPreview {
     type Event = ItemEvent;
 
@@ -516,7 +747,7 @@ impl Item for ComponentPreview {
     fn clone_on_split(
         &self,
         _workspace_id: Option<WorkspaceId>,
-        _window: &mut Window,
+        window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Option<gpui::Entity<Self>>
     where
@@ -535,6 +766,7 @@ impl Item for ComponentPreview {
                 user_store,
                 selected_index,
                 Some(active_page),
+                window,
                 cx,
             )
         }))
@@ -543,6 +775,15 @@ impl Item for ComponentPreview {
     fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
         f(*event)
     }
+
+    fn added_to_workspace(
+        &mut self,
+        workspace: &mut Workspace,
+        _window: &mut Window,
+        _cx: &mut Context<Self>,
+    ) {
+        self.workspace_id = workspace.database_id();
+    }
 }
 
 impl SerializableItem for ComponentPreview {
@@ -553,26 +794,53 @@ impl SerializableItem for ComponentPreview {
     fn deserialize(
         project: Entity<Project>,
         workspace: WeakEntity<Workspace>,
-        _workspace_id: WorkspaceId,
-        _item_id: ItemId,
+        workspace_id: WorkspaceId,
+        item_id: ItemId,
         window: &mut Window,
         cx: &mut App,
     ) -> Task<gpui::Result<Entity<Self>>> {
+        let deserialized_active_page =
+            match COMPONENT_PREVIEW_DB.get_active_page(item_id, workspace_id) {
+                Ok(page) => {
+                    if let Some(page) = page {
+                        ActivePageId(page)
+                    } else {
+                        ActivePageId::default()
+                    }
+                }
+                Err(_) => ActivePageId::default(),
+            };
+
         let user_store = project.read(cx).user_store().clone();
         let language_registry = project.read(cx).languages().clone();
+        let preview_page = if deserialized_active_page.0 == ActivePageId::default().0 {
+            Some(PreviewPage::default())
+        } else {
+            let component_str = deserialized_active_page.0;
+            let component_registry = components();
+            let all_components = component_registry.all();
+            let found_component = all_components.iter().find(|c| c.id().0 == component_str);
+
+            if let Some(component) = found_component {
+                Some(PreviewPage::Component(component.id().clone()))
+            } else {
+                Some(PreviewPage::default())
+            }
+        };
 
         window.spawn(cx, async move |cx| {
             let user_store = user_store.clone();
             let language_registry = language_registry.clone();
             let weak_workspace = workspace.clone();
-            cx.update(|_, cx| {
+            cx.update(move |window, cx| {
                 Ok(cx.new(|cx| {
                     ComponentPreview::new(
                         weak_workspace,
                         language_registry,
                         user_store,
                         None,
-                        None,
+                        preview_page,
+                        window,
                         cx,
                     )
                 }))
@@ -581,34 +849,41 @@ impl SerializableItem for ComponentPreview {
     }
 
     fn cleanup(
-        _workspace_id: WorkspaceId,
-        _alive_items: Vec<ItemId>,
+        workspace_id: WorkspaceId,
+        alive_items: Vec<ItemId>,
         _window: &mut Window,
-        _cx: &mut App,
+        cx: &mut App,
     ) -> Task<gpui::Result<()>> {
-        Task::ready(Ok(()))
-        // window.spawn(cx, |_| {
-        // ...
-        // })
+        cx.background_spawn(async move {
+            COMPONENT_PREVIEW_DB
+                .delete_unloaded_items(workspace_id, alive_items)
+                .await
+        })
     }
 
     fn serialize(
         &mut self,
         _workspace: &mut Workspace,
-        _item_id: ItemId,
+        item_id: ItemId,
         _closing: bool,
         _window: &mut Window,
-        _cx: &mut Context<Self>,
+        cx: &mut Context<Self>,
     ) -> Option<Task<gpui::Result<()>>> {
-        // TODO: Serialize the active index so we can re-open to the same place
-        None
+        let active_page = self.active_page_id(cx);
+        let workspace_id = self.workspace_id?;
+        Some(cx.background_spawn(async move {
+            COMPONENT_PREVIEW_DB
+                .save_active_page(item_id, workspace_id, active_page.0)
+                .await
+        }))
     }
 
-    fn should_serialize(&self, _event: &Self::Event) -> bool {
-        false
+    fn should_serialize(&self, event: &Self::Event) -> bool {
+        matches!(event, ItemEvent::UpdateTab)
     }
 }
 
+// TODO: use language registry to allow rendering markdown
 #[derive(IntoElement)]
 pub struct ComponentPreviewPage {
     // languages: Arc<LanguageRegistry>,

crates/component_preview/src/persistence.rs πŸ”—

@@ -0,0 +1,73 @@
+use anyhow::Result;
+use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql};
+use workspace::{ItemId, WorkspaceDb, WorkspaceId};
+
+define_connection! {
+    pub static ref COMPONENT_PREVIEW_DB: ComponentPreviewDb<WorkspaceDb> =
+        &[sql!(
+            CREATE TABLE component_previews (
+                workspace_id INTEGER,
+                item_id INTEGER UNIQUE,
+                active_page_id TEXT,
+                PRIMARY KEY(workspace_id, item_id),
+                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+                ON DELETE CASCADE
+            ) STRICT;
+        )];
+}
+
+impl ComponentPreviewDb {
+    pub async fn save_active_page(
+        &self,
+        item_id: ItemId,
+        workspace_id: WorkspaceId,
+        active_page_id: String,
+    ) -> Result<()> {
+        let query = "INSERT INTO component_previews(item_id, workspace_id, active_page_id)
+            VALUES (?1, ?2, ?3)
+            ON CONFLICT DO UPDATE SET
+                active_page_id = ?3";
+        self.write(move |conn| {
+            let mut statement = Statement::prepare(conn, query)?;
+            let mut next_index = statement.bind(&item_id, 1)?;
+            next_index = statement.bind(&workspace_id, next_index)?;
+            statement.bind(&active_page_id, next_index)?;
+            statement.exec()
+        })
+        .await
+    }
+
+    query! {
+        pub fn get_active_page(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<String>> {
+            SELECT active_page_id
+            FROM component_previews
+            WHERE item_id = ? AND workspace_id = ?
+        }
+    }
+
+    pub async fn delete_unloaded_items(
+        &self,
+        workspace: WorkspaceId,
+        alive_items: Vec<ItemId>,
+    ) -> Result<()> {
+        let placeholders = alive_items
+            .iter()
+            .map(|_| "?")
+            .collect::<Vec<&str>>()
+            .join(", ");
+
+        let query = format!(
+            "DELETE FROM component_previews WHERE workspace_id = ? AND item_id NOT IN ({placeholders})"
+        );
+
+        self.write(move |conn| {
+            let mut statement = Statement::prepare(conn, query)?;
+            let mut next_index = statement.bind(&workspace, 1)?;
+            for id in alive_items {
+                next_index = statement.bind(&id, next_index)?;
+            }
+            statement.exec()
+        })
+        .await
+    }
+}

crates/ui/src/components/avatar.rs πŸ”—

@@ -236,53 +236,30 @@ impl Component for Avatar {
             v_flex()
                 .gap_6()
                 .children(vec![
+                    example_group(vec![
+                        single_example("Default", Avatar::new(example_avatar).into_any_element()),
+                        single_example(
+                            "Grayscale",
+                            Avatar::new(example_avatar)
+                                .grayscale(true)
+                                .into_any_element(),
+                        ),
+                        single_example(
+                            "Border",
+                            Avatar::new(example_avatar)
+                                .border_color(cx.theme().colors().border)
+                                .into_any_element(),
+                        ).description("Can be used to create visual space by setting the border color to match the background, which creates the appearance of a gap around the avatar."),
+                    ]),
                     example_group_with_title(
-                        "Sizes",
-                        vec![
-                            single_example(
-                                "Default",
-                                Avatar::new(example_avatar).into_any_element(),
-                            ),
-                            single_example(
-                                "Small",
-                                Avatar::new(example_avatar).size(px(24.)).into_any_element(),
-                            ),
-                            single_example(
-                                "Large",
-                                Avatar::new(example_avatar).size(px(48.)).into_any_element(),
-                            ),
-                        ],
-                    ),
-                    example_group_with_title(
-                        "Styles",
-                        vec![
-                            single_example(
-                                "Default",
-                                Avatar::new(example_avatar).into_any_element(),
-                            ),
-                            single_example(
-                                "Grayscale",
-                                Avatar::new(example_avatar)
-                                    .grayscale(true)
-                                    .into_any_element(),
-                            ),
-                            single_example(
-                                "With Border",
-                                Avatar::new(example_avatar)
-                                    .border_color(cx.theme().colors().border)
-                                    .into_any_element(),
-                            ),
-                        ],
-                    ),
-                    example_group_with_title(
-                        "Audio Status",
+                        "Indicator Styles",
                         vec![
                             single_example(
                                 "Muted",
                                 Avatar::new(example_avatar)
                                     .indicator(AvatarAudioStatusIndicator::new(AudioStatus::Muted))
                                     .into_any_element(),
-                            ),
+                            ).description("Indicates the collaborator's mic is muted."),
                             single_example(
                                 "Deafened",
                                 Avatar::new(example_avatar)
@@ -290,28 +267,23 @@ impl Component for Avatar {
                                         AudioStatus::Deafened,
                                     ))
                                     .into_any_element(),
-                            ),
-                        ],
-                    ),
-                    example_group_with_title(
-                        "Availability",
-                        vec![
+                            ).description("Indicates that both the collaborator's mic and audio are muted."),
                             single_example(
-                                "Free",
+                                "Availability: Free",
                                 Avatar::new(example_avatar)
                                     .indicator(AvatarAvailabilityIndicator::new(
                                         CollaboratorAvailability::Free,
                                     ))
                                     .into_any_element(),
-                            ),
+                            ).description("Indicates that the person is free, usually meaning they are not in a call."),
                             single_example(
-                                "Busy",
+                                "Availability: Busy",
                                 Avatar::new(example_avatar)
                                     .indicator(AvatarAvailabilityIndicator::new(
                                         CollaboratorAvailability::Busy,
                                     ))
                                     .into_any_element(),
-                            ),
+                            ).description("Indicates that the person is busy, usually meaning they are in a channel or direct call."),
                         ],
                     ),
                 ])

crates/ui/src/components/keybinding.rs πŸ”—

@@ -451,7 +451,7 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode
 
 impl Component for KeyBinding {
     fn scope() -> ComponentScope {
-        ComponentScope::Input
+        ComponentScope::Typography
     }
 
     fn name() -> &'static str {