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_sm()
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, window: &mut Window, cx: &mut Context<Self>) {
345        if let Some(workspace) = self.workspace.upgrade() {
346            workspace.update(cx, |workspace, cx| {
347                let status_toast = StatusToast::new(
348                    "`zed/new-notification-system` created!",
349                    window,
350                    cx,
351                    |this, _, cx| {
352                        this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
353                            .action(
354                                "Open Pull Request",
355                                cx.listener(|_, _, _, cx| cx.open_url("https://github.com/")),
356                            )
357                    },
358                );
359                workspace.toggle_status_toast(window, cx, status_toast)
360            });
361        }
362    }
363}
364
365impl Render for ComponentPreview {
366    fn render(&mut self, _window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
367        let sidebar_entries = self.scope_ordered_entries();
368
369        h_flex()
370            .id("component-preview")
371            .key_context("ComponentPreview")
372            .items_start()
373            .overflow_hidden()
374            .size_full()
375            .track_focus(&self.focus_handle)
376            .px_2()
377            .bg(cx.theme().colors().editor_background)
378            .child(
379                v_flex()
380                    .h_full()
381                    .child(
382                        uniform_list(
383                            cx.entity().clone(),
384                            "component-nav",
385                            sidebar_entries.len(),
386                            move |this, range, _window, cx| {
387                                range
388                                    .map(|ix| {
389                                        this.render_sidebar_entry(
390                                            ix,
391                                            &sidebar_entries[ix],
392                                            ix == this.selected_index,
393                                            cx,
394                                        )
395                                    })
396                                    .collect()
397                            },
398                        )
399                        .track_scroll(self.nav_scroll_handle.clone())
400                        .pt_4()
401                        .w(px(240.))
402                        .h_full()
403                        .flex_1(),
404                    )
405                    .child(
406                        div().w_full().pb_4().child(
407                            Button::new("toast-test", "Launch Toast")
408                                .on_click(cx.listener({
409                                    move |this, _, window, cx| {
410                                        this.test_status_toast(window, cx);
411                                        cx.notify();
412                                    }
413                                }))
414                                .full_width(),
415                        ),
416                    ),
417            )
418            .child(
419                v_flex()
420                    .id("component-list")
421                    .px_8()
422                    .pt_4()
423                    .size_full()
424                    .child(
425                        list(self.component_list.clone())
426                            .flex_grow()
427                            .with_sizing_behavior(gpui::ListSizingBehavior::Auto),
428                    ),
429            )
430    }
431}
432
433impl EventEmitter<ItemEvent> for ComponentPreview {}
434
435impl Focusable for ComponentPreview {
436    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
437        self.focus_handle.clone()
438    }
439}
440
441impl Item for ComponentPreview {
442    type Event = ItemEvent;
443
444    fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option<SharedString> {
445        Some("Component Preview".into())
446    }
447
448    fn telemetry_event_text(&self) -> Option<&'static str> {
449        None
450    }
451
452    fn show_toolbar(&self) -> bool {
453        false
454    }
455
456    fn clone_on_split(
457        &self,
458        _workspace_id: Option<WorkspaceId>,
459        _window: &mut Window,
460        cx: &mut Context<Self>,
461    ) -> Option<gpui::Entity<Self>>
462    where
463        Self: Sized,
464    {
465        let language_registry = self.language_registry.clone();
466        let user_store = self.user_store.clone();
467        let weak_workspace = self.workspace.clone();
468        let selected_index = self.selected_index;
469
470        Some(cx.new(|cx| {
471            Self::new(
472                weak_workspace,
473                language_registry,
474                user_store,
475                selected_index,
476                cx,
477            )
478        }))
479    }
480
481    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
482        f(*event)
483    }
484}
485
486impl SerializableItem for ComponentPreview {
487    fn serialized_item_kind() -> &'static str {
488        "ComponentPreview"
489    }
490
491    fn deserialize(
492        project: Entity<Project>,
493        workspace: WeakEntity<Workspace>,
494        _workspace_id: WorkspaceId,
495        _item_id: ItemId,
496        window: &mut Window,
497        cx: &mut App,
498    ) -> Task<gpui::Result<Entity<Self>>> {
499        let user_store = project.read(cx).user_store().clone();
500        let language_registry = project.read(cx).languages().clone();
501
502        window.spawn(cx, |mut cx| async move {
503            let user_store = user_store.clone();
504            let language_registry = language_registry.clone();
505            let weak_workspace = workspace.clone();
506            cx.update(|_, cx| {
507                Ok(cx.new(|cx| {
508                    ComponentPreview::new(weak_workspace, language_registry, user_store, None, cx)
509                }))
510            })?
511        })
512    }
513
514    fn cleanup(
515        _workspace_id: WorkspaceId,
516        _alive_items: Vec<ItemId>,
517        _window: &mut Window,
518        _cx: &mut App,
519    ) -> Task<gpui::Result<()>> {
520        Task::ready(Ok(()))
521        // window.spawn(cx, |_| {
522        // ...
523        // })
524    }
525
526    fn serialize(
527        &mut self,
528        _workspace: &mut Workspace,
529        _item_id: ItemId,
530        _closing: bool,
531        _window: &mut Window,
532        _cx: &mut Context<Self>,
533    ) -> Option<Task<gpui::Result<()>>> {
534        // TODO: Serialize the active index so we can re-open to the same place
535        None
536    }
537
538    fn should_serialize(&self, _event: &Self::Event) -> bool {
539        false
540    }
541}