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