extensions_ui.rs

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