@@ -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>,
@@ -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."),
],
),
])