extensions_ui.rs

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