extensions_ui.rs

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