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