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, ComponentId, ComponentMetadata};
10use gpui::{
11 list, prelude::*, uniform_list, App, Entity, EventEmitter, FocusHandle, Focusable, Task,
12 WeakEntity, Window,
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::{prelude::*, Divider, ListItem, ListSubHeader};
22
23use workspace::{item::ItemEvent, Item, Workspace, WorkspaceId};
24use workspace::{AppState, ItemId, SerializableItem};
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 {
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, window: &mut Window, cx: &mut Context<Self>) {
421 if let Some(workspace) = self.workspace.upgrade() {
422 workspace.update(cx, |workspace, cx| {
423 let status_toast = StatusToast::new(
424 "`zed/new-notification-system` created!",
425 window,
426 cx,
427 |this, _, cx| {
428 this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
429 .action(
430 "Open Pull Request",
431 cx.listener(|_, _, _, cx| cx.open_url("https://github.com/")),
432 )
433 },
434 );
435 workspace.toggle_status_toast(window, cx, status_toast)
436 });
437 }
438 }
439}
440
441impl Render for ComponentPreview {
442 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
443 let sidebar_entries = self.scope_ordered_entries();
444 let active_page = self.active_page.clone();
445
446 h_flex()
447 .id("component-preview")
448 .key_context("ComponentPreview")
449 .items_start()
450 .overflow_hidden()
451 .size_full()
452 .track_focus(&self.focus_handle)
453 .px_2()
454 .bg(cx.theme().colors().editor_background)
455 .child(
456 v_flex()
457 .h_full()
458 .child(
459 uniform_list(
460 cx.entity().clone(),
461 "component-nav",
462 sidebar_entries.len(),
463 move |this, range, _window, cx| {
464 range
465 .map(|ix| {
466 this.render_sidebar_entry(ix, &sidebar_entries[ix], cx)
467 })
468 .collect()
469 },
470 )
471 .track_scroll(self.nav_scroll_handle.clone())
472 .pt_4()
473 .w(px(240.))
474 .h_full()
475 .flex_1(),
476 )
477 .child(
478 div().w_full().pb_4().child(
479 Button::new("toast-test", "Launch Toast")
480 .on_click(cx.listener({
481 move |this, _, window, cx| {
482 this.test_status_toast(window, cx);
483 cx.notify();
484 }
485 }))
486 .full_width(),
487 ),
488 ),
489 )
490 .child(match active_page {
491 PreviewPage::AllComponents => self.render_all_components().into_any_element(),
492 PreviewPage::Component(id) => self
493 .render_component_page(&id, window, cx)
494 .into_any_element(),
495 })
496 }
497}
498
499impl EventEmitter<ItemEvent> for ComponentPreview {}
500
501impl Focusable for ComponentPreview {
502 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
503 self.focus_handle.clone()
504 }
505}
506
507impl Item for ComponentPreview {
508 type Event = ItemEvent;
509
510 fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option<SharedString> {
511 Some("Component Preview".into())
512 }
513
514 fn telemetry_event_text(&self) -> Option<&'static str> {
515 None
516 }
517
518 fn show_toolbar(&self) -> bool {
519 false
520 }
521
522 fn clone_on_split(
523 &self,
524 _workspace_id: Option<WorkspaceId>,
525 _window: &mut Window,
526 cx: &mut Context<Self>,
527 ) -> Option<gpui::Entity<Self>>
528 where
529 Self: Sized,
530 {
531 let language_registry = self.language_registry.clone();
532 let user_store = self.user_store.clone();
533 let weak_workspace = self.workspace.clone();
534 let selected_index = self.cursor_index;
535
536 Some(cx.new(|cx| {
537 Self::new(
538 weak_workspace,
539 language_registry,
540 user_store,
541 selected_index,
542 cx,
543 )
544 }))
545 }
546
547 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
548 f(*event)
549 }
550}
551
552impl SerializableItem for ComponentPreview {
553 fn serialized_item_kind() -> &'static str {
554 "ComponentPreview"
555 }
556
557 fn deserialize(
558 project: Entity<Project>,
559 workspace: WeakEntity<Workspace>,
560 _workspace_id: WorkspaceId,
561 _item_id: ItemId,
562 window: &mut Window,
563 cx: &mut App,
564 ) -> Task<gpui::Result<Entity<Self>>> {
565 let user_store = project.read(cx).user_store().clone();
566 let language_registry = project.read(cx).languages().clone();
567
568 window.spawn(cx, |mut cx| async move {
569 let user_store = user_store.clone();
570 let language_registry = language_registry.clone();
571 let weak_workspace = workspace.clone();
572 cx.update(|_, cx| {
573 Ok(cx.new(|cx| {
574 ComponentPreview::new(weak_workspace, language_registry, user_store, None, cx)
575 }))
576 })?
577 })
578 }
579
580 fn cleanup(
581 _workspace_id: WorkspaceId,
582 _alive_items: Vec<ItemId>,
583 _window: &mut Window,
584 _cx: &mut App,
585 ) -> Task<gpui::Result<()>> {
586 Task::ready(Ok(()))
587 // window.spawn(cx, |_| {
588 // ...
589 // })
590 }
591
592 fn serialize(
593 &mut self,
594 _workspace: &mut Workspace,
595 _item_id: ItemId,
596 _closing: bool,
597 _window: &mut Window,
598 _cx: &mut Context<Self>,
599 ) -> Option<Task<gpui::Result<()>>> {
600 // TODO: Serialize the active index so we can re-open to the same place
601 None
602 }
603
604 fn should_serialize(&self, _event: &Self::Event) -> bool {
605 false
606 }
607}