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