component_preview.rs

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