1//! # Component Preview
2//!
3//! A view for exploring Zed components.
4
5mod persistence;
6
7use std::iter::Iterator;
8use std::sync::Arc;
9
10use client::UserStore;
11use component::{ComponentId, ComponentMetadata, components};
12use gpui::{
13 App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,
14};
15
16use collections::HashMap;
17
18use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};
19use languages::LanguageRegistry;
20use notifications::status_toast::{StatusToast, ToastIcon};
21use persistence::COMPONENT_PREVIEW_DB;
22use project::Project;
23use ui::{Divider, HighlightedLabel, ListItem, ListSubHeader, prelude::*};
24
25use ui_input::SingleLineInput;
26use workspace::{AppState, ItemId, SerializableItem};
27use workspace::{Item, Workspace, WorkspaceId, item::ItemEvent};
28
29pub fn init(app_state: Arc<AppState>, cx: &mut App) {
30 workspace::register_serializable_item::<ComponentPreview>(cx);
31
32 let app_state = app_state.clone();
33
34 cx.observe_new(move |workspace: &mut Workspace, _window, cx| {
35 let app_state = app_state.clone();
36 let weak_workspace = cx.entity().downgrade();
37
38 workspace.register_action(
39 move |workspace, _: &workspace::OpenComponentPreview, window, cx| {
40 let app_state = app_state.clone();
41
42 let language_registry = app_state.languages.clone();
43 let user_store = app_state.user_store.clone();
44
45 let component_preview = cx.new(|cx| {
46 ComponentPreview::new(
47 weak_workspace.clone(),
48 language_registry,
49 user_store,
50 None,
51 None,
52 window,
53 cx,
54 )
55 });
56
57 workspace.add_item_to_active_pane(
58 Box::new(component_preview),
59 None,
60 true,
61 window,
62 cx,
63 )
64 },
65 );
66 })
67 .detach();
68}
69
70enum PreviewEntry {
71 AllComponents,
72 Separator,
73 Component(ComponentMetadata, Option<Vec<usize>>),
74 SectionHeader(SharedString),
75}
76
77impl From<ComponentMetadata> for PreviewEntry {
78 fn from(component: ComponentMetadata) -> Self {
79 PreviewEntry::Component(component, None)
80 }
81}
82
83impl From<SharedString> for PreviewEntry {
84 fn from(section_header: SharedString) -> Self {
85 PreviewEntry::SectionHeader(section_header)
86 }
87}
88
89#[derive(Default, Debug, Clone, PartialEq, Eq)]
90enum PreviewPage {
91 #[default]
92 AllComponents,
93 Component(ComponentId),
94}
95
96struct ComponentPreview {
97 workspace_id: Option<WorkspaceId>,
98 focus_handle: FocusHandle,
99 _view_scroll_handle: ScrollHandle,
100 nav_scroll_handle: UniformListScrollHandle,
101 component_map: HashMap<ComponentId, ComponentMetadata>,
102 active_page: PreviewPage,
103 components: Vec<ComponentMetadata>,
104 component_list: ListState,
105 cursor_index: usize,
106 language_registry: Arc<LanguageRegistry>,
107 workspace: WeakEntity<Workspace>,
108 user_store: Entity<UserStore>,
109 filter_editor: Entity<SingleLineInput>,
110 filter_text: String,
111}
112
113impl ComponentPreview {
114 pub fn new(
115 workspace: WeakEntity<Workspace>,
116 language_registry: Arc<LanguageRegistry>,
117 user_store: Entity<UserStore>,
118 selected_index: impl Into<Option<usize>>,
119 active_page: Option<PreviewPage>,
120 window: &mut Window,
121 cx: &mut Context<Self>,
122 ) -> Self {
123 let sorted_components = components().all_sorted();
124 let selected_index = selected_index.into().unwrap_or(0);
125 let active_page = active_page.unwrap_or(PreviewPage::AllComponents);
126 let filter_editor =
127 cx.new(|cx| SingleLineInput::new(window, cx, "Find components or usages…"));
128
129 let component_list = ListState::new(
130 sorted_components.len(),
131 gpui::ListAlignment::Top,
132 px(1500.0),
133 {
134 let this = cx.entity().downgrade();
135 move |ix, window: &mut Window, cx: &mut App| {
136 this.update(cx, |this, cx| {
137 let component = this.get_component(ix);
138 this.render_preview(&component, window, cx)
139 .into_any_element()
140 })
141 .unwrap()
142 }
143 },
144 );
145
146 let mut component_preview = Self {
147 workspace_id: None,
148 focus_handle: cx.focus_handle(),
149 _view_scroll_handle: ScrollHandle::new(),
150 nav_scroll_handle: UniformListScrollHandle::new(),
151 language_registry,
152 user_store,
153 workspace,
154 active_page,
155 component_map: components().0,
156 components: sorted_components,
157 component_list,
158 cursor_index: selected_index,
159 filter_editor,
160 filter_text: String::new(),
161 };
162
163 if component_preview.cursor_index > 0 {
164 component_preview.scroll_to_preview(component_preview.cursor_index, cx);
165 }
166
167 component_preview.update_component_list(cx);
168
169 component_preview
170 }
171
172 pub fn active_page_id(&self, _cx: &App) -> ActivePageId {
173 match &self.active_page {
174 PreviewPage::AllComponents => ActivePageId::default(),
175 PreviewPage::Component(component_id) => ActivePageId(component_id.0.to_string()),
176 }
177 }
178
179 fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
180 self.component_list.scroll_to_reveal_item(ix);
181 self.cursor_index = ix;
182 cx.notify();
183 }
184
185 fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) {
186 self.active_page = page;
187 cx.emit(ItemEvent::UpdateTab);
188 cx.notify();
189 }
190
191 fn get_component(&self, ix: usize) -> ComponentMetadata {
192 self.components[ix].clone()
193 }
194
195 fn filtered_components(&self) -> Vec<ComponentMetadata> {
196 if self.filter_text.is_empty() {
197 return self.components.clone();
198 }
199
200 let filter = self.filter_text.to_lowercase();
201 self.components
202 .iter()
203 .filter(|component| {
204 let component_name = component.name().to_lowercase();
205 let scope_name = component.scope().to_string().to_lowercase();
206 let description = component
207 .description()
208 .map(|d| d.to_lowercase())
209 .unwrap_or_default();
210
211 component_name.contains(&filter)
212 || scope_name.contains(&filter)
213 || description.contains(&filter)
214 })
215 .cloned()
216 .collect()
217 }
218
219 fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
220 use std::collections::HashMap;
221
222 let mut scope_groups: HashMap<
223 ComponentScope,
224 Vec<(ComponentMetadata, Option<Vec<usize>>)>,
225 > = HashMap::default();
226 let lowercase_filter = self.filter_text.to_lowercase();
227
228 for component in &self.components {
229 if self.filter_text.is_empty() {
230 scope_groups
231 .entry(component.scope())
232 .or_insert_with(Vec::new)
233 .push((component.clone(), None));
234 continue;
235 }
236
237 // let full_component_name = component.name();
238 let scopeless_name = component.scopeless_name();
239 let scope_name = component.scope().to_string();
240 let description = component.description().unwrap_or_default();
241
242 let lowercase_scopeless = scopeless_name.to_lowercase();
243 let lowercase_scope = scope_name.to_lowercase();
244 let lowercase_desc = description.to_lowercase();
245
246 if lowercase_scopeless.contains(&lowercase_filter) {
247 if let Some(index) = lowercase_scopeless.find(&lowercase_filter) {
248 let end = index + lowercase_filter.len();
249
250 if end <= scopeless_name.len() {
251 let mut positions = Vec::new();
252 for i in index..end {
253 if scopeless_name.is_char_boundary(i) {
254 positions.push(i);
255 }
256 }
257
258 if !positions.is_empty() {
259 scope_groups
260 .entry(component.scope())
261 .or_insert_with(Vec::new)
262 .push((component.clone(), Some(positions)));
263 continue;
264 }
265 }
266 }
267 }
268
269 if lowercase_scopeless.contains(&lowercase_filter)
270 || lowercase_scope.contains(&lowercase_filter)
271 || lowercase_desc.contains(&lowercase_filter)
272 {
273 scope_groups
274 .entry(component.scope())
275 .or_insert_with(Vec::new)
276 .push((component.clone(), None));
277 }
278 }
279
280 // Sort the components in each group
281 for components in scope_groups.values_mut() {
282 components.sort_by_key(|(c, _)| c.sort_name());
283 }
284
285 let mut entries = Vec::new();
286
287 // Always show all components first
288 entries.push(PreviewEntry::AllComponents);
289 entries.push(PreviewEntry::Separator);
290
291 let mut scopes: Vec<_> = scope_groups
292 .keys()
293 .filter(|scope| !matches!(**scope, ComponentScope::None))
294 .cloned()
295 .collect();
296
297 scopes.sort_by_key(|s| s.to_string());
298
299 for scope in scopes {
300 if let Some(components) = scope_groups.remove(&scope) {
301 if !components.is_empty() {
302 entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
303 let mut sorted_components = components;
304 sorted_components.sort_by_key(|(component, _)| component.sort_name());
305
306 for (component, positions) in sorted_components {
307 entries.push(PreviewEntry::Component(component, positions));
308 }
309 }
310 }
311 }
312
313 // Add uncategorized components last
314 if let Some(components) = scope_groups.get(&ComponentScope::None) {
315 if !components.is_empty() {
316 entries.push(PreviewEntry::Separator);
317 entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
318 let mut sorted_components = components.clone();
319 sorted_components.sort_by_key(|(c, _)| c.sort_name());
320
321 for (component, positions) in sorted_components {
322 entries.push(PreviewEntry::Component(component, positions));
323 }
324 }
325 }
326
327 entries
328 }
329
330 fn render_sidebar_entry(
331 &self,
332 ix: usize,
333 entry: &PreviewEntry,
334 cx: &Context<Self>,
335 ) -> impl IntoElement + use<> {
336 match entry {
337 PreviewEntry::Component(component_metadata, highlight_positions) => {
338 let id = component_metadata.id();
339 let selected = self.active_page == PreviewPage::Component(id.clone());
340 let name = component_metadata.scopeless_name();
341
342 ListItem::new(ix)
343 .child(if let Some(_positions) = highlight_positions {
344 let name_lower = name.to_lowercase();
345 let filter_lower = self.filter_text.to_lowercase();
346 let valid_positions = if let Some(start) = name_lower.find(&filter_lower) {
347 let end = start + filter_lower.len();
348 (start..end).collect()
349 } else {
350 Vec::new()
351 };
352 if valid_positions.is_empty() {
353 Label::new(name.clone())
354 .color(Color::Default)
355 .into_any_element()
356 } else {
357 HighlightedLabel::new(name.clone(), valid_positions).into_any_element()
358 }
359 } else {
360 Label::new(name.clone())
361 .color(Color::Default)
362 .into_any_element()
363 })
364 .selectable(true)
365 .toggle_state(selected)
366 .inset(true)
367 .on_click(cx.listener(move |this, _, _, cx| {
368 let id = id.clone();
369 this.set_active_page(PreviewPage::Component(id), cx);
370 }))
371 .into_any_element()
372 }
373 PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
374 .inset(true)
375 .into_any_element(),
376 PreviewEntry::AllComponents => {
377 let selected = self.active_page == PreviewPage::AllComponents;
378
379 ListItem::new(ix)
380 .child(Label::new("All Components").color(Color::Default))
381 .selectable(true)
382 .toggle_state(selected)
383 .inset(true)
384 .on_click(cx.listener(move |this, _, _, cx| {
385 this.set_active_page(PreviewPage::AllComponents, cx);
386 }))
387 .into_any_element()
388 }
389 PreviewEntry::Separator => ListItem::new(ix)
390 .child(
391 h_flex()
392 .occlude()
393 .pt_3()
394 .child(Divider::horizontal_dashed()),
395 )
396 .into_any_element(),
397 }
398 }
399
400 fn update_component_list(&mut self, cx: &mut Context<Self>) {
401 let entries = self.scope_ordered_entries();
402 let new_len = entries.len();
403 let weak_entity = cx.entity().downgrade();
404
405 if new_len > 0 {
406 self.nav_scroll_handle
407 .scroll_to_item(0, ScrollStrategy::Top);
408 }
409
410 let filtered_components = self.filtered_components();
411
412 if !self.filter_text.is_empty() && !matches!(self.active_page, PreviewPage::AllComponents) {
413 if let PreviewPage::Component(ref component_id) = self.active_page {
414 let component_still_visible = filtered_components
415 .iter()
416 .any(|component| component.id() == *component_id);
417
418 if !component_still_visible {
419 if !filtered_components.is_empty() {
420 let first_component = &filtered_components[0];
421 self.set_active_page(PreviewPage::Component(first_component.id()), cx);
422 } else {
423 self.set_active_page(PreviewPage::AllComponents, cx);
424 }
425 }
426 }
427 }
428
429 self.component_list = ListState::new(
430 filtered_components.len(),
431 gpui::ListAlignment::Top,
432 px(1500.0),
433 {
434 let components = filtered_components.clone();
435 let this = cx.entity().downgrade();
436 move |ix, window: &mut Window, cx: &mut App| {
437 if ix >= components.len() {
438 return div().w_full().h_0().into_any_element();
439 }
440
441 this.update(cx, |this, cx| {
442 let component = &components[ix];
443 this.render_preview(component, window, cx)
444 .into_any_element()
445 })
446 .unwrap()
447 }
448 },
449 );
450
451 let new_list = ListState::new(
452 new_len,
453 gpui::ListAlignment::Top,
454 px(1500.0),
455 move |ix, window, cx| {
456 if ix >= entries.len() {
457 return div().w_full().h_0().into_any_element();
458 }
459
460 let entry = &entries[ix];
461
462 weak_entity
463 .update(cx, |this, cx| match entry {
464 PreviewEntry::Component(component, _) => this
465 .render_preview(component, window, cx)
466 .into_any_element(),
467 PreviewEntry::SectionHeader(shared_string) => this
468 .render_scope_header(ix, shared_string.clone(), window, cx)
469 .into_any_element(),
470 PreviewEntry::AllComponents => div().w_full().h_0().into_any_element(),
471 PreviewEntry::Separator => div().w_full().h_0().into_any_element(),
472 })
473 .unwrap()
474 },
475 );
476
477 self.component_list = new_list;
478 cx.emit(ItemEvent::UpdateTab);
479 }
480
481 fn render_scope_header(
482 &self,
483 _ix: usize,
484 title: SharedString,
485 _window: &Window,
486 _cx: &App,
487 ) -> impl IntoElement {
488 h_flex()
489 .w_full()
490 .h_10()
491 .items_center()
492 .child(Headline::new(title).size(HeadlineSize::XSmall))
493 .child(Divider::horizontal())
494 }
495
496 fn render_preview(
497 &self,
498 component: &ComponentMetadata,
499 window: &mut Window,
500 cx: &mut App,
501 ) -> impl IntoElement {
502 let name = component.scopeless_name();
503 let scope = component.scope();
504
505 let description = component.description();
506
507 v_flex()
508 .py_2()
509 .child(
510 v_flex()
511 .border_1()
512 .border_color(cx.theme().colors().border)
513 .rounded_sm()
514 .w_full()
515 .gap_4()
516 .py_4()
517 .px_6()
518 .flex_none()
519 .child(
520 v_flex()
521 .gap_1()
522 .child(
523 h_flex().gap_1().text_xl().child(div().child(name)).when(
524 !matches!(scope, ComponentScope::None),
525 |this| {
526 this.child(div().opacity(0.5).child(format!("({})", scope)))
527 },
528 ),
529 )
530 .when_some(description, |this, description| {
531 this.child(
532 div()
533 .text_ui_sm(cx)
534 .text_color(cx.theme().colors().text_muted)
535 .max_w(px(600.0))
536 .child(description),
537 )
538 }),
539 )
540 .when_some(component.preview(), |this, preview| {
541 this.children(preview(window, cx))
542 }),
543 )
544 .into_any_element()
545 }
546
547 fn render_all_components(&self, cx: &Context<Self>) -> impl IntoElement {
548 v_flex()
549 .id("component-list")
550 .px_8()
551 .pt_4()
552 .size_full()
553 .child(
554 if self.filtered_components().is_empty() && !self.filter_text.is_empty() {
555 div()
556 .size_full()
557 .items_center()
558 .justify_center()
559 .text_color(cx.theme().colors().text_muted)
560 .child(format!("No components matching '{}'.", self.filter_text))
561 .into_any_element()
562 } else {
563 list(self.component_list.clone())
564 .flex_grow()
565 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
566 .into_any_element()
567 },
568 )
569 }
570
571 fn render_component_page(
572 &mut self,
573 component_id: &ComponentId,
574 _window: &mut Window,
575 _cx: &mut Context<Self>,
576 ) -> impl IntoElement {
577 let component = self.component_map.get(&component_id);
578
579 if let Some(component) = component {
580 v_flex()
581 .id("render-component-page")
582 .size_full()
583 .child(ComponentPreviewPage::new(component.clone()))
584 .into_any_element()
585 } else {
586 v_flex()
587 .size_full()
588 .items_center()
589 .justify_center()
590 .child("Component not found")
591 .into_any_element()
592 }
593 }
594
595 fn test_status_toast(&self, cx: &mut Context<Self>) {
596 if let Some(workspace) = self.workspace.upgrade() {
597 workspace.update(cx, |workspace, cx| {
598 let status_toast =
599 StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
600 this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
601 .action("Open Pull Request", |_, cx| {
602 cx.open_url("https://github.com/")
603 })
604 });
605 workspace.toggle_status_toast(status_toast, cx)
606 });
607 }
608 }
609}
610
611impl Render for ComponentPreview {
612 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
613 // TODO: move this into the struct
614 let current_filter = self.filter_editor.update(cx, |input, cx| {
615 if input.is_empty(cx) {
616 String::new()
617 } else {
618 input.editor().read(cx).text(cx).to_string()
619 }
620 });
621
622 if current_filter != self.filter_text {
623 self.filter_text = current_filter;
624 self.update_component_list(cx);
625 }
626 let sidebar_entries = self.scope_ordered_entries();
627 let active_page = self.active_page.clone();
628
629 h_flex()
630 .id("component-preview")
631 .key_context("ComponentPreview")
632 .items_start()
633 .overflow_hidden()
634 .size_full()
635 .track_focus(&self.focus_handle)
636 .bg(cx.theme().colors().editor_background)
637 .child(
638 v_flex()
639 .border_r_1()
640 .border_color(cx.theme().colors().border)
641 .h_full()
642 .child(
643 gpui::uniform_list(
644 cx.entity().clone(),
645 "component-nav",
646 sidebar_entries.len(),
647 move |this, range, _window, cx| {
648 range
649 .filter_map(|ix| {
650 if ix < sidebar_entries.len() {
651 Some(this.render_sidebar_entry(
652 ix,
653 &sidebar_entries[ix],
654 cx,
655 ))
656 } else {
657 None
658 }
659 })
660 .collect()
661 },
662 )
663 .track_scroll(self.nav_scroll_handle.clone())
664 .pt_4()
665 .px_4()
666 .w(px(240.))
667 .h_full()
668 .flex_1(),
669 )
670 .child(
671 div().w_full().pb_4().child(
672 Button::new("toast-test", "Launch Toast")
673 .on_click(cx.listener({
674 move |this, _, _window, cx| {
675 this.test_status_toast(cx);
676 cx.notify();
677 }
678 }))
679 .full_width(),
680 ),
681 ),
682 )
683 .child(
684 v_flex()
685 .id("content-area")
686 .flex_1()
687 .size_full()
688 .overflow_hidden()
689 .child(
690 div()
691 .p_2()
692 .w_full()
693 .border_b_1()
694 .border_color(cx.theme().colors().border)
695 .child(self.filter_editor.clone()),
696 )
697 .child(match active_page {
698 PreviewPage::AllComponents => {
699 self.render_all_components(cx).into_any_element()
700 }
701 PreviewPage::Component(id) => self
702 .render_component_page(&id, window, cx)
703 .into_any_element(),
704 }),
705 )
706 }
707}
708
709impl EventEmitter<ItemEvent> for ComponentPreview {}
710
711impl Focusable for ComponentPreview {
712 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
713 self.focus_handle.clone()
714 }
715}
716
717#[derive(Debug, Clone, PartialEq, Eq, Hash)]
718pub struct ActivePageId(pub String);
719
720impl Default for ActivePageId {
721 fn default() -> Self {
722 ActivePageId("AllComponents".to_string())
723 }
724}
725
726impl From<ComponentId> for ActivePageId {
727 fn from(id: ComponentId) -> Self {
728 ActivePageId(id.0.to_string())
729 }
730}
731
732impl Item for ComponentPreview {
733 type Event = ItemEvent;
734
735 fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option<SharedString> {
736 Some("Component Preview".into())
737 }
738
739 fn telemetry_event_text(&self) -> Option<&'static str> {
740 None
741 }
742
743 fn show_toolbar(&self) -> bool {
744 false
745 }
746
747 fn clone_on_split(
748 &self,
749 _workspace_id: Option<WorkspaceId>,
750 window: &mut Window,
751 cx: &mut Context<Self>,
752 ) -> Option<gpui::Entity<Self>>
753 where
754 Self: Sized,
755 {
756 let language_registry = self.language_registry.clone();
757 let user_store = self.user_store.clone();
758 let weak_workspace = self.workspace.clone();
759 let selected_index = self.cursor_index;
760 let active_page = self.active_page.clone();
761
762 Some(cx.new(|cx| {
763 Self::new(
764 weak_workspace,
765 language_registry,
766 user_store,
767 selected_index,
768 Some(active_page),
769 window,
770 cx,
771 )
772 }))
773 }
774
775 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
776 f(*event)
777 }
778
779 fn added_to_workspace(
780 &mut self,
781 workspace: &mut Workspace,
782 _window: &mut Window,
783 _cx: &mut Context<Self>,
784 ) {
785 self.workspace_id = workspace.database_id();
786 }
787}
788
789impl SerializableItem for ComponentPreview {
790 fn serialized_item_kind() -> &'static str {
791 "ComponentPreview"
792 }
793
794 fn deserialize(
795 project: Entity<Project>,
796 workspace: WeakEntity<Workspace>,
797 workspace_id: WorkspaceId,
798 item_id: ItemId,
799 window: &mut Window,
800 cx: &mut App,
801 ) -> Task<gpui::Result<Entity<Self>>> {
802 let deserialized_active_page =
803 match COMPONENT_PREVIEW_DB.get_active_page(item_id, workspace_id) {
804 Ok(page) => {
805 if let Some(page) = page {
806 ActivePageId(page)
807 } else {
808 ActivePageId::default()
809 }
810 }
811 Err(_) => ActivePageId::default(),
812 };
813
814 let user_store = project.read(cx).user_store().clone();
815 let language_registry = project.read(cx).languages().clone();
816 let preview_page = if deserialized_active_page.0 == ActivePageId::default().0 {
817 Some(PreviewPage::default())
818 } else {
819 let component_str = deserialized_active_page.0;
820 let component_registry = components();
821 let all_components = component_registry.all();
822 let found_component = all_components.iter().find(|c| c.id().0 == component_str);
823
824 if let Some(component) = found_component {
825 Some(PreviewPage::Component(component.id().clone()))
826 } else {
827 Some(PreviewPage::default())
828 }
829 };
830
831 window.spawn(cx, async move |cx| {
832 let user_store = user_store.clone();
833 let language_registry = language_registry.clone();
834 let weak_workspace = workspace.clone();
835 cx.update(move |window, cx| {
836 Ok(cx.new(|cx| {
837 ComponentPreview::new(
838 weak_workspace,
839 language_registry,
840 user_store,
841 None,
842 preview_page,
843 window,
844 cx,
845 )
846 }))
847 })?
848 })
849 }
850
851 fn cleanup(
852 workspace_id: WorkspaceId,
853 alive_items: Vec<ItemId>,
854 _window: &mut Window,
855 cx: &mut App,
856 ) -> Task<gpui::Result<()>> {
857 cx.background_spawn(async move {
858 COMPONENT_PREVIEW_DB
859 .delete_unloaded_items(workspace_id, alive_items)
860 .await
861 })
862 }
863
864 fn serialize(
865 &mut self,
866 _workspace: &mut Workspace,
867 item_id: ItemId,
868 _closing: bool,
869 _window: &mut Window,
870 cx: &mut Context<Self>,
871 ) -> Option<Task<gpui::Result<()>>> {
872 let active_page = self.active_page_id(cx);
873 let workspace_id = self.workspace_id?;
874 Some(cx.background_spawn(async move {
875 COMPONENT_PREVIEW_DB
876 .save_active_page(item_id, workspace_id, active_page.0)
877 .await
878 }))
879 }
880
881 fn should_serialize(&self, event: &Self::Event) -> bool {
882 matches!(event, ItemEvent::UpdateTab)
883 }
884}
885
886// TODO: use language registry to allow rendering markdown
887#[derive(IntoElement)]
888pub struct ComponentPreviewPage {
889 // languages: Arc<LanguageRegistry>,
890 component: ComponentMetadata,
891}
892
893impl ComponentPreviewPage {
894 pub fn new(
895 component: ComponentMetadata,
896 // languages: Arc<LanguageRegistry>
897 ) -> Self {
898 Self {
899 // languages,
900 component,
901 }
902 }
903
904 fn render_header(&self, _: &Window, cx: &App) -> impl IntoElement {
905 v_flex()
906 .px_12()
907 .pt_16()
908 .pb_12()
909 .gap_6()
910 .bg(cx.theme().colors().surface_background)
911 .border_b_1()
912 .border_color(cx.theme().colors().border)
913 .child(
914 v_flex()
915 .gap_0p5()
916 .child(
917 Label::new(self.component.scope().to_string())
918 .size(LabelSize::Small)
919 .color(Color::Muted),
920 )
921 .child(
922 Headline::new(self.component.scopeless_name()).size(HeadlineSize::XLarge),
923 ),
924 )
925 .when_some(self.component.description(), |this, description| {
926 this.child(div().text_sm().child(description))
927 })
928 }
929
930 fn render_preview(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
931 v_flex()
932 .flex_1()
933 .px_12()
934 .py_6()
935 .bg(cx.theme().colors().editor_background)
936 .child(if let Some(preview) = self.component.preview() {
937 preview(window, cx).unwrap_or_else(|| {
938 div()
939 .child("Failed to load preview. This path should be unreachable")
940 .into_any_element()
941 })
942 } else {
943 div().child("No preview available").into_any_element()
944 })
945 }
946}
947
948impl RenderOnce for ComponentPreviewPage {
949 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
950 v_flex()
951 .id("component-preview-page")
952 .overflow_y_scroll()
953 .overflow_x_hidden()
954 .w_full()
955 .child(self.render_header(window, cx))
956 .child(self.render_preview(window, cx))
957 }
958}