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