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