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                        cx,
 47                    )
 48                });
 49
 50                workspace.add_item_to_active_pane(
 51                    Box::new(component_preview),
 52                    None,
 53                    true,
 54                    window,
 55                    cx,
 56                )
 57            },
 58        );
 59    })
 60    .detach();
 61}
 62
 63enum PreviewEntry {
 64    AllComponents,
 65    Separator,
 66    Component(ComponentMetadata),
 67    SectionHeader(SharedString),
 68}
 69
 70impl From<ComponentMetadata> for PreviewEntry {
 71    fn from(component: ComponentMetadata) -> Self {
 72        PreviewEntry::Component(component)
 73    }
 74}
 75
 76impl From<SharedString> for PreviewEntry {
 77    fn from(section_header: SharedString) -> Self {
 78        PreviewEntry::SectionHeader(section_header)
 79    }
 80}
 81
 82#[derive(Default, Debug, Clone, PartialEq, Eq)]
 83enum PreviewPage {
 84    #[default]
 85    AllComponents,
 86    Component(ComponentId),
 87}
 88
 89struct ComponentPreview {
 90    focus_handle: FocusHandle,
 91    _view_scroll_handle: ScrollHandle,
 92    nav_scroll_handle: UniformListScrollHandle,
 93    component_map: HashMap<ComponentId, ComponentMetadata>,
 94    active_page: PreviewPage,
 95    components: Vec<ComponentMetadata>,
 96    component_list: ListState,
 97    cursor_index: usize,
 98    language_registry: Arc<LanguageRegistry>,
 99    workspace: WeakEntity<Workspace>,
100    user_store: Entity<UserStore>,
101}
102
103impl ComponentPreview {
104    pub fn new(
105        workspace: WeakEntity<Workspace>,
106        language_registry: Arc<LanguageRegistry>,
107        user_store: Entity<UserStore>,
108        selected_index: impl Into<Option<usize>>,
109        cx: &mut Context<Self>,
110    ) -> Self {
111        let sorted_components = components().all_sorted();
112        let selected_index = selected_index.into().unwrap_or(0);
113
114        let component_list = ListState::new(
115            sorted_components.len(),
116            gpui::ListAlignment::Top,
117            px(1500.0),
118            {
119                let this = cx.entity().downgrade();
120                move |ix, window: &mut Window, cx: &mut App| {
121                    this.update(cx, |this, cx| {
122                        let component = this.get_component(ix);
123                        this.render_preview(&component, window, cx)
124                            .into_any_element()
125                    })
126                    .unwrap()
127                }
128            },
129        );
130
131        let mut component_preview = Self {
132            focus_handle: cx.focus_handle(),
133            _view_scroll_handle: ScrollHandle::new(),
134            nav_scroll_handle: UniformListScrollHandle::new(),
135            language_registry,
136            user_store,
137            workspace,
138            active_page: PreviewPage::AllComponents,
139            component_map: components().0,
140            components: sorted_components,
141            component_list,
142            cursor_index: selected_index,
143        };
144
145        if component_preview.cursor_index > 0 {
146            component_preview.scroll_to_preview(component_preview.cursor_index, cx);
147        }
148
149        component_preview.update_component_list(cx);
150
151        component_preview
152    }
153
154    fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
155        self.component_list.scroll_to_reveal_item(ix);
156        self.cursor_index = ix;
157        cx.notify();
158    }
159
160    fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) {
161        self.active_page = page;
162        cx.notify();
163    }
164
165    fn get_component(&self, ix: usize) -> ComponentMetadata {
166        self.components[ix].clone()
167    }
168
169    fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
170        use std::collections::HashMap;
171
172        let mut scope_groups: HashMap<Option<ComponentScope>, Vec<ComponentMetadata>> =
173            HashMap::default();
174
175        for component in &self.components {
176            scope_groups
177                .entry(component.scope())
178                .or_insert_with(Vec::new)
179                .push(component.clone());
180        }
181
182        for components in scope_groups.values_mut() {
183            components.sort_by_key(|c| c.name().to_lowercase());
184        }
185
186        let mut entries = Vec::new();
187
188        let known_scopes = [
189            ComponentScope::Layout,
190            ComponentScope::Input,
191            ComponentScope::Editor,
192            ComponentScope::Notification,
193            ComponentScope::Collaboration,
194            ComponentScope::VersionControl,
195        ];
196
197        // Always show all components first
198        entries.push(PreviewEntry::AllComponents);
199        entries.push(PreviewEntry::Separator);
200
201        for scope in known_scopes.iter() {
202            let scope_key = Some(scope.clone());
203            if let Some(components) = scope_groups.remove(&scope_key) {
204                if !components.is_empty() {
205                    entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
206
207                    for component in components {
208                        entries.push(PreviewEntry::Component(component));
209                    }
210                }
211            }
212        }
213
214        for (scope, components) in &scope_groups {
215            if let Some(ComponentScope::Unknown(_)) = scope {
216                if !components.is_empty() {
217                    if let Some(scope_value) = scope {
218                        entries.push(PreviewEntry::SectionHeader(scope_value.to_string().into()));
219                    }
220
221                    for component in components {
222                        entries.push(PreviewEntry::Component(component.clone()));
223                    }
224                }
225            }
226        }
227
228        if let Some(components) = scope_groups.get(&None) {
229            if !components.is_empty() {
230                entries.push(PreviewEntry::Separator);
231                entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
232
233                for component in components {
234                    entries.push(PreviewEntry::Component(component.clone()));
235                }
236            }
237        }
238
239        entries
240    }
241
242    fn render_sidebar_entry(
243        &self,
244        ix: usize,
245        entry: &PreviewEntry,
246        cx: &Context<Self>,
247    ) -> impl IntoElement + use<> {
248        match entry {
249            PreviewEntry::Component(component_metadata) => {
250                let id = component_metadata.id();
251                let selected = self.active_page == PreviewPage::Component(id.clone());
252                ListItem::new(ix)
253                    .child(Label::new(component_metadata.name().clone()).color(Color::Default))
254                    .selectable(true)
255                    .toggle_state(selected)
256                    .inset(true)
257                    .on_click(cx.listener(move |this, _, _, cx| {
258                        let id = id.clone();
259                        this.set_active_page(PreviewPage::Component(id), cx);
260                    }))
261                    .into_any_element()
262            }
263            PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
264                .inset(true)
265                .into_any_element(),
266            PreviewEntry::AllComponents => {
267                let selected = self.active_page == PreviewPage::AllComponents;
268
269                ListItem::new(ix)
270                    .child(Label::new("All Components").color(Color::Default))
271                    .selectable(true)
272                    .toggle_state(selected)
273                    .inset(true)
274                    .on_click(cx.listener(move |this, _, _, cx| {
275                        this.set_active_page(PreviewPage::AllComponents, cx);
276                    }))
277                    .into_any_element()
278            }
279            PreviewEntry::Separator => ListItem::new(ix)
280                .child(h_flex().pt_3().child(Divider::horizontal_dashed()))
281                .into_any_element(),
282        }
283    }
284
285    fn update_component_list(&mut self, cx: &mut Context<Self>) {
286        let new_len = self.scope_ordered_entries().len();
287        let entries = self.scope_ordered_entries();
288        let weak_entity = cx.entity().downgrade();
289
290        let new_list = ListState::new(
291            new_len,
292            gpui::ListAlignment::Top,
293            px(1500.0),
294            move |ix, window, cx| {
295                let entry = &entries[ix];
296
297                weak_entity
298                    .update(cx, |this, cx| match entry {
299                        PreviewEntry::Component(component) => this
300                            .render_preview(component, window, cx)
301                            .into_any_element(),
302                        PreviewEntry::SectionHeader(shared_string) => this
303                            .render_scope_header(ix, shared_string.clone(), window, cx)
304                            .into_any_element(),
305                        PreviewEntry::AllComponents => div().w_full().h_0().into_any_element(),
306                        PreviewEntry::Separator => div().w_full().h_0().into_any_element(),
307                    })
308                    .unwrap()
309            },
310        );
311
312        self.component_list = new_list;
313    }
314
315    fn render_scope_header(
316        &self,
317        _ix: usize,
318        title: SharedString,
319        _window: &Window,
320        _cx: &App,
321    ) -> impl IntoElement {
322        h_flex()
323            .w_full()
324            .h_10()
325            .items_center()
326            .child(Headline::new(title).size(HeadlineSize::XSmall))
327            .child(Divider::horizontal())
328    }
329
330    fn render_preview(
331        &self,
332        component: &ComponentMetadata,
333        window: &mut Window,
334        cx: &mut App,
335    ) -> impl IntoElement {
336        let name = component.name();
337        let scope = component.scope();
338
339        let description = component.description();
340
341        v_flex()
342            .py_2()
343            .child(
344                v_flex()
345                    .border_1()
346                    .border_color(cx.theme().colors().border)
347                    .rounded_sm()
348                    .w_full()
349                    .gap_4()
350                    .py_4()
351                    .px_6()
352                    .flex_none()
353                    .child(
354                        v_flex()
355                            .gap_1()
356                            .child(
357                                h_flex()
358                                    .gap_1()
359                                    .text_xl()
360                                    .child(div().child(name))
361                                    .when_some(scope, |this, scope| {
362                                        this.child(div().opacity(0.5).child(format!("({})", scope)))
363                                    }),
364                            )
365                            .when_some(description, |this, description| {
366                                this.child(
367                                    div()
368                                        .text_ui_sm(cx)
369                                        .text_color(cx.theme().colors().text_muted)
370                                        .max_w(px(600.0))
371                                        .child(description),
372                                )
373                            }),
374                    )
375                    .when_some(component.preview(), |this, preview| {
376                        this.child(preview(window, cx))
377                    }),
378            )
379            .into_any_element()
380    }
381
382    fn render_all_components(&self) -> impl IntoElement {
383        v_flex()
384            .id("component-list")
385            .px_8()
386            .pt_4()
387            .size_full()
388            .child(
389                list(self.component_list.clone())
390                    .flex_grow()
391                    .with_sizing_behavior(gpui::ListSizingBehavior::Auto),
392            )
393    }
394
395    fn render_component_page(
396        &mut self,
397        component_id: &ComponentId,
398        window: &mut Window,
399        cx: &mut Context<Self>,
400    ) -> impl IntoElement {
401        let component = self.component_map.get(&component_id);
402
403        if let Some(component) = component {
404            v_flex()
405                .w_full()
406                .flex_initial()
407                .min_h_full()
408                .child(self.render_preview(component, window, cx))
409                .into_any_element()
410        } else {
411            v_flex()
412                .size_full()
413                .items_center()
414                .justify_center()
415                .child("Component not found")
416                .into_any_element()
417        }
418    }
419
420    fn test_status_toast(&self, cx: &mut Context<Self>) {
421        if let Some(workspace) = self.workspace.upgrade() {
422            workspace.update(cx, |workspace, cx| {
423                let status_toast =
424                    StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
425                        this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
426                            .action("Open Pull Request", |_, cx| {
427                                cx.open_url("https://github.com/")
428                            })
429                    });
430                workspace.toggle_status_toast(status_toast, cx)
431            });
432        }
433    }
434}
435
436impl Render for ComponentPreview {
437    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
438        let sidebar_entries = self.scope_ordered_entries();
439        let active_page = self.active_page.clone();
440
441        h_flex()
442            .id("component-preview")
443            .key_context("ComponentPreview")
444            .items_start()
445            .overflow_hidden()
446            .size_full()
447            .track_focus(&self.focus_handle)
448            .px_2()
449            .bg(cx.theme().colors().editor_background)
450            .child(
451                v_flex()
452                    .h_full()
453                    .child(
454                        uniform_list(
455                            cx.entity().clone(),
456                            "component-nav",
457                            sidebar_entries.len(),
458                            move |this, range, _window, cx| {
459                                range
460                                    .map(|ix| {
461                                        this.render_sidebar_entry(ix, &sidebar_entries[ix], cx)
462                                    })
463                                    .collect()
464                            },
465                        )
466                        .track_scroll(self.nav_scroll_handle.clone())
467                        .pt_4()
468                        .w(px(240.))
469                        .h_full()
470                        .flex_1(),
471                    )
472                    .child(
473                        div().w_full().pb_4().child(
474                            Button::new("toast-test", "Launch Toast")
475                                .on_click(cx.listener({
476                                    move |this, _, _window, cx| {
477                                        this.test_status_toast(cx);
478                                        cx.notify();
479                                    }
480                                }))
481                                .full_width(),
482                        ),
483                    ),
484            )
485            .child(match active_page {
486                PreviewPage::AllComponents => self.render_all_components().into_any_element(),
487                PreviewPage::Component(id) => self
488                    .render_component_page(&id, window, cx)
489                    .into_any_element(),
490            })
491    }
492}
493
494impl EventEmitter<ItemEvent> for ComponentPreview {}
495
496impl Focusable for ComponentPreview {
497    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
498        self.focus_handle.clone()
499    }
500}
501
502impl Item for ComponentPreview {
503    type Event = ItemEvent;
504
505    fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option<SharedString> {
506        Some("Component Preview".into())
507    }
508
509    fn telemetry_event_text(&self) -> Option<&'static str> {
510        None
511    }
512
513    fn show_toolbar(&self) -> bool {
514        false
515    }
516
517    fn clone_on_split(
518        &self,
519        _workspace_id: Option<WorkspaceId>,
520        _window: &mut Window,
521        cx: &mut Context<Self>,
522    ) -> Option<gpui::Entity<Self>>
523    where
524        Self: Sized,
525    {
526        let language_registry = self.language_registry.clone();
527        let user_store = self.user_store.clone();
528        let weak_workspace = self.workspace.clone();
529        let selected_index = self.cursor_index;
530
531        Some(cx.new(|cx| {
532            Self::new(
533                weak_workspace,
534                language_registry,
535                user_store,
536                selected_index,
537                cx,
538            )
539        }))
540    }
541
542    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
543        f(*event)
544    }
545}
546
547impl SerializableItem for ComponentPreview {
548    fn serialized_item_kind() -> &'static str {
549        "ComponentPreview"
550    }
551
552    fn deserialize(
553        project: Entity<Project>,
554        workspace: WeakEntity<Workspace>,
555        _workspace_id: WorkspaceId,
556        _item_id: ItemId,
557        window: &mut Window,
558        cx: &mut App,
559    ) -> Task<gpui::Result<Entity<Self>>> {
560        let user_store = project.read(cx).user_store().clone();
561        let language_registry = project.read(cx).languages().clone();
562
563        window.spawn(cx, async move |cx| {
564            let user_store = user_store.clone();
565            let language_registry = language_registry.clone();
566            let weak_workspace = workspace.clone();
567            cx.update(|_, cx| {
568                Ok(cx.new(|cx| {
569                    ComponentPreview::new(weak_workspace, language_registry, user_store, None, cx)
570                }))
571            })?
572        })
573    }
574
575    fn cleanup(
576        _workspace_id: WorkspaceId,
577        _alive_items: Vec<ItemId>,
578        _window: &mut Window,
579        _cx: &mut App,
580    ) -> Task<gpui::Result<()>> {
581        Task::ready(Ok(()))
582        // window.spawn(cx, |_| {
583        // ...
584        // })
585    }
586
587    fn serialize(
588        &mut self,
589        _workspace: &mut Workspace,
590        _item_id: ItemId,
591        _closing: bool,
592        _window: &mut Window,
593        _cx: &mut Context<Self>,
594    ) -> Option<Task<gpui::Result<()>>> {
595        // TODO: Serialize the active index so we can re-open to the same place
596        None
597    }
598
599    fn should_serialize(&self, _event: &Self::Event) -> bool {
600        false
601    }
602}