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