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