Skip to repository content1036 lines · 37.0 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:32:47.496Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
component_preview.rs
1mod persistence;
2
3use client::UserStore;
4use collections::HashMap;
5use component::{ComponentId, ComponentMetadata, ComponentStatus, components};
6use gpui::{
7 App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,
8};
9use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};
10use language::LanguageRegistry;
11use notifications::status_toast::StatusToast;
12use persistence::ComponentPreviewDb;
13use project::Project;
14use std::{iter::Iterator, ops::Range, sync::Arc};
15use ui::{
16 ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Scrollbars, Tooltip,
17 WithScrollbar, prelude::*,
18};
19use ui_input::InputField;
20use workspace::AppState;
21use workspace::{
22 Item, ItemId, SerializableItem, Workspace, WorkspaceId, delete_unloaded_items, item::ItemEvent,
23};
24
25/// OMEGA-DELTA-0022. `workspace: open component preview` is a developer
26/// surface: it renders every component's `preview` fn, which is authored for
27/// developers and is not reviewed as product copy or product artwork. It
28/// shipped in the **release** command palette of `0.2.0-rc11` with no dev
29/// gate, unlike `dev::ToggleInspector` and `dev::ResetOnboarding`, and drawing
30/// the `Vector` preview put a competitor's logo on screen in a signed,
31/// notarized build. A logo carries no text, so no string scan can see it.
32///
33/// The artwork is gone as well — this gate is the second half, so an
34/// unreviewed preview cannot reach an owner's screen through a command they
35/// were never meant to be offered.
36#[cfg(not(debug_assertions))]
37fn hide_component_preview_from_release_palette(cx: &mut App) {
38 use std::any::TypeId;
39 use workspace::notifications::NotifyResultExt as _;
40
41 cx.on_action(|_: &workspace::OpenComponentPreview, cx| {
42 Err::<(), anyhow::Error>(anyhow::anyhow!(
43 "workspace::OpenComponentPreview is only available in debug builds"
44 ))
45 .notify_app_err(cx);
46 });
47
48 command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _cx| {
49 filter.hide_action_types(&[TypeId::of::<workspace::OpenComponentPreview>()]);
50 });
51}
52
53pub fn init(app_state: Arc<AppState>, cx: &mut App) {
54 workspace::register_serializable_item::<ComponentPreview>(cx);
55
56 #[cfg(not(debug_assertions))]
57 {
58 hide_component_preview_from_release_palette(cx);
59 let _ = app_state;
60 return;
61 }
62
63 #[cfg(debug_assertions)]
64 cx.observe_new(move |workspace: &mut Workspace, _window, cx| {
65 let app_state = app_state.clone();
66 let project = workspace.project().clone();
67 let weak_workspace = cx.entity().downgrade();
68
69 workspace.register_action(
70 move |workspace, _: &workspace::OpenComponentPreview, window, cx| {
71 let app_state = app_state.clone();
72
73 let language_registry = app_state.languages.clone();
74 let user_store = app_state.user_store.clone();
75
76 let component_preview = cx.new(|cx| {
77 ComponentPreview::new(
78 weak_workspace.clone(),
79 project.clone(),
80 language_registry,
81 user_store,
82 None,
83 None,
84 window,
85 cx,
86 )
87 .expect("Failed to create component preview")
88 });
89
90 workspace.add_item_to_active_pane(
91 Box::new(component_preview),
92 None,
93 true,
94 window,
95 cx,
96 )
97 },
98 );
99 })
100 .detach();
101}
102
103enum PreviewEntry {
104 AllComponents,
105 Separator,
106 Component(ComponentMetadata, Option<Vec<usize>>),
107 SectionHeader(SharedString),
108}
109
110impl From<ComponentMetadata> for PreviewEntry {
111 fn from(component: ComponentMetadata) -> Self {
112 PreviewEntry::Component(component, None)
113 }
114}
115
116impl From<SharedString> for PreviewEntry {
117 fn from(section_header: SharedString) -> Self {
118 PreviewEntry::SectionHeader(section_header)
119 }
120}
121
122#[derive(Default, Debug, Clone, PartialEq, Eq)]
123pub enum PreviewPage {
124 #[default]
125 AllComponents,
126 Component(ComponentId),
127}
128
129pub struct ComponentPreview {
130 active_page: PreviewPage,
131 reset_key: usize,
132 component_list: ListState,
133 entries: Vec<PreviewEntry>,
134 component_map: HashMap<ComponentId, ComponentMetadata>,
135 components: Vec<ComponentMetadata>,
136 cursor_index: usize,
137 filter_editor: Entity<InputField>,
138 filter_text: String,
139 focus_handle: FocusHandle,
140 language_registry: Arc<LanguageRegistry>,
141 nav_scroll_handle: UniformListScrollHandle,
142 project: Entity<Project>,
143 user_store: Entity<UserStore>,
144 workspace: WeakEntity<Workspace>,
145 workspace_id: Option<WorkspaceId>,
146 _view_scroll_handle: ScrollHandle,
147}
148
149impl ComponentPreview {
150 pub fn new(
151 workspace: WeakEntity<Workspace>,
152 project: Entity<Project>,
153 language_registry: Arc<LanguageRegistry>,
154 user_store: Entity<UserStore>,
155 selected_index: impl Into<Option<usize>>,
156 active_page: Option<PreviewPage>,
157 window: &mut Window,
158 cx: &mut Context<Self>,
159 ) -> anyhow::Result<Self> {
160 let component_registry = Arc::new(components());
161 let sorted_components = component_registry.sorted_components();
162 let selected_index = selected_index.into().unwrap_or(0);
163 let active_page = active_page.unwrap_or(PreviewPage::AllComponents);
164 let filter_editor = cx.new(|cx| InputField::new(window, cx, "Find components or usages…"));
165
166 let component_list = ListState::new(
167 sorted_components.len(),
168 gpui::ListAlignment::Top,
169 px(1500.0),
170 );
171
172 let mut component_preview = Self {
173 active_page,
174 reset_key: 0,
175 component_list,
176 entries: Vec::new(),
177 component_map: component_registry.component_map(),
178 components: sorted_components,
179 cursor_index: selected_index,
180 filter_editor,
181 filter_text: String::new(),
182 focus_handle: cx.focus_handle(),
183 language_registry,
184 nav_scroll_handle: UniformListScrollHandle::new(),
185 project,
186 user_store,
187 workspace,
188 workspace_id: None,
189 _view_scroll_handle: ScrollHandle::new(),
190 };
191
192 if component_preview.cursor_index > 0 {
193 component_preview.scroll_to_preview(component_preview.cursor_index, cx);
194 }
195
196 component_preview.update_component_list(cx);
197
198 let focus_handle = component_preview.filter_editor.read(cx).focus_handle(cx);
199 window.focus(&focus_handle, cx);
200
201 Ok(component_preview)
202 }
203
204 pub fn active_page_id(&self, _cx: &App) -> ActivePageId {
205 match &self.active_page {
206 PreviewPage::AllComponents => ActivePageId::default(),
207 PreviewPage::Component(component_id) => ActivePageId(component_id.0.to_string()),
208 }
209 }
210
211 fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
212 self.component_list.scroll_to_reveal_item(ix);
213 self.cursor_index = ix;
214 cx.notify();
215 }
216
217 fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) {
218 if self.active_page == page {
219 // Force the current preview page to render again
220 self.reset_key = self.reset_key.wrapping_add(1);
221 } else {
222 self.active_page = page;
223 cx.emit(ItemEvent::UpdateTab);
224 }
225 cx.notify();
226 }
227
228 fn filtered_components(&self) -> Vec<ComponentMetadata> {
229 if self.filter_text.is_empty() {
230 return self.components.clone();
231 }
232
233 let filter = self.filter_text.to_lowercase();
234 self.components
235 .iter()
236 .filter(|component| {
237 let component_name = component.name().to_lowercase();
238 let scope_name = component.scope().to_string().to_lowercase();
239 let description = component.description().to_lowercase();
240
241 component_name.contains(&filter)
242 || scope_name.contains(&filter)
243 || description.contains(&filter)
244 })
245 .cloned()
246 .collect()
247 }
248
249 fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
250 use collections::HashMap;
251
252 let mut scope_groups: HashMap<
253 ComponentScope,
254 Vec<(ComponentMetadata, Option<Vec<usize>>)>,
255 > = HashMap::default();
256 let lowercase_filter = self.filter_text.to_lowercase();
257
258 for component in &self.components {
259 if self.filter_text.is_empty() {
260 scope_groups
261 .entry(component.scope())
262 .or_insert_with(Vec::new)
263 .push((component.clone(), None));
264 continue;
265 }
266
267 // let full_component_name = component.name();
268 let scopeless_name = component.scopeless_name();
269 let scope_name = component.scope().to_string();
270 let description = component.description();
271
272 let lowercase_scopeless = scopeless_name.to_lowercase();
273 let lowercase_scope = scope_name.to_lowercase();
274 let lowercase_desc = description.to_lowercase();
275
276 if lowercase_scopeless.contains(&lowercase_filter)
277 && let Some(index) = lowercase_scopeless.find(&lowercase_filter)
278 {
279 let end = index + lowercase_filter.len();
280
281 if end <= scopeless_name.len() {
282 let mut positions = Vec::new();
283 for i in index..end {
284 if scopeless_name.is_char_boundary(i) {
285 positions.push(i);
286 }
287 }
288
289 if !positions.is_empty() {
290 scope_groups
291 .entry(component.scope())
292 .or_insert_with(Vec::new)
293 .push((component.clone(), Some(positions)));
294 continue;
295 }
296 }
297 }
298
299 if lowercase_scopeless.contains(&lowercase_filter)
300 || lowercase_scope.contains(&lowercase_filter)
301 || lowercase_desc.contains(&lowercase_filter)
302 {
303 scope_groups
304 .entry(component.scope())
305 .or_insert_with(Vec::new)
306 .push((component.clone(), None));
307 }
308 }
309
310 // Sort the components in each group
311 for components in scope_groups.values_mut() {
312 components.sort_by_key(|(c, _)| c.sort_name());
313 }
314
315 let mut entries = Vec::new();
316
317 // Always show all components first
318 entries.push(PreviewEntry::AllComponents);
319
320 let mut scopes: Vec<_> = scope_groups
321 .keys()
322 .filter(|scope| !matches!(**scope, ComponentScope::None))
323 .cloned()
324 .collect();
325
326 scopes.sort_by_key(|s| s.to_string());
327
328 for scope in scopes {
329 if let Some(components) = scope_groups.remove(&scope)
330 && !components.is_empty()
331 {
332 entries.push(PreviewEntry::Separator);
333 entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
334
335 let mut sorted_components = components;
336 sorted_components.sort_by_key(|(component, _)| component.sort_name());
337
338 for (component, positions) in sorted_components {
339 entries.push(PreviewEntry::Component(component, positions));
340 }
341 }
342 }
343
344 // Add uncategorized components last
345 if let Some(components) = scope_groups.get(&ComponentScope::None)
346 && !components.is_empty()
347 {
348 entries.push(PreviewEntry::Separator);
349 entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
350 let mut sorted_components = components.clone();
351 sorted_components.sort_by_key(|(c, _)| c.sort_name());
352
353 for (component, positions) in sorted_components {
354 entries.push(PreviewEntry::Component(component, positions));
355 }
356 }
357
358 entries
359 }
360
361 fn update_component_list(&mut self, cx: &mut Context<Self>) {
362 let entries = self.scope_ordered_entries();
363 let new_len = entries.len();
364
365 if new_len > 0 {
366 self.nav_scroll_handle
367 .scroll_to_item(0, ScrollStrategy::Top);
368 }
369
370 let filtered_components = self.filtered_components();
371
372 if !self.filter_text.is_empty()
373 && !matches!(self.active_page, PreviewPage::AllComponents)
374 && let PreviewPage::Component(ref component_id) = self.active_page
375 {
376 let component_still_visible = filtered_components
377 .iter()
378 .any(|component| component.id() == *component_id);
379
380 if !component_still_visible {
381 if !filtered_components.is_empty() {
382 let first_component = &filtered_components[0];
383 self.set_active_page(PreviewPage::Component(first_component.id()), cx);
384 } else {
385 self.set_active_page(PreviewPage::AllComponents, cx);
386 }
387 }
388 }
389
390 self.component_list = ListState::new(new_len, gpui::ListAlignment::Top, px(1500.0));
391 self.entries = entries;
392
393 cx.emit(ItemEvent::UpdateTab);
394 }
395
396 fn render_sidebar_entry(
397 &self,
398 ix: usize,
399 entry: &PreviewEntry,
400 cx: &Context<Self>,
401 ) -> impl IntoElement + use<> {
402 match entry {
403 PreviewEntry::Component(component_metadata, highlight_positions) => {
404 let id = component_metadata.id();
405 let selected = self.active_page == PreviewPage::Component(id.clone());
406 let name = component_metadata.scopeless_name();
407
408 ListItem::new(ix)
409 .child(if let Some(_positions) = highlight_positions {
410 let name_lower = name.to_lowercase();
411 let filter_lower = self.filter_text.to_lowercase();
412 let valid_positions = if let Some(start) = name_lower.find(&filter_lower) {
413 let end = start + filter_lower.len();
414 (start..end).collect()
415 } else {
416 Vec::new()
417 };
418 if valid_positions.is_empty() {
419 Label::new(name).into_any_element()
420 } else {
421 HighlightedLabel::new(name, valid_positions).into_any_element()
422 }
423 } else {
424 Label::new(name).into_any_element()
425 })
426 .selectable(true)
427 .toggle_state(selected)
428 .inset(true)
429 .on_click(cx.listener(move |this, _, _, cx| {
430 let id = id.clone();
431 this.set_active_page(PreviewPage::Component(id), cx);
432 }))
433 .into_any_element()
434 }
435 PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
436 .inset(true)
437 .into_any_element(),
438 PreviewEntry::AllComponents => {
439 let selected = self.active_page == PreviewPage::AllComponents;
440
441 ListItem::new(ix)
442 .child(Label::new("All Components"))
443 .selectable(true)
444 .toggle_state(selected)
445 .inset(true)
446 .on_click(cx.listener(move |this, _, _, cx| {
447 this.set_active_page(PreviewPage::AllComponents, cx);
448 }))
449 .into_any_element()
450 }
451 PreviewEntry::Separator => ListItem::new(ix)
452 .disabled(true)
453 .child(div().w_full().py_2().child(Divider::horizontal()))
454 .into_any_element(),
455 }
456 }
457
458 fn render_scope_header(
459 &self,
460 _ix: usize,
461 title: SharedString,
462 _window: &Window,
463 _cx: &App,
464 ) -> impl IntoElement {
465 h_flex()
466 .w_full()
467 .h_10()
468 .child(Headline::new(title).size(HeadlineSize::XSmall))
469 .child(Divider::horizontal())
470 }
471
472 fn render_preview(
473 &self,
474 component: &ComponentMetadata,
475 window: &mut Window,
476 cx: &mut App,
477 ) -> impl IntoElement {
478 let name = component.scopeless_name();
479 let scope = component.scope();
480
481 let description = component.description();
482
483 // Build the content container
484 v_flex()
485 .py_2()
486 .child(
487 v_flex()
488 .border_1()
489 .border_color(cx.theme().colors().border)
490 .rounded_sm()
491 .w_full()
492 .gap_4()
493 .py_4()
494 .px_6()
495 .flex_none()
496 .child(
497 v_flex()
498 .gap_1()
499 .child(
500 h_flex().gap_1().text_xl().child(div().child(name)).when(
501 scope != ComponentScope::None,
502 |this| {
503 this.child(div().opacity(0.5).child(format!("({})", scope)))
504 },
505 ),
506 )
507 .child(
508 div()
509 .text_ui_sm(cx)
510 .text_color(cx.theme().colors().text_muted)
511 .max_w(px(600.0))
512 .child(description),
513 ),
514 ),
515 )
516 .child((component.preview())(window, cx))
517 .into_any_element()
518 }
519
520 fn render_all_components(&self, cx: &Context<Self>) -> impl IntoElement {
521 v_flex()
522 .id("component-list")
523 .px_8()
524 .pt_4()
525 .size_full()
526 .child(
527 if self.filtered_components().is_empty() && !self.filter_text.is_empty() {
528 div()
529 .size_full()
530 .items_center()
531 .justify_center()
532 .text_color(cx.theme().colors().text_muted)
533 .child(format!("No components matching '{}'.", self.filter_text))
534 .into_any_element()
535 } else {
536 list(
537 self.component_list.clone(),
538 cx.processor(|this, ix, window, cx| {
539 if ix >= this.entries.len() {
540 return div().w_full().h_0().into_any_element();
541 }
542
543 let entry = &this.entries[ix];
544
545 match entry {
546 PreviewEntry::Component(component, _) => this
547 .render_preview(component, window, cx)
548 .into_any_element(),
549 PreviewEntry::SectionHeader(shared_string) => this
550 .render_scope_header(ix, shared_string.clone(), window, cx)
551 .into_any_element(),
552 PreviewEntry::AllComponents => {
553 div().w_full().h_0().into_any_element()
554 }
555 PreviewEntry::Separator => div().w_full().h_0().into_any_element(),
556 }
557 }),
558 )
559 .flex_grow_1()
560 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
561 .into_any_element()
562 },
563 )
564 }
565
566 fn render_component_page(
567 &mut self,
568 component_id: &ComponentId,
569 _window: &mut Window,
570 _cx: &mut Context<Self>,
571 ) -> impl IntoElement {
572 let component = self.component_map.get(component_id);
573
574 if let Some(component) = component {
575 v_flex()
576 .id("render-component-page")
577 .flex_1()
578 .child(ComponentPreviewPage::new(component.clone(), self.reset_key))
579 .into_any_element()
580 } else {
581 v_flex()
582 .size_full()
583 .items_center()
584 .justify_center()
585 .child("Component not found")
586 .into_any_element()
587 }
588 }
589
590 fn test_status_toast(&self, cx: &mut Context<Self>) {
591 if let Some(workspace) = self.workspace.upgrade() {
592 workspace.update(cx, |workspace, cx| {
593 let status_toast =
594 StatusToast::new("`omega/new-notification-system` created!", cx, |this, _cx| {
595 this.icon(
596 Icon::new(IconName::GitBranch)
597 .size(IconSize::Small)
598 .color(Color::Muted),
599 )
600 .action("Open Pull Request", |_, cx| {
601 cx.open_url("https://github.com/")
602 })
603 });
604 workspace.toggle_status_toast(status_toast, cx)
605 });
606 }
607 }
608}
609
610impl Render for ComponentPreview {
611 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
612 // TODO: move this into the struct
613 let current_filter = self.filter_editor.update(cx, |input, cx| {
614 if input.is_empty(cx) {
615 String::new()
616 } else {
617 input.text(cx)
618 }
619 });
620
621 if current_filter != self.filter_text {
622 self.filter_text = current_filter;
623 self.update_component_list(cx);
624 }
625 let sidebar_entries = self.scope_ordered_entries();
626 let active_page = self.active_page.clone();
627 let background_color = cx.theme().colors().editor_background;
628
629 h_flex()
630 .id("component-preview")
631 .key_context("ComponentPreview")
632 .items_start()
633 .overflow_hidden()
634 .size_full()
635 .track_focus(&self.focus_handle)
636 .bg(background_color)
637 .child(
638 v_flex()
639 .h_full()
640 .border_r_1()
641 .border_color(cx.theme().colors().border)
642 .child(
643 div()
644 .size_full()
645 .child(
646 gpui::uniform_list(
647 "component-nav",
648 sidebar_entries.len(),
649 cx.processor(move |this, range: Range<usize>, _window, cx| {
650 range
651 .filter(|ix| ix < &sidebar_entries.len())
652 .map(|ix| {
653 this.render_sidebar_entry(
654 ix,
655 &sidebar_entries[ix],
656 cx,
657 )
658 })
659 .collect()
660 }),
661 )
662 .track_scroll(&self.nav_scroll_handle)
663 .p_2p5()
664 .w(px(231.)) // Matches perfectly with the size of the "Component Preview" tab, if that's the first one in the pane
665 .h_full()
666 .flex_1(),
667 )
668 .custom_scrollbars(
669 Scrollbars::new(ui::ScrollAxes::Vertical)
670 .with_track_along(ui::ScrollAxes::Vertical, background_color)
671 .tracked_scroll_handle(&self.nav_scroll_handle),
672 window,
673 cx,
674 ),
675 )
676 .child(
677 div()
678 .w_full()
679 .p_2p5()
680 .border_t_1()
681 .border_color(cx.theme().colors().border)
682 .child(
683 Button::new("toast-test", "Launch Toast")
684 .full_width()
685 .on_click(cx.listener({
686 move |this, _, _window, cx| {
687 this.test_status_toast(cx);
688 cx.notify();
689 }
690 })),
691 ),
692 ),
693 )
694 .child(
695 // `size_full` would set width to 100% of the whole row, not of
696 // the space the sidebar leaves, so the pane rendered one
697 // sidebar-width too wide and its content ran off the right
698 // edge. `min_w_0` is what lets a flex child shrink below its
699 // content width; without it `flex_1` still cannot pull the
700 // pane in.
701 v_flex()
702 .flex_1()
703 .min_w_0()
704 .h_full()
705 .child(
706 div()
707 .p_2()
708 .w_full()
709 .border_b_1()
710 .border_color(cx.theme().colors().border)
711 .child(self.filter_editor.clone()),
712 )
713 .child(
714 div().id("content-area").flex_1().overflow_y_scroll().child(
715 match active_page {
716 PreviewPage::AllComponents => {
717 self.render_all_components(cx).into_any_element()
718 }
719 PreviewPage::Component(id) => self
720 .render_component_page(&id, window, cx)
721 .into_any_element(),
722 },
723 ),
724 ),
725 )
726 }
727}
728
729impl EventEmitter<ItemEvent> for ComponentPreview {}
730
731impl Focusable for ComponentPreview {
732 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
733 self.focus_handle.clone()
734 }
735}
736
737#[derive(Debug, Clone, PartialEq, Eq, Hash)]
738pub struct ActivePageId(pub String);
739
740impl Default for ActivePageId {
741 fn default() -> Self {
742 ActivePageId("AllComponents".to_string())
743 }
744}
745
746impl From<ComponentId> for ActivePageId {
747 fn from(id: ComponentId) -> Self {
748 Self(id.0.to_string())
749 }
750}
751
752impl Item for ComponentPreview {
753 type Event = ItemEvent;
754
755 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
756 "Component Preview".into()
757 }
758
759 fn telemetry_event_text(&self) -> Option<&'static str> {
760 None
761 }
762
763 fn show_toolbar(&self) -> bool {
764 false
765 }
766
767 fn can_split(&self) -> bool {
768 true
769 }
770
771 fn clone_on_split(
772 &self,
773 _workspace_id: Option<WorkspaceId>,
774 window: &mut Window,
775 cx: &mut Context<Self>,
776 ) -> Task<Option<gpui::Entity<Self>>>
777 where
778 Self: Sized,
779 {
780 let language_registry = self.language_registry.clone();
781 let user_store = self.user_store.clone();
782 let weak_workspace = self.workspace.clone();
783 let project = self.project.clone();
784 let selected_index = self.cursor_index;
785 let active_page = self.active_page.clone();
786
787 let self_result = Self::new(
788 weak_workspace,
789 project,
790 language_registry,
791 user_store,
792 selected_index,
793 Some(active_page),
794 window,
795 cx,
796 );
797
798 Task::ready(match self_result {
799 Ok(preview) => Some(cx.new(|_cx| preview)),
800 Err(e) => {
801 log::error!("Failed to clone component preview: {}", e);
802 None
803 }
804 })
805 }
806
807 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
808 f(*event)
809 }
810
811 fn added_to_workspace(
812 &mut self,
813 workspace: &mut Workspace,
814 window: &mut Window,
815 cx: &mut Context<Self>,
816 ) {
817 self.workspace_id = workspace.database_id();
818
819 let focus_handle = self.filter_editor.read(cx).focus_handle(cx);
820 window.focus(&focus_handle, cx);
821 }
822}
823
824impl SerializableItem for ComponentPreview {
825 fn serialized_item_kind() -> &'static str {
826 "ComponentPreview"
827 }
828
829 fn deserialize(
830 project: Entity<Project>,
831 workspace: WeakEntity<Workspace>,
832 workspace_id: WorkspaceId,
833 item_id: ItemId,
834 window: &mut Window,
835 cx: &mut App,
836 ) -> Task<anyhow::Result<Entity<Self>>> {
837 let deserialized_active_page =
838 match ComponentPreviewDb::global(cx).get_active_page(item_id, workspace_id) {
839 Ok(page) => {
840 if let Some(page) = page {
841 ActivePageId(page)
842 } else {
843 ActivePageId::default()
844 }
845 }
846 Err(_) => ActivePageId::default(),
847 };
848
849 let user_store = project.read(cx).user_store();
850 let language_registry = project.read(cx).languages().clone();
851 let preview_page = if deserialized_active_page.0 == ActivePageId::default().0 {
852 Some(PreviewPage::default())
853 } else {
854 let component_str = deserialized_active_page.0;
855 let component_registry = components();
856 let all_components = component_registry.components();
857 let found_component = all_components.iter().find(|c| c.id().0 == component_str);
858
859 if let Some(component) = found_component {
860 Some(PreviewPage::Component(component.id()))
861 } else {
862 Some(PreviewPage::default())
863 }
864 };
865
866 window.spawn(cx, async move |cx| {
867 let user_store = user_store.clone();
868 let language_registry = language_registry.clone();
869 let weak_workspace = workspace.clone();
870 let project = project.clone();
871 cx.update(move |window, cx| {
872 Ok(cx.new(|cx| {
873 ComponentPreview::new(
874 weak_workspace,
875 project,
876 language_registry,
877 user_store,
878 None,
879 preview_page,
880 window,
881 cx,
882 )
883 .expect("Failed to create component preview")
884 }))
885 })?
886 })
887 }
888
889 fn cleanup(
890 workspace_id: WorkspaceId,
891 alive_items: Vec<ItemId>,
892 _window: &mut Window,
893 cx: &mut App,
894 ) -> Task<anyhow::Result<()>> {
895 delete_unloaded_items(
896 alive_items,
897 workspace_id,
898 "component_previews",
899 &ComponentPreviewDb::global(cx),
900 cx,
901 )
902 }
903
904 fn serialize(
905 &mut self,
906 _workspace: &mut Workspace,
907 item_id: ItemId,
908 _closing: bool,
909 _window: &mut Window,
910 cx: &mut Context<Self>,
911 ) -> Option<Task<anyhow::Result<()>>> {
912 let active_page = self.active_page_id(cx);
913 let workspace_id = self.workspace_id?;
914 let db = ComponentPreviewDb::global(cx);
915 Some(cx.background_spawn(async move {
916 db.save_active_page(item_id, workspace_id, active_page.0)
917 .await
918 }))
919 }
920
921 fn should_serialize(&self, event: &Self::Event) -> bool {
922 matches!(event, ItemEvent::UpdateTab)
923 }
924}
925
926// TODO: use language registry to allow rendering markdown
927#[derive(IntoElement)]
928pub struct ComponentPreviewPage {
929 // languages: Arc<LanguageRegistry>,
930 component: ComponentMetadata,
931 reset_key: usize,
932}
933
934impl ComponentPreviewPage {
935 pub fn new(
936 component: ComponentMetadata,
937 reset_key: usize,
938 // languages: Arc<LanguageRegistry>
939 ) -> Self {
940 Self {
941 // languages,
942 component,
943 reset_key,
944 }
945 }
946
947 /// Renders the component status when it would be useful
948 ///
949 /// Doesn't render if the component is `ComponentStatus::Live`
950 /// as that is the default state
951 fn render_component_status(&self, cx: &App) -> Option<impl IntoElement> {
952 let status = self.component.status();
953 let status_description = status.description().to_string();
954
955 let color = match status {
956 ComponentStatus::Deprecated => Color::Error,
957 ComponentStatus::EngineeringReady => Color::Info,
958 ComponentStatus::Live => Color::Success,
959 ComponentStatus::WorkInProgress => Color::Warning,
960 };
961
962 if status != ComponentStatus::Live {
963 Some(
964 ButtonLike::new("component_status")
965 .child(
966 div()
967 .px_1p5()
968 .rounded_sm()
969 .bg(color.color(cx).alpha(0.12))
970 .child(
971 Label::new(status.to_string())
972 .size(LabelSize::Small)
973 .color(color),
974 ),
975 )
976 .tooltip(Tooltip::text(status_description))
977 .disabled(true),
978 )
979 } else {
980 None
981 }
982 }
983
984 fn render_header(&self, _: &Window, cx: &App) -> impl IntoElement {
985 v_flex()
986 .min_w_0()
987 .w_full()
988 .p_12()
989 .gap_6()
990 .bg(cx.theme().colors().surface_background)
991 .border_b_1()
992 .border_color(cx.theme().colors().border)
993 .child(
994 v_flex()
995 .gap_1()
996 .child(
997 Label::new(self.component.scope().to_string())
998 .size(LabelSize::Small)
999 .color(Color::Muted),
1000 )
1001 .child(
1002 h_flex()
1003 .gap_2()
1004 .child(
1005 Headline::new(self.component.scopeless_name())
1006 .size(HeadlineSize::XLarge),
1007 )
1008 .children(self.render_component_status(cx)),
1009 ),
1010 )
1011 .child(Label::new(self.component.description()).size(LabelSize::Small))
1012 }
1013
1014 fn render_preview(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1015 v_flex()
1016 .id(("component-preview", self.reset_key))
1017 .size_full()
1018 .flex_1()
1019 .px_12()
1020 .py_6()
1021 .bg(cx.theme().colors().editor_background)
1022 .child((self.component.preview())(window, cx))
1023 }
1024}
1025
1026impl RenderOnce for ComponentPreviewPage {
1027 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1028 v_flex()
1029 .size_full()
1030 .flex_1()
1031 .overflow_x_hidden()
1032 .child(self.render_header(window, cx))
1033 .child(self.render_preview(window, cx))
1034 }
1035}
1036