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