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}