component_preview.rs

  1//! # Component Preview
  2//!
  3//! A view for exploring Zed components.
  4
  5use std::iter::Iterator;
  6use std::sync::Arc;
  7
  8use client::UserStore;
  9use component::{ComponentId, ComponentMetadata, components};
 10use gpui::{
 11    App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,
 12    uniform_list,
 13};
 14
 15use collections::HashMap;
 16
 17use gpui::{ListState, ScrollHandle, UniformListScrollHandle};
 18use languages::LanguageRegistry;
 19use notifications::status_toast::{StatusToast, ToastIcon};
 20use project::Project;
 21use ui::{Divider, ListItem, ListSubHeader, prelude::*};
 22
 23use workspace::{AppState, ItemId, SerializableItem};
 24use workspace::{Item, Workspace, WorkspaceId, item::ItemEvent};
 25
 26pub fn init(app_state: Arc<AppState>, cx: &mut App) {
 27    let app_state = app_state.clone();
 28
 29    cx.observe_new(move |workspace: &mut Workspace, _, cx| {
 30        let app_state = app_state.clone();
 31        let weak_workspace = cx.entity().downgrade();
 32
 33        workspace.register_action(
 34            move |workspace, _: &workspace::OpenComponentPreview, window, cx| {
 35                let app_state = app_state.clone();
 36
 37                let language_registry = app_state.languages.clone();
 38                let user_store = app_state.user_store.clone();
 39
 40                let component_preview = cx.new(|cx| {
 41                    ComponentPreview::new(
 42                        weak_workspace.clone(),
 43                        language_registry,
 44                        user_store,
 45                        None,
 46                        None,
 47                        cx,
 48                    )
 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),
 68    SectionHeader(SharedString),
 69}
 70
 71impl From<ComponentMetadata> for PreviewEntry {
 72    fn from(component: ComponentMetadata) -> Self {
 73        PreviewEntry::Component(component)
 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)]
 84enum PreviewPage {
 85    #[default]
 86    AllComponents,
 87    Component(ComponentId),
 88}
 89
 90struct ComponentPreview {
 91    focus_handle: FocusHandle,
 92    _view_scroll_handle: ScrollHandle,
 93    nav_scroll_handle: UniformListScrollHandle,
 94    component_map: HashMap<ComponentId, ComponentMetadata>,
 95    active_page: PreviewPage,
 96    components: Vec<ComponentMetadata>,
 97    component_list: ListState,
 98    cursor_index: usize,
 99    language_registry: Arc<LanguageRegistry>,
100    workspace: WeakEntity<Workspace>,
101    user_store: Entity<UserStore>,
102}
103
104impl ComponentPreview {
105    pub fn new(
106        workspace: WeakEntity<Workspace>,
107        language_registry: Arc<LanguageRegistry>,
108        user_store: Entity<UserStore>,
109        selected_index: impl Into<Option<usize>>,
110        active_page: Option<PreviewPage>,
111        cx: &mut Context<Self>,
112    ) -> Self {
113        let sorted_components = components().all_sorted();
114        let selected_index = selected_index.into().unwrap_or(0);
115        let active_page = active_page.unwrap_or(PreviewPage::AllComponents);
116
117        let component_list = ListState::new(
118            sorted_components.len(),
119            gpui::ListAlignment::Top,
120            px(1500.0),
121            {
122                let this = cx.entity().downgrade();
123                move |ix, window: &mut Window, cx: &mut App| {
124                    this.update(cx, |this, cx| {
125                        let component = this.get_component(ix);
126                        this.render_preview(&component, window, cx)
127                            .into_any_element()
128                    })
129                    .unwrap()
130                }
131            },
132        );
133
134        let mut component_preview = Self {
135            focus_handle: cx.focus_handle(),
136            _view_scroll_handle: ScrollHandle::new(),
137            nav_scroll_handle: UniformListScrollHandle::new(),
138            language_registry,
139            user_store,
140            workspace,
141            active_page,
142            component_map: components().0,
143            components: sorted_components,
144            component_list,
145            cursor_index: selected_index,
146        };
147
148        if component_preview.cursor_index > 0 {
149            component_preview.scroll_to_preview(component_preview.cursor_index, cx);
150        }
151
152        component_preview.update_component_list(cx);
153
154        component_preview
155    }
156
157    fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
158        self.component_list.scroll_to_reveal_item(ix);
159        self.cursor_index = ix;
160        cx.notify();
161    }
162
163    fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) {
164        self.active_page = page;
165        cx.notify();
166    }
167
168    fn get_component(&self, ix: usize) -> ComponentMetadata {
169        self.components[ix].clone()
170    }
171
172    fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
173        use std::collections::HashMap;
174
175        let mut scope_groups: HashMap<ComponentScope, Vec<ComponentMetadata>> = HashMap::default();
176
177        for component in &self.components {
178            scope_groups
179                .entry(component.scope())
180                .or_insert_with(Vec::new)
181                .push(component.clone());
182        }
183
184        for components in scope_groups.values_mut() {
185            components.sort_by_key(|c| c.name().to_lowercase());
186        }
187
188        let mut entries = Vec::new();
189
190        // Always show all components first
191        entries.push(PreviewEntry::AllComponents);
192        entries.push(PreviewEntry::Separator);
193
194        let mut scopes: Vec<_> = scope_groups
195            .keys()
196            .filter(|scope| !matches!(**scope, ComponentScope::None))
197            .cloned()
198            .collect();
199
200        scopes.sort_by_key(|s| s.to_string());
201
202        for scope in scopes {
203            if let Some(components) = scope_groups.remove(&scope) {
204                if !components.is_empty() {
205                    entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
206                    let mut sorted_components = components;
207                    sorted_components.sort_by_key(|component| component.sort_name());
208
209                    for component in sorted_components {
210                        entries.push(PreviewEntry::Component(component));
211                    }
212                }
213            }
214        }
215
216        // Add uncategorized components last
217        if let Some(components) = scope_groups.get(&ComponentScope::None) {
218            if !components.is_empty() {
219                entries.push(PreviewEntry::Separator);
220                entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
221                let mut sorted_components = components.clone();
222                sorted_components.sort_by_key(|c| c.sort_name());
223
224                for component in sorted_components {
225                    entries.push(PreviewEntry::Component(component.clone()));
226                }
227            }
228        }
229
230        entries
231    }
232
233    fn render_sidebar_entry(
234        &self,
235        ix: usize,
236        entry: &PreviewEntry,
237        cx: &Context<Self>,
238    ) -> impl IntoElement + use<> {
239        match entry {
240            PreviewEntry::Component(component_metadata) => {
241                let id = component_metadata.id();
242                let selected = self.active_page == PreviewPage::Component(id.clone());
243                ListItem::new(ix)
244                    .child(
245                        Label::new(component_metadata.scopeless_name().clone())
246                            .color(Color::Default),
247                    )
248                    .selectable(true)
249                    .toggle_state(selected)
250                    .inset(true)
251                    .on_click(cx.listener(move |this, _, _, cx| {
252                        let id = id.clone();
253                        this.set_active_page(PreviewPage::Component(id), cx);
254                    }))
255                    .into_any_element()
256            }
257            PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
258                .inset(true)
259                .into_any_element(),
260            PreviewEntry::AllComponents => {
261                let selected = self.active_page == PreviewPage::AllComponents;
262
263                ListItem::new(ix)
264                    .child(Label::new("All Components").color(Color::Default))
265                    .selectable(true)
266                    .toggle_state(selected)
267                    .inset(true)
268                    .on_click(cx.listener(move |this, _, _, cx| {
269                        this.set_active_page(PreviewPage::AllComponents, cx);
270                    }))
271                    .into_any_element()
272            }
273            PreviewEntry::Separator => ListItem::new(ix)
274                .child(
275                    h_flex()
276                        .occlude()
277                        .pt_3()
278                        .child(Divider::horizontal_dashed()),
279                )
280                .into_any_element(),
281        }
282    }
283
284    fn update_component_list(&mut self, cx: &mut Context<Self>) {
285        let new_len = self.scope_ordered_entries().len();
286        let entries = self.scope_ordered_entries();
287        let weak_entity = cx.entity().downgrade();
288
289        let new_list = ListState::new(
290            new_len,
291            gpui::ListAlignment::Top,
292            px(1500.0),
293            move |ix, window, cx| {
294                let entry = &entries[ix];
295
296                weak_entity
297                    .update(cx, |this, cx| match entry {
298                        PreviewEntry::Component(component) => this
299                            .render_preview(component, window, cx)
300                            .into_any_element(),
301                        PreviewEntry::SectionHeader(shared_string) => this
302                            .render_scope_header(ix, shared_string.clone(), window, cx)
303                            .into_any_element(),
304                        PreviewEntry::AllComponents => div().w_full().h_0().into_any_element(),
305                        PreviewEntry::Separator => div().w_full().h_0().into_any_element(),
306                    })
307                    .unwrap()
308            },
309        );
310
311        self.component_list = new_list;
312    }
313
314    fn render_scope_header(
315        &self,
316        _ix: usize,
317        title: SharedString,
318        _window: &Window,
319        _cx: &App,
320    ) -> impl IntoElement {
321        h_flex()
322            .w_full()
323            .h_10()
324            .items_center()
325            .child(Headline::new(title).size(HeadlineSize::XSmall))
326            .child(Divider::horizontal())
327    }
328
329    fn render_preview(
330        &self,
331        component: &ComponentMetadata,
332        window: &mut Window,
333        cx: &mut App,
334    ) -> impl IntoElement {
335        let name = component.scopeless_name();
336        let scope = component.scope();
337
338        let description = component.description();
339
340        v_flex()
341            .py_2()
342            .child(
343                v_flex()
344                    .border_1()
345                    .border_color(cx.theme().colors().border)
346                    .rounded_sm()
347                    .w_full()
348                    .gap_4()
349                    .py_4()
350                    .px_6()
351                    .flex_none()
352                    .child(
353                        v_flex()
354                            .gap_1()
355                            .child(
356                                h_flex().gap_1().text_xl().child(div().child(name)).when(
357                                    !matches!(scope, ComponentScope::None),
358                                    |this| {
359                                        this.child(div().opacity(0.5).child(format!("({})", scope)))
360                                    },
361                                ),
362                            )
363                            .when_some(description, |this, description| {
364                                this.child(
365                                    div()
366                                        .text_ui_sm(cx)
367                                        .text_color(cx.theme().colors().text_muted)
368                                        .max_w(px(600.0))
369                                        .child(description),
370                                )
371                            }),
372                    )
373                    .when_some(component.preview(), |this, preview| {
374                        this.children(preview(window, cx))
375                    }),
376            )
377            .into_any_element()
378    }
379
380    fn render_all_components(&self) -> impl IntoElement {
381        v_flex()
382            .id("component-list")
383            .px_8()
384            .pt_4()
385            .size_full()
386            .child(
387                list(self.component_list.clone())
388                    .flex_grow()
389                    .with_sizing_behavior(gpui::ListSizingBehavior::Auto),
390            )
391    }
392
393    fn render_component_page(
394        &mut self,
395        component_id: &ComponentId,
396        _window: &mut Window,
397        _cx: &mut Context<Self>,
398    ) -> impl IntoElement {
399        let component = self.component_map.get(&component_id);
400
401        if let Some(component) = component {
402            v_flex()
403                .id("render-component-page")
404                .size_full()
405                .child(ComponentPreviewPage::new(component.clone()))
406                .into_any_element()
407        } else {
408            v_flex()
409                .size_full()
410                .items_center()
411                .justify_center()
412                .child("Component not found")
413                .into_any_element()
414        }
415    }
416
417    fn test_status_toast(&self, cx: &mut Context<Self>) {
418        if let Some(workspace) = self.workspace.upgrade() {
419            workspace.update(cx, |workspace, cx| {
420                let status_toast =
421                    StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
422                        this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
423                            .action("Open Pull Request", |_, cx| {
424                                cx.open_url("https://github.com/")
425                            })
426                    });
427                workspace.toggle_status_toast(status_toast, cx)
428            });
429        }
430    }
431}
432
433impl Render for ComponentPreview {
434    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
435        let sidebar_entries = self.scope_ordered_entries();
436        let active_page = self.active_page.clone();
437
438        h_flex()
439            .id("component-preview")
440            .key_context("ComponentPreview")
441            .items_start()
442            .overflow_hidden()
443            .size_full()
444            .track_focus(&self.focus_handle)
445            .bg(cx.theme().colors().editor_background)
446            .child(
447                v_flex()
448                    .border_r_1()
449                    .border_color(cx.theme().colors().border)
450                    .h_full()
451                    .child(
452                        uniform_list(
453                            cx.entity().clone(),
454                            "component-nav",
455                            sidebar_entries.len(),
456                            move |this, range, _window, cx| {
457                                range
458                                    .map(|ix| {
459                                        this.render_sidebar_entry(ix, &sidebar_entries[ix], cx)
460                                    })
461                                    .collect()
462                            },
463                        )
464                        .track_scroll(self.nav_scroll_handle.clone())
465                        .pt_4()
466                        .px_4()
467                        .w(px(240.))
468                        .h_full()
469                        .flex_1(),
470                    )
471                    .child(
472                        div().w_full().pb_4().child(
473                            Button::new("toast-test", "Launch Toast")
474                                .on_click(cx.listener({
475                                    move |this, _, _window, cx| {
476                                        this.test_status_toast(cx);
477                                        cx.notify();
478                                    }
479                                }))
480                                .full_width(),
481                        ),
482                    ),
483            )
484            .child(match active_page {
485                PreviewPage::AllComponents => self.render_all_components().into_any_element(),
486                PreviewPage::Component(id) => self
487                    .render_component_page(&id, window, cx)
488                    .into_any_element(),
489            })
490    }
491}
492
493impl EventEmitter<ItemEvent> for ComponentPreview {}
494
495impl Focusable for ComponentPreview {
496    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
497        self.focus_handle.clone()
498    }
499}
500
501impl Item for ComponentPreview {
502    type Event = ItemEvent;
503
504    fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option<SharedString> {
505        Some("Component Preview".into())
506    }
507
508    fn telemetry_event_text(&self) -> Option<&'static str> {
509        None
510    }
511
512    fn show_toolbar(&self) -> bool {
513        false
514    }
515
516    fn clone_on_split(
517        &self,
518        _workspace_id: Option<WorkspaceId>,
519        _window: &mut Window,
520        cx: &mut Context<Self>,
521    ) -> Option<gpui::Entity<Self>>
522    where
523        Self: Sized,
524    {
525        let language_registry = self.language_registry.clone();
526        let user_store = self.user_store.clone();
527        let weak_workspace = self.workspace.clone();
528        let selected_index = self.cursor_index;
529        let active_page = self.active_page.clone();
530
531        Some(cx.new(|cx| {
532            Self::new(
533                weak_workspace,
534                language_registry,
535                user_store,
536                selected_index,
537                Some(active_page),
538                cx,
539            )
540        }))
541    }
542
543    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
544        f(*event)
545    }
546}
547
548impl SerializableItem for ComponentPreview {
549    fn serialized_item_kind() -> &'static str {
550        "ComponentPreview"
551    }
552
553    fn deserialize(
554        project: Entity<Project>,
555        workspace: WeakEntity<Workspace>,
556        _workspace_id: WorkspaceId,
557        _item_id: ItemId,
558        window: &mut Window,
559        cx: &mut App,
560    ) -> Task<gpui::Result<Entity<Self>>> {
561        let user_store = project.read(cx).user_store().clone();
562        let language_registry = project.read(cx).languages().clone();
563
564        window.spawn(cx, async move |cx| {
565            let user_store = user_store.clone();
566            let language_registry = language_registry.clone();
567            let weak_workspace = workspace.clone();
568            cx.update(|_, cx| {
569                Ok(cx.new(|cx| {
570                    ComponentPreview::new(
571                        weak_workspace,
572                        language_registry,
573                        user_store,
574                        None,
575                        None,
576                        cx,
577                    )
578                }))
579            })?
580        })
581    }
582
583    fn cleanup(
584        _workspace_id: WorkspaceId,
585        _alive_items: Vec<ItemId>,
586        _window: &mut Window,
587        _cx: &mut App,
588    ) -> Task<gpui::Result<()>> {
589        Task::ready(Ok(()))
590        // window.spawn(cx, |_| {
591        // ...
592        // })
593    }
594
595    fn serialize(
596        &mut self,
597        _workspace: &mut Workspace,
598        _item_id: ItemId,
599        _closing: bool,
600        _window: &mut Window,
601        _cx: &mut Context<Self>,
602    ) -> Option<Task<gpui::Result<()>>> {
603        // TODO: Serialize the active index so we can re-open to the same place
604        None
605    }
606
607    fn should_serialize(&self, _event: &Self::Event) -> bool {
608        false
609    }
610}
611
612#[derive(IntoElement)]
613pub struct ComponentPreviewPage {
614    // languages: Arc<LanguageRegistry>,
615    component: ComponentMetadata,
616}
617
618impl ComponentPreviewPage {
619    pub fn new(
620        component: ComponentMetadata,
621        // languages: Arc<LanguageRegistry>
622    ) -> Self {
623        Self {
624            // languages,
625            component,
626        }
627    }
628
629    fn render_header(&self, _: &Window, cx: &App) -> impl IntoElement {
630        v_flex()
631            .px_12()
632            .pt_16()
633            .pb_12()
634            .gap_6()
635            .bg(cx.theme().colors().surface_background)
636            .border_b_1()
637            .border_color(cx.theme().colors().border)
638            .child(
639                v_flex()
640                    .gap_0p5()
641                    .child(
642                        Label::new(self.component.scope().to_string())
643                            .size(LabelSize::Small)
644                            .color(Color::Muted),
645                    )
646                    .child(
647                        Headline::new(self.component.scopeless_name()).size(HeadlineSize::XLarge),
648                    ),
649            )
650            .when_some(self.component.description(), |this, description| {
651                this.child(div().text_sm().child(description))
652            })
653    }
654
655    fn render_preview(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
656        v_flex()
657            .flex_1()
658            .px_12()
659            .py_6()
660            .bg(cx.theme().colors().editor_background)
661            .child(if let Some(preview) = self.component.preview() {
662                preview(window, cx).unwrap_or_else(|| {
663                    div()
664                        .child("Failed to load preview. This path should be unreachable")
665                        .into_any_element()
666                })
667            } else {
668                div().child("No preview available").into_any_element()
669            })
670    }
671}
672
673impl RenderOnce for ComponentPreviewPage {
674    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
675        v_flex()
676            .id("component-preview-page")
677            .overflow_y_scroll()
678            .overflow_x_hidden()
679            .w_full()
680            .child(self.render_header(window, cx))
681            .child(self.render_preview(window, cx))
682    }
683}