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