mod persistence; use client::UserStore;use collections::HashMap;use component::{ComponentId, ComponentMetadata, ComponentStatus, components};use gpui::{ App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,};use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};use language::LanguageRegistry;use notifications::status_toast::StatusToast;use persistence::ComponentPreviewDb;use project::Project;use std::{iter::Iterator, ops::Range, sync::Arc};use ui::{ ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Scrollbars, Tooltip, WithScrollbar, prelude::*,};use ui_input::InputField;use workspace::AppState;use workspace::{ Item, ItemId, SerializableItem, Workspace, WorkspaceId, delete_unloaded_items, item::ItemEvent,}; pub fn init(app_state: Arc<AppState>, cx: &mut App) { workspace::register_serializable_item::<ComponentPreview>(cx); cx.observe_new(move |workspace: &mut Workspace, _window, cx| { let app_state = app_state.clone(); let project = workspace.project().clone(); let weak_workspace = cx.entity().downgrade(); workspace.register_action( move |workspace, _: &workspace::OpenComponentPreview, window, cx| { let app_state = app_state.clone(); let language_registry = app_state.languages.clone(); let user_store = app_state.user_store.clone(); let component_preview = cx.new(|cx| { ComponentPreview::new( weak_workspace.clone(), project.clone(), language_registry, user_store, None, None, window, cx, ) .expect("Failed to create component preview") }); workspace.add_item_to_active_pane( Box::new(component_preview), None, true, window, cx, ) }, ); }) .detach();} enum PreviewEntry { AllComponents, Separator, Component(ComponentMetadata, Option<Vec<usize>>), SectionHeader(SharedString),} impl From<ComponentMetadata> for PreviewEntry { fn from(component: ComponentMetadata) -> Self { PreviewEntry::Component(component, None) }} impl From<SharedString> for PreviewEntry { fn from(section_header: SharedString) -> Self { PreviewEntry::SectionHeader(section_header) }} #[derive(Default, Debug, Clone, PartialEq, Eq)]pub enum PreviewPage { #[default] AllComponents, Component(ComponentId),} pub struct ComponentPreview { active_page: PreviewPage, reset_key: usize, component_list: ListState, entries: Vec<PreviewEntry>, component_map: HashMap<ComponentId, ComponentMetadata>, components: Vec<ComponentMetadata>, cursor_index: usize, filter_editor: Entity<InputField>, filter_text: String, focus_handle: FocusHandle, language_registry: Arc<LanguageRegistry>, nav_scroll_handle: UniformListScrollHandle, project: Entity<Project>, user_store: Entity<UserStore>, workspace: WeakEntity<Workspace>, workspace_id: Option<WorkspaceId>, _view_scroll_handle: ScrollHandle,} impl ComponentPreview { pub fn new( workspace: WeakEntity<Workspace>, project: Entity<Project>, language_registry: Arc<LanguageRegistry>, user_store: Entity<UserStore>, selected_index: impl Into<Option<usize>>, active_page: Option<PreviewPage>, window: &mut Window, cx: &mut Context<Self>, ) -> anyhow::Result<Self> { let component_registry = Arc::new(components()); let sorted_components = component_registry.sorted_components(); let selected_index = selected_index.into().unwrap_or(0); let active_page = active_page.unwrap_or(PreviewPage::AllComponents); let filter_editor = cx.new(|cx| InputField::new(window, cx, "Find components or usages…")); let component_list = ListState::new( sorted_components.len(), gpui::ListAlignment::Top, px(1500.0), ); let mut component_preview = Self { active_page, reset_key: 0, component_list, entries: Vec::new(), component_map: component_registry.component_map(), components: sorted_components, cursor_index: selected_index, filter_editor, filter_text: String::new(), focus_handle: cx.focus_handle(), language_registry, nav_scroll_handle: UniformListScrollHandle::new(), project, user_store, workspace, workspace_id: None, _view_scroll_handle: ScrollHandle::new(), }; if component_preview.cursor_index > 0 { component_preview.scroll_to_preview(component_preview.cursor_index, cx); } component_preview.update_component_list(cx); let focus_handle = component_preview.filter_editor.read(cx).focus_handle(cx); window.focus(&focus_handle, cx); Ok(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; cx.notify(); } fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) { if self.active_page == page { // Force the current preview page to render again self.reset_key = self.reset_key.wrapping_add(1); } else { self.active_page = page; cx.emit(ItemEvent::UpdateTab); } cx.notify(); } 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().to_lowercase(); component_name.contains(&filter) || scope_name.contains(&filter) || description.contains(&filter) }) .cloned() .collect() } fn scope_ordered_entries(&self) -> Vec<PreviewEntry> { use collections::HashMap; 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 { 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(); 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) && 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.sort_name()); } let mut entries = Vec::new(); // Always show all components first entries.push(PreviewEntry::AllComponents); let mut scopes: Vec<_> = scope_groups .keys() .filter(|scope| !matches!(**scope, ComponentScope::None)) .cloned() .collect(); scopes.sort_by_key(|s| s.to_string()); for scope in scopes { if let Some(components) = scope_groups.remove(&scope) && !components.is_empty() { entries.push(PreviewEntry::Separator); entries.push(PreviewEntry::SectionHeader(scope.to_string().into())); let mut sorted_components = components; sorted_components.sort_by_key(|(component, _)| component.sort_name()); for (component, positions) in sorted_components { entries.push(PreviewEntry::Component(component, positions)); } } } // Add uncategorized components last if let Some(components) = scope_groups.get(&ComponentScope::None) && !components.is_empty() { 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()); for (component, positions) in sorted_components { entries.push(PreviewEntry::Component(component, positions)); } } entries } fn update_component_list(&mut self, cx: &mut Context<Self>) { let entries = self.scope_ordered_entries(); let new_len = entries.len(); 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) && 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(new_len, gpui::ListAlignment::Top, px(1500.0)); self.entries = entries; cx.emit(ItemEvent::UpdateTab); } fn render_sidebar_entry( &self, ix: usize, entry: &PreviewEntry, cx: &Context<Self>, ) -> impl IntoElement + use<> { match entry { 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(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).into_any_element() } else { HighlightedLabel::new(name, valid_positions).into_any_element() } } else { Label::new(name).into_any_element() }) .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")) .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) .disabled(true) .child(div().w_full().py_2().child(Divider::horizontal())) .into_any_element(), } } fn render_scope_header( &self, _ix: usize, title: SharedString, _window: &Window, _cx: &App, ) -> impl IntoElement { h_flex() .w_full() .h_10() .child(Headline::new(title).size(HeadlineSize::XSmall)) .child(Divider::horizontal()) } fn render_preview( &self, component: &ComponentMetadata, window: &mut Window, cx: &mut App, ) -> impl IntoElement { let name = component.scopeless_name(); let scope = component.scope(); let description = component.description(); // Build the content container v_flex() .py_2() .child( v_flex() .border_1() .border_color(cx.theme().colors().border) .rounded_sm() .w_full() .gap_4() .py_4() .px_6() .flex_none() .child( v_flex() .gap_1() .child( h_flex().gap_1().text_xl().child(div().child(name)).when( scope != ComponentScope::None, |this| { this.child(div().opacity(0.5).child(format!("({})", scope))) }, ), ) .child( div() .text_ui_sm(cx) .text_color(cx.theme().colors().text_muted) .max_w(px(600.0)) .child(description), ), ), ) .child((component.preview())(window, cx)) .into_any_element() } fn render_all_components(&self, cx: &Context<Self>) -> impl IntoElement { v_flex() .id("component-list") .px_8() .pt_4() .size_full() .child( 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(Lines 1–500 of 992. Load more
mod persistence; use client::UserStore; use collections::HashMap; use component::{ComponentId, ComponentMetadata, ComponentStatus, components}; use gpui::{ App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*, }; use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle}; use language::LanguageRegistry; use notifications::status_toast::StatusToast; use persistence::ComponentPreviewDb; use project::Project; use std::{iter::Iterator, ops::Range, sync::Arc}; use ui::{ ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Scrollbars, Tooltip, WithScrollbar, prelude::*, }; use ui_input::InputField; use workspace::AppState; use workspace::{ Item, ItemId, SerializableItem, Workspace, WorkspaceId, delete_unloaded_items, item::ItemEvent, }; pub fn init(app_state: Arc<AppState>, cx: &mut App) { workspace::register_serializable_item::<ComponentPreview>(cx); cx.observe_new(move |workspace: &mut Workspace, _window, cx| { let app_state = app_state.clone(); let project = workspace.project().clone(); let weak_workspace = cx.entity().downgrade(); workspace.register_action( move |workspace, _: &workspace::OpenComponentPreview, window, cx| { let app_state = app_state.clone(); let language_registry = app_state.languages.clone(); let user_store = app_state.user_store.clone(); let component_preview = cx.new(|cx| { ComponentPreview::new( weak_workspace.clone(), project.clone(), language_registry, user_store, None, None, window, cx, ) .expect("Failed to create component preview") }); workspace.add_item_to_active_pane( Box::new(component_preview), None, true, window, cx, ) }, ); }) .detach(); } enum PreviewEntry { AllComponents, Separator, Component(ComponentMetadata, Option<Vec<usize>>), SectionHeader(SharedString), } impl From<ComponentMetadata> for PreviewEntry { fn from(component: ComponentMetadata) -> Self { PreviewEntry::Component(component, None) } } impl From<SharedString> for PreviewEntry { fn from(section_header: SharedString) -> Self { PreviewEntry::SectionHeader(section_header) } } #[derive(Default, Debug, Clone, PartialEq, Eq)] pub enum PreviewPage { #[default] AllComponents, Component(ComponentId), } pub struct ComponentPreview { active_page: PreviewPage, reset_key: usize, component_list: ListState, entries: Vec<PreviewEntry>, component_map: HashMap<ComponentId, ComponentMetadata>, components: Vec<ComponentMetadata>, cursor_index: usize, filter_editor: Entity<InputField>, filter_text: String, focus_handle: FocusHandle, language_registry: Arc<LanguageRegistry>, nav_scroll_handle: UniformListScrollHandle, project: Entity<Project>, user_store: Entity<UserStore>, workspace: WeakEntity<Workspace>, workspace_id: Option<WorkspaceId>, _view_scroll_handle: ScrollHandle, } impl ComponentPreview { pub fn new( workspace: WeakEntity<Workspace>, project: Entity<Project>, language_registry: Arc<LanguageRegistry>, user_store: Entity<UserStore>, selected_index: impl Into<Option<usize>>, active_page: Option<PreviewPage>, window: &mut Window, cx: &mut Context<Self>, ) -> anyhow::Result<Self> { let component_registry = Arc::new(components()); let sorted_components = component_registry.sorted_components(); let selected_index = selected_index.into().unwrap_or(0); let active_page = active_page.unwrap_or(PreviewPage::AllComponents); let filter_editor = cx.new(|cx| InputField::new(window, cx, "Find components or usages…")); let component_list = ListState::new( sorted_components.len(), gpui::ListAlignment::Top, px(1500.0), ); let mut component_preview = Self { active_page, reset_key: 0, component_list, entries: Vec::new(), component_map: component_registry.component_map(), components: sorted_components, cursor_index: selected_index, filter_editor, filter_text: String::new(), focus_handle: cx.focus_handle(), language_registry, nav_scroll_handle: UniformListScrollHandle::new(), project, user_store, workspace, workspace_id: None, _view_scroll_handle: ScrollHandle::new(), }; if component_preview.cursor_index > 0 { component_preview.scroll_to_preview(component_preview.cursor_index, cx); } component_preview.update_component_list(cx); let focus_handle = component_preview.filter_editor.read(cx).focus_handle(cx); window.focus(&focus_handle, cx); Ok(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; cx.notify(); } fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) { if self.active_page == page { // Force the current preview page to render again self.reset_key = self.reset_key.wrapping_add(1); } else { self.active_page = page; cx.emit(ItemEvent::UpdateTab); } cx.notify(); } 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().to_lowercase(); component_name.contains(&filter) || scope_name.contains(&filter) || description.contains(&filter) }) .cloned() .collect() } fn scope_ordered_entries(&self) -> Vec<PreviewEntry> { use collections::HashMap; 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 { 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(); 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) && 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.sort_name()); } let mut entries = Vec::new(); // Always show all components first entries.push(PreviewEntry::AllComponents); let mut scopes: Vec<_> = scope_groups .keys() .filter(|scope| !matches!(**scope, ComponentScope::None)) .cloned() .collect(); scopes.sort_by_key(|s| s.to_string()); for scope in scopes { if let Some(components) = scope_groups.remove(&scope) && !components.is_empty() { entries.push(PreviewEntry::Separator); entries.push(PreviewEntry::SectionHeader(scope.to_string().into())); let mut sorted_components = components; sorted_components.sort_by_key(|(component, _)| component.sort_name()); for (component, positions) in sorted_components { entries.push(PreviewEntry::Component(component, positions)); } } } // Add uncategorized components last if let Some(components) = scope_groups.get(&ComponentScope::None) && !components.is_empty() { 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()); for (component, positions) in sorted_components { entries.push(PreviewEntry::Component(component, positions)); } } entries } fn update_component_list(&mut self, cx: &mut Context<Self>) { let entries = self.scope_ordered_entries(); let new_len = entries.len(); 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) && 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(new_len, gpui::ListAlignment::Top, px(1500.0)); self.entries = entries; cx.emit(ItemEvent::UpdateTab); } fn render_sidebar_entry( &self, ix: usize, entry: &PreviewEntry, cx: &Context<Self>, ) -> impl IntoElement + use<> { match entry { 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(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).into_any_element() } else { HighlightedLabel::new(name, valid_positions).into_any_element() } } else { Label::new(name).into_any_element() }) .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")) .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) .disabled(true) .child(div().w_full().py_2().child(Divider::horizontal())) .into_any_element(), } } fn render_scope_header( &self, _ix: usize, title: SharedString, _window: &Window, _cx: &App, ) -> impl IntoElement { h_flex() .w_full() .h_10() .child(Headline::new(title).size(HeadlineSize::XSmall)) .child(Divider::horizontal()) } fn render_preview( &self, component: &ComponentMetadata, window: &mut Window, cx: &mut App, ) -> impl IntoElement { let name = component.scopeless_name(); let scope = component.scope(); let description = component.description(); // Build the content container v_flex() .py_2() .child( v_flex() .border_1() .border_color(cx.theme().colors().border) .rounded_sm() .w_full() .gap_4() .py_4() .px_6() .flex_none() .child( v_flex() .gap_1() .child( h_flex().gap_1().text_xl().child(div().child(name)).when( scope != ComponentScope::None, |this| { this.child(div().opacity(0.5).child(format!("({})", scope))) }, ), ) .child( div() .text_ui_sm(cx) .text_color(cx.theme().colors().text_muted) .max_w(px(600.0)) .child(description), ), ), ) .child((component.preview())(window, cx)) .into_any_element() } fn render_all_components(&self, cx: &Context<Self>) -> impl IntoElement { v_flex() .id("component-list") .px_8() .pt_4() .size_full() .child( 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(