Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:32:47.496Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

component_preview.rs

1036 lines · 37.0 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.