extensions_ui.rs

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