extensions_ui.rs

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