extensions_ui.rs

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