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