extensions_ui.rs

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