component_preview.rs

   1//! # Component Preview
   2//!
   3//! A view for exploring Zed components.
   4
   5mod persistence;
   6mod preview_support;
   7
   8use std::iter::Iterator;
   9use std::sync::Arc;
  10
  11use agent::{ActiveThread, ThreadStore};
  12use client::UserStore;
  13use component::{ComponentId, ComponentMetadata, components};
  14use gpui::{
  15    App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,
  16};
  17
  18use collections::HashMap;
  19
  20use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};
  21use languages::LanguageRegistry;
  22use notifications::status_toast::{StatusToast, ToastIcon};
  23use persistence::COMPONENT_PREVIEW_DB;
  24use preview_support::active_thread::{load_preview_thread_store, static_active_thread};
  25use project::Project;
  26use ui::{Divider, HighlightedLabel, ListItem, ListSubHeader, prelude::*};
  27
  28use ui_input::SingleLineInput;
  29use workspace::{AppState, ItemId, SerializableItem, delete_unloaded_items};
  30use workspace::{Item, Workspace, WorkspaceId, item::ItemEvent};
  31
  32pub fn init(app_state: Arc<AppState>, cx: &mut App) {
  33    workspace::register_serializable_item::<ComponentPreview>(cx);
  34
  35    let app_state = app_state.clone();
  36
  37    cx.observe_new(move |workspace: &mut Workspace, _window, cx| {
  38        let app_state = app_state.clone();
  39        let project = workspace.project().clone();
  40        let weak_workspace = cx.entity().downgrade();
  41
  42        workspace.register_action(
  43            move |workspace, _: &workspace::OpenComponentPreview, window, cx| {
  44                let app_state = app_state.clone();
  45
  46                let language_registry = app_state.languages.clone();
  47                let user_store = app_state.user_store.clone();
  48
  49                let component_preview = cx.new(|cx| {
  50                    ComponentPreview::new(
  51                        weak_workspace.clone(),
  52                        project.clone(),
  53                        language_registry,
  54                        user_store,
  55                        None,
  56                        None,
  57                        window,
  58                        cx,
  59                    )
  60                    .expect("Failed to create component preview")
  61                });
  62
  63                workspace.add_item_to_active_pane(
  64                    Box::new(component_preview),
  65                    None,
  66                    true,
  67                    window,
  68                    cx,
  69                )
  70            },
  71        );
  72    })
  73    .detach();
  74}
  75
  76enum PreviewEntry {
  77    AllComponents,
  78    ActiveThread,
  79    Separator,
  80    Component(ComponentMetadata, Option<Vec<usize>>),
  81    SectionHeader(SharedString),
  82}
  83
  84impl From<ComponentMetadata> for PreviewEntry {
  85    fn from(component: ComponentMetadata) -> Self {
  86        PreviewEntry::Component(component, None)
  87    }
  88}
  89
  90impl From<SharedString> for PreviewEntry {
  91    fn from(section_header: SharedString) -> Self {
  92        PreviewEntry::SectionHeader(section_header)
  93    }
  94}
  95
  96#[derive(Default, Debug, Clone, PartialEq, Eq)]
  97enum PreviewPage {
  98    #[default]
  99    AllComponents,
 100    Component(ComponentId),
 101    ActiveThread,
 102}
 103
 104struct ComponentPreview {
 105    workspace_id: Option<WorkspaceId>,
 106    focus_handle: FocusHandle,
 107    _view_scroll_handle: ScrollHandle,
 108    nav_scroll_handle: UniformListScrollHandle,
 109    component_map: HashMap<ComponentId, ComponentMetadata>,
 110    active_page: PreviewPage,
 111    components: Vec<ComponentMetadata>,
 112    component_list: ListState,
 113    cursor_index: usize,
 114    language_registry: Arc<LanguageRegistry>,
 115    workspace: WeakEntity<Workspace>,
 116    project: Entity<Project>,
 117    user_store: Entity<UserStore>,
 118    filter_editor: Entity<SingleLineInput>,
 119    filter_text: String,
 120
 121    // preview support
 122    thread_store: Option<Entity<ThreadStore>>,
 123    active_thread: Option<Entity<ActiveThread>>,
 124}
 125
 126impl ComponentPreview {
 127    pub fn new(
 128        workspace: WeakEntity<Workspace>,
 129        project: Entity<Project>,
 130        language_registry: Arc<LanguageRegistry>,
 131        user_store: Entity<UserStore>,
 132        selected_index: impl Into<Option<usize>>,
 133        active_page: Option<PreviewPage>,
 134        window: &mut Window,
 135        cx: &mut Context<Self>,
 136    ) -> anyhow::Result<Self> {
 137        let workspace_clone = workspace.clone();
 138        let project_clone = project.clone();
 139
 140        let entity = cx.weak_entity();
 141        window
 142            .spawn(cx, async move |cx| {
 143                let thread_store_task =
 144                    load_preview_thread_store(workspace_clone.clone(), project_clone.clone(), cx)
 145                        .await;
 146
 147                if let Ok(thread_store) = thread_store_task.await {
 148                    entity
 149                        .update_in(cx, |this, window, cx| {
 150                            this.thread_store = Some(thread_store.clone());
 151                            this.create_active_thread(window, cx);
 152                        })
 153                        .ok();
 154                }
 155            })
 156            .detach();
 157
 158        let sorted_components = components().all_sorted();
 159        let selected_index = selected_index.into().unwrap_or(0);
 160        let active_page = active_page.unwrap_or(PreviewPage::AllComponents);
 161        let filter_editor =
 162            cx.new(|cx| SingleLineInput::new(window, cx, "Find components or usages…"));
 163
 164        let component_list = ListState::new(
 165            sorted_components.len(),
 166            gpui::ListAlignment::Top,
 167            px(1500.0),
 168            {
 169                let this = cx.entity().downgrade();
 170                move |ix, window: &mut Window, cx: &mut App| {
 171                    this.update(cx, |this, cx| {
 172                        let component = this.get_component(ix);
 173                        this.render_preview(&component, window, cx)
 174                            .into_any_element()
 175                    })
 176                    .unwrap()
 177                }
 178            },
 179        );
 180
 181        let mut component_preview = Self {
 182            workspace_id: None,
 183            focus_handle: cx.focus_handle(),
 184            _view_scroll_handle: ScrollHandle::new(),
 185            nav_scroll_handle: UniformListScrollHandle::new(),
 186            language_registry,
 187            user_store,
 188            workspace,
 189            project,
 190            active_page,
 191            component_map: components().0,
 192            components: sorted_components,
 193            component_list,
 194            cursor_index: selected_index,
 195            filter_editor,
 196            filter_text: String::new(),
 197            thread_store: None,
 198            active_thread: None,
 199        };
 200
 201        if component_preview.cursor_index > 0 {
 202            component_preview.scroll_to_preview(component_preview.cursor_index, cx);
 203        }
 204
 205        component_preview.update_component_list(cx);
 206
 207        let focus_handle = component_preview.filter_editor.read(cx).focus_handle(cx);
 208        window.focus(&focus_handle);
 209
 210        Ok(component_preview)
 211    }
 212
 213    pub fn create_active_thread(
 214        &mut self,
 215        window: &mut Window,
 216        cx: &mut Context<Self>,
 217    ) -> &mut Self {
 218        let workspace = self.workspace.clone();
 219        let language_registry = self.language_registry.clone();
 220        let weak_handle = self.workspace.clone();
 221        if let Some(workspace) = workspace.upgrade() {
 222            let project = workspace.read(cx).project().clone();
 223            if let Some(thread_store) = self.thread_store.clone() {
 224                let active_thread = static_active_thread(
 225                    weak_handle,
 226                    project,
 227                    language_registry,
 228                    thread_store,
 229                    window,
 230                    cx,
 231                );
 232                self.active_thread = Some(active_thread);
 233                cx.notify();
 234            }
 235        }
 236
 237        self
 238    }
 239
 240    pub fn active_page_id(&self, _cx: &App) -> ActivePageId {
 241        match &self.active_page {
 242            PreviewPage::AllComponents => ActivePageId::default(),
 243            PreviewPage::Component(component_id) => ActivePageId(component_id.0.to_string()),
 244            PreviewPage::ActiveThread => ActivePageId("active_thread".to_string()),
 245        }
 246    }
 247
 248    fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
 249        self.component_list.scroll_to_reveal_item(ix);
 250        self.cursor_index = ix;
 251        cx.notify();
 252    }
 253
 254    fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) {
 255        self.active_page = page;
 256        cx.emit(ItemEvent::UpdateTab);
 257        cx.notify();
 258    }
 259
 260    fn get_component(&self, ix: usize) -> ComponentMetadata {
 261        self.components[ix].clone()
 262    }
 263
 264    fn filtered_components(&self) -> Vec<ComponentMetadata> {
 265        if self.filter_text.is_empty() {
 266            return self.components.clone();
 267        }
 268
 269        let filter = self.filter_text.to_lowercase();
 270        self.components
 271            .iter()
 272            .filter(|component| {
 273                let component_name = component.name().to_lowercase();
 274                let scope_name = component.scope().to_string().to_lowercase();
 275                let description = component
 276                    .description()
 277                    .map(|d| d.to_lowercase())
 278                    .unwrap_or_default();
 279
 280                component_name.contains(&filter)
 281                    || scope_name.contains(&filter)
 282                    || description.contains(&filter)
 283            })
 284            .cloned()
 285            .collect()
 286    }
 287
 288    fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
 289        use std::collections::HashMap;
 290
 291        let mut scope_groups: HashMap<
 292            ComponentScope,
 293            Vec<(ComponentMetadata, Option<Vec<usize>>)>,
 294        > = HashMap::default();
 295        let lowercase_filter = self.filter_text.to_lowercase();
 296
 297        for component in &self.components {
 298            if self.filter_text.is_empty() {
 299                scope_groups
 300                    .entry(component.scope())
 301                    .or_insert_with(Vec::new)
 302                    .push((component.clone(), None));
 303                continue;
 304            }
 305
 306            // let full_component_name = component.name();
 307            let scopeless_name = component.scopeless_name();
 308            let scope_name = component.scope().to_string();
 309            let description = component.description().unwrap_or_default();
 310
 311            let lowercase_scopeless = scopeless_name.to_lowercase();
 312            let lowercase_scope = scope_name.to_lowercase();
 313            let lowercase_desc = description.to_lowercase();
 314
 315            if lowercase_scopeless.contains(&lowercase_filter) {
 316                if let Some(index) = lowercase_scopeless.find(&lowercase_filter) {
 317                    let end = index + lowercase_filter.len();
 318
 319                    if end <= scopeless_name.len() {
 320                        let mut positions = Vec::new();
 321                        for i in index..end {
 322                            if scopeless_name.is_char_boundary(i) {
 323                                positions.push(i);
 324                            }
 325                        }
 326
 327                        if !positions.is_empty() {
 328                            scope_groups
 329                                .entry(component.scope())
 330                                .or_insert_with(Vec::new)
 331                                .push((component.clone(), Some(positions)));
 332                            continue;
 333                        }
 334                    }
 335                }
 336            }
 337
 338            if lowercase_scopeless.contains(&lowercase_filter)
 339                || lowercase_scope.contains(&lowercase_filter)
 340                || lowercase_desc.contains(&lowercase_filter)
 341            {
 342                scope_groups
 343                    .entry(component.scope())
 344                    .or_insert_with(Vec::new)
 345                    .push((component.clone(), None));
 346            }
 347        }
 348
 349        // Sort the components in each group
 350        for components in scope_groups.values_mut() {
 351            components.sort_by_key(|(c, _)| c.sort_name());
 352        }
 353
 354        let mut entries = Vec::new();
 355
 356        // Always show all components first
 357        entries.push(PreviewEntry::AllComponents);
 358        entries.push(PreviewEntry::ActiveThread);
 359        entries.push(PreviewEntry::Separator);
 360
 361        let mut scopes: Vec<_> = scope_groups
 362            .keys()
 363            .filter(|scope| !matches!(**scope, ComponentScope::None))
 364            .cloned()
 365            .collect();
 366
 367        scopes.sort_by_key(|s| s.to_string());
 368
 369        for scope in scopes {
 370            if let Some(components) = scope_groups.remove(&scope) {
 371                if !components.is_empty() {
 372                    entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
 373                    let mut sorted_components = components;
 374                    sorted_components.sort_by_key(|(component, _)| component.sort_name());
 375
 376                    for (component, positions) in sorted_components {
 377                        entries.push(PreviewEntry::Component(component, positions));
 378                    }
 379                }
 380            }
 381        }
 382
 383        // Add uncategorized components last
 384        if let Some(components) = scope_groups.get(&ComponentScope::None) {
 385            if !components.is_empty() {
 386                entries.push(PreviewEntry::Separator);
 387                entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
 388                let mut sorted_components = components.clone();
 389                sorted_components.sort_by_key(|(c, _)| c.sort_name());
 390
 391                for (component, positions) in sorted_components {
 392                    entries.push(PreviewEntry::Component(component, positions));
 393                }
 394            }
 395        }
 396
 397        entries
 398    }
 399
 400    fn render_sidebar_entry(
 401        &self,
 402        ix: usize,
 403        entry: &PreviewEntry,
 404        cx: &Context<Self>,
 405    ) -> impl IntoElement + use<> {
 406        match entry {
 407            PreviewEntry::Component(component_metadata, highlight_positions) => {
 408                let id = component_metadata.id();
 409                let selected = self.active_page == PreviewPage::Component(id.clone());
 410                let name = component_metadata.scopeless_name();
 411
 412                ListItem::new(ix)
 413                    .child(if let Some(_positions) = highlight_positions {
 414                        let name_lower = name.to_lowercase();
 415                        let filter_lower = self.filter_text.to_lowercase();
 416                        let valid_positions = if let Some(start) = name_lower.find(&filter_lower) {
 417                            let end = start + filter_lower.len();
 418                            (start..end).collect()
 419                        } else {
 420                            Vec::new()
 421                        };
 422                        if valid_positions.is_empty() {
 423                            Label::new(name.clone())
 424                                .color(Color::Default)
 425                                .into_any_element()
 426                        } else {
 427                            HighlightedLabel::new(name.clone(), valid_positions).into_any_element()
 428                        }
 429                    } else {
 430                        Label::new(name.clone())
 431                            .color(Color::Default)
 432                            .into_any_element()
 433                    })
 434                    .selectable(true)
 435                    .toggle_state(selected)
 436                    .inset(true)
 437                    .on_click(cx.listener(move |this, _, _, cx| {
 438                        let id = id.clone();
 439                        this.set_active_page(PreviewPage::Component(id), cx);
 440                    }))
 441                    .into_any_element()
 442            }
 443            PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
 444                .inset(true)
 445                .into_any_element(),
 446            PreviewEntry::AllComponents => {
 447                let selected = self.active_page == PreviewPage::AllComponents;
 448
 449                ListItem::new(ix)
 450                    .child(Label::new("All Components").color(Color::Default))
 451                    .selectable(true)
 452                    .toggle_state(selected)
 453                    .inset(true)
 454                    .on_click(cx.listener(move |this, _, _, cx| {
 455                        this.set_active_page(PreviewPage::AllComponents, cx);
 456                    }))
 457                    .into_any_element()
 458            }
 459            PreviewEntry::ActiveThread => {
 460                let selected = self.active_page == PreviewPage::ActiveThread;
 461
 462                ListItem::new(ix)
 463                    .child(Label::new("Active Thread").color(Color::Default))
 464                    .selectable(true)
 465                    .toggle_state(selected)
 466                    .inset(true)
 467                    .on_click(cx.listener(move |this, _, _, cx| {
 468                        this.set_active_page(PreviewPage::ActiveThread, cx);
 469                    }))
 470                    .into_any_element()
 471            }
 472            PreviewEntry::Separator => ListItem::new(ix)
 473                .child(
 474                    h_flex()
 475                        .occlude()
 476                        .pt_3()
 477                        .child(Divider::horizontal_dashed()),
 478                )
 479                .into_any_element(),
 480        }
 481    }
 482
 483    fn update_component_list(&mut self, cx: &mut Context<Self>) {
 484        let entries = self.scope_ordered_entries();
 485        let new_len = entries.len();
 486        let weak_entity = cx.entity().downgrade();
 487
 488        if new_len > 0 {
 489            self.nav_scroll_handle
 490                .scroll_to_item(0, ScrollStrategy::Top);
 491        }
 492
 493        let filtered_components = self.filtered_components();
 494
 495        if !self.filter_text.is_empty() && !matches!(self.active_page, PreviewPage::AllComponents) {
 496            if let PreviewPage::Component(ref component_id) = self.active_page {
 497                let component_still_visible = filtered_components
 498                    .iter()
 499                    .any(|component| component.id() == *component_id);
 500
 501                if !component_still_visible {
 502                    if !filtered_components.is_empty() {
 503                        let first_component = &filtered_components[0];
 504                        self.set_active_page(PreviewPage::Component(first_component.id()), cx);
 505                    } else {
 506                        self.set_active_page(PreviewPage::AllComponents, cx);
 507                    }
 508                }
 509            }
 510        }
 511
 512        self.component_list = ListState::new(
 513            filtered_components.len(),
 514            gpui::ListAlignment::Top,
 515            px(1500.0),
 516            {
 517                let components = filtered_components.clone();
 518                let this = cx.entity().downgrade();
 519                move |ix, window: &mut Window, cx: &mut App| {
 520                    if ix >= components.len() {
 521                        return div().w_full().h_0().into_any_element();
 522                    }
 523
 524                    this.update(cx, |this, cx| {
 525                        let component = &components[ix];
 526                        this.render_preview(component, window, cx)
 527                            .into_any_element()
 528                    })
 529                    .unwrap()
 530                }
 531            },
 532        );
 533
 534        let new_list = ListState::new(
 535            new_len,
 536            gpui::ListAlignment::Top,
 537            px(1500.0),
 538            move |ix, window, cx| {
 539                if ix >= entries.len() {
 540                    return div().w_full().h_0().into_any_element();
 541                }
 542
 543                let entry = &entries[ix];
 544
 545                weak_entity
 546                    .update(cx, |this, cx| match entry {
 547                        PreviewEntry::Component(component, _) => this
 548                            .render_preview(component, window, cx)
 549                            .into_any_element(),
 550                        PreviewEntry::SectionHeader(shared_string) => this
 551                            .render_scope_header(ix, shared_string.clone(), window, cx)
 552                            .into_any_element(),
 553                        PreviewEntry::AllComponents => div().w_full().h_0().into_any_element(),
 554                        PreviewEntry::ActiveThread => div().w_full().h_0().into_any_element(),
 555                        PreviewEntry::Separator => div().w_full().h_0().into_any_element(),
 556                    })
 557                    .unwrap()
 558            },
 559        );
 560
 561        self.component_list = new_list;
 562        cx.emit(ItemEvent::UpdateTab);
 563    }
 564
 565    fn render_scope_header(
 566        &self,
 567        _ix: usize,
 568        title: SharedString,
 569        _window: &Window,
 570        _cx: &App,
 571    ) -> impl IntoElement {
 572        h_flex()
 573            .w_full()
 574            .h_10()
 575            .items_center()
 576            .child(Headline::new(title).size(HeadlineSize::XSmall))
 577            .child(Divider::horizontal())
 578    }
 579
 580    fn render_preview(
 581        &self,
 582        component: &ComponentMetadata,
 583        window: &mut Window,
 584        cx: &mut App,
 585    ) -> impl IntoElement {
 586        let name = component.scopeless_name();
 587        let scope = component.scope();
 588
 589        let description = component.description();
 590
 591        // Build the content container
 592        let mut preview_container = v_flex().py_2().child(
 593            v_flex()
 594                .border_1()
 595                .border_color(cx.theme().colors().border)
 596                .rounded_sm()
 597                .w_full()
 598                .gap_4()
 599                .py_4()
 600                .px_6()
 601                .flex_none()
 602                .child(
 603                    v_flex()
 604                        .gap_1()
 605                        .child(
 606                            h_flex()
 607                                .gap_1()
 608                                .text_xl()
 609                                .child(div().child(name))
 610                                .when(!matches!(scope, ComponentScope::None), |this| {
 611                                    this.child(div().opacity(0.5).child(format!("({})", scope)))
 612                                }),
 613                        )
 614                        .when_some(description, |this, description| {
 615                            this.child(
 616                                div()
 617                                    .text_ui_sm(cx)
 618                                    .text_color(cx.theme().colors().text_muted)
 619                                    .max_w(px(600.0))
 620                                    .child(description),
 621                            )
 622                        }),
 623                ),
 624        );
 625
 626        // Check if the component's scope is Agent
 627        if scope == ComponentScope::Agent {
 628            if let (Some(thread_store), Some(active_thread)) = (
 629                self.thread_store.as_ref().map(|ts| ts.downgrade()),
 630                self.active_thread.clone(),
 631            ) {
 632                if let Some(element) = agent::get_agent_preview(
 633                    &component.id(),
 634                    self.workspace.clone(),
 635                    active_thread,
 636                    thread_store,
 637                    window,
 638                    cx,
 639                ) {
 640                    preview_container = preview_container.child(element);
 641                } else if let Some(preview) = component.preview() {
 642                    preview_container = preview_container.children(preview(window, cx));
 643                }
 644            }
 645        } else if let Some(preview) = component.preview() {
 646            preview_container = preview_container.children(preview(window, cx));
 647        }
 648
 649        preview_container.into_any_element()
 650    }
 651
 652    fn render_all_components(&self, cx: &Context<Self>) -> impl IntoElement {
 653        v_flex()
 654            .id("component-list")
 655            .px_8()
 656            .pt_4()
 657            .size_full()
 658            .child(
 659                if self.filtered_components().is_empty() && !self.filter_text.is_empty() {
 660                    div()
 661                        .size_full()
 662                        .items_center()
 663                        .justify_center()
 664                        .text_color(cx.theme().colors().text_muted)
 665                        .child(format!("No components matching '{}'.", self.filter_text))
 666                        .into_any_element()
 667                } else {
 668                    list(self.component_list.clone())
 669                        .flex_grow()
 670                        .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
 671                        .into_any_element()
 672                },
 673            )
 674    }
 675
 676    fn render_component_page(
 677        &mut self,
 678        component_id: &ComponentId,
 679        _window: &mut Window,
 680        _cx: &mut Context<Self>,
 681    ) -> impl IntoElement {
 682        let component = self.component_map.get(&component_id);
 683
 684        if let Some(component) = component {
 685            v_flex()
 686                .id("render-component-page")
 687                .size_full()
 688                .child(ComponentPreviewPage::new(
 689                    component.clone(),
 690                    self.workspace.clone(),
 691                    self.thread_store.as_ref().map(|ts| ts.downgrade()),
 692                    self.active_thread.clone(),
 693                ))
 694                .into_any_element()
 695        } else {
 696            v_flex()
 697                .size_full()
 698                .items_center()
 699                .justify_center()
 700                .child("Component not found")
 701                .into_any_element()
 702        }
 703    }
 704
 705    fn render_active_thread(&self, cx: &mut Context<Self>) -> impl IntoElement {
 706        v_flex()
 707            .id("render-active-thread")
 708            .size_full()
 709            .child(
 710                div()
 711                    .mx_auto()
 712                    .w(px(640.))
 713                    .h_full()
 714                    .py_8()
 715                    .bg(cx.theme().colors().panel_background)
 716                    .children(self.active_thread.clone().map(|thread| thread.clone()))
 717                    .when_none(&self.active_thread.clone(), |this| {
 718                        this.child("No active thread")
 719                    }),
 720            )
 721            .into_any_element()
 722    }
 723
 724    fn test_status_toast(&self, cx: &mut Context<Self>) {
 725        if let Some(workspace) = self.workspace.upgrade() {
 726            workspace.update(cx, |workspace, cx| {
 727                let status_toast =
 728                    StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
 729                        this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
 730                            .action("Open Pull Request", |_, cx| {
 731                                cx.open_url("https://github.com/")
 732                            })
 733                    });
 734                workspace.toggle_status_toast(status_toast, cx)
 735            });
 736        }
 737    }
 738}
 739
 740impl Render for ComponentPreview {
 741    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 742        // TODO: move this into the struct
 743        let current_filter = self.filter_editor.update(cx, |input, cx| {
 744            if input.is_empty(cx) {
 745                String::new()
 746            } else {
 747                input.editor().read(cx).text(cx).to_string()
 748            }
 749        });
 750
 751        if current_filter != self.filter_text {
 752            self.filter_text = current_filter;
 753            self.update_component_list(cx);
 754        }
 755        let sidebar_entries = self.scope_ordered_entries();
 756        let active_page = self.active_page.clone();
 757
 758        h_flex()
 759            .id("component-preview")
 760            .key_context("ComponentPreview")
 761            .items_start()
 762            .overflow_hidden()
 763            .size_full()
 764            .track_focus(&self.focus_handle)
 765            .bg(cx.theme().colors().editor_background)
 766            .child(
 767                v_flex()
 768                    .border_r_1()
 769                    .border_color(cx.theme().colors().border)
 770                    .h_full()
 771                    .child(
 772                        gpui::uniform_list(
 773                            cx.entity().clone(),
 774                            "component-nav",
 775                            sidebar_entries.len(),
 776                            move |this, range, _window, cx| {
 777                                range
 778                                    .filter_map(|ix| {
 779                                        if ix < sidebar_entries.len() {
 780                                            Some(this.render_sidebar_entry(
 781                                                ix,
 782                                                &sidebar_entries[ix],
 783                                                cx,
 784                                            ))
 785                                        } else {
 786                                            None
 787                                        }
 788                                    })
 789                                    .collect()
 790                            },
 791                        )
 792                        .track_scroll(self.nav_scroll_handle.clone())
 793                        .pt_4()
 794                        .px_4()
 795                        .w(px(240.))
 796                        .h_full()
 797                        .flex_1(),
 798                    )
 799                    .child(
 800                        div().w_full().pb_4().child(
 801                            Button::new("toast-test", "Launch Toast")
 802                                .on_click(cx.listener({
 803                                    move |this, _, _window, cx| {
 804                                        this.test_status_toast(cx);
 805                                        cx.notify();
 806                                    }
 807                                }))
 808                                .full_width(),
 809                        ),
 810                    ),
 811            )
 812            .child(
 813                v_flex()
 814                    .id("content-area")
 815                    .flex_1()
 816                    .size_full()
 817                    .overflow_hidden()
 818                    .child(
 819                        div()
 820                            .p_2()
 821                            .w_full()
 822                            .border_b_1()
 823                            .border_color(cx.theme().colors().border)
 824                            .child(self.filter_editor.clone()),
 825                    )
 826                    .child(match active_page {
 827                        PreviewPage::AllComponents => {
 828                            self.render_all_components(cx).into_any_element()
 829                        }
 830                        PreviewPage::Component(id) => self
 831                            .render_component_page(&id, window, cx)
 832                            .into_any_element(),
 833                        PreviewPage::ActiveThread => {
 834                            self.render_active_thread(cx).into_any_element()
 835                        }
 836                    }),
 837            )
 838    }
 839}
 840
 841impl EventEmitter<ItemEvent> for ComponentPreview {}
 842
 843impl Focusable for ComponentPreview {
 844    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
 845        self.focus_handle.clone()
 846    }
 847}
 848
 849#[derive(Debug, Clone, PartialEq, Eq, Hash)]
 850pub struct ActivePageId(pub String);
 851
 852impl Default for ActivePageId {
 853    fn default() -> Self {
 854        ActivePageId("AllComponents".to_string())
 855    }
 856}
 857
 858impl From<ComponentId> for ActivePageId {
 859    fn from(id: ComponentId) -> Self {
 860        Self(id.0.to_string())
 861    }
 862}
 863
 864impl Item for ComponentPreview {
 865    type Event = ItemEvent;
 866
 867    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
 868        "Component Preview".into()
 869    }
 870
 871    fn telemetry_event_text(&self) -> Option<&'static str> {
 872        None
 873    }
 874
 875    fn show_toolbar(&self) -> bool {
 876        false
 877    }
 878
 879    fn clone_on_split(
 880        &self,
 881        _workspace_id: Option<WorkspaceId>,
 882        window: &mut Window,
 883        cx: &mut Context<Self>,
 884    ) -> Option<gpui::Entity<Self>>
 885    where
 886        Self: Sized,
 887    {
 888        let language_registry = self.language_registry.clone();
 889        let user_store = self.user_store.clone();
 890        let weak_workspace = self.workspace.clone();
 891        let project = self.project.clone();
 892        let selected_index = self.cursor_index;
 893        let active_page = self.active_page.clone();
 894
 895        let self_result = Self::new(
 896            weak_workspace,
 897            project,
 898            language_registry,
 899            user_store,
 900            selected_index,
 901            Some(active_page),
 902            window,
 903            cx,
 904        );
 905
 906        match self_result {
 907            Ok(preview) => Some(cx.new(|_cx| preview)),
 908            Err(e) => {
 909                log::error!("Failed to clone component preview: {}", e);
 910                None
 911            }
 912        }
 913    }
 914
 915    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
 916        f(*event)
 917    }
 918
 919    fn added_to_workspace(
 920        &mut self,
 921        workspace: &mut Workspace,
 922        window: &mut Window,
 923        cx: &mut Context<Self>,
 924    ) {
 925        self.workspace_id = workspace.database_id();
 926
 927        let focus_handle = self.filter_editor.read(cx).focus_handle(cx);
 928        window.focus(&focus_handle);
 929    }
 930}
 931
 932impl SerializableItem for ComponentPreview {
 933    fn serialized_item_kind() -> &'static str {
 934        "ComponentPreview"
 935    }
 936
 937    fn deserialize(
 938        project: Entity<Project>,
 939        workspace: WeakEntity<Workspace>,
 940        workspace_id: WorkspaceId,
 941        item_id: ItemId,
 942        window: &mut Window,
 943        cx: &mut App,
 944    ) -> Task<gpui::Result<Entity<Self>>> {
 945        let deserialized_active_page =
 946            match COMPONENT_PREVIEW_DB.get_active_page(item_id, workspace_id) {
 947                Ok(page) => {
 948                    if let Some(page) = page {
 949                        ActivePageId(page)
 950                    } else {
 951                        ActivePageId::default()
 952                    }
 953                }
 954                Err(_) => ActivePageId::default(),
 955            };
 956
 957        let user_store = project.read(cx).user_store().clone();
 958        let language_registry = project.read(cx).languages().clone();
 959        let preview_page = if deserialized_active_page.0 == ActivePageId::default().0 {
 960            Some(PreviewPage::default())
 961        } else {
 962            let component_str = deserialized_active_page.0;
 963            let component_registry = components();
 964            let all_components = component_registry.all();
 965            let found_component = all_components.iter().find(|c| c.id().0 == component_str);
 966
 967            if let Some(component) = found_component {
 968                Some(PreviewPage::Component(component.id().clone()))
 969            } else {
 970                Some(PreviewPage::default())
 971            }
 972        };
 973
 974        window.spawn(cx, async move |cx| {
 975            let user_store = user_store.clone();
 976            let language_registry = language_registry.clone();
 977            let weak_workspace = workspace.clone();
 978            let project = project.clone();
 979            cx.update(move |window, cx| {
 980                Ok(cx.new(|cx| {
 981                    ComponentPreview::new(
 982                        weak_workspace,
 983                        project,
 984                        language_registry,
 985                        user_store,
 986                        None,
 987                        preview_page,
 988                        window,
 989                        cx,
 990                    )
 991                    .expect("Failed to create component preview")
 992                }))
 993            })?
 994        })
 995    }
 996
 997    fn cleanup(
 998        workspace_id: WorkspaceId,
 999        alive_items: Vec<ItemId>,
1000        _window: &mut Window,
1001        cx: &mut App,
1002    ) -> Task<gpui::Result<()>> {
1003        delete_unloaded_items(
1004            alive_items,
1005            workspace_id,
1006            "component_previews",
1007            &COMPONENT_PREVIEW_DB,
1008            cx,
1009        )
1010    }
1011
1012    fn serialize(
1013        &mut self,
1014        _workspace: &mut Workspace,
1015        item_id: ItemId,
1016        _closing: bool,
1017        _window: &mut Window,
1018        cx: &mut Context<Self>,
1019    ) -> Option<Task<gpui::Result<()>>> {
1020        let active_page = self.active_page_id(cx);
1021        let workspace_id = self.workspace_id?;
1022        Some(cx.background_spawn(async move {
1023            COMPONENT_PREVIEW_DB
1024                .save_active_page(item_id, workspace_id, active_page.0)
1025                .await
1026        }))
1027    }
1028
1029    fn should_serialize(&self, event: &Self::Event) -> bool {
1030        matches!(event, ItemEvent::UpdateTab)
1031    }
1032}
1033
1034// TODO: use language registry to allow rendering markdown
1035#[derive(IntoElement)]
1036pub struct ComponentPreviewPage {
1037    // languages: Arc<LanguageRegistry>,
1038    component: ComponentMetadata,
1039    workspace: WeakEntity<Workspace>,
1040    thread_store: Option<WeakEntity<ThreadStore>>,
1041    active_thread: Option<Entity<ActiveThread>>,
1042}
1043
1044impl ComponentPreviewPage {
1045    pub fn new(
1046        component: ComponentMetadata,
1047        workspace: WeakEntity<Workspace>,
1048        thread_store: Option<WeakEntity<ThreadStore>>,
1049        active_thread: Option<Entity<ActiveThread>>,
1050        // languages: Arc<LanguageRegistry>
1051    ) -> Self {
1052        Self {
1053            // languages,
1054            component,
1055            workspace,
1056            thread_store,
1057            active_thread,
1058        }
1059    }
1060
1061    fn render_header(&self, _: &Window, cx: &App) -> impl IntoElement {
1062        v_flex()
1063            .px_12()
1064            .pt_16()
1065            .pb_12()
1066            .gap_6()
1067            .bg(cx.theme().colors().surface_background)
1068            .border_b_1()
1069            .border_color(cx.theme().colors().border)
1070            .child(
1071                v_flex()
1072                    .gap_0p5()
1073                    .child(
1074                        Label::new(self.component.scope().to_string())
1075                            .size(LabelSize::Small)
1076                            .color(Color::Muted),
1077                    )
1078                    .child(
1079                        Headline::new(self.component.scopeless_name()).size(HeadlineSize::XLarge),
1080                    ),
1081            )
1082            .when_some(self.component.description(), |this, description| {
1083                this.child(div().text_sm().child(description))
1084            })
1085    }
1086
1087    fn render_preview(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1088        // Try to get agent preview first if we have an active thread
1089        let maybe_agent_preview = if let (Some(thread_store), Some(active_thread)) =
1090            (self.thread_store.as_ref(), self.active_thread.as_ref())
1091        {
1092            agent::get_agent_preview(
1093                &self.component.id(),
1094                self.workspace.clone(),
1095                active_thread.clone(),
1096                thread_store.clone(),
1097                window,
1098                cx,
1099            )
1100        } else {
1101            None
1102        };
1103
1104        v_flex()
1105            .flex_1()
1106            .px_12()
1107            .py_6()
1108            .bg(cx.theme().colors().editor_background)
1109            .child(if let Some(element) = maybe_agent_preview {
1110                // Use agent preview if available
1111                element
1112            } else if let Some(preview) = self.component.preview() {
1113                // Fall back to component preview
1114                preview(window, cx).unwrap_or_else(|| {
1115                    div()
1116                        .child("Failed to load preview. This path should be unreachable")
1117                        .into_any_element()
1118                })
1119            } else {
1120                div().child("No preview available").into_any_element()
1121            })
1122    }
1123}
1124
1125impl RenderOnce for ComponentPreviewPage {
1126    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1127        v_flex()
1128            .id("component-preview-page")
1129            .overflow_y_scroll()
1130            .overflow_x_hidden()
1131            .w_full()
1132            .child(self.render_header(window, cx))
1133            .child(self.render_preview(window, cx))
1134    }
1135}