extensions_ui.rs

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