component_preview.rs

  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}