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