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 => "Context 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(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(None, Some(BTreeSet::from_iter(this.provides_filter)), cx);
 323            this
 324        })
 325    }
 326
 327    fn on_extension_installed(
 328        &mut self,
 329        workspace: WeakEntity<Workspace>,
 330        extension_id: &str,
 331        window: &mut Window,
 332        cx: &mut Context<Self>,
 333    ) {
 334        let extension_store = ExtensionStore::global(cx).read(cx);
 335        let themes = extension_store
 336            .extension_themes(extension_id)
 337            .map(|name| name.to_string())
 338            .collect::<Vec<_>>();
 339        if !themes.is_empty() {
 340            workspace
 341                .update(cx, |_workspace, cx| {
 342                    window.dispatch_action(
 343                        zed_actions::theme_selector::Toggle {
 344                            themes_filter: Some(themes),
 345                        }
 346                        .boxed_clone(),
 347                        cx,
 348                    );
 349                })
 350                .ok();
 351            return;
 352        }
 353
 354        let icon_themes = extension_store
 355            .extension_icon_themes(extension_id)
 356            .map(|name| name.to_string())
 357            .collect::<Vec<_>>();
 358        if !icon_themes.is_empty() {
 359            workspace
 360                .update(cx, |_workspace, cx| {
 361                    window.dispatch_action(
 362                        zed_actions::icon_theme_selector::Toggle {
 363                            themes_filter: Some(icon_themes),
 364                        }
 365                        .boxed_clone(),
 366                        cx,
 367                    );
 368                })
 369                .ok();
 370        }
 371    }
 372
 373    /// Returns whether a dev extension currently exists for the extension with the given ID.
 374    fn dev_extension_exists(extension_id: &str, cx: &mut Context<Self>) -> bool {
 375        let extension_store = ExtensionStore::global(cx).read(cx);
 376
 377        extension_store
 378            .dev_extensions()
 379            .any(|dev_extension| dev_extension.id.as_ref() == extension_id)
 380    }
 381
 382    fn extension_status(extension_id: &str, cx: &mut Context<Self>) -> ExtensionStatus {
 383        let extension_store = ExtensionStore::global(cx).read(cx);
 384
 385        match extension_store.outstanding_operations().get(extension_id) {
 386            Some(ExtensionOperation::Install) => ExtensionStatus::Installing,
 387            Some(ExtensionOperation::Remove) => ExtensionStatus::Removing,
 388            Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading,
 389            None => match extension_store.installed_extensions().get(extension_id) {
 390                Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()),
 391                None => ExtensionStatus::NotInstalled,
 392            },
 393        }
 394    }
 395
 396    fn filter_extension_entries(&mut self, cx: &mut Context<Self>) {
 397        self.filtered_remote_extension_indices.clear();
 398        self.filtered_remote_extension_indices.extend(
 399            self.remote_extension_entries
 400                .iter()
 401                .enumerate()
 402                .filter(|(_, extension)| match self.filter {
 403                    ExtensionFilter::All => true,
 404                    ExtensionFilter::Installed => {
 405                        let status = Self::extension_status(&extension.id, cx);
 406                        matches!(status, ExtensionStatus::Installed(_))
 407                    }
 408                    ExtensionFilter::NotInstalled => {
 409                        let status = Self::extension_status(&extension.id, cx);
 410
 411                        matches!(status, ExtensionStatus::NotInstalled)
 412                    }
 413                })
 414                .map(|(ix, _)| ix),
 415        );
 416        self.list.set_offset(point(px(0.), px(0.)));
 417        cx.notify();
 418    }
 419
 420    fn fetch_extensions(
 421        &mut self,
 422        search: Option<String>,
 423        provides_filter: Option<BTreeSet<ExtensionProvides>>,
 424        cx: &mut Context<Self>,
 425    ) {
 426        self.is_fetching_extensions = true;
 427        cx.notify();
 428
 429        let extension_store = ExtensionStore::global(cx);
 430
 431        let dev_extensions = extension_store.update(cx, |store, _| {
 432            store.dev_extensions().cloned().collect::<Vec<_>>()
 433        });
 434
 435        let remote_extensions = extension_store.update(cx, |store, cx| {
 436            store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx)
 437        });
 438
 439        cx.spawn(async move |this, cx| {
 440            let dev_extensions = if let Some(search) = search {
 441                let match_candidates = dev_extensions
 442                    .iter()
 443                    .enumerate()
 444                    .map(|(ix, manifest)| StringMatchCandidate::new(ix, &manifest.name))
 445                    .collect::<Vec<_>>();
 446
 447                let matches = match_strings(
 448                    &match_candidates,
 449                    &search,
 450                    false,
 451                    match_candidates.len(),
 452                    &Default::default(),
 453                    cx.background_executor().clone(),
 454                )
 455                .await;
 456                matches
 457                    .into_iter()
 458                    .map(|mat| dev_extensions[mat.candidate_id].clone())
 459                    .collect()
 460            } else {
 461                dev_extensions
 462            };
 463
 464            let fetch_result = remote_extensions.await;
 465            this.update(cx, |this, cx| {
 466                cx.notify();
 467                this.dev_extension_entries = dev_extensions;
 468                this.is_fetching_extensions = false;
 469                this.remote_extension_entries = fetch_result?;
 470                this.filter_extension_entries(cx);
 471                anyhow::Ok(())
 472            })?
 473        })
 474        .detach_and_log_err(cx);
 475    }
 476
 477    fn render_extensions(
 478        &mut self,
 479        range: Range<usize>,
 480        _: &mut Window,
 481        cx: &mut Context<Self>,
 482    ) -> Vec<ExtensionCard> {
 483        let dev_extension_entries_len = if self.filter.include_dev_extensions() {
 484            self.dev_extension_entries.len()
 485        } else {
 486            0
 487        };
 488        range
 489            .map(|ix| {
 490                if ix < dev_extension_entries_len {
 491                    let extension = &self.dev_extension_entries[ix];
 492                    self.render_dev_extension(extension, cx)
 493                } else {
 494                    let extension_ix =
 495                        self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
 496                    let extension = &self.remote_extension_entries[extension_ix];
 497                    self.render_remote_extension(extension, cx)
 498                }
 499            })
 500            .collect()
 501    }
 502
 503    fn render_dev_extension(
 504        &self,
 505        extension: &ExtensionManifest,
 506        cx: &mut Context<Self>,
 507    ) -> ExtensionCard {
 508        let status = Self::extension_status(&extension.id, cx);
 509
 510        let repository_url = extension.repository.clone();
 511
 512        ExtensionCard::new()
 513            .child(
 514                h_flex()
 515                    .justify_between()
 516                    .child(
 517                        h_flex()
 518                            .gap_2()
 519                            .items_end()
 520                            .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
 521                            .child(
 522                                Headline::new(format!("v{}", extension.version))
 523                                    .size(HeadlineSize::XSmall),
 524                            ),
 525                    )
 526                    .child(
 527                        h_flex()
 528                            .gap_2()
 529                            .justify_between()
 530                            .child(
 531                                Button::new(
 532                                    SharedString::from(format!("rebuild-{}", extension.id)),
 533                                    "Rebuild",
 534                                )
 535                                .on_click({
 536                                    let extension_id = extension.id.clone();
 537                                    move |_, _, cx| {
 538                                        ExtensionStore::global(cx).update(cx, |store, cx| {
 539                                            store.rebuild_dev_extension(extension_id.clone(), cx)
 540                                        });
 541                                    }
 542                                })
 543                                .color(Color::Accent)
 544                                .disabled(matches!(status, ExtensionStatus::Upgrading)),
 545                            )
 546                            .child(
 547                                Button::new(SharedString::from(extension.id.clone()), "Uninstall")
 548                                    .on_click({
 549                                        let extension_id = extension.id.clone();
 550                                        move |_, _, cx| {
 551                                            ExtensionStore::global(cx).update(cx, |store, cx| {
 552                                                store.uninstall_extension(extension_id.clone(), cx)
 553                                            });
 554                                        }
 555                                    })
 556                                    .color(Color::Accent)
 557                                    .disabled(matches!(status, ExtensionStatus::Removing)),
 558                            ),
 559                    ),
 560            )
 561            .child(
 562                h_flex()
 563                    .gap_2()
 564                    .justify_between()
 565                    .child(
 566                        Label::new(format!(
 567                            "{}: {}",
 568                            if extension.authors.len() > 1 {
 569                                "Authors"
 570                            } else {
 571                                "Author"
 572                            },
 573                            extension.authors.join(", ")
 574                        ))
 575                        .size(LabelSize::Small)
 576                        .truncate(),
 577                    )
 578                    .child(Label::new("<>").size(LabelSize::Small)),
 579            )
 580            .child(
 581                h_flex()
 582                    .gap_2()
 583                    .justify_between()
 584                    .children(extension.description.as_ref().map(|description| {
 585                        Label::new(description.clone())
 586                            .size(LabelSize::Small)
 587                            .color(Color::Default)
 588                            .truncate()
 589                    }))
 590                    .children(repository_url.map(|repository_url| {
 591                        IconButton::new(
 592                            SharedString::from(format!("repository-{}", extension.id)),
 593                            IconName::Github,
 594                        )
 595                        .icon_color(Color::Accent)
 596                        .icon_size(IconSize::Small)
 597                        .style(ButtonStyle::Filled)
 598                        .on_click(cx.listener({
 599                            let repository_url = repository_url.clone();
 600                            move |_, _, _, cx| {
 601                                cx.open_url(&repository_url);
 602                            }
 603                        }))
 604                        .tooltip(Tooltip::text(repository_url.clone()))
 605                    })),
 606            )
 607    }
 608
 609    fn render_remote_extension(
 610        &self,
 611        extension: &ExtensionMetadata,
 612        cx: &mut Context<Self>,
 613    ) -> ExtensionCard {
 614        let this = cx.entity().clone();
 615        let status = Self::extension_status(&extension.id, cx);
 616        let has_dev_extension = Self::dev_extension_exists(&extension.id, cx);
 617
 618        let extension_id = extension.id.clone();
 619        let (install_or_uninstall_button, upgrade_button) =
 620            self.buttons_for_entry(extension, &status, has_dev_extension, cx);
 621        let version = extension.manifest.version.clone();
 622        let repository_url = extension.manifest.repository.clone();
 623        let authors = extension.manifest.authors.clone();
 624
 625        let installed_version = match status {
 626            ExtensionStatus::Installed(installed_version) => Some(installed_version),
 627            _ => None,
 628        };
 629
 630        ExtensionCard::new()
 631            .overridden_by_dev_extension(has_dev_extension)
 632            .child(
 633                h_flex()
 634                    .justify_between()
 635                    .child(
 636                        h_flex()
 637                            .gap_2()
 638                            .child(
 639                                Headline::new(extension.manifest.name.clone())
 640                                    .size(HeadlineSize::Medium),
 641                            )
 642                            .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
 643                            .children(
 644                                installed_version
 645                                    .filter(|installed_version| *installed_version != version)
 646                                    .map(|installed_version| {
 647                                        Headline::new(format!("(v{installed_version} installed)",))
 648                                            .size(HeadlineSize::XSmall)
 649                                    }),
 650                            )
 651                            .map(|parent| {
 652                                if extension.manifest.provides.is_empty() {
 653                                    return parent;
 654                                }
 655
 656                                parent.child(
 657                                    h_flex().gap_2().children(
 658                                        extension
 659                                            .manifest
 660                                            .provides
 661                                            .iter()
 662                                            .map(|provides| {
 663                                                div()
 664                                                    .bg(cx.theme().colors().element_background)
 665                                                    .px_0p5()
 666                                                    .border_1()
 667                                                    .border_color(cx.theme().colors().border)
 668                                                    .rounded_sm()
 669                                                    .child(
 670                                                        Label::new(extension_provides_label(
 671                                                            *provides,
 672                                                        ))
 673                                                        .size(LabelSize::XSmall),
 674                                                    )
 675                                            })
 676                                            .collect::<Vec<_>>(),
 677                                    ),
 678                                )
 679                            }),
 680                    )
 681                    .child(
 682                        h_flex()
 683                            .gap_2()
 684                            .justify_between()
 685                            .children(upgrade_button)
 686                            .child(install_or_uninstall_button),
 687                    ),
 688            )
 689            .child(
 690                h_flex()
 691                    .gap_2()
 692                    .justify_between()
 693                    .child(
 694                        Label::new(format!(
 695                            "{}: {}",
 696                            if extension.manifest.authors.len() > 1 {
 697                                "Authors"
 698                            } else {
 699                                "Author"
 700                            },
 701                            extension.manifest.authors.join(", ")
 702                        ))
 703                        .size(LabelSize::Small)
 704                        .truncate(),
 705                    )
 706                    .child(
 707                        Label::new(format!(
 708                            "Downloads: {}",
 709                            extension.download_count.to_formatted_string(&Locale::en)
 710                        ))
 711                        .size(LabelSize::Small),
 712                    ),
 713            )
 714            .child(
 715                h_flex()
 716                    .gap_2()
 717                    .justify_between()
 718                    .children(extension.manifest.description.as_ref().map(|description| {
 719                        Label::new(description.clone())
 720                            .size(LabelSize::Small)
 721                            .color(Color::Default)
 722                            .truncate()
 723                    }))
 724                    .child(
 725                        h_flex()
 726                            .gap_2()
 727                            .child(
 728                                IconButton::new(
 729                                    SharedString::from(format!("repository-{}", extension.id)),
 730                                    IconName::Github,
 731                                )
 732                                .icon_color(Color::Accent)
 733                                .icon_size(IconSize::Small)
 734                                .style(ButtonStyle::Filled)
 735                                .on_click(cx.listener({
 736                                    let repository_url = repository_url.clone();
 737                                    move |_, _, _, cx| {
 738                                        cx.open_url(&repository_url);
 739                                    }
 740                                }))
 741                                .tooltip(Tooltip::text(repository_url.clone())),
 742                            )
 743                            .child(
 744                                PopoverMenu::new(SharedString::from(format!(
 745                                    "more-{}",
 746                                    extension.id
 747                                )))
 748                                .trigger(
 749                                    IconButton::new(
 750                                        SharedString::from(format!("more-{}", extension.id)),
 751                                        IconName::Ellipsis,
 752                                    )
 753                                    .icon_color(Color::Accent)
 754                                    .icon_size(IconSize::Small)
 755                                    .style(ButtonStyle::Filled),
 756                                )
 757                                .menu(move |window, cx| {
 758                                    Some(Self::render_remote_extension_context_menu(
 759                                        &this,
 760                                        extension_id.clone(),
 761                                        authors.clone(),
 762                                        window,
 763                                        cx,
 764                                    ))
 765                                }),
 766                            ),
 767                    ),
 768            )
 769    }
 770
 771    fn render_remote_extension_context_menu(
 772        this: &Entity<Self>,
 773        extension_id: Arc<str>,
 774        authors: Vec<String>,
 775        window: &mut Window,
 776        cx: &mut App,
 777    ) -> Entity<ContextMenu> {
 778        let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| {
 779            context_menu
 780                .entry(
 781                    "Install Another Version...",
 782                    None,
 783                    window.handler_for(this, {
 784                        let extension_id = extension_id.clone();
 785                        move |this, window, cx| {
 786                            this.show_extension_version_list(extension_id.clone(), window, cx)
 787                        }
 788                    }),
 789                )
 790                .entry("Copy Extension ID", None, {
 791                    let extension_id = extension_id.clone();
 792                    move |_, cx| {
 793                        cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
 794                    }
 795                })
 796                .entry("Copy Author Info", None, {
 797                    let authors = authors.clone();
 798                    move |_, cx| {
 799                        cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", ")));
 800                    }
 801                })
 802        });
 803
 804        context_menu
 805    }
 806
 807    fn show_extension_version_list(
 808        &mut self,
 809        extension_id: Arc<str>,
 810        window: &mut Window,
 811        cx: &mut Context<Self>,
 812    ) {
 813        let Some(workspace) = self.workspace.upgrade() else {
 814            return;
 815        };
 816
 817        cx.spawn_in(window, async move |this, cx| {
 818            let extension_versions_task = this.update(cx, |_, cx| {
 819                let extension_store = ExtensionStore::global(cx);
 820
 821                extension_store.update(cx, |store, cx| {
 822                    store.fetch_extension_versions(&extension_id, cx)
 823                })
 824            })?;
 825
 826            let extension_versions = extension_versions_task.await?;
 827
 828            workspace.update_in(cx, |workspace, window, cx| {
 829                let fs = workspace.project().read(cx).fs().clone();
 830                workspace.toggle_modal(window, cx, |window, cx| {
 831                    let delegate = ExtensionVersionSelectorDelegate::new(
 832                        fs,
 833                        cx.entity().downgrade(),
 834                        extension_versions,
 835                    );
 836
 837                    ExtensionVersionSelector::new(delegate, window, cx)
 838                });
 839            })?;
 840
 841            anyhow::Ok(())
 842        })
 843        .detach_and_log_err(cx);
 844    }
 845
 846    fn buttons_for_entry(
 847        &self,
 848        extension: &ExtensionMetadata,
 849        status: &ExtensionStatus,
 850        has_dev_extension: bool,
 851        cx: &mut Context<Self>,
 852    ) -> (Button, Option<Button>) {
 853        let is_compatible =
 854            extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
 855
 856        if has_dev_extension {
 857            // If we have a dev extension for the given extension, just treat it as uninstalled.
 858            // The button here is a placeholder, as it won't be interactable anyways.
 859            return (
 860                Button::new(SharedString::from(extension.id.clone()), "Install"),
 861                None,
 862            );
 863        }
 864
 865        match status.clone() {
 866            ExtensionStatus::NotInstalled => (
 867                Button::new(SharedString::from(extension.id.clone()), "Install").on_click({
 868                    let extension_id = extension.id.clone();
 869                    move |_, _, cx| {
 870                        telemetry::event!("Extension Installed");
 871                        ExtensionStore::global(cx).update(cx, |store, cx| {
 872                            store.install_latest_extension(extension_id.clone(), cx)
 873                        });
 874                    }
 875                }),
 876                None,
 877            ),
 878            ExtensionStatus::Installing => (
 879                Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
 880                None,
 881            ),
 882            ExtensionStatus::Upgrading => (
 883                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
 884                Some(
 885                    Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
 886                ),
 887            ),
 888            ExtensionStatus::Installed(installed_version) => (
 889                Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click({
 890                    let extension_id = extension.id.clone();
 891                    move |_, _, cx| {
 892                        telemetry::event!("Extension Uninstalled", extension_id);
 893                        ExtensionStore::global(cx).update(cx, |store, cx| {
 894                            store.uninstall_extension(extension_id.clone(), cx)
 895                        });
 896                    }
 897                }),
 898                if installed_version == extension.manifest.version {
 899                    None
 900                } else {
 901                    Some(
 902                        Button::new(SharedString::from(extension.id.clone()), "Upgrade")
 903                            .when(!is_compatible, |upgrade_button| {
 904                                upgrade_button.disabled(true).tooltip({
 905                                    let version = extension.manifest.version.clone();
 906                                    move |_, cx| {
 907                                        Tooltip::simple(
 908                                            format!(
 909                                                "v{version} is not compatible with this version of Zed.",
 910                                            ),
 911                                             cx,
 912                                        )
 913                                    }
 914                                })
 915                            })
 916                            .disabled(!is_compatible)
 917                            .on_click({
 918                                let extension_id = extension.id.clone();
 919                                let version = extension.manifest.version.clone();
 920                                move |_, _, cx| {
 921                                    telemetry::event!("Extension Installed", extension_id, version);
 922                                    ExtensionStore::global(cx).update(cx, |store, cx| {
 923                                        store
 924                                            .upgrade_extension(
 925                                                extension_id.clone(),
 926                                                version.clone(),
 927                                                cx,
 928                                            )
 929                                            .detach_and_log_err(cx)
 930                                    });
 931                                }
 932                            }),
 933                    )
 934                },
 935            ),
 936            ExtensionStatus::Removing => (
 937                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
 938                None,
 939            ),
 940        }
 941    }
 942
 943    fn render_search(&self, cx: &mut Context<Self>) -> Div {
 944        let mut key_context = KeyContext::new_with_defaults();
 945        key_context.add("BufferSearchBar");
 946
 947        let editor_border = if self.query_contains_error {
 948            Color::Error.color(cx)
 949        } else {
 950            cx.theme().colors().border
 951        };
 952
 953        h_flex().w_full().gap_2().key_context(key_context).child(
 954            h_flex()
 955                .flex_1()
 956                .px_2()
 957                .py_1()
 958                .gap_2()
 959                .border_1()
 960                .border_color(editor_border)
 961                .min_w(rems_from_px(384.))
 962                .rounded_lg()
 963                .child(Icon::new(IconName::MagnifyingGlass))
 964                .child(self.render_text_input(&self.query_editor, cx)),
 965        )
 966    }
 967
 968    fn render_text_input(
 969        &self,
 970        editor: &Entity<Editor>,
 971        cx: &mut Context<Self>,
 972    ) -> impl IntoElement {
 973        let settings = ThemeSettings::get_global(cx);
 974        let text_style = TextStyle {
 975            color: if editor.read(cx).read_only(cx) {
 976                cx.theme().colors().text_disabled
 977            } else {
 978                cx.theme().colors().text
 979            },
 980            font_family: settings.ui_font.family.clone(),
 981            font_features: settings.ui_font.features.clone(),
 982            font_fallbacks: settings.ui_font.fallbacks.clone(),
 983            font_size: rems(0.875).into(),
 984            font_weight: settings.ui_font.weight,
 985            line_height: relative(1.3),
 986            ..Default::default()
 987        };
 988
 989        EditorElement::new(
 990            editor,
 991            EditorStyle {
 992                background: cx.theme().colors().editor_background,
 993                local_player: cx.theme().players().local(),
 994                text: text_style,
 995                ..Default::default()
 996            },
 997        )
 998    }
 999
1000    fn on_query_change(
1001        &mut self,
1002        _: Entity<Editor>,
1003        event: &editor::EditorEvent,
1004        cx: &mut Context<Self>,
1005    ) {
1006        if let editor::EditorEvent::Edited { .. } = event {
1007            self.query_contains_error = false;
1008            self.refresh_search(cx);
1009        }
1010    }
1011
1012    fn refresh_search(&mut self, cx: &mut Context<Self>) {
1013        self.fetch_extensions_debounced(cx);
1014        self.refresh_feature_upsells(cx);
1015    }
1016
1017    pub fn change_provides_filter(
1018        &mut self,
1019        provides_filter: Option<ExtensionProvides>,
1020        cx: &mut Context<Self>,
1021    ) {
1022        self.provides_filter = provides_filter;
1023        self.refresh_search(cx);
1024    }
1025
1026    fn fetch_extensions_debounced(&mut self, cx: &mut Context<ExtensionsPage>) {
1027        self.extension_fetch_task = Some(cx.spawn(async move |this, cx| {
1028            let search = this
1029                .update(cx, |this, cx| this.search_query(cx))
1030                .ok()
1031                .flatten();
1032
1033            // Only debounce the fetching of extensions if we have a search
1034            // query.
1035            //
1036            // If the search was just cleared then we can just reload the list
1037            // of extensions without a debounce, which allows us to avoid seeing
1038            // an intermittent flash of a "no extensions" state.
1039            if search.is_some() {
1040                cx.background_executor()
1041                    .timer(Duration::from_millis(250))
1042                    .await;
1043            };
1044
1045            this.update(cx, |this, cx| {
1046                this.fetch_extensions(search, Some(BTreeSet::from_iter(this.provides_filter)), cx);
1047            })
1048            .ok();
1049        }));
1050    }
1051
1052    pub fn search_query(&self, cx: &mut App) -> Option<String> {
1053        let search = self.query_editor.read(cx).text(cx);
1054        if search.trim().is_empty() {
1055            None
1056        } else {
1057            Some(search)
1058        }
1059    }
1060
1061    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1062        let has_search = self.search_query(cx).is_some();
1063
1064        let message = if self.is_fetching_extensions {
1065            "Loading extensions..."
1066        } else {
1067            match self.filter {
1068                ExtensionFilter::All => {
1069                    if has_search {
1070                        "No extensions that match your search."
1071                    } else {
1072                        "No extensions."
1073                    }
1074                }
1075                ExtensionFilter::Installed => {
1076                    if has_search {
1077                        "No installed extensions that match your search."
1078                    } else {
1079                        "No installed extensions."
1080                    }
1081                }
1082                ExtensionFilter::NotInstalled => {
1083                    if has_search {
1084                        "No not installed extensions that match your search."
1085                    } else {
1086                        "No not installed extensions."
1087                    }
1088                }
1089            }
1090        };
1091
1092        Label::new(message)
1093    }
1094
1095    fn update_settings<T: Settings>(
1096        &mut self,
1097        selection: &ToggleState,
1098
1099        cx: &mut Context<Self>,
1100        callback: impl 'static + Send + Fn(&mut T::FileContent, bool),
1101    ) {
1102        if let Some(workspace) = self.workspace.upgrade() {
1103            let fs = workspace.read(cx).app_state().fs.clone();
1104            let selection = *selection;
1105            settings::update_settings_file::<T>(fs, cx, move |settings, _| {
1106                let value = match selection {
1107                    ToggleState::Unselected => false,
1108                    ToggleState::Selected => true,
1109                    _ => return,
1110                };
1111
1112                callback(settings, value)
1113            });
1114        }
1115    }
1116
1117    fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1118        let Some(search) = self.search_query(cx) else {
1119            self.upsells.clear();
1120            return;
1121        };
1122
1123        let search = search.to_lowercase();
1124        let search_terms = search
1125            .split_whitespace()
1126            .map(|term| term.trim())
1127            .collect::<Vec<_>>();
1128
1129        for (feature, keywords) in keywords_by_feature() {
1130            if keywords
1131                .iter()
1132                .any(|keyword| search_terms.contains(keyword))
1133            {
1134                self.upsells.insert(*feature);
1135            } else {
1136                self.upsells.remove(feature);
1137            }
1138        }
1139    }
1140
1141    fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1142        let upsells_count = self.upsells.len();
1143
1144        v_flex().children(self.upsells.iter().enumerate().map(|(ix, feature)| {
1145            let upsell = match feature {
1146                Feature::Git => FeatureUpsell::new(
1147                    "Zed comes with basic Git support. More Git features are coming in the future.",
1148                )
1149                .docs_url("https://zed.dev/docs/git"),
1150                Feature::OpenIn => FeatureUpsell::new(
1151                    "Zed supports linking to a source line on GitHub and others.",
1152                )
1153                .docs_url("https://zed.dev/docs/git#git-integrations"),
1154                Feature::Vim => FeatureUpsell::new("Vim support is built-in to Zed!")
1155                    .docs_url("https://zed.dev/docs/vim")
1156                    .child(CheckboxWithLabel::new(
1157                        "enable-vim",
1158                        Label::new("Enable vim mode"),
1159                        if VimModeSetting::get_global(cx).0 {
1160                            ui::ToggleState::Selected
1161                        } else {
1162                            ui::ToggleState::Unselected
1163                        },
1164                        cx.listener(move |this, selection, _, cx| {
1165                            telemetry::event!("Vim Mode Toggled", source = "Feature Upsell");
1166                            this.update_settings::<VimModeSetting>(
1167                                selection,
1168                                cx,
1169                                |setting, value| *setting = Some(value),
1170                            );
1171                        }),
1172                    )),
1173                Feature::LanguageBash => FeatureUpsell::new("Shell support is built-in to Zed!")
1174                    .docs_url("https://zed.dev/docs/languages/bash"),
1175                Feature::LanguageC => FeatureUpsell::new("C support is built-in to Zed!")
1176                    .docs_url("https://zed.dev/docs/languages/c"),
1177                Feature::LanguageCpp => FeatureUpsell::new("C++ support is built-in to Zed!")
1178                    .docs_url("https://zed.dev/docs/languages/cpp"),
1179                Feature::LanguageGo => FeatureUpsell::new("Go support is built-in to Zed!")
1180                    .docs_url("https://zed.dev/docs/languages/go"),
1181                Feature::LanguagePython => FeatureUpsell::new("Python support is built-in to Zed!")
1182                    .docs_url("https://zed.dev/docs/languages/python"),
1183                Feature::LanguageReact => FeatureUpsell::new("React support is built-in to Zed!")
1184                    .docs_url("https://zed.dev/docs/languages/typescript"),
1185                Feature::LanguageRust => FeatureUpsell::new("Rust support is built-in to Zed!")
1186                    .docs_url("https://zed.dev/docs/languages/rust"),
1187                Feature::LanguageTypescript => {
1188                    FeatureUpsell::new("Typescript support is built-in to Zed!")
1189                        .docs_url("https://zed.dev/docs/languages/typescript")
1190                }
1191            };
1192
1193            upsell.when(ix < upsells_count, |upsell| upsell.border_b_1())
1194        }))
1195    }
1196
1197    fn build_extension_provides_filter_menu(
1198        &self,
1199        window: &mut Window,
1200        cx: &mut Context<Self>,
1201    ) -> Entity<ContextMenu> {
1202        let this = cx.entity();
1203        ContextMenu::build(window, cx, |mut menu, _window, _cx| {
1204            menu = menu.header("Extension Category").toggleable_entry(
1205                "All",
1206                self.provides_filter.is_none(),
1207                IconPosition::End,
1208                None,
1209                {
1210                    let this = this.clone();
1211                    move |_window, cx| {
1212                        this.update(cx, |this, cx| {
1213                            this.change_provides_filter(None, cx);
1214                        });
1215                    }
1216                },
1217            );
1218
1219            for provides in ExtensionProvides::iter() {
1220                let label = extension_provides_label(provides);
1221
1222                menu = menu.toggleable_entry(
1223                    label,
1224                    self.provides_filter == Some(provides),
1225                    IconPosition::End,
1226                    None,
1227                    {
1228                        let this = this.clone();
1229                        move |_window, cx| {
1230                            this.update(cx, |this, cx| {
1231                                this.change_provides_filter(Some(provides), cx);
1232                                this.provides_filter = Some(provides);
1233                            });
1234                        }
1235                    },
1236                )
1237            }
1238
1239            menu
1240        })
1241    }
1242}
1243
1244impl Render for ExtensionsPage {
1245    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1246        v_flex()
1247            .size_full()
1248            .bg(cx.theme().colors().editor_background)
1249            .child(
1250                v_flex()
1251                    .gap_4()
1252                    .p_4()
1253                    .border_b_1()
1254                    .border_color(cx.theme().colors().border)
1255                    .bg(cx.theme().colors().editor_background)
1256                    .child(
1257                        h_flex()
1258                            .w_full()
1259                            .gap_2()
1260                            .justify_between()
1261                            .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
1262                            .child(
1263                                Button::new("install-dev-extension", "Install Dev Extension")
1264                                    .style(ButtonStyle::Filled)
1265                                    .size(ButtonSize::Large)
1266                                    .on_click(|_event, window, cx| {
1267                                        window.dispatch_action(Box::new(InstallDevExtension), cx)
1268                                    }),
1269                            ),
1270                    )
1271                    .child(
1272                        h_flex()
1273                            .w_full()
1274                            .gap_2()
1275                            .justify_between()
1276                            .child(h_flex().gap_2().child(self.render_search(cx)).child({
1277                                let this = cx.entity().clone();
1278                                PopoverMenu::new("extension-provides-filter")
1279                                    .menu(move |window, cx| {
1280                                        Some(this.update(cx, |this, cx| {
1281                                            this.build_extension_provides_filter_menu(window, cx)
1282                                        }))
1283                                    })
1284                                    .trigger_with_tooltip(
1285                                        Button::new(
1286                                            "extension-provides-filter-button",
1287                                            self.provides_filter
1288                                                .map(extension_provides_label)
1289                                                .unwrap_or("All"),
1290                                        )
1291                                        .icon(IconName::Filter)
1292                                        .icon_position(IconPosition::Start),
1293                                        Tooltip::text("Filter extensions by category"),
1294                                    )
1295                                    .anchor(gpui::Corner::TopLeft)
1296                            }))
1297                            .child(
1298                                h_flex()
1299                                    .child(
1300                                        ToggleButton::new("filter-all", "All")
1301                                            .style(ButtonStyle::Filled)
1302                                            .size(ButtonSize::Large)
1303                                            .toggle_state(self.filter == ExtensionFilter::All)
1304                                            .on_click(cx.listener(|this, _event, _, cx| {
1305                                                this.filter = ExtensionFilter::All;
1306                                                this.filter_extension_entries(cx);
1307                                            }))
1308                                            .tooltip(move |_, cx| {
1309                                                Tooltip::simple("Show all extensions", cx)
1310                                            })
1311                                            .first(),
1312                                    )
1313                                    .child(
1314                                        ToggleButton::new("filter-installed", "Installed")
1315                                            .style(ButtonStyle::Filled)
1316                                            .size(ButtonSize::Large)
1317                                            .toggle_state(self.filter == ExtensionFilter::Installed)
1318                                            .on_click(cx.listener(|this, _event, _, cx| {
1319                                                this.filter = ExtensionFilter::Installed;
1320                                                this.filter_extension_entries(cx);
1321                                            }))
1322                                            .tooltip(move |_, cx| {
1323                                                Tooltip::simple("Show installed extensions", cx)
1324                                            })
1325                                            .middle(),
1326                                    )
1327                                    .child(
1328                                        ToggleButton::new("filter-not-installed", "Not Installed")
1329                                            .style(ButtonStyle::Filled)
1330                                            .size(ButtonSize::Large)
1331                                            .toggle_state(
1332                                                self.filter == ExtensionFilter::NotInstalled,
1333                                            )
1334                                            .on_click(cx.listener(|this, _event, _, cx| {
1335                                                this.filter = ExtensionFilter::NotInstalled;
1336                                                this.filter_extension_entries(cx);
1337                                            }))
1338                                            .tooltip(move |_, cx| {
1339                                                Tooltip::simple("Show not installed extensions", cx)
1340                                            })
1341                                            .last(),
1342                                    ),
1343                            ),
1344                    ),
1345            )
1346            .child(self.render_feature_upsells(cx))
1347            .child(
1348                v_flex()
1349                    .pl_4()
1350                    .pr_6()
1351                    .size_full()
1352                    .overflow_y_hidden()
1353                    .map(|this| {
1354                        let mut count = self.filtered_remote_extension_indices.len();
1355                        if self.filter.include_dev_extensions() {
1356                            count += self.dev_extension_entries.len();
1357                        }
1358
1359                        if count == 0 {
1360                            return this.py_4().child(self.render_empty_state(cx));
1361                        }
1362
1363                        let extensions_page = cx.entity().clone();
1364                        let scroll_handle = self.list.clone();
1365                        this.child(
1366                            uniform_list(
1367                                extensions_page,
1368                                "entries",
1369                                count,
1370                                Self::render_extensions,
1371                            )
1372                            .flex_grow()
1373                            .pb_4()
1374                            .track_scroll(scroll_handle),
1375                        )
1376                        .child(
1377                            div()
1378                                .absolute()
1379                                .right_1()
1380                                .top_0()
1381                                .bottom_0()
1382                                .w(px(12.))
1383                                .children(Scrollbar::vertical(self.scrollbar_state.clone())),
1384                        )
1385                    }),
1386            )
1387    }
1388}
1389
1390impl EventEmitter<ItemEvent> for ExtensionsPage {}
1391
1392impl Focusable for ExtensionsPage {
1393    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1394        self.query_editor.read(cx).focus_handle(cx)
1395    }
1396}
1397
1398impl Item for ExtensionsPage {
1399    type Event = ItemEvent;
1400
1401    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1402        "Extensions".into()
1403    }
1404
1405    fn telemetry_event_text(&self) -> Option<&'static str> {
1406        Some("Extensions Page Opened")
1407    }
1408
1409    fn show_toolbar(&self) -> bool {
1410        false
1411    }
1412
1413    fn clone_on_split(
1414        &self,
1415        _workspace_id: Option<WorkspaceId>,
1416        _window: &mut Window,
1417        _: &mut Context<Self>,
1418    ) -> Option<Entity<Self>> {
1419        None
1420    }
1421
1422    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
1423        f(*event)
1424    }
1425}