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        let known_scopes = [
191            ComponentScope::Layout,
192            ComponentScope::Input,
193            ComponentScope::Editor,
194            ComponentScope::Notification,
195            ComponentScope::Collaboration,
196            ComponentScope::VersionControl,
197            ComponentScope::None,
198        ];
199
200        // Always show all components first
201        entries.push(PreviewEntry::AllComponents);
202        entries.push(PreviewEntry::Separator);
203
204        for scope in known_scopes.iter() {
205            if let Some(components) = scope_groups.remove(scope) {
206                if !components.is_empty() {
207                    entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
208                    let mut sorted_components = components;
209                    sorted_components.sort_by_key(|component| component.sort_name());
210
211                    for component in sorted_components {
212                        entries.push(PreviewEntry::Component(component));
213                    }
214                }
215            }
216        }
217
218        if let Some(components) = scope_groups.get(&ComponentScope::None) {
219            if !components.is_empty() {
220                entries.push(PreviewEntry::Separator);
221                entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
222                let mut sorted_components = components.clone();
223                sorted_components.sort_by_key(|c| c.sort_name());
224
225                for component in sorted_components {
226                    entries.push(PreviewEntry::Component(component.clone()));
227                }
228            }
229        }
230
231        entries
232    }
233
234    fn render_sidebar_entry(
235        &self,
236        ix: usize,
237        entry: &PreviewEntry,
238        cx: &Context<Self>,
239    ) -> impl IntoElement + use<> {
240        match entry {
241            PreviewEntry::Component(component_metadata) => {
242                let id = component_metadata.id();
243                let selected = self.active_page == PreviewPage::Component(id.clone());
244                ListItem::new(ix)
245                    .child(
246                        Label::new(component_metadata.scopeless_name().clone())
247                            .color(Color::Default),
248                    )
249                    .selectable(true)
250                    .toggle_state(selected)
251                    .inset(true)
252                    .on_click(cx.listener(move |this, _, _, cx| {
253                        let id = id.clone();
254                        this.set_active_page(PreviewPage::Component(id), cx);
255                    }))
256                    .into_any_element()
257            }
258            PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
259                .inset(true)
260                .into_any_element(),
261            PreviewEntry::AllComponents => {
262                let selected = self.active_page == PreviewPage::AllComponents;
263
264                ListItem::new(ix)
265                    .child(Label::new("All Components").color(Color::Default))
266                    .selectable(true)
267                    .toggle_state(selected)
268                    .inset(true)
269                    .on_click(cx.listener(move |this, _, _, cx| {
270                        this.set_active_page(PreviewPage::AllComponents, cx);
271                    }))
272                    .into_any_element()
273            }
274            PreviewEntry::Separator => ListItem::new(ix)
275                .child(h_flex().pt_3().child(Divider::horizontal_dashed()))
276                .into_any_element(),
277        }
278    }
279
280    fn update_component_list(&mut self, cx: &mut Context<Self>) {
281        let new_len = self.scope_ordered_entries().len();
282        let entries = self.scope_ordered_entries();
283        let weak_entity = cx.entity().downgrade();
284
285        let new_list = ListState::new(
286            new_len,
287            gpui::ListAlignment::Top,
288            px(1500.0),
289            move |ix, window, cx| {
290                let entry = &entries[ix];
291
292                weak_entity
293                    .update(cx, |this, cx| match entry {
294                        PreviewEntry::Component(component) => this
295                            .render_preview(component, window, cx)
296                            .into_any_element(),
297                        PreviewEntry::SectionHeader(shared_string) => this
298                            .render_scope_header(ix, shared_string.clone(), window, cx)
299                            .into_any_element(),
300                        PreviewEntry::AllComponents => div().w_full().h_0().into_any_element(),
301                        PreviewEntry::Separator => div().w_full().h_0().into_any_element(),
302                    })
303                    .unwrap()
304            },
305        );
306
307        self.component_list = new_list;
308    }
309
310    fn render_scope_header(
311        &self,
312        _ix: usize,
313        title: SharedString,
314        _window: &Window,
315        _cx: &App,
316    ) -> impl IntoElement {
317        h_flex()
318            .w_full()
319            .h_10()
320            .items_center()
321            .child(Headline::new(title).size(HeadlineSize::XSmall))
322            .child(Divider::horizontal())
323    }
324
325    fn render_preview(
326        &self,
327        component: &ComponentMetadata,
328        window: &mut Window,
329        cx: &mut App,
330    ) -> impl IntoElement {
331        let name = component.scopeless_name();
332        let scope = component.scope();
333
334        let description = component.description();
335
336        v_flex()
337            .py_2()
338            .child(
339                v_flex()
340                    .border_1()
341                    .border_color(cx.theme().colors().border)
342                    .rounded_sm()
343                    .w_full()
344                    .gap_4()
345                    .py_4()
346                    .px_6()
347                    .flex_none()
348                    .child(
349                        v_flex()
350                            .gap_1()
351                            .child(
352                                h_flex().gap_1().text_xl().child(div().child(name)).when(
353                                    !matches!(scope, ComponentScope::None),
354                                    |this| {
355                                        this.child(div().opacity(0.5).child(format!("({})", scope)))
356                                    },
357                                ),
358                            )
359                            .when_some(description, |this, description| {
360                                this.child(
361                                    div()
362                                        .text_ui_sm(cx)
363                                        .text_color(cx.theme().colors().text_muted)
364                                        .max_w(px(600.0))
365                                        .child(description),
366                                )
367                            }),
368                    )
369                    .when_some(component.preview(), |this, preview| {
370                        this.children(preview(window, cx))
371                    }),
372            )
373            .into_any_element()
374    }
375
376    fn render_all_components(&self) -> impl IntoElement {
377        v_flex()
378            .id("component-list")
379            .px_8()
380            .pt_4()
381            .size_full()
382            .child(
383                list(self.component_list.clone())
384                    .flex_grow()
385                    .with_sizing_behavior(gpui::ListSizingBehavior::Auto),
386            )
387    }
388
389    fn render_component_page(
390        &mut self,
391        component_id: &ComponentId,
392        _window: &mut Window,
393        _cx: &mut Context<Self>,
394    ) -> impl IntoElement {
395        let component = self.component_map.get(&component_id);
396
397        if let Some(component) = component {
398            v_flex()
399                .id("render-component-page")
400                .size_full()
401                .child(ComponentPreviewPage::new(component.clone()))
402                .into_any_element()
403        } else {
404            v_flex()
405                .size_full()
406                .items_center()
407                .justify_center()
408                .child("Component not found")
409                .into_any_element()
410        }
411    }
412
413    fn test_status_toast(&self, cx: &mut Context<Self>) {
414        if let Some(workspace) = self.workspace.upgrade() {
415            workspace.update(cx, |workspace, cx| {
416                let status_toast =
417                    StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
418                        this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
419                            .action("Open Pull Request", |_, cx| {
420                                cx.open_url("https://github.com/")
421                            })
422                    });
423                workspace.toggle_status_toast(status_toast, cx)
424            });
425        }
426    }
427}
428
429impl Render for ComponentPreview {
430    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
431        let sidebar_entries = self.scope_ordered_entries();
432        let active_page = self.active_page.clone();
433
434        h_flex()
435            .id("component-preview")
436            .key_context("ComponentPreview")
437            .items_start()
438            .overflow_hidden()
439            .size_full()
440            .track_focus(&self.focus_handle)
441            .bg(cx.theme().colors().editor_background)
442            .child(
443                v_flex()
444                    .border_r_1()
445                    .border_color(cx.theme().colors().border)
446                    .h_full()
447                    .child(
448                        uniform_list(
449                            cx.entity().clone(),
450                            "component-nav",
451                            sidebar_entries.len(),
452                            move |this, range, _window, cx| {
453                                range
454                                    .map(|ix| {
455                                        this.render_sidebar_entry(ix, &sidebar_entries[ix], cx)
456                                    })
457                                    .collect()
458                            },
459                        )
460                        .track_scroll(self.nav_scroll_handle.clone())
461                        .pt_4()
462                        .px_4()
463                        .w(px(240.))
464                        .h_full()
465                        .flex_1(),
466                    )
467                    .child(
468                        div().w_full().pb_4().child(
469                            Button::new("toast-test", "Launch Toast")
470                                .on_click(cx.listener({
471                                    move |this, _, _window, cx| {
472                                        this.test_status_toast(cx);
473                                        cx.notify();
474                                    }
475                                }))
476                                .full_width(),
477                        ),
478                    ),
479            )
480            .child(match active_page {
481                PreviewPage::AllComponents => self.render_all_components().into_any_element(),
482                PreviewPage::Component(id) => self
483                    .render_component_page(&id, window, cx)
484                    .into_any_element(),
485            })
486    }
487}
488
489impl EventEmitter<ItemEvent> for ComponentPreview {}
490
491impl Focusable for ComponentPreview {
492    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
493        self.focus_handle.clone()
494    }
495}
496
497impl Item for ComponentPreview {
498    type Event = ItemEvent;
499
500    fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option<SharedString> {
501        Some("Component Preview".into())
502    }
503
504    fn telemetry_event_text(&self) -> Option<&'static str> {
505        None
506    }
507
508    fn show_toolbar(&self) -> bool {
509        false
510    }
511
512    fn clone_on_split(
513        &self,
514        _workspace_id: Option<WorkspaceId>,
515        _window: &mut Window,
516        cx: &mut Context<Self>,
517    ) -> Option<gpui::Entity<Self>>
518    where
519        Self: Sized,
520    {
521        let language_registry = self.language_registry.clone();
522        let user_store = self.user_store.clone();
523        let weak_workspace = self.workspace.clone();
524        let selected_index = self.cursor_index;
525        let active_page = self.active_page.clone();
526
527        Some(cx.new(|cx| {
528            Self::new(
529                weak_workspace,
530                language_registry,
531                user_store,
532                selected_index,
533                Some(active_page),
534                cx,
535            )
536        }))
537    }
538
539    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
540        f(*event)
541    }
542}
543
544impl SerializableItem for ComponentPreview {
545    fn serialized_item_kind() -> &'static str {
546        "ComponentPreview"
547    }
548
549    fn deserialize(
550        project: Entity<Project>,
551        workspace: WeakEntity<Workspace>,
552        _workspace_id: WorkspaceId,
553        _item_id: ItemId,
554        window: &mut Window,
555        cx: &mut App,
556    ) -> Task<gpui::Result<Entity<Self>>> {
557        let user_store = project.read(cx).user_store().clone();
558        let language_registry = project.read(cx).languages().clone();
559
560        window.spawn(cx, async move |cx| {
561            let user_store = user_store.clone();
562            let language_registry = language_registry.clone();
563            let weak_workspace = workspace.clone();
564            cx.update(|_, cx| {
565                Ok(cx.new(|cx| {
566                    ComponentPreview::new(
567                        weak_workspace,
568                        language_registry,
569                        user_store,
570                        None,
571                        None,
572                        cx,
573                    )
574                }))
575            })?
576        })
577    }
578
579    fn cleanup(
580        _workspace_id: WorkspaceId,
581        _alive_items: Vec<ItemId>,
582        _window: &mut Window,
583        _cx: &mut App,
584    ) -> Task<gpui::Result<()>> {
585        Task::ready(Ok(()))
586        // window.spawn(cx, |_| {
587        // ...
588        // })
589    }
590
591    fn serialize(
592        &mut self,
593        _workspace: &mut Workspace,
594        _item_id: ItemId,
595        _closing: bool,
596        _window: &mut Window,
597        _cx: &mut Context<Self>,
598    ) -> Option<Task<gpui::Result<()>>> {
599        // TODO: Serialize the active index so we can re-open to the same place
600        None
601    }
602
603    fn should_serialize(&self, _event: &Self::Event) -> bool {
604        false
605    }
606}
607
608#[derive(IntoElement)]
609pub struct ComponentPreviewPage {
610    // languages: Arc<LanguageRegistry>,
611    component: ComponentMetadata,
612}
613
614impl ComponentPreviewPage {
615    pub fn new(
616        component: ComponentMetadata,
617        // languages: Arc<LanguageRegistry>
618    ) -> Self {
619        Self {
620            // languages,
621            component,
622        }
623    }
624
625    fn render_header(&self, _: &Window, cx: &App) -> impl IntoElement {
626        v_flex()
627            .px_12()
628            .pt_16()
629            .pb_12()
630            .gap_6()
631            .bg(cx.theme().colors().surface_background)
632            .border_b_1()
633            .border_color(cx.theme().colors().border)
634            .child(
635                v_flex()
636                    .gap_0p5()
637                    .child(
638                        Label::new(self.component.scope().to_string())
639                            .size(LabelSize::Small)
640                            .color(Color::Muted),
641                    )
642                    .child(
643                        Headline::new(self.component.scopeless_name()).size(HeadlineSize::XLarge),
644                    ),
645            )
646            .when_some(self.component.description(), |this, description| {
647                this.child(div().text_sm().child(description))
648            })
649    }
650
651    fn render_preview(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
652        v_flex()
653            .flex_1()
654            .px_12()
655            .py_6()
656            .bg(cx.theme().colors().editor_background)
657            .child(if let Some(preview) = self.component.preview() {
658                preview(window, cx).unwrap_or_else(|| {
659                    div()
660                        .child("Failed to load preview. This path should be unreachable")
661                        .into_any_element()
662                })
663            } else {
664                div().child("No preview available").into_any_element()
665            })
666    }
667}
668
669impl RenderOnce for ComponentPreviewPage {
670    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
671        v_flex()
672            .id("component-preview-page")
673            .overflow_y_scroll()
674            .overflow_x_hidden()
675            .w_full()
676            .child(self.render_header(window, cx))
677            .child(self.render_preview(window, cx))
678    }
679}