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