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