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