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, ToastIcon};
12use persistence::COMPONENT_PREVIEW_DB;
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(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
565 .action("Open Pull Request", |_, cx| {
566 cx.open_url("https://github.com/")
567 })
568 });
569 workspace.toggle_status_toast(status_toast, cx)
570 });
571 }
572 }
573}
574
575impl Render for ComponentPreview {
576 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
577 // TODO: move this into the struct
578 let current_filter = self.filter_editor.update(cx, |input, cx| {
579 if input.is_empty(cx) {
580 String::new()
581 } else {
582 input.text(cx)
583 }
584 });
585
586 if current_filter != self.filter_text {
587 self.filter_text = current_filter;
588 self.update_component_list(cx);
589 }
590 let sidebar_entries = self.scope_ordered_entries();
591 let active_page = self.active_page.clone();
592
593 h_flex()
594 .id("component-preview")
595 .key_context("ComponentPreview")
596 .items_start()
597 .overflow_hidden()
598 .size_full()
599 .track_focus(&self.focus_handle)
600 .bg(cx.theme().colors().editor_background)
601 .child(
602 v_flex()
603 .h_full()
604 .border_r_1()
605 .border_color(cx.theme().colors().border)
606 .child(
607 gpui::uniform_list(
608 "component-nav",
609 sidebar_entries.len(),
610 cx.processor(move |this, range: Range<usize>, _window, cx| {
611 range
612 .filter_map(|ix| {
613 if ix < sidebar_entries.len() {
614 Some(this.render_sidebar_entry(
615 ix,
616 &sidebar_entries[ix],
617 cx,
618 ))
619 } else {
620 None
621 }
622 })
623 .collect()
624 }),
625 )
626 .track_scroll(&self.nav_scroll_handle)
627 .p_2p5()
628 .w(px(231.)) // Matches perfectly with the size of the "Component Preview" tab, if that's the first one in the pane
629 .h_full()
630 .flex_1(),
631 )
632 .child(
633 div()
634 .w_full()
635 .p_2p5()
636 .border_t_1()
637 .border_color(cx.theme().colors().border)
638 .child(
639 Button::new("toast-test", "Launch Toast")
640 .full_width()
641 .on_click(cx.listener({
642 move |this, _, _window, cx| {
643 this.test_status_toast(cx);
644 cx.notify();
645 }
646 })),
647 ),
648 ),
649 )
650 .child(
651 v_flex()
652 .flex_1()
653 .size_full()
654 .child(
655 div()
656 .p_2()
657 .w_full()
658 .border_b_1()
659 .border_color(cx.theme().colors().border)
660 .child(self.filter_editor.clone()),
661 )
662 .child(
663 div().id("content-area").flex_1().overflow_y_scroll().child(
664 match active_page {
665 PreviewPage::AllComponents => {
666 self.render_all_components(cx).into_any_element()
667 }
668 PreviewPage::Component(id) => self
669 .render_component_page(&id, window, cx)
670 .into_any_element(),
671 },
672 ),
673 ),
674 )
675 }
676}
677
678impl EventEmitter<ItemEvent> for ComponentPreview {}
679
680impl Focusable for ComponentPreview {
681 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
682 self.focus_handle.clone()
683 }
684}
685
686#[derive(Debug, Clone, PartialEq, Eq, Hash)]
687pub struct ActivePageId(pub String);
688
689impl Default for ActivePageId {
690 fn default() -> Self {
691 ActivePageId("AllComponents".to_string())
692 }
693}
694
695impl From<ComponentId> for ActivePageId {
696 fn from(id: ComponentId) -> Self {
697 Self(id.0.to_string())
698 }
699}
700
701impl Item for ComponentPreview {
702 type Event = ItemEvent;
703
704 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
705 "Component Preview".into()
706 }
707
708 fn telemetry_event_text(&self) -> Option<&'static str> {
709 None
710 }
711
712 fn show_toolbar(&self) -> bool {
713 false
714 }
715
716 fn can_split(&self) -> bool {
717 true
718 }
719
720 fn clone_on_split(
721 &self,
722 _workspace_id: Option<WorkspaceId>,
723 window: &mut Window,
724 cx: &mut Context<Self>,
725 ) -> Task<Option<gpui::Entity<Self>>>
726 where
727 Self: Sized,
728 {
729 let language_registry = self.language_registry.clone();
730 let user_store = self.user_store.clone();
731 let weak_workspace = self.workspace.clone();
732 let project = self.project.clone();
733 let selected_index = self.cursor_index;
734 let active_page = self.active_page.clone();
735
736 let self_result = Self::new(
737 weak_workspace,
738 project,
739 language_registry,
740 user_store,
741 selected_index,
742 Some(active_page),
743 window,
744 cx,
745 );
746
747 Task::ready(match self_result {
748 Ok(preview) => Some(cx.new(|_cx| preview)),
749 Err(e) => {
750 log::error!("Failed to clone component preview: {}", e);
751 None
752 }
753 })
754 }
755
756 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
757 f(*event)
758 }
759
760 fn added_to_workspace(
761 &mut self,
762 workspace: &mut Workspace,
763 window: &mut Window,
764 cx: &mut Context<Self>,
765 ) {
766 self.workspace_id = workspace.database_id();
767
768 let focus_handle = self.filter_editor.read(cx).focus_handle(cx);
769 window.focus(&focus_handle, cx);
770 }
771}
772
773impl SerializableItem for ComponentPreview {
774 fn serialized_item_kind() -> &'static str {
775 "ComponentPreview"
776 }
777
778 fn deserialize(
779 project: Entity<Project>,
780 workspace: WeakEntity<Workspace>,
781 workspace_id: WorkspaceId,
782 item_id: ItemId,
783 window: &mut Window,
784 cx: &mut App,
785 ) -> Task<anyhow::Result<Entity<Self>>> {
786 let deserialized_active_page =
787 match COMPONENT_PREVIEW_DB.get_active_page(item_id, workspace_id) {
788 Ok(page) => {
789 if let Some(page) = page {
790 ActivePageId(page)
791 } else {
792 ActivePageId::default()
793 }
794 }
795 Err(_) => ActivePageId::default(),
796 };
797
798 let user_store = project.read(cx).user_store();
799 let language_registry = project.read(cx).languages().clone();
800 let preview_page = if deserialized_active_page.0 == ActivePageId::default().0 {
801 Some(PreviewPage::default())
802 } else {
803 let component_str = deserialized_active_page.0;
804 let component_registry = components();
805 let all_components = component_registry.components();
806 let found_component = all_components.iter().find(|c| c.id().0 == component_str);
807
808 if let Some(component) = found_component {
809 Some(PreviewPage::Component(component.id()))
810 } else {
811 Some(PreviewPage::default())
812 }
813 };
814
815 window.spawn(cx, async move |cx| {
816 let user_store = user_store.clone();
817 let language_registry = language_registry.clone();
818 let weak_workspace = workspace.clone();
819 let project = project.clone();
820 cx.update(move |window, cx| {
821 Ok(cx.new(|cx| {
822 ComponentPreview::new(
823 weak_workspace,
824 project,
825 language_registry,
826 user_store,
827 None,
828 preview_page,
829 window,
830 cx,
831 )
832 .expect("Failed to create component preview")
833 }))
834 })?
835 })
836 }
837
838 fn cleanup(
839 workspace_id: WorkspaceId,
840 alive_items: Vec<ItemId>,
841 _window: &mut Window,
842 cx: &mut App,
843 ) -> Task<anyhow::Result<()>> {
844 delete_unloaded_items(
845 alive_items,
846 workspace_id,
847 "component_previews",
848 &COMPONENT_PREVIEW_DB,
849 cx,
850 )
851 }
852
853 fn serialize(
854 &mut self,
855 _workspace: &mut Workspace,
856 item_id: ItemId,
857 _closing: bool,
858 _window: &mut Window,
859 cx: &mut Context<Self>,
860 ) -> Option<Task<anyhow::Result<()>>> {
861 let active_page = self.active_page_id(cx);
862 let workspace_id = self.workspace_id?;
863 Some(cx.background_spawn(async move {
864 COMPONENT_PREVIEW_DB
865 .save_active_page(item_id, workspace_id, active_page.0)
866 .await
867 }))
868 }
869
870 fn should_serialize(&self, event: &Self::Event) -> bool {
871 matches!(event, ItemEvent::UpdateTab)
872 }
873}
874
875// TODO: use language registry to allow rendering markdown
876#[derive(IntoElement)]
877pub struct ComponentPreviewPage {
878 // languages: Arc<LanguageRegistry>,
879 component: ComponentMetadata,
880 reset_key: usize,
881}
882
883impl ComponentPreviewPage {
884 pub fn new(
885 component: ComponentMetadata,
886 reset_key: usize,
887 // languages: Arc<LanguageRegistry>
888 ) -> Self {
889 Self {
890 // languages,
891 component,
892 reset_key,
893 }
894 }
895
896 /// Renders the component status when it would be useful
897 ///
898 /// Doesn't render if the component is `ComponentStatus::Live`
899 /// as that is the default state
900 fn render_component_status(&self, cx: &App) -> Option<impl IntoElement> {
901 let status = self.component.status();
902 let status_description = status.description().to_string();
903
904 let color = match status {
905 ComponentStatus::Deprecated => Color::Error,
906 ComponentStatus::EngineeringReady => Color::Info,
907 ComponentStatus::Live => Color::Success,
908 ComponentStatus::WorkInProgress => Color::Warning,
909 };
910
911 if status != ComponentStatus::Live {
912 Some(
913 ButtonLike::new("component_status")
914 .child(
915 div()
916 .px_1p5()
917 .rounded_sm()
918 .bg(color.color(cx).alpha(0.12))
919 .child(
920 Label::new(status.to_string())
921 .size(LabelSize::Small)
922 .color(color),
923 ),
924 )
925 .tooltip(Tooltip::text(status_description))
926 .disabled(true),
927 )
928 } else {
929 None
930 }
931 }
932
933 fn render_header(&self, _: &Window, cx: &App) -> impl IntoElement {
934 v_flex()
935 .min_w_0()
936 .w_full()
937 .p_12()
938 .gap_6()
939 .bg(cx.theme().colors().surface_background)
940 .border_b_1()
941 .border_color(cx.theme().colors().border)
942 .child(
943 v_flex()
944 .gap_1()
945 .child(
946 Label::new(self.component.scope().to_string())
947 .size(LabelSize::Small)
948 .color(Color::Muted),
949 )
950 .child(
951 h_flex()
952 .gap_2()
953 .child(
954 Headline::new(self.component.scopeless_name())
955 .size(HeadlineSize::XLarge),
956 )
957 .children(self.render_component_status(cx)),
958 ),
959 )
960 .when_some(self.component.description(), |this, description| {
961 this.child(Label::new(description).size(LabelSize::Small))
962 })
963 }
964
965 fn render_preview(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
966 let content = if let Some(preview) = self.component.preview() {
967 // Fall back to component preview
968 preview(window, cx).unwrap_or_else(|| {
969 div()
970 .child("Failed to load preview. This path should be unreachable")
971 .into_any_element()
972 })
973 } else {
974 div().child("No preview available").into_any_element()
975 };
976
977 v_flex()
978 .id(("component-preview", self.reset_key))
979 .size_full()
980 .flex_1()
981 .px_12()
982 .py_6()
983 .bg(cx.theme().colors().editor_background)
984 .child(content)
985 }
986}
987
988impl RenderOnce for ComponentPreviewPage {
989 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
990 v_flex()
991 .size_full()
992 .flex_1()
993 .overflow_x_hidden()
994 .child(self.render_header(window, cx))
995 .child(self.render_preview(window, cx))
996 }
997}