extensions_ui.rs

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