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