1mod component_preview_example;
2mod persistence;
3
4use client::UserStore;
5use collections::HashMap;
6use component::{ComponentId, ComponentMetadata, ComponentStatus, components};
7use gpui::{
8 App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,
9};
10use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};
11use language::LanguageRegistry;
12use notifications::status_toast::{StatusToast, ToastIcon};
13use persistence::COMPONENT_PREVIEW_DB;
14use project::Project;
15use std::{iter::Iterator, ops::Range, sync::Arc};
16use ui::{ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Tooltip, prelude::*};
17use ui_input::InputField;
18use workspace::AppState;
19use workspace::{
20 Item, ItemId, SerializableItem, Workspace, WorkspaceId, delete_unloaded_items, item::ItemEvent,
21};
22
23#[allow(unused_imports)]
24pub use component_preview_example::*;
25
26pub fn init(app_state: Arc<AppState>, cx: &mut App) {
27 workspace::register_serializable_item::<ComponentPreview>(cx);
28
29 cx.observe_new(move |workspace: &mut Workspace, _window, cx| {
30 let app_state = app_state.clone();
31 let project = workspace.project().clone();
32 let weak_workspace = cx.entity().downgrade();
33
34 workspace.register_action(
35 move |workspace, _: &workspace::OpenComponentPreview, window, cx| {
36 let app_state = app_state.clone();
37
38 let language_registry = app_state.languages.clone();
39 let user_store = app_state.user_store.clone();
40
41 let component_preview = cx.new(|cx| {
42 ComponentPreview::new(
43 weak_workspace.clone(),
44 project.clone(),
45 language_registry,
46 user_store,
47 None,
48 None,
49 window,
50 cx,
51 )
52 .expect("Failed to create component preview")
53 });
54
55 workspace.add_item_to_active_pane(
56 Box::new(component_preview),
57 None,
58 true,
59 window,
60 cx,
61 )
62 },
63 );
64 })
65 .detach();
66}
67
68enum PreviewEntry {
69 AllComponents,
70 Separator,
71 Component(ComponentMetadata, Option<Vec<usize>>),
72 SectionHeader(SharedString),
73}
74
75impl From<ComponentMetadata> for PreviewEntry {
76 fn from(component: ComponentMetadata) -> Self {
77 PreviewEntry::Component(component, None)
78 }
79}
80
81impl From<SharedString> for PreviewEntry {
82 fn from(section_header: SharedString) -> Self {
83 PreviewEntry::SectionHeader(section_header)
84 }
85}
86
87#[derive(Default, Debug, Clone, PartialEq, Eq)]
88enum PreviewPage {
89 #[default]
90 AllComponents,
91 Component(ComponentId),
92}
93
94struct ComponentPreview {
95 active_page: PreviewPage,
96 reset_key: usize,
97 component_list: ListState,
98 entries: Vec<PreviewEntry>,
99 component_map: HashMap<ComponentId, ComponentMetadata>,
100 components: Vec<ComponentMetadata>,
101 cursor_index: usize,
102 filter_editor: Entity<InputField>,
103 filter_text: String,
104 focus_handle: FocusHandle,
105 language_registry: Arc<LanguageRegistry>,
106 nav_scroll_handle: UniformListScrollHandle,
107 project: Entity<Project>,
108 user_store: Entity<UserStore>,
109 workspace: WeakEntity<Workspace>,
110 workspace_id: Option<WorkspaceId>,
111 _view_scroll_handle: ScrollHandle,
112}
113
114impl ComponentPreview {
115 pub fn new(
116 workspace: WeakEntity<Workspace>,
117 project: Entity<Project>,
118 language_registry: Arc<LanguageRegistry>,
119 user_store: Entity<UserStore>,
120 selected_index: impl Into<Option<usize>>,
121 active_page: Option<PreviewPage>,
122 window: &mut Window,
123 cx: &mut Context<Self>,
124 ) -> anyhow::Result<Self> {
125 let component_registry = Arc::new(components());
126 let sorted_components = component_registry.sorted_components();
127 let selected_index = selected_index.into().unwrap_or(0);
128 let active_page = active_page.unwrap_or(PreviewPage::AllComponents);
129 let filter_editor = cx.new(|cx| InputField::new(window, cx, "Find components or usages…"));
130
131 let component_list = ListState::new(
132 sorted_components.len(),
133 gpui::ListAlignment::Top,
134 px(1500.0),
135 );
136
137 let mut component_preview = Self {
138 active_page,
139 reset_key: 0,
140 component_list,
141 entries: Vec::new(),
142 component_map: component_registry.component_map(),
143 components: sorted_components,
144 cursor_index: selected_index,
145 filter_editor,
146 filter_text: String::new(),
147 focus_handle: cx.focus_handle(),
148 language_registry,
149 nav_scroll_handle: UniformListScrollHandle::new(),
150 project,
151 user_store,
152 workspace,
153 workspace_id: None,
154 _view_scroll_handle: ScrollHandle::new(),
155 };
156
157 if component_preview.cursor_index > 0 {
158 component_preview.scroll_to_preview(component_preview.cursor_index, cx);
159 }
160
161 component_preview.update_component_list(cx);
162
163 let focus_handle = component_preview.filter_editor.read(cx).focus_handle(cx);
164 window.focus(&focus_handle, cx);
165
166 Ok(component_preview)
167 }
168
169 pub fn active_page_id(&self, _cx: &App) -> ActivePageId {
170 match &self.active_page {
171 PreviewPage::AllComponents => ActivePageId::default(),
172 PreviewPage::Component(component_id) => ActivePageId(component_id.0.to_string()),
173 }
174 }
175
176 fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
177 self.component_list.scroll_to_reveal_item(ix);
178 self.cursor_index = ix;
179 cx.notify();
180 }
181
182 fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) {
183 if self.active_page == page {
184 // Force the current preview page to render again
185 self.reset_key = self.reset_key.wrapping_add(1);
186 } else {
187 self.active_page = page;
188 cx.emit(ItemEvent::UpdateTab);
189 }
190 cx.notify();
191 }
192
193 fn filtered_components(&self) -> Vec<ComponentMetadata> {
194 if self.filter_text.is_empty() {
195 return self.components.clone();
196 }
197
198 let filter = self.filter_text.to_lowercase();
199 self.components
200 .iter()
201 .filter(|component| {
202 let component_name = component.name().to_lowercase();
203 let scope_name = component.scope().to_string().to_lowercase();
204 let description = component
205 .description()
206 .map(|d| d.to_lowercase())
207 .unwrap_or_default();
208
209 component_name.contains(&filter)
210 || scope_name.contains(&filter)
211 || description.contains(&filter)
212 })
213 .cloned()
214 .collect()
215 }
216
217 fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
218 use collections::HashMap;
219
220 let mut scope_groups: HashMap<
221 ComponentScope,
222 Vec<(ComponentMetadata, Option<Vec<usize>>)>,
223 > = HashMap::default();
224 let lowercase_filter = self.filter_text.to_lowercase();
225
226 for component in &self.components {
227 if self.filter_text.is_empty() {
228 scope_groups
229 .entry(component.scope())
230 .or_insert_with(Vec::new)
231 .push((component.clone(), None));
232 continue;
233 }
234
235 // let full_component_name = component.name();
236 let scopeless_name = component.scopeless_name();
237 let scope_name = component.scope().to_string();
238 let description = component.description().unwrap_or_default();
239
240 let lowercase_scopeless = scopeless_name.to_lowercase();
241 let lowercase_scope = scope_name.to_lowercase();
242 let lowercase_desc = description.to_lowercase();
243
244 if lowercase_scopeless.contains(&lowercase_filter)
245 && let Some(index) = lowercase_scopeless.find(&lowercase_filter)
246 {
247 let end = index + lowercase_filter.len();
248
249 if end <= scopeless_name.len() {
250 let mut positions = Vec::new();
251 for i in index..end {
252 if scopeless_name.is_char_boundary(i) {
253 positions.push(i);
254 }
255 }
256
257 if !positions.is_empty() {
258 scope_groups
259 .entry(component.scope())
260 .or_insert_with(Vec::new)
261 .push((component.clone(), Some(positions)));
262 continue;
263 }
264 }
265 }
266
267 if lowercase_scopeless.contains(&lowercase_filter)
268 || lowercase_scope.contains(&lowercase_filter)
269 || lowercase_desc.contains(&lowercase_filter)
270 {
271 scope_groups
272 .entry(component.scope())
273 .or_insert_with(Vec::new)
274 .push((component.clone(), None));
275 }
276 }
277
278 // Sort the components in each group
279 for components in scope_groups.values_mut() {
280 components.sort_by_key(|(c, _)| c.sort_name());
281 }
282
283 let mut entries = Vec::new();
284
285 // Always show all components first
286 entries.push(PreviewEntry::AllComponents);
287
288 let mut scopes: Vec<_> = scope_groups
289 .keys()
290 .filter(|scope| !matches!(**scope, ComponentScope::None))
291 .cloned()
292 .collect();
293
294 scopes.sort_by_key(|s| s.to_string());
295
296 for scope in scopes {
297 if let Some(components) = scope_groups.remove(&scope)
298 && !components.is_empty()
299 {
300 entries.push(PreviewEntry::Separator);
301 entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
302
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 // Add uncategorized components last
313 if let Some(components) = scope_groups.get(&ComponentScope::None)
314 && !components.is_empty()
315 {
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 entries
327 }
328
329 fn update_component_list(&mut self, cx: &mut Context<Self>) {
330 let entries = self.scope_ordered_entries();
331 let new_len = entries.len();
332
333 if new_len > 0 {
334 self.nav_scroll_handle
335 .scroll_to_item(0, ScrollStrategy::Top);
336 }
337
338 let filtered_components = self.filtered_components();
339
340 if !self.filter_text.is_empty()
341 && !matches!(self.active_page, PreviewPage::AllComponents)
342 && let PreviewPage::Component(ref component_id) = self.active_page
343 {
344 let component_still_visible = filtered_components
345 .iter()
346 .any(|component| component.id() == *component_id);
347
348 if !component_still_visible {
349 if !filtered_components.is_empty() {
350 let first_component = &filtered_components[0];
351 self.set_active_page(PreviewPage::Component(first_component.id()), cx);
352 } else {
353 self.set_active_page(PreviewPage::AllComponents, cx);
354 }
355 }
356 }
357
358 self.component_list = ListState::new(new_len, gpui::ListAlignment::Top, px(1500.0));
359 self.entries = entries;
360
361 cx.emit(ItemEvent::UpdateTab);
362 }
363
364 fn render_sidebar_entry(
365 &self,
366 ix: usize,
367 entry: &PreviewEntry,
368 cx: &Context<Self>,
369 ) -> impl IntoElement + use<> {
370 match entry {
371 PreviewEntry::Component(component_metadata, highlight_positions) => {
372 let id = component_metadata.id();
373 let selected = self.active_page == PreviewPage::Component(id.clone());
374 let name = component_metadata.scopeless_name();
375
376 ListItem::new(ix)
377 .child(if let Some(_positions) = highlight_positions {
378 let name_lower = name.to_lowercase();
379 let filter_lower = self.filter_text.to_lowercase();
380 let valid_positions = if let Some(start) = name_lower.find(&filter_lower) {
381 let end = start + filter_lower.len();
382 (start..end).collect()
383 } else {
384 Vec::new()
385 };
386 if valid_positions.is_empty() {
387 Label::new(name).into_any_element()
388 } else {
389 HighlightedLabel::new(name, valid_positions).into_any_element()
390 }
391 } else {
392 Label::new(name).into_any_element()
393 })
394 .selectable(true)
395 .toggle_state(selected)
396 .inset(true)
397 .on_click(cx.listener(move |this, _, _, cx| {
398 let id = id.clone();
399 this.set_active_page(PreviewPage::Component(id), cx);
400 }))
401 .into_any_element()
402 }
403 PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
404 .inset(true)
405 .into_any_element(),
406 PreviewEntry::AllComponents => {
407 let selected = self.active_page == PreviewPage::AllComponents;
408
409 ListItem::new(ix)
410 .child(Label::new("All Components"))
411 .selectable(true)
412 .toggle_state(selected)
413 .inset(true)
414 .on_click(cx.listener(move |this, _, _, cx| {
415 this.set_active_page(PreviewPage::AllComponents, cx);
416 }))
417 .into_any_element()
418 }
419 PreviewEntry::Separator => ListItem::new(ix)
420 .disabled(true)
421 .child(div().w_full().py_2().child(Divider::horizontal()))
422 .into_any_element(),
423 }
424 }
425
426 fn render_scope_header(
427 &self,
428 _ix: usize,
429 title: SharedString,
430 _window: &Window,
431 _cx: &App,
432 ) -> impl IntoElement {
433 h_flex()
434 .w_full()
435 .h_10()
436 .child(Headline::new(title).size(HeadlineSize::XSmall))
437 .child(Divider::horizontal())
438 }
439
440 fn render_preview(
441 &self,
442 component: &ComponentMetadata,
443 window: &mut Window,
444 cx: &mut App,
445 ) -> impl IntoElement {
446 let name = component.scopeless_name();
447 let scope = component.scope();
448
449 let description = component.description();
450
451 // Build the content container
452 let mut preview_container = v_flex().py_2().child(
453 v_flex()
454 .border_1()
455 .border_color(cx.theme().colors().border)
456 .rounded_sm()
457 .w_full()
458 .gap_4()
459 .py_4()
460 .px_6()
461 .flex_none()
462 .child(
463 v_flex()
464 .gap_1()
465 .child(
466 h_flex()
467 .gap_1()
468 .text_xl()
469 .child(div().child(name))
470 .when(!matches!(scope, ComponentScope::None), |this| {
471 this.child(div().opacity(0.5).child(format!("({})", scope)))
472 }),
473 )
474 .when_some(description, |this, description| {
475 this.child(
476 div()
477 .text_ui_sm(cx)
478 .text_color(cx.theme().colors().text_muted)
479 .max_w(px(600.0))
480 .child(description),
481 )
482 }),
483 ),
484 );
485
486 if let Some(preview) = component.preview() {
487 preview_container = preview_container.children(preview(window, cx));
488 }
489
490 preview_container.into_any_element()
491 }
492
493 fn render_all_components(&self, cx: &Context<Self>) -> impl IntoElement {
494 v_flex()
495 .id("component-list")
496 .px_8()
497 .pt_4()
498 .size_full()
499 .child(
500 if self.filtered_components().is_empty() && !self.filter_text.is_empty() {
501 div()
502 .size_full()
503 .items_center()
504 .justify_center()
505 .text_color(cx.theme().colors().text_muted)
506 .child(format!("No components matching '{}'.", self.filter_text))
507 .into_any_element()
508 } else {
509 list(
510 self.component_list.clone(),
511 cx.processor(|this, ix, window, cx| {
512 if ix >= this.entries.len() {
513 return div().w_full().h_0().into_any_element();
514 }
515
516 let entry = &this.entries[ix];
517
518 match entry {
519 PreviewEntry::Component(component, _) => this
520 .render_preview(component, window, cx)
521 .into_any_element(),
522 PreviewEntry::SectionHeader(shared_string) => this
523 .render_scope_header(ix, shared_string.clone(), window, cx)
524 .into_any_element(),
525 PreviewEntry::AllComponents => {
526 div().w_full().h_0().into_any_element()
527 }
528 PreviewEntry::Separator => div().w_full().h_0().into_any_element(),
529 }
530 }),
531 )
532 .flex_grow()
533 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
534 .into_any_element()
535 },
536 )
537 }
538
539 fn render_component_page(
540 &mut self,
541 component_id: &ComponentId,
542 _window: &mut Window,
543 _cx: &mut Context<Self>,
544 ) -> impl IntoElement {
545 let component = self.component_map.get(component_id);
546
547 if let Some(component) = component {
548 v_flex()
549 .id("render-component-page")
550 .flex_1()
551 .child(ComponentPreviewPage::new(component.clone(), self.reset_key))
552 .into_any_element()
553 } else {
554 v_flex()
555 .size_full()
556 .items_center()
557 .justify_center()
558 .child("Component not found")
559 .into_any_element()
560 }
561 }
562
563 fn test_status_toast(&self, cx: &mut Context<Self>) {
564 if let Some(workspace) = self.workspace.upgrade() {
565 workspace.update(cx, |workspace, cx| {
566 let status_toast =
567 StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
568 this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
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.editor().read(cx).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, mut f: impl 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 COMPONENT_PREVIEW_DB.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 &COMPONENT_PREVIEW_DB,
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 Some(cx.background_spawn(async move {
868 COMPONENT_PREVIEW_DB
869 .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}