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                    .gap_2()
395                    .justify_between()
396                    .children(extension.description.as_ref().map(|description| {
397                        h_flex().overflow_x_hidden().child(
398                            Label::new(description.clone())
399                                .size(LabelSize::Small)
400                                .color(Color::Default),
401                        )
402                    }))
403                    .child(
404                        IconButton::new(
405                            SharedString::from(format!("repository-{}", extension.id)),
406                            IconName::Github,
407                        )
408                        .icon_color(Color::Accent)
409                        .icon_size(IconSize::Small)
410                        .style(ButtonStyle::Filled)
411                        .on_click(cx.listener({
412                            let repository_url = repository_url.clone();
413                            move |_, _, cx| {
414                                cx.open_url(&repository_url);
415                            }
416                        }))
417                        .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx)),
418                    ),
419            )
420    }
421
422    fn buttons_for_entry(
423        &self,
424        extension: &ExtensionApiResponse,
425        status: &ExtensionStatus,
426        cx: &mut ViewContext<Self>,
427    ) -> (Button, Option<Button>) {
428        match status.clone() {
429            ExtensionStatus::NotInstalled => (
430                Button::new(SharedString::from(extension.id.clone()), "Install").on_click(
431                    cx.listener({
432                        let extension_id = extension.id.clone();
433                        let version = extension.version.clone();
434                        move |this, _, cx| {
435                            this.telemetry
436                                .report_app_event("extensions: install extension".to_string());
437                            ExtensionStore::global(cx).update(cx, |store, cx| {
438                                store.install_extension(extension_id.clone(), version.clone(), cx)
439                            });
440                        }
441                    }),
442                ),
443                None,
444            ),
445            ExtensionStatus::Installing => (
446                Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
447                None,
448            ),
449            ExtensionStatus::Upgrading => (
450                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
451                Some(
452                    Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
453                ),
454            ),
455            ExtensionStatus::Installed(installed_version) => (
456                Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click(
457                    cx.listener({
458                        let extension_id = extension.id.clone();
459                        move |this, _, cx| {
460                            this.telemetry
461                                .report_app_event("extensions: uninstall extension".to_string());
462                            ExtensionStore::global(cx).update(cx, |store, cx| {
463                                store.uninstall_extension(extension_id.clone(), cx)
464                            });
465                        }
466                    }),
467                ),
468                if installed_version == extension.version {
469                    None
470                } else {
471                    Some(
472                        Button::new(SharedString::from(extension.id.clone()), "Upgrade").on_click(
473                            cx.listener({
474                                let extension_id = extension.id.clone();
475                                let version = extension.version.clone();
476                                move |this, _, cx| {
477                                    this.telemetry.report_app_event(
478                                        "extensions: install extension".to_string(),
479                                    );
480                                    ExtensionStore::global(cx).update(cx, |store, cx| {
481                                        store.upgrade_extension(
482                                            extension_id.clone(),
483                                            version.clone(),
484                                            cx,
485                                        )
486                                    });
487                                }
488                            }),
489                        ),
490                    )
491                },
492            ),
493            ExtensionStatus::Removing => (
494                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
495                None,
496            ),
497        }
498    }
499
500    fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
501        let mut key_context = KeyContext::default();
502        key_context.add("BufferSearchBar");
503
504        let editor_border = if self.query_contains_error {
505            Color::Error.color(cx)
506        } else {
507            cx.theme().colors().border
508        };
509
510        h_flex()
511            .w_full()
512            .gap_2()
513            .key_context(key_context)
514            // .capture_action(cx.listener(Self::tab))
515            // .on_action(cx.listener(Self::dismiss))
516            .child(
517                h_flex()
518                    .flex_1()
519                    .px_2()
520                    .py_1()
521                    .gap_2()
522                    .border_1()
523                    .border_color(editor_border)
524                    .min_w(rems_from_px(384.))
525                    .rounded_lg()
526                    .child(Icon::new(IconName::MagnifyingGlass))
527                    .child(self.render_text_input(&self.query_editor, cx)),
528            )
529    }
530
531    fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
532        let settings = ThemeSettings::get_global(cx);
533        let text_style = TextStyle {
534            color: if editor.read(cx).read_only(cx) {
535                cx.theme().colors().text_disabled
536            } else {
537                cx.theme().colors().text
538            },
539            font_family: settings.ui_font.family.clone(),
540            font_features: settings.ui_font.features,
541            font_size: rems(0.875).into(),
542            font_weight: FontWeight::NORMAL,
543            font_style: FontStyle::Normal,
544            line_height: relative(1.3),
545            background_color: None,
546            underline: None,
547            strikethrough: None,
548            white_space: WhiteSpace::Normal,
549        };
550
551        EditorElement::new(
552            &editor,
553            EditorStyle {
554                background: cx.theme().colors().editor_background,
555                local_player: cx.theme().players().local(),
556                text: text_style,
557                ..Default::default()
558            },
559        )
560    }
561
562    fn on_query_change(
563        &mut self,
564        _: View<Editor>,
565        event: &editor::EditorEvent,
566        cx: &mut ViewContext<Self>,
567    ) {
568        if let editor::EditorEvent::Edited = event {
569            self.query_contains_error = false;
570            self.fetch_extensions_debounced(cx);
571        }
572    }
573
574    fn fetch_extensions_debounced(&mut self, cx: &mut ViewContext<'_, ExtensionsPage>) {
575        self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
576            let search = this
577                .update(&mut cx, |this, cx| this.search_query(cx))
578                .ok()
579                .flatten();
580
581            // Only debounce the fetching of extensions if we have a search
582            // query.
583            //
584            // If the search was just cleared then we can just reload the list
585            // of extensions without a debounce, which allows us to avoid seeing
586            // an intermittent flash of a "no extensions" state.
587            if let Some(_) = search {
588                cx.background_executor()
589                    .timer(Duration::from_millis(250))
590                    .await;
591            };
592
593            this.update(&mut cx, |this, cx| {
594                this.fetch_extensions(search, cx);
595            })
596            .ok();
597        }));
598    }
599
600    pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
601        let search = self.query_editor.read(cx).text(cx);
602        if search.trim().is_empty() {
603            None
604        } else {
605            Some(search)
606        }
607    }
608
609    fn render_empty_state(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
610        let has_search = self.search_query(cx).is_some();
611
612        let message = if self.is_fetching_extensions {
613            "Loading extensions..."
614        } else {
615            match self.filter {
616                ExtensionFilter::All => {
617                    if has_search {
618                        "No extensions that match your search."
619                    } else {
620                        "No extensions."
621                    }
622                }
623                ExtensionFilter::Installed => {
624                    if has_search {
625                        "No installed extensions that match your search."
626                    } else {
627                        "No installed extensions."
628                    }
629                }
630                ExtensionFilter::NotInstalled => {
631                    if has_search {
632                        "No not installed extensions that match your search."
633                    } else {
634                        "No not installed extensions."
635                    }
636                }
637            }
638        };
639
640        Label::new(message)
641    }
642}
643
644impl Render for ExtensionsPage {
645    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
646        v_flex()
647            .size_full()
648            .bg(cx.theme().colors().editor_background)
649            .child(
650                v_flex()
651                    .gap_4()
652                    .p_4()
653                    .border_b()
654                    .border_color(cx.theme().colors().border)
655                    .bg(cx.theme().colors().editor_background)
656                    .child(
657                        h_flex()
658                            .w_full()
659                            .gap_2()
660                            .justify_between()
661                            .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
662                            .child(
663                                Button::new("add-dev-extension", "Add Dev Extension")
664                                    .style(ButtonStyle::Filled)
665                                    .size(ButtonSize::Large)
666                                    .on_click(|_event, cx| {
667                                        cx.dispatch_action(Box::new(InstallDevExtension))
668                                    }),
669                            ),
670                    )
671                    .child(
672                        h_flex()
673                            .w_full()
674                            .gap_2()
675                            .justify_between()
676                            .child(h_flex().child(self.render_search(cx)))
677                            .child(
678                                h_flex()
679                                    .child(
680                                        ToggleButton::new("filter-all", "All")
681                                            .style(ButtonStyle::Filled)
682                                            .size(ButtonSize::Large)
683                                            .selected(self.filter == ExtensionFilter::All)
684                                            .on_click(cx.listener(|this, _event, cx| {
685                                                this.filter = ExtensionFilter::All;
686                                                this.filter_extension_entries(cx);
687                                            }))
688                                            .tooltip(move |cx| {
689                                                Tooltip::text("Show all extensions", cx)
690                                            })
691                                            .first(),
692                                    )
693                                    .child(
694                                        ToggleButton::new("filter-installed", "Installed")
695                                            .style(ButtonStyle::Filled)
696                                            .size(ButtonSize::Large)
697                                            .selected(self.filter == ExtensionFilter::Installed)
698                                            .on_click(cx.listener(|this, _event, cx| {
699                                                this.filter = ExtensionFilter::Installed;
700                                                this.filter_extension_entries(cx);
701                                            }))
702                                            .tooltip(move |cx| {
703                                                Tooltip::text("Show installed extensions", cx)
704                                            })
705                                            .middle(),
706                                    )
707                                    .child(
708                                        ToggleButton::new("filter-not-installed", "Not Installed")
709                                            .style(ButtonStyle::Filled)
710                                            .size(ButtonSize::Large)
711                                            .selected(self.filter == ExtensionFilter::NotInstalled)
712                                            .on_click(cx.listener(|this, _event, cx| {
713                                                this.filter = ExtensionFilter::NotInstalled;
714                                                this.filter_extension_entries(cx);
715                                            }))
716                                            .tooltip(move |cx| {
717                                                Tooltip::text("Show not installed extensions", cx)
718                                            })
719                                            .last(),
720                                    ),
721                            ),
722                    ),
723            )
724            .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
725                let mut count = self.filtered_remote_extension_indices.len();
726                if self.filter.include_dev_extensions() {
727                    count += self.dev_extension_entries.len();
728                }
729
730                if count == 0 {
731                    return this.py_4().child(self.render_empty_state(cx));
732                }
733
734                let view = cx.view().clone();
735                let scroll_handle = self.list.clone();
736                this.child(
737                    canvas(
738                        move |bounds, cx| {
739                            let mut list = uniform_list::<_, ExtensionCard, _>(
740                                view,
741                                "entries",
742                                count,
743                                Self::render_extensions,
744                            )
745                            .size_full()
746                            .pb_4()
747                            .track_scroll(scroll_handle)
748                            .into_any_element();
749                            list.layout(bounds.origin, bounds.size.into(), cx);
750                            list
751                        },
752                        |_bounds, mut list, cx| list.paint(cx),
753                    )
754                    .size_full(),
755                )
756            }))
757    }
758}
759
760impl EventEmitter<ItemEvent> for ExtensionsPage {}
761
762impl FocusableView for ExtensionsPage {
763    fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
764        self.query_editor.read(cx).focus_handle(cx)
765    }
766}
767
768impl Item for ExtensionsPage {
769    type Event = ItemEvent;
770
771    fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
772        Label::new("Extensions")
773            .color(if selected {
774                Color::Default
775            } else {
776                Color::Muted
777            })
778            .into_any_element()
779    }
780
781    fn telemetry_event_text(&self) -> Option<&'static str> {
782        Some("extensions page")
783    }
784
785    fn show_toolbar(&self) -> bool {
786        false
787    }
788
789    fn clone_on_split(
790        &self,
791        _workspace_id: WorkspaceId,
792        _: &mut ViewContext<Self>,
793    ) -> Option<View<Self>> {
794        None
795    }
796
797    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
798        f(*event)
799    }
800}