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