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    fn extension_status(extension_id: &str, cx: &mut ViewContext<Self>) -> ExtensionStatus {
194        let extension_store = ExtensionStore::global(cx).read(cx);
195
196        match extension_store.outstanding_operations().get(extension_id) {
197            Some(ExtensionOperation::Install) => ExtensionStatus::Installing,
198            Some(ExtensionOperation::Remove) => ExtensionStatus::Removing,
199            Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading,
200            None => match extension_store.installed_extensions().get(extension_id) {
201                Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()),
202                None => ExtensionStatus::NotInstalled,
203            },
204        }
205    }
206
207    fn filter_extension_entries(&mut self, cx: &mut ViewContext<Self>) {
208        self.filtered_remote_extension_indices.clear();
209        self.filtered_remote_extension_indices.extend(
210            self.remote_extension_entries
211                .iter()
212                .enumerate()
213                .filter(|(_, extension)| match self.filter {
214                    ExtensionFilter::All => true,
215                    ExtensionFilter::Installed => {
216                        let status = Self::extension_status(&extension.id, cx);
217                        matches!(status, ExtensionStatus::Installed(_))
218                    }
219                    ExtensionFilter::NotInstalled => {
220                        let status = Self::extension_status(&extension.id, cx);
221
222                        matches!(status, ExtensionStatus::NotInstalled)
223                    }
224                })
225                .map(|(ix, _)| ix),
226        );
227        cx.notify();
228    }
229
230    fn fetch_extensions(&mut self, search: Option<String>, cx: &mut ViewContext<Self>) {
231        self.is_fetching_extensions = true;
232        cx.notify();
233
234        let extension_store = ExtensionStore::global(cx);
235
236        let dev_extensions = extension_store.update(cx, |store, _| {
237            store.dev_extensions().cloned().collect::<Vec<_>>()
238        });
239
240        let remote_extensions = extension_store.update(cx, |store, cx| {
241            store.fetch_extensions(search.as_deref(), cx)
242        });
243
244        cx.spawn(move |this, mut cx| async move {
245            let dev_extensions = if let Some(search) = search {
246                let match_candidates = dev_extensions
247                    .iter()
248                    .enumerate()
249                    .map(|(ix, manifest)| StringMatchCandidate {
250                        id: ix,
251                        string: manifest.name.clone(),
252                        char_bag: manifest.name.as_str().into(),
253                    })
254                    .collect::<Vec<_>>();
255
256                let matches = match_strings(
257                    &match_candidates,
258                    &search,
259                    false,
260                    match_candidates.len(),
261                    &Default::default(),
262                    cx.background_executor().clone(),
263                )
264                .await;
265                matches
266                    .into_iter()
267                    .map(|mat| dev_extensions[mat.candidate_id].clone())
268                    .collect()
269            } else {
270                dev_extensions
271            };
272
273            let fetch_result = remote_extensions.await;
274            this.update(&mut cx, |this, cx| {
275                cx.notify();
276                this.dev_extension_entries = dev_extensions;
277                this.is_fetching_extensions = false;
278                this.remote_extension_entries = fetch_result?;
279                this.filter_extension_entries(cx);
280                anyhow::Ok(())
281            })?
282        })
283        .detach_and_log_err(cx);
284    }
285
286    fn render_extensions(
287        &mut self,
288        range: Range<usize>,
289        cx: &mut ViewContext<Self>,
290    ) -> Vec<ExtensionCard> {
291        let dev_extension_entries_len = if self.filter.include_dev_extensions() {
292            self.dev_extension_entries.len()
293        } else {
294            0
295        };
296        range
297            .map(|ix| {
298                if ix < dev_extension_entries_len {
299                    let extension = &self.dev_extension_entries[ix];
300                    self.render_dev_extension(extension, cx)
301                } else {
302                    let extension_ix =
303                        self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
304                    let extension = &self.remote_extension_entries[extension_ix];
305                    self.render_remote_extension(extension, cx)
306                }
307            })
308            .collect()
309    }
310
311    fn render_dev_extension(
312        &self,
313        extension: &ExtensionManifest,
314        cx: &mut ViewContext<Self>,
315    ) -> ExtensionCard {
316        let status = Self::extension_status(&extension.id, cx);
317
318        let repository_url = extension.repository.clone();
319
320        ExtensionCard::new()
321            .child(
322                h_flex()
323                    .justify_between()
324                    .child(
325                        h_flex()
326                            .gap_2()
327                            .items_end()
328                            .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
329                            .child(
330                                Headline::new(format!("v{}", extension.version))
331                                    .size(HeadlineSize::XSmall),
332                            ),
333                    )
334                    .child(
335                        h_flex()
336                            .gap_2()
337                            .justify_between()
338                            .child(
339                                Button::new(
340                                    SharedString::from(format!("rebuild-{}", extension.id)),
341                                    "Rebuild",
342                                )
343                                .on_click({
344                                    let extension_id = extension.id.clone();
345                                    move |_, cx| {
346                                        ExtensionStore::global(cx).update(cx, |store, cx| {
347                                            store.rebuild_dev_extension(extension_id.clone(), cx)
348                                        });
349                                    }
350                                })
351                                .color(Color::Accent)
352                                .disabled(matches!(status, ExtensionStatus::Upgrading)),
353                            )
354                            .child(
355                                Button::new(SharedString::from(extension.id.clone()), "Uninstall")
356                                    .on_click({
357                                        let extension_id = extension.id.clone();
358                                        move |_, cx| {
359                                            ExtensionStore::global(cx).update(cx, |store, cx| {
360                                                store.uninstall_extension(extension_id.clone(), cx)
361                                            });
362                                        }
363                                    })
364                                    .color(Color::Accent)
365                                    .disabled(matches!(status, ExtensionStatus::Removing)),
366                            ),
367                    ),
368            )
369            .child(
370                h_flex()
371                    .justify_between()
372                    .child(
373                        Label::new(format!(
374                            "{}: {}",
375                            if extension.authors.len() > 1 {
376                                "Authors"
377                            } else {
378                                "Author"
379                            },
380                            extension.authors.join(", ")
381                        ))
382                        .size(LabelSize::Small),
383                    )
384                    .child(Label::new("<>").size(LabelSize::Small)),
385            )
386            .child(
387                h_flex()
388                    .justify_between()
389                    .children(extension.description.as_ref().map(|description| {
390                        Label::new(description.clone())
391                            .size(LabelSize::Small)
392                            .color(Color::Default)
393                    }))
394                    .children(repository_url.map(|repository_url| {
395                        IconButton::new(
396                            SharedString::from(format!("repository-{}", extension.id)),
397                            IconName::Github,
398                        )
399                        .icon_color(Color::Accent)
400                        .icon_size(IconSize::Small)
401                        .style(ButtonStyle::Filled)
402                        .on_click(cx.listener({
403                            let repository_url = repository_url.clone();
404                            move |_, _, cx| {
405                                cx.open_url(&repository_url);
406                            }
407                        }))
408                        .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx))
409                    })),
410            )
411    }
412
413    fn render_remote_extension(
414        &self,
415        extension: &ExtensionMetadata,
416        cx: &mut ViewContext<Self>,
417    ) -> ExtensionCard {
418        let this = cx.view().clone();
419        let status = Self::extension_status(&extension.id, cx);
420
421        let extension_id = extension.id.clone();
422        let (install_or_uninstall_button, upgrade_button) =
423            self.buttons_for_entry(extension, &status, cx);
424        let version = extension.manifest.version.clone();
425        let repository_url = extension.manifest.repository.clone();
426
427        let installed_version = match status {
428            ExtensionStatus::Installed(installed_version) => Some(installed_version),
429            _ => None,
430        };
431
432        ExtensionCard::new()
433            .child(
434                h_flex()
435                    .justify_between()
436                    .child(
437                        h_flex()
438                            .gap_2()
439                            .items_end()
440                            .child(
441                                Headline::new(extension.manifest.name.clone())
442                                    .size(HeadlineSize::Medium),
443                            )
444                            .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
445                            .children(
446                                installed_version
447                                    .filter(|installed_version| *installed_version != version)
448                                    .map(|installed_version| {
449                                        Headline::new(format!("(v{installed_version} installed)",))
450                                            .size(HeadlineSize::XSmall)
451                                    }),
452                            ),
453                    )
454                    .child(
455                        h_flex()
456                            .gap_2()
457                            .justify_between()
458                            .children(upgrade_button)
459                            .child(install_or_uninstall_button),
460                    ),
461            )
462            .child(
463                h_flex()
464                    .justify_between()
465                    .child(
466                        Label::new(format!(
467                            "{}: {}",
468                            if extension.manifest.authors.len() > 1 {
469                                "Authors"
470                            } else {
471                                "Author"
472                            },
473                            extension.manifest.authors.join(", ")
474                        ))
475                        .size(LabelSize::Small),
476                    )
477                    .child(
478                        Label::new(format!("Downloads: {}", extension.download_count))
479                            .size(LabelSize::Small),
480                    ),
481            )
482            .child(
483                h_flex()
484                    .gap_2()
485                    .justify_between()
486                    .children(extension.manifest.description.as_ref().map(|description| {
487                        h_flex().overflow_x_hidden().child(
488                            Label::new(description.clone())
489                                .size(LabelSize::Small)
490                                .color(Color::Default),
491                        )
492                    }))
493                    .child(
494                        h_flex()
495                            .gap_2()
496                            .child(
497                                IconButton::new(
498                                    SharedString::from(format!("repository-{}", extension.id)),
499                                    IconName::Github,
500                                )
501                                .icon_color(Color::Accent)
502                                .icon_size(IconSize::Small)
503                                .style(ButtonStyle::Filled)
504                                .on_click(cx.listener({
505                                    let repository_url = repository_url.clone();
506                                    move |_, _, cx| {
507                                        cx.open_url(&repository_url);
508                                    }
509                                }))
510                                .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx)),
511                            )
512                            .child(
513                                popover_menu(SharedString::from(format!("more-{}", extension.id)))
514                                    .trigger(
515                                        IconButton::new(
516                                            SharedString::from(format!("more-{}", extension.id)),
517                                            IconName::Ellipsis,
518                                        )
519                                        .icon_color(Color::Accent)
520                                        .icon_size(IconSize::Small)
521                                        .style(ButtonStyle::Filled),
522                                    )
523                                    .menu(move |cx| {
524                                        Some(Self::render_remote_extension_context_menu(
525                                            &this,
526                                            extension_id.clone(),
527                                            cx,
528                                        ))
529                                    }),
530                            ),
531                    ),
532            )
533    }
534
535    fn render_remote_extension_context_menu(
536        this: &View<Self>,
537        extension_id: Arc<str>,
538        cx: &mut WindowContext,
539    ) -> View<ContextMenu> {
540        let context_menu = ContextMenu::build(cx, |context_menu, cx| {
541            context_menu.entry(
542                "Install Another Version...",
543                None,
544                cx.handler_for(&this, move |this, cx| {
545                    this.show_extension_version_list(extension_id.clone(), cx)
546                }),
547            )
548        });
549
550        context_menu
551    }
552
553    fn show_extension_version_list(&mut self, extension_id: Arc<str>, cx: &mut ViewContext<Self>) {
554        let Some(workspace) = self.workspace.upgrade() else {
555            return;
556        };
557
558        cx.spawn(move |this, mut cx| async move {
559            let extension_versions_task = this.update(&mut cx, |_, cx| {
560                let extension_store = ExtensionStore::global(cx);
561
562                extension_store.update(cx, |store, cx| {
563                    store.fetch_extension_versions(&extension_id, cx)
564                })
565            })?;
566
567            let extension_versions = extension_versions_task.await?;
568
569            workspace.update(&mut cx, |workspace, cx| {
570                let fs = workspace.project().read(cx).fs().clone();
571                workspace.toggle_modal(cx, |cx| {
572                    let delegate = ExtensionVersionSelectorDelegate::new(
573                        fs,
574                        cx.view().downgrade(),
575                        extension_versions,
576                    );
577
578                    ExtensionVersionSelector::new(delegate, cx)
579                });
580            })?;
581
582            anyhow::Ok(())
583        })
584        .detach_and_log_err(cx);
585    }
586
587    fn buttons_for_entry(
588        &self,
589        extension: &ExtensionMetadata,
590        status: &ExtensionStatus,
591        cx: &mut ViewContext<Self>,
592    ) -> (Button, Option<Button>) {
593        let is_compatible = extension::is_version_compatible(&extension);
594
595        match status.clone() {
596            ExtensionStatus::NotInstalled => (
597                Button::new(SharedString::from(extension.id.clone()), "Install").on_click(
598                    cx.listener({
599                        let extension_id = extension.id.clone();
600                        move |this, _, cx| {
601                            this.telemetry
602                                .report_app_event("extensions: install extension".to_string());
603                            ExtensionStore::global(cx).update(cx, |store, cx| {
604                                store.install_latest_extension(extension_id.clone(), cx)
605                            });
606                        }
607                    }),
608                ),
609                None,
610            ),
611            ExtensionStatus::Installing => (
612                Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
613                None,
614            ),
615            ExtensionStatus::Upgrading => (
616                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
617                Some(
618                    Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
619                ),
620            ),
621            ExtensionStatus::Installed(installed_version) => (
622                Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click(
623                    cx.listener({
624                        let extension_id = extension.id.clone();
625                        move |this, _, cx| {
626                            this.telemetry
627                                .report_app_event("extensions: uninstall extension".to_string());
628                            ExtensionStore::global(cx).update(cx, |store, cx| {
629                                store.uninstall_extension(extension_id.clone(), cx)
630                            });
631                        }
632                    }),
633                ),
634                if installed_version == extension.manifest.version {
635                    None
636                } else {
637                    Some(
638                        Button::new(SharedString::from(extension.id.clone()), "Upgrade")
639                            .when(!is_compatible, |upgrade_button| {
640                                upgrade_button.disabled(true).tooltip({
641                                    let version = extension.manifest.version.clone();
642                                    move |cx| {
643                                        Tooltip::text(
644                                            format!(
645                                                "v{version} is not compatible with this version of Zed.",
646                                            ),
647                                            cx,
648                                        )
649                                    }
650                                })
651                            })
652                            .disabled(!is_compatible)
653                            .on_click(cx.listener({
654                                let extension_id = extension.id.clone();
655                                let version = extension.manifest.version.clone();
656                                move |this, _, cx| {
657                                    this.telemetry.report_app_event(
658                                        "extensions: install extension".to_string(),
659                                    );
660                                    ExtensionStore::global(cx).update(cx, |store, cx| {
661                                        store
662                                            .upgrade_extension(
663                                                extension_id.clone(),
664                                                version.clone(),
665                                                cx,
666                                            )
667                                            .detach_and_log_err(cx)
668                                    });
669                                }
670                            })),
671                    )
672                },
673            ),
674            ExtensionStatus::Removing => (
675                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
676                None,
677            ),
678        }
679    }
680
681    fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
682        let mut key_context = KeyContext::default();
683        key_context.add("BufferSearchBar");
684
685        let editor_border = if self.query_contains_error {
686            Color::Error.color(cx)
687        } else {
688            cx.theme().colors().border
689        };
690
691        h_flex()
692            .w_full()
693            .gap_2()
694            .key_context(key_context)
695            // .capture_action(cx.listener(Self::tab))
696            // .on_action(cx.listener(Self::dismiss))
697            .child(
698                h_flex()
699                    .flex_1()
700                    .px_2()
701                    .py_1()
702                    .gap_2()
703                    .border_1()
704                    .border_color(editor_border)
705                    .min_w(rems_from_px(384.))
706                    .rounded_lg()
707                    .child(Icon::new(IconName::MagnifyingGlass))
708                    .child(self.render_text_input(&self.query_editor, cx)),
709            )
710    }
711
712    fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
713        let settings = ThemeSettings::get_global(cx);
714        let text_style = TextStyle {
715            color: if editor.read(cx).read_only(cx) {
716                cx.theme().colors().text_disabled
717            } else {
718                cx.theme().colors().text
719            },
720            font_family: settings.ui_font.family.clone(),
721            font_features: settings.ui_font.features,
722            font_size: rems(0.875).into(),
723            font_weight: FontWeight::NORMAL,
724            font_style: FontStyle::Normal,
725            line_height: relative(1.3),
726            background_color: None,
727            underline: None,
728            strikethrough: None,
729            white_space: WhiteSpace::Normal,
730        };
731
732        EditorElement::new(
733            &editor,
734            EditorStyle {
735                background: cx.theme().colors().editor_background,
736                local_player: cx.theme().players().local(),
737                text: text_style,
738                ..Default::default()
739            },
740        )
741    }
742
743    fn on_query_change(
744        &mut self,
745        _: View<Editor>,
746        event: &editor::EditorEvent,
747        cx: &mut ViewContext<Self>,
748    ) {
749        if let editor::EditorEvent::Edited = event {
750            self.query_contains_error = false;
751            self.fetch_extensions_debounced(cx);
752        }
753    }
754
755    fn fetch_extensions_debounced(&mut self, cx: &mut ViewContext<'_, ExtensionsPage>) {
756        self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
757            let search = this
758                .update(&mut cx, |this, cx| this.search_query(cx))
759                .ok()
760                .flatten();
761
762            // Only debounce the fetching of extensions if we have a search
763            // query.
764            //
765            // If the search was just cleared then we can just reload the list
766            // of extensions without a debounce, which allows us to avoid seeing
767            // an intermittent flash of a "no extensions" state.
768            if let Some(_) = search {
769                cx.background_executor()
770                    .timer(Duration::from_millis(250))
771                    .await;
772            };
773
774            this.update(&mut cx, |this, cx| {
775                this.fetch_extensions(search, cx);
776            })
777            .ok();
778        }));
779    }
780
781    pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
782        let search = self.query_editor.read(cx).text(cx);
783        if search.trim().is_empty() {
784            None
785        } else {
786            Some(search)
787        }
788    }
789
790    fn render_empty_state(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
791        let has_search = self.search_query(cx).is_some();
792
793        let message = if self.is_fetching_extensions {
794            "Loading extensions..."
795        } else {
796            match self.filter {
797                ExtensionFilter::All => {
798                    if has_search {
799                        "No extensions that match your search."
800                    } else {
801                        "No extensions."
802                    }
803                }
804                ExtensionFilter::Installed => {
805                    if has_search {
806                        "No installed extensions that match your search."
807                    } else {
808                        "No installed extensions."
809                    }
810                }
811                ExtensionFilter::NotInstalled => {
812                    if has_search {
813                        "No not installed extensions that match your search."
814                    } else {
815                        "No not installed extensions."
816                    }
817                }
818            }
819        };
820
821        Label::new(message)
822    }
823}
824
825impl Render for ExtensionsPage {
826    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
827        v_flex()
828            .size_full()
829            .bg(cx.theme().colors().editor_background)
830            .child(
831                v_flex()
832                    .gap_4()
833                    .p_4()
834                    .border_b()
835                    .border_color(cx.theme().colors().border)
836                    .bg(cx.theme().colors().editor_background)
837                    .child(
838                        h_flex()
839                            .w_full()
840                            .gap_2()
841                            .justify_between()
842                            .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
843                            .child(
844                                Button::new("install-dev-extension", "Install Dev Extension")
845                                    .style(ButtonStyle::Filled)
846                                    .size(ButtonSize::Large)
847                                    .on_click(|_event, cx| {
848                                        cx.dispatch_action(Box::new(InstallDevExtension))
849                                    }),
850                            ),
851                    )
852                    .child(
853                        h_flex()
854                            .w_full()
855                            .gap_2()
856                            .justify_between()
857                            .child(h_flex().child(self.render_search(cx)))
858                            .child(
859                                h_flex()
860                                    .child(
861                                        ToggleButton::new("filter-all", "All")
862                                            .style(ButtonStyle::Filled)
863                                            .size(ButtonSize::Large)
864                                            .selected(self.filter == ExtensionFilter::All)
865                                            .on_click(cx.listener(|this, _event, cx| {
866                                                this.filter = ExtensionFilter::All;
867                                                this.filter_extension_entries(cx);
868                                            }))
869                                            .tooltip(move |cx| {
870                                                Tooltip::text("Show all extensions", cx)
871                                            })
872                                            .first(),
873                                    )
874                                    .child(
875                                        ToggleButton::new("filter-installed", "Installed")
876                                            .style(ButtonStyle::Filled)
877                                            .size(ButtonSize::Large)
878                                            .selected(self.filter == ExtensionFilter::Installed)
879                                            .on_click(cx.listener(|this, _event, cx| {
880                                                this.filter = ExtensionFilter::Installed;
881                                                this.filter_extension_entries(cx);
882                                            }))
883                                            .tooltip(move |cx| {
884                                                Tooltip::text("Show installed extensions", cx)
885                                            })
886                                            .middle(),
887                                    )
888                                    .child(
889                                        ToggleButton::new("filter-not-installed", "Not Installed")
890                                            .style(ButtonStyle::Filled)
891                                            .size(ButtonSize::Large)
892                                            .selected(self.filter == ExtensionFilter::NotInstalled)
893                                            .on_click(cx.listener(|this, _event, cx| {
894                                                this.filter = ExtensionFilter::NotInstalled;
895                                                this.filter_extension_entries(cx);
896                                            }))
897                                            .tooltip(move |cx| {
898                                                Tooltip::text("Show not installed extensions", cx)
899                                            })
900                                            .last(),
901                                    ),
902                            ),
903                    ),
904            )
905            .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
906                let mut count = self.filtered_remote_extension_indices.len();
907                if self.filter.include_dev_extensions() {
908                    count += self.dev_extension_entries.len();
909                }
910
911                if count == 0 {
912                    return this.py_4().child(self.render_empty_state(cx));
913                }
914
915                let view = cx.view().clone();
916                let scroll_handle = self.list.clone();
917                this.child(
918                    canvas(
919                        move |bounds, cx| {
920                            let mut list = uniform_list::<_, ExtensionCard, _>(
921                                view,
922                                "entries",
923                                count,
924                                Self::render_extensions,
925                            )
926                            .size_full()
927                            .pb_4()
928                            .track_scroll(scroll_handle)
929                            .into_any_element();
930                            list.layout(bounds.origin, bounds.size.into(), cx);
931                            list
932                        },
933                        |_bounds, mut list, cx| list.paint(cx),
934                    )
935                    .size_full(),
936                )
937            }))
938    }
939}
940
941impl EventEmitter<ItemEvent> for ExtensionsPage {}
942
943impl FocusableView for ExtensionsPage {
944    fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
945        self.query_editor.read(cx).focus_handle(cx)
946    }
947}
948
949impl Item for ExtensionsPage {
950    type Event = ItemEvent;
951
952    fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement {
953        Label::new("Extensions")
954            .color(if params.selected {
955                Color::Default
956            } else {
957                Color::Muted
958            })
959            .into_any_element()
960    }
961
962    fn telemetry_event_text(&self) -> Option<&'static str> {
963        Some("extensions page")
964    }
965
966    fn show_toolbar(&self) -> bool {
967        false
968    }
969
970    fn clone_on_split(
971        &self,
972        _workspace_id: WorkspaceId,
973        _: &mut ViewContext<Self>,
974    ) -> Option<View<Self>> {
975        None
976    }
977
978    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
979        f(*event)
980    }
981}