1mod components;
2mod extension_suggest;
3mod extension_version_selector;
4
5use std::sync::OnceLock;
6use std::time::Duration;
7use std::{ops::Range, sync::Arc};
8
9use anyhow::Context as _;
10use client::{ExtensionMetadata, ExtensionProvides};
11use collections::{BTreeMap, BTreeSet};
12use editor::{Editor, EditorElement, EditorStyle};
13use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore};
14use fuzzy::{StringMatchCandidate, match_strings};
15use gpui::{
16 Action, App, ClipboardItem, Context, Entity, EventEmitter, Flatten, Focusable,
17 InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle,
18 UniformListScrollHandle, WeakEntity, Window, actions, point, uniform_list,
19};
20use num_format::{Locale, ToFormattedString};
21use project::DirectoryLister;
22use release_channel::ReleaseChannel;
23use settings::Settings;
24use strum::IntoEnumIterator as _;
25use theme::ThemeSettings;
26use ui::{
27 CheckboxWithLabel, ContextMenu, PopoverMenu, ScrollableHandle, Scrollbar, ScrollbarState,
28 ToggleButton, Tooltip, prelude::*,
29};
30use vim_mode_setting::VimModeSetting;
31use workspace::{
32 Workspace, WorkspaceId,
33 item::{Item, ItemEvent},
34};
35use zed_actions::ExtensionCategoryFilter;
36
37use crate::components::{ExtensionCard, FeatureUpsell};
38use crate::extension_version_selector::{
39 ExtensionVersionSelector, ExtensionVersionSelectorDelegate,
40};
41
42actions!(
43 zed,
44 [
45 /// Installs an extension from a local directory for development.
46 InstallDevExtension
47 ]
48);
49
50pub fn init(cx: &mut App) {
51 cx.observe_new(move |workspace: &mut Workspace, window, cx| {
52 let Some(window) = window else {
53 return;
54 };
55 workspace
56 .register_action(
57 move |workspace, action: &zed_actions::Extensions, window, cx| {
58 let provides_filter = action.category_filter.map(|category| match category {
59 ExtensionCategoryFilter::Themes => ExtensionProvides::Themes,
60 ExtensionCategoryFilter::IconThemes => ExtensionProvides::IconThemes,
61 ExtensionCategoryFilter::Languages => ExtensionProvides::Languages,
62 ExtensionCategoryFilter::Grammars => ExtensionProvides::Grammars,
63 ExtensionCategoryFilter::LanguageServers => {
64 ExtensionProvides::LanguageServers
65 }
66 ExtensionCategoryFilter::ContextServers => {
67 ExtensionProvides::ContextServers
68 }
69 ExtensionCategoryFilter::SlashCommands => ExtensionProvides::SlashCommands,
70 ExtensionCategoryFilter::IndexedDocsProviders => {
71 ExtensionProvides::IndexedDocsProviders
72 }
73 ExtensionCategoryFilter::Snippets => ExtensionProvides::Snippets,
74 ExtensionCategoryFilter::DebugAdapters => ExtensionProvides::DebugAdapters,
75 });
76
77 let existing = workspace
78 .active_pane()
79 .read(cx)
80 .items()
81 .find_map(|item| item.downcast::<ExtensionsPage>());
82
83 if let Some(existing) = existing {
84 existing.update(cx, |extensions_page, cx| {
85 if provides_filter.is_some() {
86 extensions_page.change_provides_filter(provides_filter, cx);
87 }
88 if let Some(id) = action.id.as_ref() {
89 extensions_page.focus_extension(id, window, cx);
90 }
91 });
92
93 workspace.activate_item(&existing, true, true, window, cx);
94 } else {
95 let extensions_page = ExtensionsPage::new(
96 workspace,
97 provides_filter,
98 action.id.as_deref(),
99 window,
100 cx,
101 );
102 workspace.add_item_to_active_pane(
103 Box::new(extensions_page),
104 None,
105 true,
106 window,
107 cx,
108 )
109 }
110 },
111 )
112 .register_action(move |workspace, _: &InstallDevExtension, window, cx| {
113 let store = ExtensionStore::global(cx);
114 let prompt = workspace.prompt_for_open_path(
115 gpui::PathPromptOptions {
116 files: false,
117 directories: true,
118 multiple: false,
119 },
120 DirectoryLister::Local(
121 workspace.project().clone(),
122 workspace.app_state().fs.clone(),
123 ),
124 window,
125 cx,
126 );
127
128 let workspace_handle = cx.entity().downgrade();
129 window
130 .spawn(cx, async move |cx| {
131 let extension_path =
132 match Flatten::flatten(prompt.await.map_err(|e| e.into())) {
133 Ok(Some(mut paths)) => paths.pop()?,
134 Ok(None) => return None,
135 Err(err) => {
136 workspace_handle
137 .update(cx, |workspace, cx| {
138 workspace.show_portal_error(err.to_string(), cx);
139 })
140 .ok();
141 return None;
142 }
143 };
144
145 let install_task = store
146 .update(cx, |store, cx| {
147 store.install_dev_extension(extension_path, cx)
148 })
149 .ok()?;
150
151 match install_task.await {
152 Ok(_) => {}
153 Err(err) => {
154 log::error!("Failed to install dev extension: {:?}", err);
155 workspace_handle
156 .update(cx, |workspace, cx| {
157 workspace.show_error(
158 // NOTE: using `anyhow::context` here ends up not printing
159 // the error
160 &format!("Failed to install dev extension: {}", err),
161 cx,
162 );
163 })
164 .ok();
165 }
166 }
167
168 Some(())
169 })
170 .detach();
171 });
172
173 cx.subscribe_in(workspace.project(), window, |_, _, event, window, cx| {
174 if let project::Event::LanguageNotFound(buffer) = event {
175 extension_suggest::suggest(buffer.clone(), window, cx);
176 }
177 })
178 .detach();
179 })
180 .detach();
181}
182
183fn extension_provides_label(provides: ExtensionProvides) -> &'static str {
184 match provides {
185 ExtensionProvides::Themes => "Themes",
186 ExtensionProvides::IconThemes => "Icon Themes",
187 ExtensionProvides::Languages => "Languages",
188 ExtensionProvides::Grammars => "Grammars",
189 ExtensionProvides::LanguageServers => "Language Servers",
190 ExtensionProvides::ContextServers => "MCP Servers",
191 ExtensionProvides::SlashCommands => "Slash Commands",
192 ExtensionProvides::IndexedDocsProviders => "Indexed Docs Providers",
193 ExtensionProvides::Snippets => "Snippets",
194 ExtensionProvides::DebugAdapters => "Debug Adapters",
195 }
196}
197
198#[derive(Clone)]
199pub enum ExtensionStatus {
200 NotInstalled,
201 Installing,
202 Upgrading,
203 Installed(Arc<str>),
204 Removing,
205}
206
207#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
208enum ExtensionFilter {
209 All,
210 Installed,
211 NotInstalled,
212}
213
214impl ExtensionFilter {
215 pub fn include_dev_extensions(&self) -> bool {
216 match self {
217 Self::All | Self::Installed => true,
218 Self::NotInstalled => false,
219 }
220 }
221}
222
223#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
224enum Feature {
225 Git,
226 OpenIn,
227 Vim,
228 LanguageBash,
229 LanguageC,
230 LanguageCpp,
231 LanguageGo,
232 LanguagePython,
233 LanguageReact,
234 LanguageRust,
235 LanguageTypescript,
236}
237
238fn keywords_by_feature() -> &'static BTreeMap<Feature, Vec<&'static str>> {
239 static KEYWORDS_BY_FEATURE: OnceLock<BTreeMap<Feature, Vec<&'static str>>> = OnceLock::new();
240 KEYWORDS_BY_FEATURE.get_or_init(|| {
241 BTreeMap::from_iter([
242 (Feature::Git, vec!["git"]),
243 (
244 Feature::OpenIn,
245 vec![
246 "github",
247 "gitlab",
248 "bitbucket",
249 "codeberg",
250 "sourcehut",
251 "permalink",
252 "link",
253 "open in",
254 ],
255 ),
256 (Feature::Vim, vec!["vim"]),
257 (Feature::LanguageBash, vec!["sh", "bash"]),
258 (Feature::LanguageC, vec!["c", "clang"]),
259 (Feature::LanguageCpp, vec!["c++", "cpp", "clang"]),
260 (Feature::LanguageGo, vec!["go", "golang"]),
261 (Feature::LanguagePython, vec!["python", "py"]),
262 (Feature::LanguageReact, vec!["react"]),
263 (Feature::LanguageRust, vec!["rust", "rs"]),
264 (
265 Feature::LanguageTypescript,
266 vec!["type", "typescript", "ts"],
267 ),
268 ])
269 })
270}
271
272struct ExtensionCardButtons {
273 install_or_uninstall: Button,
274 upgrade: Option<Button>,
275 configure: Option<Button>,
276}
277
278pub struct ExtensionsPage {
279 workspace: WeakEntity<Workspace>,
280 list: UniformListScrollHandle,
281 is_fetching_extensions: bool,
282 filter: ExtensionFilter,
283 remote_extension_entries: Vec<ExtensionMetadata>,
284 dev_extension_entries: Vec<Arc<ExtensionManifest>>,
285 filtered_remote_extension_indices: Vec<usize>,
286 query_editor: Entity<Editor>,
287 query_contains_error: bool,
288 provides_filter: Option<ExtensionProvides>,
289 _subscriptions: [gpui::Subscription; 2],
290 extension_fetch_task: Option<Task<()>>,
291 upsells: BTreeSet<Feature>,
292 scrollbar_state: ScrollbarState,
293}
294
295impl ExtensionsPage {
296 pub fn new(
297 workspace: &Workspace,
298 provides_filter: Option<ExtensionProvides>,
299 focus_extension_id: Option<&str>,
300 window: &mut Window,
301 cx: &mut Context<Workspace>,
302 ) -> Entity<Self> {
303 cx.new(|cx| {
304 let store = ExtensionStore::global(cx);
305 let workspace_handle = workspace.weak_handle();
306 let subscriptions = [
307 cx.observe(&store, |_: &mut Self, _, cx| cx.notify()),
308 cx.subscribe_in(
309 &store,
310 window,
311 move |this, _, event, window, cx| match event {
312 extension_host::Event::ExtensionsUpdated => {
313 this.fetch_extensions_debounced(None, cx)
314 }
315 extension_host::Event::ExtensionInstalled(extension_id) => this
316 .on_extension_installed(
317 workspace_handle.clone(),
318 extension_id,
319 window,
320 cx,
321 ),
322 _ => {}
323 },
324 ),
325 ];
326
327 let query_editor = cx.new(|cx| {
328 let mut input = Editor::single_line(window, cx);
329 input.set_placeholder_text("Search extensions...", cx);
330 if let Some(id) = focus_extension_id {
331 input.set_text(format!("id:{id}"), window, cx);
332 }
333 input
334 });
335 cx.subscribe(&query_editor, Self::on_query_change).detach();
336
337 let scroll_handle = UniformListScrollHandle::new();
338
339 let mut this = Self {
340 workspace: workspace.weak_handle(),
341 list: scroll_handle.clone(),
342 is_fetching_extensions: false,
343 filter: ExtensionFilter::All,
344 dev_extension_entries: Vec::new(),
345 filtered_remote_extension_indices: Vec::new(),
346 remote_extension_entries: Vec::new(),
347 query_contains_error: false,
348 provides_filter,
349 extension_fetch_task: None,
350 _subscriptions: subscriptions,
351 query_editor,
352 upsells: BTreeSet::default(),
353 scrollbar_state: ScrollbarState::new(scroll_handle),
354 };
355 this.fetch_extensions(
356 this.search_query(cx),
357 Some(BTreeSet::from_iter(this.provides_filter)),
358 None,
359 cx,
360 );
361 this
362 })
363 }
364
365 fn on_extension_installed(
366 &mut self,
367 workspace: WeakEntity<Workspace>,
368 extension_id: &str,
369 window: &mut Window,
370 cx: &mut Context<Self>,
371 ) {
372 let extension_store = ExtensionStore::global(cx).read(cx);
373 let themes = extension_store
374 .extension_themes(extension_id)
375 .map(|name| name.to_string())
376 .collect::<Vec<_>>();
377 if !themes.is_empty() {
378 workspace
379 .update(cx, |_workspace, cx| {
380 window.dispatch_action(
381 zed_actions::theme_selector::Toggle {
382 themes_filter: Some(themes),
383 }
384 .boxed_clone(),
385 cx,
386 );
387 })
388 .ok();
389 return;
390 }
391
392 let icon_themes = extension_store
393 .extension_icon_themes(extension_id)
394 .map(|name| name.to_string())
395 .collect::<Vec<_>>();
396 if !icon_themes.is_empty() {
397 workspace
398 .update(cx, |_workspace, cx| {
399 window.dispatch_action(
400 zed_actions::icon_theme_selector::Toggle {
401 themes_filter: Some(icon_themes),
402 }
403 .boxed_clone(),
404 cx,
405 );
406 })
407 .ok();
408 }
409 }
410
411 /// Returns whether a dev extension currently exists for the extension with the given ID.
412 fn dev_extension_exists(extension_id: &str, cx: &mut Context<Self>) -> bool {
413 let extension_store = ExtensionStore::global(cx).read(cx);
414
415 extension_store
416 .dev_extensions()
417 .any(|dev_extension| dev_extension.id.as_ref() == extension_id)
418 }
419
420 fn extension_status(extension_id: &str, cx: &mut Context<Self>) -> ExtensionStatus {
421 let extension_store = ExtensionStore::global(cx).read(cx);
422
423 match extension_store.outstanding_operations().get(extension_id) {
424 Some(ExtensionOperation::Install) => ExtensionStatus::Installing,
425 Some(ExtensionOperation::Remove) => ExtensionStatus::Removing,
426 Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading,
427 None => match extension_store.installed_extensions().get(extension_id) {
428 Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()),
429 None => ExtensionStatus::NotInstalled,
430 },
431 }
432 }
433
434 fn filter_extension_entries(&mut self, cx: &mut Context<Self>) {
435 self.filtered_remote_extension_indices.clear();
436 self.filtered_remote_extension_indices.extend(
437 self.remote_extension_entries
438 .iter()
439 .enumerate()
440 .filter(|(_, extension)| match self.filter {
441 ExtensionFilter::All => true,
442 ExtensionFilter::Installed => {
443 let status = Self::extension_status(&extension.id, cx);
444 matches!(status, ExtensionStatus::Installed(_))
445 }
446 ExtensionFilter::NotInstalled => {
447 let status = Self::extension_status(&extension.id, cx);
448
449 matches!(status, ExtensionStatus::NotInstalled)
450 }
451 })
452 .map(|(ix, _)| ix),
453 );
454 cx.notify();
455 }
456
457 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
458 self.list.set_offset(point(px(0.), px(0.)));
459 cx.notify();
460 }
461
462 fn fetch_extensions(
463 &mut self,
464 search: Option<String>,
465 provides_filter: Option<BTreeSet<ExtensionProvides>>,
466 on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
467 cx: &mut Context<Self>,
468 ) {
469 self.is_fetching_extensions = true;
470 cx.notify();
471
472 let extension_store = ExtensionStore::global(cx);
473
474 let dev_extensions = extension_store
475 .read(cx)
476 .dev_extensions()
477 .cloned()
478 .collect::<Vec<_>>();
479
480 let remote_extensions =
481 if let Some(id) = search.as_ref().and_then(|s| s.strip_prefix("id:")) {
482 let versions =
483 extension_store.update(cx, |store, cx| store.fetch_extension_versions(id, cx));
484 cx.foreground_executor().spawn(async move {
485 let versions = versions.await?;
486 let latest = versions
487 .into_iter()
488 .max_by_key(|v| v.published_at)
489 .context("no extension found")?;
490 Ok(vec![latest])
491 })
492 } else {
493 extension_store.update(cx, |store, cx| {
494 store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx)
495 })
496 };
497
498 cx.spawn(async move |this, cx| {
499 let dev_extensions = if let Some(search) = search {
500 let match_candidates = dev_extensions
501 .iter()
502 .enumerate()
503 .map(|(ix, manifest)| StringMatchCandidate::new(ix, &manifest.name))
504 .collect::<Vec<_>>();
505
506 let matches = match_strings(
507 &match_candidates,
508 &search,
509 false,
510 true,
511 match_candidates.len(),
512 &Default::default(),
513 cx.background_executor().clone(),
514 )
515 .await;
516 matches
517 .into_iter()
518 .map(|mat| dev_extensions[mat.candidate_id].clone())
519 .collect()
520 } else {
521 dev_extensions
522 };
523
524 let fetch_result = remote_extensions.await;
525 this.update(cx, |this, cx| {
526 cx.notify();
527 this.dev_extension_entries = dev_extensions;
528 this.is_fetching_extensions = false;
529 this.remote_extension_entries = fetch_result?;
530 this.filter_extension_entries(cx);
531 if let Some(callback) = on_complete {
532 callback(this, cx);
533 }
534 anyhow::Ok(())
535 })?
536 })
537 .detach_and_log_err(cx);
538 }
539
540 fn render_extensions(
541 &mut self,
542 range: Range<usize>,
543 _: &mut Window,
544 cx: &mut Context<Self>,
545 ) -> Vec<ExtensionCard> {
546 let dev_extension_entries_len = if self.filter.include_dev_extensions() {
547 self.dev_extension_entries.len()
548 } else {
549 0
550 };
551 range
552 .map(|ix| {
553 if ix < dev_extension_entries_len {
554 let extension = &self.dev_extension_entries[ix];
555 self.render_dev_extension(extension, cx)
556 } else {
557 let extension_ix =
558 self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
559 let extension = &self.remote_extension_entries[extension_ix];
560 self.render_remote_extension(extension, cx)
561 }
562 })
563 .collect()
564 }
565
566 fn render_dev_extension(
567 &self,
568 extension: &ExtensionManifest,
569 cx: &mut Context<Self>,
570 ) -> ExtensionCard {
571 let status = Self::extension_status(&extension.id, cx);
572
573 let repository_url = extension.repository.clone();
574
575 let can_configure = !extension.context_servers.is_empty();
576
577 ExtensionCard::new()
578 .child(
579 h_flex()
580 .justify_between()
581 .child(
582 h_flex()
583 .gap_2()
584 .items_end()
585 .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
586 .child(
587 Headline::new(format!("v{}", extension.version))
588 .size(HeadlineSize::XSmall),
589 ),
590 )
591 .child(
592 h_flex()
593 .gap_1()
594 .justify_between()
595 .child(
596 Button::new(
597 SharedString::from(format!("rebuild-{}", extension.id)),
598 "Rebuild",
599 )
600 .color(Color::Accent)
601 .disabled(matches!(status, ExtensionStatus::Upgrading))
602 .on_click({
603 let extension_id = extension.id.clone();
604 move |_, _, cx| {
605 ExtensionStore::global(cx).update(cx, |store, cx| {
606 store.rebuild_dev_extension(extension_id.clone(), cx)
607 });
608 }
609 }),
610 )
611 .child(
612 Button::new(SharedString::from(extension.id.clone()), "Uninstall")
613 .color(Color::Accent)
614 .disabled(matches!(status, ExtensionStatus::Removing))
615 .on_click({
616 let extension_id = extension.id.clone();
617 move |_, _, cx| {
618 ExtensionStore::global(cx).update(cx, |store, cx| {
619 store.uninstall_extension(extension_id.clone(), cx).detach_and_log_err(cx);
620 });
621 }
622 }),
623 )
624 .when(can_configure, |this| {
625 this.child(
626 Button::new(
627 SharedString::from(format!("configure-{}", extension.id)),
628 "Configure",
629 )
630 .color(Color::Accent)
631 .disabled(matches!(status, ExtensionStatus::Installing))
632 .on_click({
633 let manifest = Arc::new(extension.clone());
634 move |_, _, cx| {
635 if let Some(events) =
636 extension::ExtensionEvents::try_global(cx)
637 {
638 events.update(cx, |this, cx| {
639 this.emit(
640 extension::Event::ConfigureExtensionRequested(
641 manifest.clone(),
642 ),
643 cx,
644 )
645 });
646 }
647 }
648 }),
649 )
650 }),
651 ),
652 )
653 .child(
654 h_flex()
655 .gap_2()
656 .justify_between()
657 .child(
658 Label::new(format!(
659 "{}: {}",
660 if extension.authors.len() > 1 {
661 "Authors"
662 } else {
663 "Author"
664 },
665 extension.authors.join(", ")
666 ))
667 .size(LabelSize::Small)
668 .color(Color::Muted)
669 .truncate(),
670 )
671 .child(Label::new("<>").size(LabelSize::Small)),
672 )
673 .child(
674 h_flex()
675 .gap_2()
676 .justify_between()
677 .children(extension.description.as_ref().map(|description| {
678 Label::new(description.clone())
679 .size(LabelSize::Small)
680 .color(Color::Default)
681 .truncate()
682 }))
683 .children(repository_url.map(|repository_url| {
684 IconButton::new(
685 SharedString::from(format!("repository-{}", extension.id)),
686 IconName::Github,
687 )
688 .icon_color(Color::Accent)
689 .icon_size(IconSize::Small)
690 .on_click(cx.listener({
691 let repository_url = repository_url.clone();
692 move |_, _, _, cx| {
693 cx.open_url(&repository_url);
694 }
695 }))
696 .tooltip(Tooltip::text(repository_url.clone()))
697 })),
698 )
699 }
700
701 fn render_remote_extension(
702 &self,
703 extension: &ExtensionMetadata,
704 cx: &mut Context<Self>,
705 ) -> ExtensionCard {
706 let this = cx.entity().clone();
707 let status = Self::extension_status(&extension.id, cx);
708 let has_dev_extension = Self::dev_extension_exists(&extension.id, cx);
709
710 let extension_id = extension.id.clone();
711 let buttons = self.buttons_for_entry(extension, &status, has_dev_extension, cx);
712 let version = extension.manifest.version.clone();
713 let repository_url = extension.manifest.repository.clone();
714 let authors = extension.manifest.authors.clone();
715
716 let installed_version = match status {
717 ExtensionStatus::Installed(installed_version) => Some(installed_version),
718 _ => None,
719 };
720
721 ExtensionCard::new()
722 .overridden_by_dev_extension(has_dev_extension)
723 .child(
724 h_flex()
725 .justify_between()
726 .child(
727 h_flex()
728 .gap_2()
729 .child(
730 Headline::new(extension.manifest.name.clone())
731 .size(HeadlineSize::Medium),
732 )
733 .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
734 .children(
735 installed_version
736 .filter(|installed_version| *installed_version != version)
737 .map(|installed_version| {
738 Headline::new(format!("(v{installed_version} installed)",))
739 .size(HeadlineSize::XSmall)
740 }),
741 )
742 .map(|parent| {
743 if extension.manifest.provides.is_empty() {
744 return parent;
745 }
746
747 parent.child(
748 h_flex().gap_1().children(
749 extension
750 .manifest
751 .provides
752 .iter()
753 .filter_map(|provides| {
754 match provides {
755 ExtensionProvides::SlashCommands
756 | ExtensionProvides::IndexedDocsProviders => {
757 return None;
758 }
759 _ => {}
760 }
761
762 Some(
763 div()
764 .px_1()
765 .border_1()
766 .rounded_sm()
767 .border_color(cx.theme().colors().border)
768 .bg(cx.theme().colors().element_background)
769 .child(
770 Label::new(extension_provides_label(
771 *provides,
772 ))
773 .size(LabelSize::XSmall),
774 ),
775 )
776 })
777 .collect::<Vec<_>>(),
778 ),
779 )
780 }),
781 )
782 .child(
783 h_flex()
784 .gap_1()
785 .children(buttons.upgrade)
786 .children(buttons.configure)
787 .child(buttons.install_or_uninstall),
788 ),
789 )
790 .child(
791 h_flex()
792 .gap_2()
793 .justify_between()
794 .child(
795 Label::new(format!(
796 "{}: {}",
797 if extension.manifest.authors.len() > 1 {
798 "Authors"
799 } else {
800 "Author"
801 },
802 extension.manifest.authors.join(", ")
803 ))
804 .size(LabelSize::Small)
805 .color(Color::Muted)
806 .truncate(),
807 )
808 .child(
809 Label::new(format!(
810 "Downloads: {}",
811 extension.download_count.to_formatted_string(&Locale::en)
812 ))
813 .size(LabelSize::Small),
814 ),
815 )
816 .child(
817 h_flex()
818 .gap_2()
819 .justify_between()
820 .children(extension.manifest.description.as_ref().map(|description| {
821 Label::new(description.clone())
822 .size(LabelSize::Small)
823 .color(Color::Default)
824 .truncate()
825 }))
826 .child(
827 h_flex()
828 .gap_2()
829 .child(
830 IconButton::new(
831 SharedString::from(format!("repository-{}", extension.id)),
832 IconName::Github,
833 )
834 .icon_color(Color::Accent)
835 .icon_size(IconSize::Small)
836 .on_click(cx.listener({
837 let repository_url = repository_url.clone();
838 move |_, _, _, cx| {
839 cx.open_url(&repository_url);
840 }
841 }))
842 .tooltip(Tooltip::text(repository_url.clone())),
843 )
844 .child(
845 PopoverMenu::new(SharedString::from(format!(
846 "more-{}",
847 extension.id
848 )))
849 .trigger(
850 IconButton::new(
851 SharedString::from(format!("more-{}", extension.id)),
852 IconName::Ellipsis,
853 )
854 .icon_color(Color::Accent)
855 .icon_size(IconSize::Small),
856 )
857 .menu(move |window, cx| {
858 Some(Self::render_remote_extension_context_menu(
859 &this,
860 extension_id.clone(),
861 authors.clone(),
862 window,
863 cx,
864 ))
865 }),
866 ),
867 ),
868 )
869 }
870
871 fn render_remote_extension_context_menu(
872 this: &Entity<Self>,
873 extension_id: Arc<str>,
874 authors: Vec<String>,
875 window: &mut Window,
876 cx: &mut App,
877 ) -> Entity<ContextMenu> {
878 let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| {
879 context_menu
880 .entry(
881 "Install Another Version...",
882 None,
883 window.handler_for(this, {
884 let extension_id = extension_id.clone();
885 move |this, window, cx| {
886 this.show_extension_version_list(extension_id.clone(), window, cx)
887 }
888 }),
889 )
890 .entry("Copy Extension ID", None, {
891 let extension_id = extension_id.clone();
892 move |_, cx| {
893 cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
894 }
895 })
896 .entry("Copy Author Info", None, {
897 let authors = authors.clone();
898 move |_, cx| {
899 cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", ")));
900 }
901 })
902 });
903
904 context_menu
905 }
906
907 fn show_extension_version_list(
908 &mut self,
909 extension_id: Arc<str>,
910 window: &mut Window,
911 cx: &mut Context<Self>,
912 ) {
913 let Some(workspace) = self.workspace.upgrade() else {
914 return;
915 };
916
917 cx.spawn_in(window, async move |this, cx| {
918 let extension_versions_task = this.update(cx, |_, cx| {
919 let extension_store = ExtensionStore::global(cx);
920
921 extension_store.update(cx, |store, cx| {
922 store.fetch_extension_versions(&extension_id, cx)
923 })
924 })?;
925
926 let extension_versions = extension_versions_task.await?;
927
928 workspace.update_in(cx, |workspace, window, cx| {
929 let fs = workspace.project().read(cx).fs().clone();
930 workspace.toggle_modal(window, cx, |window, cx| {
931 let delegate = ExtensionVersionSelectorDelegate::new(
932 fs,
933 cx.entity().downgrade(),
934 extension_versions,
935 );
936
937 ExtensionVersionSelector::new(delegate, window, cx)
938 });
939 })?;
940
941 anyhow::Ok(())
942 })
943 .detach_and_log_err(cx);
944 }
945
946 fn buttons_for_entry(
947 &self,
948 extension: &ExtensionMetadata,
949 status: &ExtensionStatus,
950 has_dev_extension: bool,
951 cx: &mut Context<Self>,
952 ) -> ExtensionCardButtons {
953 let is_compatible =
954 extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
955
956 if has_dev_extension {
957 // If we have a dev extension for the given extension, just treat it as uninstalled.
958 // The button here is a placeholder, as it won't be interactable anyways.
959 return ExtensionCardButtons {
960 install_or_uninstall: Button::new(
961 SharedString::from(extension.id.clone()),
962 "Install",
963 ),
964 configure: None,
965 upgrade: None,
966 };
967 }
968
969 let is_configurable = extension
970 .manifest
971 .provides
972 .contains(&ExtensionProvides::ContextServers);
973
974 match status.clone() {
975 ExtensionStatus::NotInstalled => ExtensionCardButtons {
976 install_or_uninstall: Button::new(
977 SharedString::from(extension.id.clone()),
978 "Install",
979 )
980 .on_click({
981 let extension_id = extension.id.clone();
982 move |_, _, cx| {
983 telemetry::event!("Extension Installed");
984 ExtensionStore::global(cx).update(cx, |store, cx| {
985 store.install_latest_extension(extension_id.clone(), cx)
986 });
987 }
988 }),
989 configure: None,
990 upgrade: None,
991 },
992 ExtensionStatus::Installing => ExtensionCardButtons {
993 install_or_uninstall: Button::new(
994 SharedString::from(extension.id.clone()),
995 "Install",
996 )
997 .disabled(true),
998 configure: None,
999 upgrade: None,
1000 },
1001 ExtensionStatus::Upgrading => ExtensionCardButtons {
1002 install_or_uninstall: Button::new(
1003 SharedString::from(extension.id.clone()),
1004 "Uninstall",
1005 )
1006 .disabled(true),
1007 configure: is_configurable.then(|| {
1008 Button::new(
1009 SharedString::from(format!("configure-{}", extension.id)),
1010 "Configure",
1011 )
1012 .disabled(true)
1013 }),
1014 upgrade: Some(
1015 Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
1016 ),
1017 },
1018 ExtensionStatus::Installed(installed_version) => ExtensionCardButtons {
1019 install_or_uninstall: Button::new(
1020 SharedString::from(extension.id.clone()),
1021 "Uninstall",
1022 )
1023 .on_click({
1024 let extension_id = extension.id.clone();
1025 move |_, _, cx| {
1026 telemetry::event!("Extension Uninstalled", extension_id);
1027 ExtensionStore::global(cx).update(cx, |store, cx| {
1028 store
1029 .uninstall_extension(extension_id.clone(), cx)
1030 .detach_and_log_err(cx);
1031 });
1032 }
1033 }),
1034 configure: is_configurable.then(|| {
1035 Button::new(
1036 SharedString::from(format!("configure-{}", extension.id)),
1037 "Configure",
1038 )
1039 .on_click({
1040 let extension_id = extension.id.clone();
1041 move |_, _, cx| {
1042 if let Some(manifest) = ExtensionStore::global(cx)
1043 .read(cx)
1044 .extension_manifest_for_id(&extension_id)
1045 .cloned()
1046 {
1047 if let Some(events) = extension::ExtensionEvents::try_global(cx) {
1048 events.update(cx, |this, cx| {
1049 this.emit(
1050 extension::Event::ConfigureExtensionRequested(manifest),
1051 cx,
1052 )
1053 });
1054 }
1055 }
1056 }
1057 })
1058 }),
1059 upgrade: if installed_version == extension.manifest.version {
1060 None
1061 } else {
1062 Some(
1063 Button::new(SharedString::from(extension.id.clone()), "Upgrade")
1064 .when(!is_compatible, |upgrade_button| {
1065 upgrade_button.disabled(true).tooltip({
1066 let version = extension.manifest.version.clone();
1067 move |_, cx| {
1068 Tooltip::simple(
1069 format!(
1070 "v{version} is not compatible with this version of Zed.",
1071 ),
1072 cx,
1073 )
1074 }
1075 })
1076 })
1077 .disabled(!is_compatible)
1078 .on_click({
1079 let extension_id = extension.id.clone();
1080 let version = extension.manifest.version.clone();
1081 move |_, _, cx| {
1082 telemetry::event!("Extension Installed", extension_id, version);
1083 ExtensionStore::global(cx).update(cx, |store, cx| {
1084 store
1085 .upgrade_extension(
1086 extension_id.clone(),
1087 version.clone(),
1088 cx,
1089 )
1090 .detach_and_log_err(cx)
1091 });
1092 }
1093 }),
1094 )
1095 },
1096 },
1097 ExtensionStatus::Removing => ExtensionCardButtons {
1098 install_or_uninstall: Button::new(
1099 SharedString::from(extension.id.clone()),
1100 "Uninstall",
1101 )
1102 .disabled(true),
1103 configure: is_configurable.then(|| {
1104 Button::new(
1105 SharedString::from(format!("configure-{}", extension.id)),
1106 "Configure",
1107 )
1108 .disabled(true)
1109 }),
1110 upgrade: None,
1111 },
1112 }
1113 }
1114
1115 fn render_search(&self, cx: &mut Context<Self>) -> Div {
1116 let mut key_context = KeyContext::new_with_defaults();
1117 key_context.add("BufferSearchBar");
1118
1119 let editor_border = if self.query_contains_error {
1120 Color::Error.color(cx)
1121 } else {
1122 cx.theme().colors().border
1123 };
1124
1125 h_flex()
1126 .key_context(key_context)
1127 .h_8()
1128 .flex_1()
1129 .min_w(rems_from_px(384.))
1130 .pl_1p5()
1131 .pr_2()
1132 .py_1()
1133 .gap_2()
1134 .border_1()
1135 .border_color(editor_border)
1136 .rounded_lg()
1137 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
1138 .child(self.render_text_input(&self.query_editor, cx))
1139 }
1140
1141 fn render_text_input(
1142 &self,
1143 editor: &Entity<Editor>,
1144 cx: &mut Context<Self>,
1145 ) -> impl IntoElement {
1146 let settings = ThemeSettings::get_global(cx);
1147 let text_style = TextStyle {
1148 color: if editor.read(cx).read_only(cx) {
1149 cx.theme().colors().text_disabled
1150 } else {
1151 cx.theme().colors().text
1152 },
1153 font_family: settings.ui_font.family.clone(),
1154 font_features: settings.ui_font.features.clone(),
1155 font_fallbacks: settings.ui_font.fallbacks.clone(),
1156 font_size: rems(0.875).into(),
1157 font_weight: settings.ui_font.weight,
1158 line_height: relative(1.3),
1159 ..Default::default()
1160 };
1161
1162 EditorElement::new(
1163 editor,
1164 EditorStyle {
1165 background: cx.theme().colors().editor_background,
1166 local_player: cx.theme().players().local(),
1167 text: text_style,
1168 ..Default::default()
1169 },
1170 )
1171 }
1172
1173 fn on_query_change(
1174 &mut self,
1175 _: Entity<Editor>,
1176 event: &editor::EditorEvent,
1177 cx: &mut Context<Self>,
1178 ) {
1179 if let editor::EditorEvent::Edited { .. } = event {
1180 self.query_contains_error = false;
1181 self.refresh_search(cx);
1182 }
1183 }
1184
1185 fn refresh_search(&mut self, cx: &mut Context<Self>) {
1186 self.fetch_extensions_debounced(
1187 Some(Box::new(|this, cx| {
1188 this.scroll_to_top(cx);
1189 })),
1190 cx,
1191 );
1192 self.refresh_feature_upsells(cx);
1193 }
1194
1195 pub fn focus_extension(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
1196 self.query_editor.update(cx, |editor, cx| {
1197 editor.set_text(format!("id:{id}"), window, cx)
1198 });
1199 self.refresh_search(cx);
1200 }
1201
1202 pub fn change_provides_filter(
1203 &mut self,
1204 provides_filter: Option<ExtensionProvides>,
1205 cx: &mut Context<Self>,
1206 ) {
1207 self.provides_filter = provides_filter;
1208 self.refresh_search(cx);
1209 }
1210
1211 fn fetch_extensions_debounced(
1212 &mut self,
1213 on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
1214 cx: &mut Context<ExtensionsPage>,
1215 ) {
1216 self.extension_fetch_task = Some(cx.spawn(async move |this, cx| {
1217 let search = this
1218 .update(cx, |this, cx| this.search_query(cx))
1219 .ok()
1220 .flatten();
1221
1222 // Only debounce the fetching of extensions if we have a search
1223 // query.
1224 //
1225 // If the search was just cleared then we can just reload the list
1226 // of extensions without a debounce, which allows us to avoid seeing
1227 // an intermittent flash of a "no extensions" state.
1228 if search.is_some() {
1229 cx.background_executor()
1230 .timer(Duration::from_millis(250))
1231 .await;
1232 };
1233
1234 this.update(cx, |this, cx| {
1235 this.fetch_extensions(
1236 search,
1237 Some(BTreeSet::from_iter(this.provides_filter)),
1238 on_complete,
1239 cx,
1240 );
1241 })
1242 .ok();
1243 }));
1244 }
1245
1246 pub fn search_query(&self, cx: &mut App) -> Option<String> {
1247 let search = self.query_editor.read(cx).text(cx);
1248 if search.trim().is_empty() {
1249 None
1250 } else {
1251 Some(search)
1252 }
1253 }
1254
1255 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1256 let has_search = self.search_query(cx).is_some();
1257
1258 let message = if self.is_fetching_extensions {
1259 "Loading extensions..."
1260 } else {
1261 match self.filter {
1262 ExtensionFilter::All => {
1263 if has_search {
1264 "No extensions that match your search."
1265 } else {
1266 "No extensions."
1267 }
1268 }
1269 ExtensionFilter::Installed => {
1270 if has_search {
1271 "No installed extensions that match your search."
1272 } else {
1273 "No installed extensions."
1274 }
1275 }
1276 ExtensionFilter::NotInstalled => {
1277 if has_search {
1278 "No not installed extensions that match your search."
1279 } else {
1280 "No not installed extensions."
1281 }
1282 }
1283 }
1284 };
1285
1286 Label::new(message)
1287 }
1288
1289 fn update_settings<T: Settings>(
1290 &mut self,
1291 selection: &ToggleState,
1292
1293 cx: &mut Context<Self>,
1294 callback: impl 'static + Send + Fn(&mut T::FileContent, bool),
1295 ) {
1296 if let Some(workspace) = self.workspace.upgrade() {
1297 let fs = workspace.read(cx).app_state().fs.clone();
1298 let selection = *selection;
1299 settings::update_settings_file::<T>(fs, cx, move |settings, _| {
1300 let value = match selection {
1301 ToggleState::Unselected => false,
1302 ToggleState::Selected => true,
1303 _ => return,
1304 };
1305
1306 callback(settings, value)
1307 });
1308 }
1309 }
1310
1311 fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1312 let Some(search) = self.search_query(cx) else {
1313 self.upsells.clear();
1314 return;
1315 };
1316
1317 let search = search.to_lowercase();
1318 let search_terms = search
1319 .split_whitespace()
1320 .map(|term| term.trim())
1321 .collect::<Vec<_>>();
1322
1323 for (feature, keywords) in keywords_by_feature() {
1324 if keywords
1325 .iter()
1326 .any(|keyword| search_terms.contains(keyword))
1327 {
1328 self.upsells.insert(*feature);
1329 } else {
1330 self.upsells.remove(feature);
1331 }
1332 }
1333 }
1334
1335 fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1336 let upsells_count = self.upsells.len();
1337
1338 v_flex().children(self.upsells.iter().enumerate().map(|(ix, feature)| {
1339 let upsell = match feature {
1340 Feature::Git => FeatureUpsell::new(
1341 "Zed comes with basic Git support. More Git features are coming in the future.",
1342 )
1343 .docs_url("https://zed.dev/docs/git"),
1344 Feature::OpenIn => FeatureUpsell::new(
1345 "Zed supports linking to a source line on GitHub and others.",
1346 )
1347 .docs_url("https://zed.dev/docs/git#git-integrations"),
1348 Feature::Vim => FeatureUpsell::new("Vim support is built-in to Zed!")
1349 .docs_url("https://zed.dev/docs/vim")
1350 .child(CheckboxWithLabel::new(
1351 "enable-vim",
1352 Label::new("Enable vim mode"),
1353 if VimModeSetting::get_global(cx).0 {
1354 ui::ToggleState::Selected
1355 } else {
1356 ui::ToggleState::Unselected
1357 },
1358 cx.listener(move |this, selection, _, cx| {
1359 telemetry::event!("Vim Mode Toggled", source = "Feature Upsell");
1360 this.update_settings::<VimModeSetting>(
1361 selection,
1362 cx,
1363 |setting, value| *setting = Some(value),
1364 );
1365 }),
1366 )),
1367 Feature::LanguageBash => FeatureUpsell::new("Shell support is built-in to Zed!")
1368 .docs_url("https://zed.dev/docs/languages/bash"),
1369 Feature::LanguageC => FeatureUpsell::new("C support is built-in to Zed!")
1370 .docs_url("https://zed.dev/docs/languages/c"),
1371 Feature::LanguageCpp => FeatureUpsell::new("C++ support is built-in to Zed!")
1372 .docs_url("https://zed.dev/docs/languages/cpp"),
1373 Feature::LanguageGo => FeatureUpsell::new("Go support is built-in to Zed!")
1374 .docs_url("https://zed.dev/docs/languages/go"),
1375 Feature::LanguagePython => FeatureUpsell::new("Python support is built-in to Zed!")
1376 .docs_url("https://zed.dev/docs/languages/python"),
1377 Feature::LanguageReact => FeatureUpsell::new("React support is built-in to Zed!")
1378 .docs_url("https://zed.dev/docs/languages/typescript"),
1379 Feature::LanguageRust => FeatureUpsell::new("Rust support is built-in to Zed!")
1380 .docs_url("https://zed.dev/docs/languages/rust"),
1381 Feature::LanguageTypescript => {
1382 FeatureUpsell::new("Typescript support is built-in to Zed!")
1383 .docs_url("https://zed.dev/docs/languages/typescript")
1384 }
1385 };
1386
1387 upsell.when(ix < upsells_count, |upsell| upsell.border_b_1())
1388 }))
1389 }
1390}
1391
1392impl Render for ExtensionsPage {
1393 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1394 v_flex()
1395 .size_full()
1396 .bg(cx.theme().colors().editor_background)
1397 .child(
1398 v_flex()
1399 .gap_4()
1400 .pt_4()
1401 .px_4()
1402 .bg(cx.theme().colors().editor_background)
1403 .child(
1404 h_flex()
1405 .w_full()
1406 .gap_2()
1407 .justify_between()
1408 .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
1409 .child(
1410 Button::new("install-dev-extension", "Install Dev Extension")
1411 .style(ButtonStyle::Filled)
1412 .size(ButtonSize::Large)
1413 .on_click(|_event, window, cx| {
1414 window.dispatch_action(Box::new(InstallDevExtension), cx)
1415 }),
1416 ),
1417 )
1418 .child(
1419 h_flex()
1420 .w_full()
1421 .gap_4()
1422 .flex_wrap()
1423 .child(self.render_search(cx))
1424 .child(
1425 h_flex()
1426 .child(
1427 ToggleButton::new("filter-all", "All")
1428 .style(ButtonStyle::Filled)
1429 .size(ButtonSize::Large)
1430 .toggle_state(self.filter == ExtensionFilter::All)
1431 .on_click(cx.listener(|this, _event, _, cx| {
1432 this.filter = ExtensionFilter::All;
1433 this.filter_extension_entries(cx);
1434 this.scroll_to_top(cx);
1435 }))
1436 .tooltip(move |_, cx| {
1437 Tooltip::simple("Show all extensions", cx)
1438 })
1439 .first(),
1440 )
1441 .child(
1442 ToggleButton::new("filter-installed", "Installed")
1443 .style(ButtonStyle::Filled)
1444 .size(ButtonSize::Large)
1445 .toggle_state(self.filter == ExtensionFilter::Installed)
1446 .on_click(cx.listener(|this, _event, _, cx| {
1447 this.filter = ExtensionFilter::Installed;
1448 this.filter_extension_entries(cx);
1449 this.scroll_to_top(cx);
1450 }))
1451 .tooltip(move |_, cx| {
1452 Tooltip::simple("Show installed extensions", cx)
1453 })
1454 .middle(),
1455 )
1456 .child(
1457 ToggleButton::new("filter-not-installed", "Not Installed")
1458 .style(ButtonStyle::Filled)
1459 .size(ButtonSize::Large)
1460 .toggle_state(
1461 self.filter == ExtensionFilter::NotInstalled,
1462 )
1463 .on_click(cx.listener(|this, _event, _, cx| {
1464 this.filter = ExtensionFilter::NotInstalled;
1465 this.filter_extension_entries(cx);
1466 this.scroll_to_top(cx);
1467 }))
1468 .tooltip(move |_, cx| {
1469 Tooltip::simple("Show not installed extensions", cx)
1470 })
1471 .last(),
1472 ),
1473 ),
1474 ),
1475 )
1476 .child(
1477 h_flex()
1478 .id("filter-row")
1479 .gap_2()
1480 .py_2p5()
1481 .px_4()
1482 .border_b_1()
1483 .border_color(cx.theme().colors().border_variant)
1484 .overflow_x_scroll()
1485 .child(
1486 Button::new("filter-all-categories", "All")
1487 .when(self.provides_filter.is_none(), |button| {
1488 button.style(ButtonStyle::Filled)
1489 })
1490 .when(self.provides_filter.is_some(), |button| {
1491 button.style(ButtonStyle::Subtle)
1492 })
1493 .toggle_state(self.provides_filter.is_none())
1494 .on_click(cx.listener(|this, _event, _, cx| {
1495 this.change_provides_filter(None, cx);
1496 })),
1497 )
1498 .children(ExtensionProvides::iter().filter_map(|provides| {
1499 match provides {
1500 ExtensionProvides::SlashCommands
1501 | ExtensionProvides::IndexedDocsProviders => return None,
1502 _ => {}
1503 }
1504
1505 let label = extension_provides_label(provides);
1506 let button_id = SharedString::from(format!("filter-category-{}", label));
1507
1508 Some(
1509 Button::new(button_id, label)
1510 .style(if self.provides_filter == Some(provides) {
1511 ButtonStyle::Filled
1512 } else {
1513 ButtonStyle::Subtle
1514 })
1515 .toggle_state(self.provides_filter == Some(provides))
1516 .on_click({
1517 cx.listener(move |this, _event, _, cx| {
1518 this.change_provides_filter(Some(provides), cx);
1519 })
1520 }),
1521 )
1522 })),
1523 )
1524 .child(self.render_feature_upsells(cx))
1525 .child(
1526 v_flex()
1527 .pl_4()
1528 .pr_6()
1529 .size_full()
1530 .overflow_y_hidden()
1531 .map(|this| {
1532 let mut count = self.filtered_remote_extension_indices.len();
1533 if self.filter.include_dev_extensions() {
1534 count += self.dev_extension_entries.len();
1535 }
1536
1537 if count == 0 {
1538 return this.py_4().child(self.render_empty_state(cx));
1539 }
1540
1541 let scroll_handle = self.list.clone();
1542 this.child(
1543 uniform_list("entries", count, cx.processor(Self::render_extensions))
1544 .flex_grow()
1545 .pb_4()
1546 .track_scroll(scroll_handle),
1547 )
1548 .child(
1549 div()
1550 .absolute()
1551 .right_1()
1552 .top_0()
1553 .bottom_0()
1554 .w(px(12.))
1555 .children(Scrollbar::vertical(self.scrollbar_state.clone())),
1556 )
1557 }),
1558 )
1559 }
1560}
1561
1562impl EventEmitter<ItemEvent> for ExtensionsPage {}
1563
1564impl Focusable for ExtensionsPage {
1565 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1566 self.query_editor.read(cx).focus_handle(cx)
1567 }
1568}
1569
1570impl Item for ExtensionsPage {
1571 type Event = ItemEvent;
1572
1573 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1574 "Extensions".into()
1575 }
1576
1577 fn telemetry_event_text(&self) -> Option<&'static str> {
1578 Some("Extensions Page Opened")
1579 }
1580
1581 fn show_toolbar(&self) -> bool {
1582 false
1583 }
1584
1585 fn clone_on_split(
1586 &self,
1587 _workspace_id: Option<WorkspaceId>,
1588 _window: &mut Window,
1589 _: &mut Context<Self>,
1590 ) -> Option<Entity<Self>> {
1591 None
1592 }
1593
1594 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
1595 f(*event)
1596 }
1597}