extensions_ui.rs

   1mod components;
   2mod extension_suggest;
   3mod extension_version_selector;
   4
   5use std::sync::OnceLock;
   6use std::time::Duration;
   7use std::{ops::Range, sync::Arc};
   8
   9use client::{ExtensionMetadata, ExtensionProvides};
  10use collections::{BTreeMap, BTreeSet};
  11use editor::{Editor, EditorElement, EditorStyle};
  12use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore};
  13use fuzzy::{StringMatchCandidate, match_strings};
  14use gpui::{
  15    Action, App, ClipboardItem, Context, Entity, EventEmitter, Flatten, Focusable,
  16    InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle,
  17    UniformListScrollHandle, WeakEntity, Window, actions, point, uniform_list,
  18};
  19use num_format::{Locale, ToFormattedString};
  20use project::DirectoryLister;
  21use release_channel::ReleaseChannel;
  22use settings::Settings;
  23use strum::IntoEnumIterator as _;
  24use theme::ThemeSettings;
  25use ui::{
  26    CheckboxWithLabel, ContextMenu, PopoverMenu, ScrollableHandle, Scrollbar, ScrollbarState,
  27    ToggleButton, Tooltip, prelude::*,
  28};
  29use vim_mode_setting::VimModeSetting;
  30use workspace::{
  31    Workspace, WorkspaceId,
  32    item::{Item, ItemEvent},
  33};
  34use zed_actions::ExtensionCategoryFilter;
  35
  36use crate::components::{ExtensionCard, FeatureUpsell};
  37use crate::extension_version_selector::{
  38    ExtensionVersionSelector, ExtensionVersionSelectorDelegate,
  39};
  40
  41actions!(zed, [InstallDevExtension]);
  42
  43pub fn init(cx: &mut App) {
  44    cx.observe_new(move |workspace: &mut Workspace, window, cx| {
  45        let Some(window) = window else {
  46            return;
  47        };
  48        workspace
  49            .register_action(
  50                move |workspace, action: &zed_actions::Extensions, window, cx| {
  51                    let provides_filter = action.category_filter.map(|category| match category {
  52                        ExtensionCategoryFilter::Themes => ExtensionProvides::Themes,
  53                        ExtensionCategoryFilter::IconThemes => ExtensionProvides::IconThemes,
  54                        ExtensionCategoryFilter::Languages => ExtensionProvides::Languages,
  55                        ExtensionCategoryFilter::Grammars => ExtensionProvides::Grammars,
  56                        ExtensionCategoryFilter::LanguageServers => {
  57                            ExtensionProvides::LanguageServers
  58                        }
  59                        ExtensionCategoryFilter::ContextServers => {
  60                            ExtensionProvides::ContextServers
  61                        }
  62                        ExtensionCategoryFilter::SlashCommands => ExtensionProvides::SlashCommands,
  63                        ExtensionCategoryFilter::IndexedDocsProviders => {
  64                            ExtensionProvides::IndexedDocsProviders
  65                        }
  66                        ExtensionCategoryFilter::Snippets => ExtensionProvides::Snippets,
  67                    });
  68
  69                    let existing = workspace
  70                        .active_pane()
  71                        .read(cx)
  72                        .items()
  73                        .find_map(|item| item.downcast::<ExtensionsPage>());
  74
  75                    if let Some(existing) = existing {
  76                        if provides_filter.is_some() {
  77                            existing.update(cx, |extensions_page, cx| {
  78                                extensions_page.change_provides_filter(provides_filter, cx);
  79                            });
  80                        }
  81
  82                        workspace.activate_item(&existing, true, true, window, cx);
  83                    } else {
  84                        let extensions_page =
  85                            ExtensionsPage::new(workspace, provides_filter, window, cx);
  86                        workspace.add_item_to_active_pane(
  87                            Box::new(extensions_page),
  88                            None,
  89                            true,
  90                            window,
  91                            cx,
  92                        )
  93                    }
  94                },
  95            )
  96            .register_action(move |workspace, _: &InstallDevExtension, window, cx| {
  97                let store = ExtensionStore::global(cx);
  98                let prompt = workspace.prompt_for_open_path(
  99                    gpui::PathPromptOptions {
 100                        files: false,
 101                        directories: true,
 102                        multiple: false,
 103                    },
 104                    DirectoryLister::Local(workspace.app_state().fs.clone()),
 105                    window,
 106                    cx,
 107                );
 108
 109                let workspace_handle = cx.entity().downgrade();
 110                window
 111                    .spawn(cx, async move |cx| {
 112                        let extension_path =
 113                            match Flatten::flatten(prompt.await.map_err(|e| e.into())) {
 114                                Ok(Some(mut paths)) => paths.pop()?,
 115                                Ok(None) => return None,
 116                                Err(err) => {
 117                                    workspace_handle
 118                                        .update(cx, |workspace, cx| {
 119                                            workspace.show_portal_error(err.to_string(), cx);
 120                                        })
 121                                        .ok();
 122                                    return None;
 123                                }
 124                            };
 125
 126                        let install_task = store
 127                            .update(cx, |store, cx| {
 128                                store.install_dev_extension(extension_path, cx)
 129                            })
 130                            .ok()?;
 131
 132                        match install_task.await {
 133                            Ok(_) => {}
 134                            Err(err) => {
 135                                workspace_handle
 136                                    .update(cx, |workspace, cx| {
 137                                        workspace.show_error(
 138                                            &err.context("failed to install dev extension"),
 139                                            cx,
 140                                        );
 141                                    })
 142                                    .ok();
 143                            }
 144                        }
 145
 146                        Some(())
 147                    })
 148                    .detach();
 149            });
 150
 151        cx.subscribe_in(workspace.project(), window, |_, _, event, window, cx| {
 152            if let project::Event::LanguageNotFound(buffer) = event {
 153                extension_suggest::suggest(buffer.clone(), window, cx);
 154            }
 155        })
 156        .detach();
 157    })
 158    .detach();
 159}
 160
 161fn extension_provides_label(provides: ExtensionProvides) -> &'static str {
 162    match provides {
 163        ExtensionProvides::Themes => "Themes",
 164        ExtensionProvides::IconThemes => "Icon Themes",
 165        ExtensionProvides::Languages => "Languages",
 166        ExtensionProvides::Grammars => "Grammars",
 167        ExtensionProvides::LanguageServers => "Language Servers",
 168        ExtensionProvides::ContextServers => "MCP Servers",
 169        ExtensionProvides::SlashCommands => "Slash Commands",
 170        ExtensionProvides::IndexedDocsProviders => "Indexed Docs Providers",
 171        ExtensionProvides::Snippets => "Snippets",
 172    }
 173}
 174
 175#[derive(Clone)]
 176pub enum ExtensionStatus {
 177    NotInstalled,
 178    Installing,
 179    Upgrading,
 180    Installed(Arc<str>),
 181    Removing,
 182}
 183
 184#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
 185enum ExtensionFilter {
 186    All,
 187    Installed,
 188    NotInstalled,
 189}
 190
 191impl ExtensionFilter {
 192    pub fn include_dev_extensions(&self) -> bool {
 193        match self {
 194            Self::All | Self::Installed => true,
 195            Self::NotInstalled => false,
 196        }
 197    }
 198}
 199
 200#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
 201enum Feature {
 202    Git,
 203    OpenIn,
 204    Vim,
 205    LanguageBash,
 206    LanguageC,
 207    LanguageCpp,
 208    LanguageGo,
 209    LanguagePython,
 210    LanguageReact,
 211    LanguageRust,
 212    LanguageTypescript,
 213}
 214
 215fn keywords_by_feature() -> &'static BTreeMap<Feature, Vec<&'static str>> {
 216    static KEYWORDS_BY_FEATURE: OnceLock<BTreeMap<Feature, Vec<&'static str>>> = OnceLock::new();
 217    KEYWORDS_BY_FEATURE.get_or_init(|| {
 218        BTreeMap::from_iter([
 219            (Feature::Git, vec!["git"]),
 220            (
 221                Feature::OpenIn,
 222                vec![
 223                    "github",
 224                    "gitlab",
 225                    "bitbucket",
 226                    "codeberg",
 227                    "sourcehut",
 228                    "permalink",
 229                    "link",
 230                    "open in",
 231                ],
 232            ),
 233            (Feature::Vim, vec!["vim"]),
 234            (Feature::LanguageBash, vec!["sh", "bash"]),
 235            (Feature::LanguageC, vec!["c", "clang"]),
 236            (Feature::LanguageCpp, vec!["c++", "cpp", "clang"]),
 237            (Feature::LanguageGo, vec!["go", "golang"]),
 238            (Feature::LanguagePython, vec!["python", "py"]),
 239            (Feature::LanguageReact, vec!["react"]),
 240            (Feature::LanguageRust, vec!["rust", "rs"]),
 241            (
 242                Feature::LanguageTypescript,
 243                vec!["type", "typescript", "ts"],
 244            ),
 245        ])
 246    })
 247}
 248
 249pub struct ExtensionsPage {
 250    workspace: WeakEntity<Workspace>,
 251    list: UniformListScrollHandle,
 252    is_fetching_extensions: bool,
 253    filter: ExtensionFilter,
 254    remote_extension_entries: Vec<ExtensionMetadata>,
 255    dev_extension_entries: Vec<Arc<ExtensionManifest>>,
 256    filtered_remote_extension_indices: Vec<usize>,
 257    query_editor: Entity<Editor>,
 258    query_contains_error: bool,
 259    provides_filter: Option<ExtensionProvides>,
 260    _subscriptions: [gpui::Subscription; 2],
 261    extension_fetch_task: Option<Task<()>>,
 262    upsells: BTreeSet<Feature>,
 263    scrollbar_state: ScrollbarState,
 264}
 265
 266impl ExtensionsPage {
 267    pub fn new(
 268        workspace: &Workspace,
 269        provides_filter: Option<ExtensionProvides>,
 270        window: &mut Window,
 271        cx: &mut Context<Workspace>,
 272    ) -> Entity<Self> {
 273        cx.new(|cx| {
 274            let store = ExtensionStore::global(cx);
 275            let workspace_handle = workspace.weak_handle();
 276            let subscriptions = [
 277                cx.observe(&store, |_: &mut Self, _, cx| cx.notify()),
 278                cx.subscribe_in(
 279                    &store,
 280                    window,
 281                    move |this, _, event, window, cx| match event {
 282                        extension_host::Event::ExtensionsUpdated => {
 283                            this.fetch_extensions_debounced(None, cx)
 284                        }
 285                        extension_host::Event::ExtensionInstalled(extension_id) => this
 286                            .on_extension_installed(
 287                                workspace_handle.clone(),
 288                                extension_id,
 289                                window,
 290                                cx,
 291                            ),
 292                        _ => {}
 293                    },
 294                ),
 295            ];
 296
 297            let query_editor = cx.new(|cx| {
 298                let mut input = Editor::single_line(window, cx);
 299                input.set_placeholder_text("Search extensions...", cx);
 300                input
 301            });
 302            cx.subscribe(&query_editor, Self::on_query_change).detach();
 303
 304            let scroll_handle = UniformListScrollHandle::new();
 305
 306            let mut this = Self {
 307                workspace: workspace.weak_handle(),
 308                list: scroll_handle.clone(),
 309                is_fetching_extensions: false,
 310                filter: ExtensionFilter::All,
 311                dev_extension_entries: Vec::new(),
 312                filtered_remote_extension_indices: Vec::new(),
 313                remote_extension_entries: Vec::new(),
 314                query_contains_error: false,
 315                provides_filter,
 316                extension_fetch_task: None,
 317                _subscriptions: subscriptions,
 318                query_editor,
 319                upsells: BTreeSet::default(),
 320                scrollbar_state: ScrollbarState::new(scroll_handle),
 321            };
 322            this.fetch_extensions(
 323                None,
 324                Some(BTreeSet::from_iter(this.provides_filter)),
 325                None,
 326                cx,
 327            );
 328            this
 329        })
 330    }
 331
 332    fn on_extension_installed(
 333        &mut self,
 334        workspace: WeakEntity<Workspace>,
 335        extension_id: &str,
 336        window: &mut Window,
 337        cx: &mut Context<Self>,
 338    ) {
 339        let extension_store = ExtensionStore::global(cx).read(cx);
 340        let themes = extension_store
 341            .extension_themes(extension_id)
 342            .map(|name| name.to_string())
 343            .collect::<Vec<_>>();
 344        if !themes.is_empty() {
 345            workspace
 346                .update(cx, |_workspace, cx| {
 347                    window.dispatch_action(
 348                        zed_actions::theme_selector::Toggle {
 349                            themes_filter: Some(themes),
 350                        }
 351                        .boxed_clone(),
 352                        cx,
 353                    );
 354                })
 355                .ok();
 356            return;
 357        }
 358
 359        let icon_themes = extension_store
 360            .extension_icon_themes(extension_id)
 361            .map(|name| name.to_string())
 362            .collect::<Vec<_>>();
 363        if !icon_themes.is_empty() {
 364            workspace
 365                .update(cx, |_workspace, cx| {
 366                    window.dispatch_action(
 367                        zed_actions::icon_theme_selector::Toggle {
 368                            themes_filter: Some(icon_themes),
 369                        }
 370                        .boxed_clone(),
 371                        cx,
 372                    );
 373                })
 374                .ok();
 375        }
 376    }
 377
 378    /// Returns whether a dev extension currently exists for the extension with the given ID.
 379    fn dev_extension_exists(extension_id: &str, cx: &mut Context<Self>) -> bool {
 380        let extension_store = ExtensionStore::global(cx).read(cx);
 381
 382        extension_store
 383            .dev_extensions()
 384            .any(|dev_extension| dev_extension.id.as_ref() == extension_id)
 385    }
 386
 387    fn extension_status(extension_id: &str, cx: &mut Context<Self>) -> ExtensionStatus {
 388        let extension_store = ExtensionStore::global(cx).read(cx);
 389
 390        match extension_store.outstanding_operations().get(extension_id) {
 391            Some(ExtensionOperation::Install) => ExtensionStatus::Installing,
 392            Some(ExtensionOperation::Remove) => ExtensionStatus::Removing,
 393            Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading,
 394            None => match extension_store.installed_extensions().get(extension_id) {
 395                Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()),
 396                None => ExtensionStatus::NotInstalled,
 397            },
 398        }
 399    }
 400
 401    fn filter_extension_entries(&mut self, cx: &mut Context<Self>) {
 402        self.filtered_remote_extension_indices.clear();
 403        self.filtered_remote_extension_indices.extend(
 404            self.remote_extension_entries
 405                .iter()
 406                .enumerate()
 407                .filter(|(_, extension)| match self.filter {
 408                    ExtensionFilter::All => true,
 409                    ExtensionFilter::Installed => {
 410                        let status = Self::extension_status(&extension.id, cx);
 411                        matches!(status, ExtensionStatus::Installed(_))
 412                    }
 413                    ExtensionFilter::NotInstalled => {
 414                        let status = Self::extension_status(&extension.id, cx);
 415
 416                        matches!(status, ExtensionStatus::NotInstalled)
 417                    }
 418                })
 419                .map(|(ix, _)| ix),
 420        );
 421        cx.notify();
 422    }
 423
 424    fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
 425        self.list.set_offset(point(px(0.), px(0.)));
 426        cx.notify();
 427    }
 428
 429    fn fetch_extensions(
 430        &mut self,
 431        search: Option<String>,
 432        provides_filter: Option<BTreeSet<ExtensionProvides>>,
 433        on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
 434        cx: &mut Context<Self>,
 435    ) {
 436        self.is_fetching_extensions = true;
 437        cx.notify();
 438
 439        let extension_store = ExtensionStore::global(cx);
 440
 441        let dev_extensions = extension_store.update(cx, |store, _| {
 442            store.dev_extensions().cloned().collect::<Vec<_>>()
 443        });
 444
 445        let remote_extensions = extension_store.update(cx, |store, cx| {
 446            store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx)
 447        });
 448
 449        cx.spawn(async move |this, cx| {
 450            let dev_extensions = if let Some(search) = search {
 451                let match_candidates = dev_extensions
 452                    .iter()
 453                    .enumerate()
 454                    .map(|(ix, manifest)| StringMatchCandidate::new(ix, &manifest.name))
 455                    .collect::<Vec<_>>();
 456
 457                let matches = match_strings(
 458                    &match_candidates,
 459                    &search,
 460                    false,
 461                    match_candidates.len(),
 462                    &Default::default(),
 463                    cx.background_executor().clone(),
 464                )
 465                .await;
 466                matches
 467                    .into_iter()
 468                    .map(|mat| dev_extensions[mat.candidate_id].clone())
 469                    .collect()
 470            } else {
 471                dev_extensions
 472            };
 473
 474            let fetch_result = remote_extensions.await;
 475            this.update(cx, |this, cx| {
 476                cx.notify();
 477                this.dev_extension_entries = dev_extensions;
 478                this.is_fetching_extensions = false;
 479                this.remote_extension_entries = fetch_result?;
 480                this.filter_extension_entries(cx);
 481                if let Some(callback) = on_complete {
 482                    callback(this, cx);
 483                }
 484                anyhow::Ok(())
 485            })?
 486        })
 487        .detach_and_log_err(cx);
 488    }
 489
 490    fn render_extensions(
 491        &mut self,
 492        range: Range<usize>,
 493        _: &mut Window,
 494        cx: &mut Context<Self>,
 495    ) -> Vec<ExtensionCard> {
 496        let dev_extension_entries_len = if self.filter.include_dev_extensions() {
 497            self.dev_extension_entries.len()
 498        } else {
 499            0
 500        };
 501        range
 502            .map(|ix| {
 503                if ix < dev_extension_entries_len {
 504                    let extension = &self.dev_extension_entries[ix];
 505                    self.render_dev_extension(extension, cx)
 506                } else {
 507                    let extension_ix =
 508                        self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
 509                    let extension = &self.remote_extension_entries[extension_ix];
 510                    self.render_remote_extension(extension, cx)
 511                }
 512            })
 513            .collect()
 514    }
 515
 516    fn render_dev_extension(
 517        &self,
 518        extension: &ExtensionManifest,
 519        cx: &mut Context<Self>,
 520    ) -> ExtensionCard {
 521        let status = Self::extension_status(&extension.id, cx);
 522
 523        let repository_url = extension.repository.clone();
 524
 525        ExtensionCard::new()
 526            .child(
 527                h_flex()
 528                    .justify_between()
 529                    .child(
 530                        h_flex()
 531                            .gap_2()
 532                            .items_end()
 533                            .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
 534                            .child(
 535                                Headline::new(format!("v{}", extension.version))
 536                                    .size(HeadlineSize::XSmall),
 537                            ),
 538                    )
 539                    .child(
 540                        h_flex()
 541                            .gap_2()
 542                            .justify_between()
 543                            .child(
 544                                Button::new(
 545                                    SharedString::from(format!("rebuild-{}", extension.id)),
 546                                    "Rebuild",
 547                                )
 548                                .on_click({
 549                                    let extension_id = extension.id.clone();
 550                                    move |_, _, cx| {
 551                                        ExtensionStore::global(cx).update(cx, |store, cx| {
 552                                            store.rebuild_dev_extension(extension_id.clone(), cx)
 553                                        });
 554                                    }
 555                                })
 556                                .color(Color::Accent)
 557                                .disabled(matches!(status, ExtensionStatus::Upgrading)),
 558                            )
 559                            .child(
 560                                Button::new(SharedString::from(extension.id.clone()), "Uninstall")
 561                                    .on_click({
 562                                        let extension_id = extension.id.clone();
 563                                        move |_, _, cx| {
 564                                            ExtensionStore::global(cx).update(cx, |store, cx| {
 565                                                store.uninstall_extension(extension_id.clone(), cx)
 566                                            });
 567                                        }
 568                                    })
 569                                    .color(Color::Accent)
 570                                    .disabled(matches!(status, ExtensionStatus::Removing)),
 571                            ),
 572                    ),
 573            )
 574            .child(
 575                h_flex()
 576                    .gap_2()
 577                    .justify_between()
 578                    .child(
 579                        Label::new(format!(
 580                            "{}: {}",
 581                            if extension.authors.len() > 1 {
 582                                "Authors"
 583                            } else {
 584                                "Author"
 585                            },
 586                            extension.authors.join(", ")
 587                        ))
 588                        .size(LabelSize::Small)
 589                        .color(Color::Muted)
 590                        .truncate(),
 591                    )
 592                    .child(Label::new("<>").size(LabelSize::Small)),
 593            )
 594            .child(
 595                h_flex()
 596                    .gap_2()
 597                    .justify_between()
 598                    .children(extension.description.as_ref().map(|description| {
 599                        Label::new(description.clone())
 600                            .size(LabelSize::Small)
 601                            .color(Color::Default)
 602                            .truncate()
 603                    }))
 604                    .children(repository_url.map(|repository_url| {
 605                        IconButton::new(
 606                            SharedString::from(format!("repository-{}", extension.id)),
 607                            IconName::Github,
 608                        )
 609                        .icon_color(Color::Accent)
 610                        .icon_size(IconSize::Small)
 611                        .on_click(cx.listener({
 612                            let repository_url = repository_url.clone();
 613                            move |_, _, _, cx| {
 614                                cx.open_url(&repository_url);
 615                            }
 616                        }))
 617                        .tooltip(Tooltip::text(repository_url.clone()))
 618                    })),
 619            )
 620    }
 621
 622    fn render_remote_extension(
 623        &self,
 624        extension: &ExtensionMetadata,
 625        cx: &mut Context<Self>,
 626    ) -> ExtensionCard {
 627        let this = cx.entity().clone();
 628        let status = Self::extension_status(&extension.id, cx);
 629        let has_dev_extension = Self::dev_extension_exists(&extension.id, cx);
 630
 631        let extension_id = extension.id.clone();
 632        let (install_or_uninstall_button, upgrade_button) =
 633            self.buttons_for_entry(extension, &status, has_dev_extension, cx);
 634        let version = extension.manifest.version.clone();
 635        let repository_url = extension.manifest.repository.clone();
 636        let authors = extension.manifest.authors.clone();
 637
 638        let installed_version = match status {
 639            ExtensionStatus::Installed(installed_version) => Some(installed_version),
 640            _ => None,
 641        };
 642
 643        ExtensionCard::new()
 644            .overridden_by_dev_extension(has_dev_extension)
 645            .child(
 646                h_flex()
 647                    .justify_between()
 648                    .child(
 649                        h_flex()
 650                            .gap_2()
 651                            .child(
 652                                Headline::new(extension.manifest.name.clone())
 653                                    .size(HeadlineSize::Medium),
 654                            )
 655                            .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
 656                            .children(
 657                                installed_version
 658                                    .filter(|installed_version| *installed_version != version)
 659                                    .map(|installed_version| {
 660                                        Headline::new(format!("(v{installed_version} installed)",))
 661                                            .size(HeadlineSize::XSmall)
 662                                    }),
 663                            )
 664                            .map(|parent| {
 665                                if extension.manifest.provides.is_empty() {
 666                                    return parent;
 667                                }
 668
 669                                parent.child(
 670                                    h_flex().gap_2().children(
 671                                        extension
 672                                            .manifest
 673                                            .provides
 674                                            .iter()
 675                                            .map(|provides| {
 676                                                div()
 677                                                    .bg(cx.theme().colors().element_background)
 678                                                    .px_0p5()
 679                                                    .border_1()
 680                                                    .border_color(cx.theme().colors().border)
 681                                                    .rounded_sm()
 682                                                    .child(
 683                                                        Label::new(extension_provides_label(
 684                                                            *provides,
 685                                                        ))
 686                                                        .size(LabelSize::XSmall),
 687                                                    )
 688                                            })
 689                                            .collect::<Vec<_>>(),
 690                                    ),
 691                                )
 692                            }),
 693                    )
 694                    .child(
 695                        h_flex()
 696                            .gap_2()
 697                            .justify_between()
 698                            .children(upgrade_button)
 699                            .child(install_or_uninstall_button),
 700                    ),
 701            )
 702            .child(
 703                h_flex()
 704                    .gap_2()
 705                    .justify_between()
 706                    .child(
 707                        Label::new(format!(
 708                            "{}: {}",
 709                            if extension.manifest.authors.len() > 1 {
 710                                "Authors"
 711                            } else {
 712                                "Author"
 713                            },
 714                            extension.manifest.authors.join(", ")
 715                        ))
 716                        .size(LabelSize::Small)
 717                        .color(Color::Muted)
 718                        .truncate(),
 719                    )
 720                    .child(
 721                        Label::new(format!(
 722                            "Downloads: {}",
 723                            extension.download_count.to_formatted_string(&Locale::en)
 724                        ))
 725                        .size(LabelSize::Small),
 726                    ),
 727            )
 728            .child(
 729                h_flex()
 730                    .gap_2()
 731                    .justify_between()
 732                    .children(extension.manifest.description.as_ref().map(|description| {
 733                        Label::new(description.clone())
 734                            .size(LabelSize::Small)
 735                            .color(Color::Default)
 736                            .truncate()
 737                    }))
 738                    .child(
 739                        h_flex()
 740                            .gap_2()
 741                            .child(
 742                                IconButton::new(
 743                                    SharedString::from(format!("repository-{}", extension.id)),
 744                                    IconName::Github,
 745                                )
 746                                .icon_color(Color::Accent)
 747                                .icon_size(IconSize::Small)
 748                                .on_click(cx.listener({
 749                                    let repository_url = repository_url.clone();
 750                                    move |_, _, _, cx| {
 751                                        cx.open_url(&repository_url);
 752                                    }
 753                                }))
 754                                .tooltip(Tooltip::text(repository_url.clone())),
 755                            )
 756                            .child(
 757                                PopoverMenu::new(SharedString::from(format!(
 758                                    "more-{}",
 759                                    extension.id
 760                                )))
 761                                .trigger(
 762                                    IconButton::new(
 763                                        SharedString::from(format!("more-{}", extension.id)),
 764                                        IconName::Ellipsis,
 765                                    )
 766                                    .icon_color(Color::Accent)
 767                                    .icon_size(IconSize::Small),
 768                                )
 769                                .menu(move |window, cx| {
 770                                    Some(Self::render_remote_extension_context_menu(
 771                                        &this,
 772                                        extension_id.clone(),
 773                                        authors.clone(),
 774                                        window,
 775                                        cx,
 776                                    ))
 777                                }),
 778                            ),
 779                    ),
 780            )
 781    }
 782
 783    fn render_remote_extension_context_menu(
 784        this: &Entity<Self>,
 785        extension_id: Arc<str>,
 786        authors: Vec<String>,
 787        window: &mut Window,
 788        cx: &mut App,
 789    ) -> Entity<ContextMenu> {
 790        let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| {
 791            context_menu
 792                .entry(
 793                    "Install Another Version...",
 794                    None,
 795                    window.handler_for(this, {
 796                        let extension_id = extension_id.clone();
 797                        move |this, window, cx| {
 798                            this.show_extension_version_list(extension_id.clone(), window, cx)
 799                        }
 800                    }),
 801                )
 802                .entry("Copy Extension ID", None, {
 803                    let extension_id = extension_id.clone();
 804                    move |_, cx| {
 805                        cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
 806                    }
 807                })
 808                .entry("Copy Author Info", None, {
 809                    let authors = authors.clone();
 810                    move |_, cx| {
 811                        cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", ")));
 812                    }
 813                })
 814        });
 815
 816        context_menu
 817    }
 818
 819    fn show_extension_version_list(
 820        &mut self,
 821        extension_id: Arc<str>,
 822        window: &mut Window,
 823        cx: &mut Context<Self>,
 824    ) {
 825        let Some(workspace) = self.workspace.upgrade() else {
 826            return;
 827        };
 828
 829        cx.spawn_in(window, async move |this, cx| {
 830            let extension_versions_task = this.update(cx, |_, cx| {
 831                let extension_store = ExtensionStore::global(cx);
 832
 833                extension_store.update(cx, |store, cx| {
 834                    store.fetch_extension_versions(&extension_id, cx)
 835                })
 836            })?;
 837
 838            let extension_versions = extension_versions_task.await?;
 839
 840            workspace.update_in(cx, |workspace, window, cx| {
 841                let fs = workspace.project().read(cx).fs().clone();
 842                workspace.toggle_modal(window, cx, |window, cx| {
 843                    let delegate = ExtensionVersionSelectorDelegate::new(
 844                        fs,
 845                        cx.entity().downgrade(),
 846                        extension_versions,
 847                    );
 848
 849                    ExtensionVersionSelector::new(delegate, window, cx)
 850                });
 851            })?;
 852
 853            anyhow::Ok(())
 854        })
 855        .detach_and_log_err(cx);
 856    }
 857
 858    fn buttons_for_entry(
 859        &self,
 860        extension: &ExtensionMetadata,
 861        status: &ExtensionStatus,
 862        has_dev_extension: bool,
 863        cx: &mut Context<Self>,
 864    ) -> (Button, Option<Button>) {
 865        let is_compatible =
 866            extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
 867
 868        if has_dev_extension {
 869            // If we have a dev extension for the given extension, just treat it as uninstalled.
 870            // The button here is a placeholder, as it won't be interactable anyways.
 871            return (
 872                Button::new(SharedString::from(extension.id.clone()), "Install"),
 873                None,
 874            );
 875        }
 876
 877        match status.clone() {
 878            ExtensionStatus::NotInstalled => (
 879                Button::new(SharedString::from(extension.id.clone()), "Install").on_click({
 880                    let extension_id = extension.id.clone();
 881                    move |_, _, cx| {
 882                        telemetry::event!("Extension Installed");
 883                        ExtensionStore::global(cx).update(cx, |store, cx| {
 884                            store.install_latest_extension(extension_id.clone(), cx)
 885                        });
 886                    }
 887                }),
 888                None,
 889            ),
 890            ExtensionStatus::Installing => (
 891                Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
 892                None,
 893            ),
 894            ExtensionStatus::Upgrading => (
 895                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
 896                Some(
 897                    Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
 898                ),
 899            ),
 900            ExtensionStatus::Installed(installed_version) => (
 901                Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click({
 902                    let extension_id = extension.id.clone();
 903                    move |_, _, cx| {
 904                        telemetry::event!("Extension Uninstalled", extension_id);
 905                        ExtensionStore::global(cx).update(cx, |store, cx| {
 906                            store.uninstall_extension(extension_id.clone(), cx)
 907                        });
 908                    }
 909                }),
 910                if installed_version == extension.manifest.version {
 911                    None
 912                } else {
 913                    Some(
 914                        Button::new(SharedString::from(extension.id.clone()), "Upgrade")
 915                            .when(!is_compatible, |upgrade_button| {
 916                                upgrade_button.disabled(true).tooltip({
 917                                    let version = extension.manifest.version.clone();
 918                                    move |_, cx| {
 919                                        Tooltip::simple(
 920                                            format!(
 921                                                "v{version} is not compatible with this version of Zed.",
 922                                            ),
 923                                             cx,
 924                                        )
 925                                    }
 926                                })
 927                            })
 928                            .disabled(!is_compatible)
 929                            .on_click({
 930                                let extension_id = extension.id.clone();
 931                                let version = extension.manifest.version.clone();
 932                                move |_, _, cx| {
 933                                    telemetry::event!("Extension Installed", extension_id, version);
 934                                    ExtensionStore::global(cx).update(cx, |store, cx| {
 935                                        store
 936                                            .upgrade_extension(
 937                                                extension_id.clone(),
 938                                                version.clone(),
 939                                                cx,
 940                                            )
 941                                            .detach_and_log_err(cx)
 942                                    });
 943                                }
 944                            }),
 945                    )
 946                },
 947            ),
 948            ExtensionStatus::Removing => (
 949                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
 950                None,
 951            ),
 952        }
 953    }
 954
 955    fn render_search(&self, cx: &mut Context<Self>) -> Div {
 956        let mut key_context = KeyContext::new_with_defaults();
 957        key_context.add("BufferSearchBar");
 958
 959        let editor_border = if self.query_contains_error {
 960            Color::Error.color(cx)
 961        } else {
 962            cx.theme().colors().border
 963        };
 964
 965        h_flex()
 966            .key_context(key_context)
 967            .h_8()
 968            .flex_1()
 969            .min_w(rems_from_px(384.))
 970            .pl_1p5()
 971            .pr_2()
 972            .py_1()
 973            .gap_2()
 974            .border_1()
 975            .border_color(editor_border)
 976            .rounded_lg()
 977            .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
 978            .child(self.render_text_input(&self.query_editor, cx))
 979    }
 980
 981    fn render_text_input(
 982        &self,
 983        editor: &Entity<Editor>,
 984        cx: &mut Context<Self>,
 985    ) -> impl IntoElement {
 986        let settings = ThemeSettings::get_global(cx);
 987        let text_style = TextStyle {
 988            color: if editor.read(cx).read_only(cx) {
 989                cx.theme().colors().text_disabled
 990            } else {
 991                cx.theme().colors().text
 992            },
 993            font_family: settings.ui_font.family.clone(),
 994            font_features: settings.ui_font.features.clone(),
 995            font_fallbacks: settings.ui_font.fallbacks.clone(),
 996            font_size: rems(0.875).into(),
 997            font_weight: settings.ui_font.weight,
 998            line_height: relative(1.3),
 999            ..Default::default()
1000        };
1001
1002        EditorElement::new(
1003            editor,
1004            EditorStyle {
1005                background: cx.theme().colors().editor_background,
1006                local_player: cx.theme().players().local(),
1007                text: text_style,
1008                ..Default::default()
1009            },
1010        )
1011    }
1012
1013    fn on_query_change(
1014        &mut self,
1015        _: Entity<Editor>,
1016        event: &editor::EditorEvent,
1017        cx: &mut Context<Self>,
1018    ) {
1019        if let editor::EditorEvent::Edited { .. } = event {
1020            self.query_contains_error = false;
1021            self.refresh_search(cx);
1022        }
1023    }
1024
1025    fn refresh_search(&mut self, cx: &mut Context<Self>) {
1026        self.fetch_extensions_debounced(
1027            Some(Box::new(|this, cx| {
1028                this.scroll_to_top(cx);
1029            })),
1030            cx,
1031        );
1032        self.refresh_feature_upsells(cx);
1033    }
1034
1035    pub fn change_provides_filter(
1036        &mut self,
1037        provides_filter: Option<ExtensionProvides>,
1038        cx: &mut Context<Self>,
1039    ) {
1040        self.provides_filter = provides_filter;
1041        self.refresh_search(cx);
1042    }
1043
1044    fn fetch_extensions_debounced(
1045        &mut self,
1046        on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
1047        cx: &mut Context<ExtensionsPage>,
1048    ) {
1049        self.extension_fetch_task = Some(cx.spawn(async move |this, cx| {
1050            let search = this
1051                .update(cx, |this, cx| this.search_query(cx))
1052                .ok()
1053                .flatten();
1054
1055            // Only debounce the fetching of extensions if we have a search
1056            // query.
1057            //
1058            // If the search was just cleared then we can just reload the list
1059            // of extensions without a debounce, which allows us to avoid seeing
1060            // an intermittent flash of a "no extensions" state.
1061            if search.is_some() {
1062                cx.background_executor()
1063                    .timer(Duration::from_millis(250))
1064                    .await;
1065            };
1066
1067            this.update(cx, |this, cx| {
1068                this.fetch_extensions(
1069                    search,
1070                    Some(BTreeSet::from_iter(this.provides_filter)),
1071                    on_complete,
1072                    cx,
1073                );
1074            })
1075            .ok();
1076        }));
1077    }
1078
1079    pub fn search_query(&self, cx: &mut App) -> Option<String> {
1080        let search = self.query_editor.read(cx).text(cx);
1081        if search.trim().is_empty() {
1082            None
1083        } else {
1084            Some(search)
1085        }
1086    }
1087
1088    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1089        let has_search = self.search_query(cx).is_some();
1090
1091        let message = if self.is_fetching_extensions {
1092            "Loading extensions..."
1093        } else {
1094            match self.filter {
1095                ExtensionFilter::All => {
1096                    if has_search {
1097                        "No extensions that match your search."
1098                    } else {
1099                        "No extensions."
1100                    }
1101                }
1102                ExtensionFilter::Installed => {
1103                    if has_search {
1104                        "No installed extensions that match your search."
1105                    } else {
1106                        "No installed extensions."
1107                    }
1108                }
1109                ExtensionFilter::NotInstalled => {
1110                    if has_search {
1111                        "No not installed extensions that match your search."
1112                    } else {
1113                        "No not installed extensions."
1114                    }
1115                }
1116            }
1117        };
1118
1119        Label::new(message)
1120    }
1121
1122    fn update_settings<T: Settings>(
1123        &mut self,
1124        selection: &ToggleState,
1125
1126        cx: &mut Context<Self>,
1127        callback: impl 'static + Send + Fn(&mut T::FileContent, bool),
1128    ) {
1129        if let Some(workspace) = self.workspace.upgrade() {
1130            let fs = workspace.read(cx).app_state().fs.clone();
1131            let selection = *selection;
1132            settings::update_settings_file::<T>(fs, cx, move |settings, _| {
1133                let value = match selection {
1134                    ToggleState::Unselected => false,
1135                    ToggleState::Selected => true,
1136                    _ => return,
1137                };
1138
1139                callback(settings, value)
1140            });
1141        }
1142    }
1143
1144    fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1145        let Some(search) = self.search_query(cx) else {
1146            self.upsells.clear();
1147            return;
1148        };
1149
1150        let search = search.to_lowercase();
1151        let search_terms = search
1152            .split_whitespace()
1153            .map(|term| term.trim())
1154            .collect::<Vec<_>>();
1155
1156        for (feature, keywords) in keywords_by_feature() {
1157            if keywords
1158                .iter()
1159                .any(|keyword| search_terms.contains(keyword))
1160            {
1161                self.upsells.insert(*feature);
1162            } else {
1163                self.upsells.remove(feature);
1164            }
1165        }
1166    }
1167
1168    fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1169        let upsells_count = self.upsells.len();
1170
1171        v_flex().children(self.upsells.iter().enumerate().map(|(ix, feature)| {
1172            let upsell = match feature {
1173                Feature::Git => FeatureUpsell::new(
1174                    "Zed comes with basic Git support. More Git features are coming in the future.",
1175                )
1176                .docs_url("https://zed.dev/docs/git"),
1177                Feature::OpenIn => FeatureUpsell::new(
1178                    "Zed supports linking to a source line on GitHub and others.",
1179                )
1180                .docs_url("https://zed.dev/docs/git#git-integrations"),
1181                Feature::Vim => FeatureUpsell::new("Vim support is built-in to Zed!")
1182                    .docs_url("https://zed.dev/docs/vim")
1183                    .child(CheckboxWithLabel::new(
1184                        "enable-vim",
1185                        Label::new("Enable vim mode"),
1186                        if VimModeSetting::get_global(cx).0 {
1187                            ui::ToggleState::Selected
1188                        } else {
1189                            ui::ToggleState::Unselected
1190                        },
1191                        cx.listener(move |this, selection, _, cx| {
1192                            telemetry::event!("Vim Mode Toggled", source = "Feature Upsell");
1193                            this.update_settings::<VimModeSetting>(
1194                                selection,
1195                                cx,
1196                                |setting, value| *setting = Some(value),
1197                            );
1198                        }),
1199                    )),
1200                Feature::LanguageBash => FeatureUpsell::new("Shell support is built-in to Zed!")
1201                    .docs_url("https://zed.dev/docs/languages/bash"),
1202                Feature::LanguageC => FeatureUpsell::new("C support is built-in to Zed!")
1203                    .docs_url("https://zed.dev/docs/languages/c"),
1204                Feature::LanguageCpp => FeatureUpsell::new("C++ support is built-in to Zed!")
1205                    .docs_url("https://zed.dev/docs/languages/cpp"),
1206                Feature::LanguageGo => FeatureUpsell::new("Go support is built-in to Zed!")
1207                    .docs_url("https://zed.dev/docs/languages/go"),
1208                Feature::LanguagePython => FeatureUpsell::new("Python support is built-in to Zed!")
1209                    .docs_url("https://zed.dev/docs/languages/python"),
1210                Feature::LanguageReact => FeatureUpsell::new("React support is built-in to Zed!")
1211                    .docs_url("https://zed.dev/docs/languages/typescript"),
1212                Feature::LanguageRust => FeatureUpsell::new("Rust support is built-in to Zed!")
1213                    .docs_url("https://zed.dev/docs/languages/rust"),
1214                Feature::LanguageTypescript => {
1215                    FeatureUpsell::new("Typescript support is built-in to Zed!")
1216                        .docs_url("https://zed.dev/docs/languages/typescript")
1217                }
1218            };
1219
1220            upsell.when(ix < upsells_count, |upsell| upsell.border_b_1())
1221        }))
1222    }
1223}
1224
1225impl Render for ExtensionsPage {
1226    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1227        v_flex()
1228            .size_full()
1229            .bg(cx.theme().colors().editor_background)
1230            .child(
1231                v_flex()
1232                    .gap_4()
1233                    .pt_4()
1234                    .px_4()
1235                    .bg(cx.theme().colors().editor_background)
1236                    .child(
1237                        h_flex()
1238                            .w_full()
1239                            .gap_2()
1240                            .justify_between()
1241                            .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
1242                            .child(
1243                                Button::new("install-dev-extension", "Install Dev Extension")
1244                                    .style(ButtonStyle::Filled)
1245                                    .size(ButtonSize::Large)
1246                                    .on_click(|_event, window, cx| {
1247                                        window.dispatch_action(Box::new(InstallDevExtension), cx)
1248                                    }),
1249                            ),
1250                    )
1251                    .child(
1252                        h_flex()
1253                            .w_full()
1254                            .gap_4()
1255                            .flex_wrap()
1256                            .child(self.render_search(cx))
1257                            .child(
1258                                h_flex()
1259                                    .child(
1260                                        ToggleButton::new("filter-all", "All")
1261                                            .style(ButtonStyle::Filled)
1262                                            .size(ButtonSize::Large)
1263                                            .toggle_state(self.filter == ExtensionFilter::All)
1264                                            .on_click(cx.listener(|this, _event, _, cx| {
1265                                                this.filter = ExtensionFilter::All;
1266                                                this.filter_extension_entries(cx);
1267                                                this.scroll_to_top(cx);
1268                                            }))
1269                                            .tooltip(move |_, cx| {
1270                                                Tooltip::simple("Show all extensions", cx)
1271                                            })
1272                                            .first(),
1273                                    )
1274                                    .child(
1275                                        ToggleButton::new("filter-installed", "Installed")
1276                                            .style(ButtonStyle::Filled)
1277                                            .size(ButtonSize::Large)
1278                                            .toggle_state(self.filter == ExtensionFilter::Installed)
1279                                            .on_click(cx.listener(|this, _event, _, cx| {
1280                                                this.filter = ExtensionFilter::Installed;
1281                                                this.filter_extension_entries(cx);
1282                                                this.scroll_to_top(cx);
1283                                            }))
1284                                            .tooltip(move |_, cx| {
1285                                                Tooltip::simple("Show installed extensions", cx)
1286                                            })
1287                                            .middle(),
1288                                    )
1289                                    .child(
1290                                        ToggleButton::new("filter-not-installed", "Not Installed")
1291                                            .style(ButtonStyle::Filled)
1292                                            .size(ButtonSize::Large)
1293                                            .toggle_state(
1294                                                self.filter == ExtensionFilter::NotInstalled,
1295                                            )
1296                                            .on_click(cx.listener(|this, _event, _, cx| {
1297                                                this.filter = ExtensionFilter::NotInstalled;
1298                                                this.filter_extension_entries(cx);
1299                                                this.scroll_to_top(cx);
1300                                            }))
1301                                            .tooltip(move |_, cx| {
1302                                                Tooltip::simple("Show not installed extensions", cx)
1303                                            })
1304                                            .last(),
1305                                    ),
1306                            ),
1307                    ),
1308            )
1309            .child(
1310                h_flex()
1311                    .id("filter-row")
1312                    .gap_2()
1313                    .py_2p5()
1314                    .px_4()
1315                    .border_b_1()
1316                    .border_color(cx.theme().colors().border_variant)
1317                    .overflow_x_scroll()
1318                    .child(
1319                        Button::new("filter-all-categories", "All")
1320                            .when(self.provides_filter.is_none(), |button| {
1321                                button.style(ButtonStyle::Filled)
1322                            })
1323                            .when(self.provides_filter.is_some(), |button| {
1324                                button.style(ButtonStyle::Subtle)
1325                            })
1326                            .toggle_state(self.provides_filter.is_none())
1327                            .on_click(cx.listener(|this, _event, _, cx| {
1328                                this.change_provides_filter(None, cx);
1329                            })),
1330                    )
1331                    .children(ExtensionProvides::iter().map(|provides| {
1332                        let label = extension_provides_label(provides);
1333                        Button::new(
1334                            SharedString::from(format!("filter-category-{}", label)),
1335                            label,
1336                        )
1337                        .style(if self.provides_filter == Some(provides) {
1338                            ButtonStyle::Filled
1339                        } else {
1340                            ButtonStyle::Subtle
1341                        })
1342                        .toggle_state(self.provides_filter == Some(provides))
1343                        .on_click({
1344                            cx.listener(move |this, _event, _, cx| {
1345                                this.change_provides_filter(Some(provides), cx);
1346                            })
1347                        })
1348                    })),
1349            )
1350            .child(self.render_feature_upsells(cx))
1351            .child(
1352                v_flex()
1353                    .pl_4()
1354                    .pr_6()
1355                    .size_full()
1356                    .overflow_y_hidden()
1357                    .map(|this| {
1358                        let mut count = self.filtered_remote_extension_indices.len();
1359                        if self.filter.include_dev_extensions() {
1360                            count += self.dev_extension_entries.len();
1361                        }
1362
1363                        if count == 0 {
1364                            return this.py_4().child(self.render_empty_state(cx));
1365                        }
1366
1367                        let extensions_page = cx.entity().clone();
1368                        let scroll_handle = self.list.clone();
1369                        this.child(
1370                            uniform_list(
1371                                extensions_page,
1372                                "entries",
1373                                count,
1374                                Self::render_extensions,
1375                            )
1376                            .flex_grow()
1377                            .pb_4()
1378                            .track_scroll(scroll_handle),
1379                        )
1380                        .child(
1381                            div()
1382                                .absolute()
1383                                .right_1()
1384                                .top_0()
1385                                .bottom_0()
1386                                .w(px(12.))
1387                                .children(Scrollbar::vertical(self.scrollbar_state.clone())),
1388                        )
1389                    }),
1390            )
1391    }
1392}
1393
1394impl EventEmitter<ItemEvent> for ExtensionsPage {}
1395
1396impl Focusable for ExtensionsPage {
1397    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1398        self.query_editor.read(cx).focus_handle(cx)
1399    }
1400}
1401
1402impl Item for ExtensionsPage {
1403    type Event = ItemEvent;
1404
1405    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1406        "Extensions".into()
1407    }
1408
1409    fn telemetry_event_text(&self) -> Option<&'static str> {
1410        Some("Extensions Page Opened")
1411    }
1412
1413    fn show_toolbar(&self) -> bool {
1414        false
1415    }
1416
1417    fn clone_on_split(
1418        &self,
1419        _workspace_id: Option<WorkspaceId>,
1420        _window: &mut Window,
1421        _: &mut Context<Self>,
1422    ) -> Option<Entity<Self>> {
1423        None
1424    }
1425
1426    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
1427        f(*event)
1428    }
1429}