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