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