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