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(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                        .color(Color::Muted)
 577                        .truncate(),
 578                    )
 579                    .child(Label::new("<>").size(LabelSize::Small)),
 580            )
 581            .child(
 582                h_flex()
 583                    .gap_2()
 584                    .justify_between()
 585                    .children(extension.description.as_ref().map(|description| {
 586                        Label::new(description.clone())
 587                            .size(LabelSize::Small)
 588                            .color(Color::Default)
 589                            .truncate()
 590                    }))
 591                    .children(repository_url.map(|repository_url| {
 592                        IconButton::new(
 593                            SharedString::from(format!("repository-{}", extension.id)),
 594                            IconName::Github,
 595                        )
 596                        .icon_color(Color::Accent)
 597                        .icon_size(IconSize::Small)
 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                        .color(Color::Muted)
 705                        .truncate(),
 706                    )
 707                    .child(
 708                        Label::new(format!(
 709                            "Downloads: {}",
 710                            extension.download_count.to_formatted_string(&Locale::en)
 711                        ))
 712                        .size(LabelSize::Small),
 713                    ),
 714            )
 715            .child(
 716                h_flex()
 717                    .gap_2()
 718                    .justify_between()
 719                    .children(extension.manifest.description.as_ref().map(|description| {
 720                        Label::new(description.clone())
 721                            .size(LabelSize::Small)
 722                            .color(Color::Default)
 723                            .truncate()
 724                    }))
 725                    .child(
 726                        h_flex()
 727                            .gap_2()
 728                            .child(
 729                                IconButton::new(
 730                                    SharedString::from(format!("repository-{}", extension.id)),
 731                                    IconName::Github,
 732                                )
 733                                .icon_color(Color::Accent)
 734                                .icon_size(IconSize::Small)
 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                                )
 756                                .menu(move |window, cx| {
 757                                    Some(Self::render_remote_extension_context_menu(
 758                                        &this,
 759                                        extension_id.clone(),
 760                                        authors.clone(),
 761                                        window,
 762                                        cx,
 763                                    ))
 764                                }),
 765                            ),
 766                    ),
 767            )
 768    }
 769
 770    fn render_remote_extension_context_menu(
 771        this: &Entity<Self>,
 772        extension_id: Arc<str>,
 773        authors: Vec<String>,
 774        window: &mut Window,
 775        cx: &mut App,
 776    ) -> Entity<ContextMenu> {
 777        let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| {
 778            context_menu
 779                .entry(
 780                    "Install Another Version...",
 781                    None,
 782                    window.handler_for(this, {
 783                        let extension_id = extension_id.clone();
 784                        move |this, window, cx| {
 785                            this.show_extension_version_list(extension_id.clone(), window, cx)
 786                        }
 787                    }),
 788                )
 789                .entry("Copy Extension ID", None, {
 790                    let extension_id = extension_id.clone();
 791                    move |_, cx| {
 792                        cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
 793                    }
 794                })
 795                .entry("Copy Author Info", None, {
 796                    let authors = authors.clone();
 797                    move |_, cx| {
 798                        cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", ")));
 799                    }
 800                })
 801        });
 802
 803        context_menu
 804    }
 805
 806    fn show_extension_version_list(
 807        &mut self,
 808        extension_id: Arc<str>,
 809        window: &mut Window,
 810        cx: &mut Context<Self>,
 811    ) {
 812        let Some(workspace) = self.workspace.upgrade() else {
 813            return;
 814        };
 815
 816        cx.spawn_in(window, async move |this, cx| {
 817            let extension_versions_task = this.update(cx, |_, cx| {
 818                let extension_store = ExtensionStore::global(cx);
 819
 820                extension_store.update(cx, |store, cx| {
 821                    store.fetch_extension_versions(&extension_id, cx)
 822                })
 823            })?;
 824
 825            let extension_versions = extension_versions_task.await?;
 826
 827            workspace.update_in(cx, |workspace, window, cx| {
 828                let fs = workspace.project().read(cx).fs().clone();
 829                workspace.toggle_modal(window, cx, |window, cx| {
 830                    let delegate = ExtensionVersionSelectorDelegate::new(
 831                        fs,
 832                        cx.entity().downgrade(),
 833                        extension_versions,
 834                    );
 835
 836                    ExtensionVersionSelector::new(delegate, window, cx)
 837                });
 838            })?;
 839
 840            anyhow::Ok(())
 841        })
 842        .detach_and_log_err(cx);
 843    }
 844
 845    fn buttons_for_entry(
 846        &self,
 847        extension: &ExtensionMetadata,
 848        status: &ExtensionStatus,
 849        has_dev_extension: bool,
 850        cx: &mut Context<Self>,
 851    ) -> (Button, Option<Button>) {
 852        let is_compatible =
 853            extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
 854
 855        if has_dev_extension {
 856            // If we have a dev extension for the given extension, just treat it as uninstalled.
 857            // The button here is a placeholder, as it won't be interactable anyways.
 858            return (
 859                Button::new(SharedString::from(extension.id.clone()), "Install"),
 860                None,
 861            );
 862        }
 863
 864        match status.clone() {
 865            ExtensionStatus::NotInstalled => (
 866                Button::new(SharedString::from(extension.id.clone()), "Install").on_click({
 867                    let extension_id = extension.id.clone();
 868                    move |_, _, cx| {
 869                        telemetry::event!("Extension Installed");
 870                        ExtensionStore::global(cx).update(cx, |store, cx| {
 871                            store.install_latest_extension(extension_id.clone(), cx)
 872                        });
 873                    }
 874                }),
 875                None,
 876            ),
 877            ExtensionStatus::Installing => (
 878                Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
 879                None,
 880            ),
 881            ExtensionStatus::Upgrading => (
 882                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
 883                Some(
 884                    Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
 885                ),
 886            ),
 887            ExtensionStatus::Installed(installed_version) => (
 888                Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click({
 889                    let extension_id = extension.id.clone();
 890                    move |_, _, cx| {
 891                        telemetry::event!("Extension Uninstalled", extension_id);
 892                        ExtensionStore::global(cx).update(cx, |store, cx| {
 893                            store.uninstall_extension(extension_id.clone(), cx)
 894                        });
 895                    }
 896                }),
 897                if installed_version == extension.manifest.version {
 898                    None
 899                } else {
 900                    Some(
 901                        Button::new(SharedString::from(extension.id.clone()), "Upgrade")
 902                            .when(!is_compatible, |upgrade_button| {
 903                                upgrade_button.disabled(true).tooltip({
 904                                    let version = extension.manifest.version.clone();
 905                                    move |_, cx| {
 906                                        Tooltip::simple(
 907                                            format!(
 908                                                "v{version} is not compatible with this version of Zed.",
 909                                            ),
 910                                             cx,
 911                                        )
 912                                    }
 913                                })
 914                            })
 915                            .disabled(!is_compatible)
 916                            .on_click({
 917                                let extension_id = extension.id.clone();
 918                                let version = extension.manifest.version.clone();
 919                                move |_, _, cx| {
 920                                    telemetry::event!("Extension Installed", extension_id, version);
 921                                    ExtensionStore::global(cx).update(cx, |store, cx| {
 922                                        store
 923                                            .upgrade_extension(
 924                                                extension_id.clone(),
 925                                                version.clone(),
 926                                                cx,
 927                                            )
 928                                            .detach_and_log_err(cx)
 929                                    });
 930                                }
 931                            }),
 932                    )
 933                },
 934            ),
 935            ExtensionStatus::Removing => (
 936                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
 937                None,
 938            ),
 939        }
 940    }
 941
 942    fn render_search(&self, cx: &mut Context<Self>) -> Div {
 943        let mut key_context = KeyContext::new_with_defaults();
 944        key_context.add("BufferSearchBar");
 945
 946        let editor_border = if self.query_contains_error {
 947            Color::Error.color(cx)
 948        } else {
 949            cx.theme().colors().border
 950        };
 951
 952        h_flex()
 953            .key_context(key_context)
 954            .h_8()
 955            .flex_1()
 956            .min_w(rems_from_px(384.))
 957            .pl_1p5()
 958            .pr_2()
 959            .py_1()
 960            .gap_2()
 961            .border_1()
 962            .border_color(editor_border)
 963            .rounded_lg()
 964            .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
 965            .child(self.render_text_input(&self.query_editor, cx))
 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
1198impl Render for ExtensionsPage {
1199    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1200        v_flex()
1201            .size_full()
1202            .bg(cx.theme().colors().editor_background)
1203            .child(
1204                v_flex()
1205                    .gap_4()
1206                    .pt_4()
1207                    .px_4()
1208                    .bg(cx.theme().colors().editor_background)
1209                    .child(
1210                        h_flex()
1211                            .w_full()
1212                            .gap_2()
1213                            .justify_between()
1214                            .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
1215                            .child(
1216                                Button::new("install-dev-extension", "Install Dev Extension")
1217                                    .style(ButtonStyle::Filled)
1218                                    .size(ButtonSize::Large)
1219                                    .on_click(|_event, window, cx| {
1220                                        window.dispatch_action(Box::new(InstallDevExtension), cx)
1221                                    }),
1222                            ),
1223                    )
1224                    .child(
1225                        h_flex()
1226                            .w_full()
1227                            .gap_4()
1228                            .flex_wrap()
1229                            .child(self.render_search(cx))
1230                            .child(
1231                                h_flex()
1232                                    .child(
1233                                        ToggleButton::new("filter-all", "All")
1234                                            .style(ButtonStyle::Filled)
1235                                            .size(ButtonSize::Large)
1236                                            .toggle_state(self.filter == ExtensionFilter::All)
1237                                            .on_click(cx.listener(|this, _event, _, cx| {
1238                                                this.filter = ExtensionFilter::All;
1239                                                this.filter_extension_entries(cx);
1240                                            }))
1241                                            .tooltip(move |_, cx| {
1242                                                Tooltip::simple("Show all extensions", cx)
1243                                            })
1244                                            .first(),
1245                                    )
1246                                    .child(
1247                                        ToggleButton::new("filter-installed", "Installed")
1248                                            .style(ButtonStyle::Filled)
1249                                            .size(ButtonSize::Large)
1250                                            .toggle_state(self.filter == ExtensionFilter::Installed)
1251                                            .on_click(cx.listener(|this, _event, _, cx| {
1252                                                this.filter = ExtensionFilter::Installed;
1253                                                this.filter_extension_entries(cx);
1254                                            }))
1255                                            .tooltip(move |_, cx| {
1256                                                Tooltip::simple("Show installed extensions", cx)
1257                                            })
1258                                            .middle(),
1259                                    )
1260                                    .child(
1261                                        ToggleButton::new("filter-not-installed", "Not Installed")
1262                                            .style(ButtonStyle::Filled)
1263                                            .size(ButtonSize::Large)
1264                                            .toggle_state(
1265                                                self.filter == ExtensionFilter::NotInstalled,
1266                                            )
1267                                            .on_click(cx.listener(|this, _event, _, cx| {
1268                                                this.filter = ExtensionFilter::NotInstalled;
1269                                                this.filter_extension_entries(cx);
1270                                            }))
1271                                            .tooltip(move |_, cx| {
1272                                                Tooltip::simple("Show not installed extensions", cx)
1273                                            })
1274                                            .last(),
1275                                    ),
1276                            ),
1277                    ),
1278            )
1279            .child(
1280                h_flex()
1281                    .id("filter-row")
1282                    .gap_2()
1283                    .py_2p5()
1284                    .px_4()
1285                    .border_b_1()
1286                    .border_color(cx.theme().colors().border_variant)
1287                    .overflow_x_scroll()
1288                    .child(
1289                        Button::new("filter-all-categories", "All")
1290                            .when(self.provides_filter.is_none(), |button| {
1291                                button.style(ButtonStyle::Filled)
1292                            })
1293                            .when(self.provides_filter.is_some(), |button| {
1294                                button.style(ButtonStyle::Subtle)
1295                            })
1296                            .toggle_state(self.provides_filter.is_none())
1297                            .on_click(cx.listener(|this, _event, _, cx| {
1298                                this.change_provides_filter(None, cx);
1299                            })),
1300                    )
1301                    .children(ExtensionProvides::iter().map(|provides| {
1302                        let label = extension_provides_label(provides);
1303                        Button::new(
1304                            SharedString::from(format!("filter-category-{}", label)),
1305                            label,
1306                        )
1307                        .style(if self.provides_filter == Some(provides) {
1308                            ButtonStyle::Filled
1309                        } else {
1310                            ButtonStyle::Subtle
1311                        })
1312                        .toggle_state(self.provides_filter == Some(provides))
1313                        .on_click({
1314                            cx.listener(move |this, _event, _, cx| {
1315                                this.change_provides_filter(Some(provides), cx);
1316                            })
1317                        })
1318                    })),
1319            )
1320            .child(self.render_feature_upsells(cx))
1321            .child(
1322                v_flex()
1323                    .pl_4()
1324                    .pr_6()
1325                    .size_full()
1326                    .overflow_y_hidden()
1327                    .map(|this| {
1328                        let mut count = self.filtered_remote_extension_indices.len();
1329                        if self.filter.include_dev_extensions() {
1330                            count += self.dev_extension_entries.len();
1331                        }
1332
1333                        if count == 0 {
1334                            return this.py_4().child(self.render_empty_state(cx));
1335                        }
1336
1337                        let extensions_page = cx.entity().clone();
1338                        let scroll_handle = self.list.clone();
1339                        this.child(
1340                            uniform_list(
1341                                extensions_page,
1342                                "entries",
1343                                count,
1344                                Self::render_extensions,
1345                            )
1346                            .flex_grow()
1347                            .pb_4()
1348                            .track_scroll(scroll_handle),
1349                        )
1350                        .child(
1351                            div()
1352                                .absolute()
1353                                .right_1()
1354                                .top_0()
1355                                .bottom_0()
1356                                .w(px(12.))
1357                                .children(Scrollbar::vertical(self.scrollbar_state.clone())),
1358                        )
1359                    }),
1360            )
1361    }
1362}
1363
1364impl EventEmitter<ItemEvent> for ExtensionsPage {}
1365
1366impl Focusable for ExtensionsPage {
1367    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1368        self.query_editor.read(cx).focus_handle(cx)
1369    }
1370}
1371
1372impl Item for ExtensionsPage {
1373    type Event = ItemEvent;
1374
1375    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1376        "Extensions".into()
1377    }
1378
1379    fn telemetry_event_text(&self) -> Option<&'static str> {
1380        Some("Extensions Page Opened")
1381    }
1382
1383    fn show_toolbar(&self) -> bool {
1384        false
1385    }
1386
1387    fn clone_on_split(
1388        &self,
1389        _workspace_id: Option<WorkspaceId>,
1390        _window: &mut Window,
1391        _: &mut Context<Self>,
1392    ) -> Option<Entity<Self>> {
1393        None
1394    }
1395
1396    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
1397        f(*event)
1398    }
1399}