extensions_ui.rs

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