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