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