extensions_ui.rs

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