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