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