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 .min_w_0()
874 .w_full()
875 .justify_between()
876 .child(
877 h_flex()
878 .min_w_0()
879 .gap_1()
880 .child(
881 Icon::new(IconName::Person)
882 .size(IconSize::XSmall)
883 .color(Color::Muted),
884 )
885 .child(
886 Label::new(extension.manifest.authors.join(", "))
887 .size(LabelSize::Small)
888 .color(Color::Muted)
889 .truncate(),
890 ),
891 )
892 .child(
893 h_flex()
894 .gap_1()
895 .flex_shrink_0()
896 .child({
897 let repo_url_for_tooltip = repository_url.clone();
898
899 IconButton::new(
900 SharedString::from(format!("repository-{}", extension.id)),
901 IconName::Github,
902 )
903 .icon_size(IconSize::Small)
904 .tooltip(move |_, cx| {
905 Tooltip::with_meta(
906 "Visit Extension Repository",
907 None,
908 repo_url_for_tooltip.clone(),
909 cx,
910 )
911 })
912 .on_click(cx.listener(
913 move |_, _, _, cx| {
914 cx.open_url(&repository_url);
915 },
916 ))
917 })
918 .child(
919 PopoverMenu::new(SharedString::from(format!(
920 "more-{}",
921 extension.id
922 )))
923 .trigger(
924 IconButton::new(
925 SharedString::from(format!("more-{}", extension.id)),
926 IconName::Ellipsis,
927 )
928 .icon_size(IconSize::Small),
929 )
930 .anchor(Corner::TopRight)
931 .offset(Point {
932 x: px(0.0),
933 y: px(2.0),
934 })
935 .menu(move |window, cx| {
936 this.upgrade().map(|this| {
937 Self::render_remote_extension_context_menu(
938 &this,
939 extension_id.clone(),
940 authors.clone(),
941 window,
942 cx,
943 )
944 })
945 }),
946 ),
947 ),
948 )
949 }
950
951 fn render_remote_extension_context_menu(
952 this: &Entity<Self>,
953 extension_id: Arc<str>,
954 authors: Vec<String>,
955 window: &mut Window,
956 cx: &mut App,
957 ) -> Entity<ContextMenu> {
958 ContextMenu::build(window, cx, |context_menu, window, _| {
959 context_menu
960 .entry(
961 "Install Another Version...",
962 None,
963 window.handler_for(this, {
964 let extension_id = extension_id.clone();
965 move |this, window, cx| {
966 this.show_extension_version_list(extension_id.clone(), window, cx)
967 }
968 }),
969 )
970 .entry("Copy Extension ID", None, {
971 let extension_id = extension_id.clone();
972 move |_, cx| {
973 cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
974 }
975 })
976 .entry("Copy Author Info", None, {
977 let authors = authors.clone();
978 move |_, cx| {
979 cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", ")));
980 }
981 })
982 })
983 }
984
985 fn show_extension_version_list(
986 &mut self,
987 extension_id: Arc<str>,
988 window: &mut Window,
989 cx: &mut Context<Self>,
990 ) {
991 let Some(workspace) = self.workspace.upgrade() else {
992 return;
993 };
994
995 cx.spawn_in(window, async move |this, cx| {
996 let extension_versions_task = this.update(cx, |_, cx| {
997 let extension_store = ExtensionStore::global(cx);
998
999 extension_store.update(cx, |store, cx| {
1000 store.fetch_extension_versions(&extension_id, cx)
1001 })
1002 })?;
1003
1004 let extension_versions = extension_versions_task.await?;
1005
1006 workspace.update_in(cx, |workspace, window, cx| {
1007 let fs = workspace.project().read(cx).fs().clone();
1008 workspace.toggle_modal(window, cx, |window, cx| {
1009 let delegate = ExtensionVersionSelectorDelegate::new(
1010 fs,
1011 cx.entity().downgrade(),
1012 extension_versions,
1013 );
1014
1015 ExtensionVersionSelector::new(delegate, window, cx)
1016 });
1017 })?;
1018
1019 anyhow::Ok(())
1020 })
1021 .detach_and_log_err(cx);
1022 }
1023
1024 fn buttons_for_entry(
1025 &self,
1026 extension: &ExtensionMetadata,
1027 status: &ExtensionStatus,
1028 has_dev_extension: bool,
1029 cx: &mut Context<Self>,
1030 ) -> ExtensionCardButtons {
1031 let is_compatible =
1032 extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
1033
1034 if has_dev_extension {
1035 // If we have a dev extension for the given extension, just treat it as uninstalled.
1036 // The button here is a placeholder, as it won't be interactable anyways.
1037 return ExtensionCardButtons {
1038 install_or_uninstall: Button::new(
1039 extension_button_id(&extension.id, ExtensionOperation::Install),
1040 "Install",
1041 ),
1042 configure: None,
1043 upgrade: None,
1044 };
1045 }
1046
1047 let is_configurable = extension
1048 .manifest
1049 .provides
1050 .contains(&ExtensionProvides::ContextServers);
1051
1052 match status.clone() {
1053 ExtensionStatus::NotInstalled => ExtensionCardButtons {
1054 install_or_uninstall: Button::new(
1055 extension_button_id(&extension.id, ExtensionOperation::Install),
1056 "Install",
1057 )
1058 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1059 .icon(IconName::Download)
1060 .icon_size(IconSize::Small)
1061 .icon_color(Color::Muted)
1062 .icon_position(IconPosition::Start)
1063 .on_click({
1064 let extension_id = extension.id.clone();
1065 move |_, _, cx| {
1066 telemetry::event!("Extension Installed");
1067 ExtensionStore::global(cx).update(cx, |store, cx| {
1068 store.install_latest_extension(extension_id.clone(), cx)
1069 });
1070 }
1071 }),
1072 configure: None,
1073 upgrade: None,
1074 },
1075 ExtensionStatus::Installing => ExtensionCardButtons {
1076 install_or_uninstall: Button::new(
1077 extension_button_id(&extension.id, ExtensionOperation::Install),
1078 "Install",
1079 )
1080 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1081 .icon(IconName::Download)
1082 .icon_size(IconSize::Small)
1083 .icon_color(Color::Muted)
1084 .icon_position(IconPosition::Start)
1085 .disabled(true),
1086 configure: None,
1087 upgrade: None,
1088 },
1089 ExtensionStatus::Upgrading => ExtensionCardButtons {
1090 install_or_uninstall: Button::new(
1091 extension_button_id(&extension.id, ExtensionOperation::Remove),
1092 "Uninstall",
1093 )
1094 .style(ButtonStyle::OutlinedGhost)
1095 .disabled(true),
1096 configure: is_configurable.then(|| {
1097 Button::new(
1098 SharedString::from(format!("configure-{}", extension.id)),
1099 "Configure",
1100 )
1101 .disabled(true)
1102 }),
1103 upgrade: Some(
1104 Button::new(
1105 extension_button_id(&extension.id, ExtensionOperation::Upgrade),
1106 "Upgrade",
1107 )
1108 .disabled(true),
1109 ),
1110 },
1111 ExtensionStatus::Installed(installed_version) => ExtensionCardButtons {
1112 install_or_uninstall: Button::new(
1113 extension_button_id(&extension.id, ExtensionOperation::Remove),
1114 "Uninstall",
1115 )
1116 .style(ButtonStyle::OutlinedGhost)
1117 .on_click({
1118 let extension_id = extension.id.clone();
1119 move |_, _, cx| {
1120 telemetry::event!("Extension Uninstalled", extension_id);
1121 ExtensionStore::global(cx).update(cx, |store, cx| {
1122 store
1123 .uninstall_extension(extension_id.clone(), cx)
1124 .detach_and_log_err(cx);
1125 });
1126 }
1127 }),
1128 configure: is_configurable.then(|| {
1129 Button::new(
1130 SharedString::from(format!("configure-{}", extension.id)),
1131 "Configure",
1132 )
1133 .style(ButtonStyle::OutlinedGhost)
1134 .on_click({
1135 let extension_id = extension.id.clone();
1136 move |_, _, cx| {
1137 if let Some(manifest) = ExtensionStore::global(cx)
1138 .read(cx)
1139 .extension_manifest_for_id(&extension_id)
1140 .cloned()
1141 && let Some(events) = extension::ExtensionEvents::try_global(cx)
1142 {
1143 events.update(cx, |this, cx| {
1144 this.emit(
1145 extension::Event::ConfigureExtensionRequested(manifest),
1146 cx,
1147 )
1148 });
1149 }
1150 }
1151 })
1152 }),
1153 upgrade: if installed_version == extension.manifest.version {
1154 None
1155 } else {
1156 Some(
1157 Button::new(extension_button_id(&extension.id, ExtensionOperation::Upgrade), "Upgrade")
1158 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1159 .when(!is_compatible, |upgrade_button| {
1160 upgrade_button.disabled(true).tooltip({
1161 let version = extension.manifest.version.clone();
1162 move |_, cx| {
1163 Tooltip::simple(
1164 format!(
1165 "v{version} is not compatible with this version of Zed.",
1166 ),
1167 cx,
1168 )
1169 }
1170 })
1171 })
1172 .disabled(!is_compatible)
1173 .on_click({
1174 let extension_id = extension.id.clone();
1175 let version = extension.manifest.version.clone();
1176 move |_, _, cx| {
1177 telemetry::event!("Extension Installed", extension_id, version);
1178 ExtensionStore::global(cx).update(cx, |store, cx| {
1179 store
1180 .upgrade_extension(
1181 extension_id.clone(),
1182 version.clone(),
1183 cx,
1184 )
1185 .detach_and_log_err(cx)
1186 });
1187 }
1188 }),
1189 )
1190 },
1191 },
1192 ExtensionStatus::Removing => ExtensionCardButtons {
1193 install_or_uninstall: Button::new(
1194 extension_button_id(&extension.id, ExtensionOperation::Remove),
1195 "Uninstall",
1196 )
1197 .style(ButtonStyle::OutlinedGhost)
1198 .disabled(true),
1199 configure: is_configurable.then(|| {
1200 Button::new(
1201 SharedString::from(format!("configure-{}", extension.id)),
1202 "Configure",
1203 )
1204 .disabled(true)
1205 }),
1206 upgrade: None,
1207 },
1208 }
1209 }
1210
1211 fn render_search(&self, cx: &mut Context<Self>) -> Div {
1212 let mut key_context = KeyContext::new_with_defaults();
1213 key_context.add("BufferSearchBar");
1214
1215 let editor_border = if self.query_contains_error {
1216 Color::Error.color(cx)
1217 } else {
1218 cx.theme().colors().border
1219 };
1220
1221 h_flex()
1222 .key_context(key_context)
1223 .h_8()
1224 .min_w(rems_from_px(384.))
1225 .flex_1()
1226 .pl_1p5()
1227 .pr_2()
1228 .gap_2()
1229 .border_1()
1230 .border_color(editor_border)
1231 .rounded_md()
1232 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
1233 .child(self.render_text_input(&self.query_editor, cx))
1234 }
1235
1236 fn render_text_input(
1237 &self,
1238 editor: &Entity<Editor>,
1239 cx: &mut Context<Self>,
1240 ) -> impl IntoElement {
1241 let settings = ThemeSettings::get_global(cx);
1242 let text_style = TextStyle {
1243 color: if editor.read(cx).read_only(cx) {
1244 cx.theme().colors().text_disabled
1245 } else {
1246 cx.theme().colors().text
1247 },
1248 font_family: settings.ui_font.family.clone(),
1249 font_features: settings.ui_font.features.clone(),
1250 font_fallbacks: settings.ui_font.fallbacks.clone(),
1251 font_size: rems(0.875).into(),
1252 font_weight: settings.ui_font.weight,
1253 line_height: relative(1.3),
1254 ..Default::default()
1255 };
1256
1257 EditorElement::new(
1258 editor,
1259 EditorStyle {
1260 background: cx.theme().colors().editor_background,
1261 local_player: cx.theme().players().local(),
1262 text: text_style,
1263 ..Default::default()
1264 },
1265 )
1266 }
1267
1268 fn on_query_change(
1269 &mut self,
1270 _: Entity<Editor>,
1271 event: &editor::EditorEvent,
1272 cx: &mut Context<Self>,
1273 ) {
1274 if let editor::EditorEvent::Edited { .. } = event {
1275 self.query_contains_error = false;
1276 self.refresh_search(cx);
1277 }
1278 }
1279
1280 fn refresh_search(&mut self, cx: &mut Context<Self>) {
1281 self.fetch_extensions_debounced(
1282 Some(Box::new(|this, cx| {
1283 this.scroll_to_top(cx);
1284 })),
1285 cx,
1286 );
1287 self.refresh_feature_upsells(cx);
1288 }
1289
1290 pub fn focus_extension(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
1291 self.query_editor.update(cx, |editor, cx| {
1292 editor.set_text(format!("id:{id}"), window, cx)
1293 });
1294 self.refresh_search(cx);
1295 }
1296
1297 pub fn change_provides_filter(
1298 &mut self,
1299 provides_filter: Option<ExtensionProvides>,
1300 cx: &mut Context<Self>,
1301 ) {
1302 self.provides_filter = provides_filter;
1303 self.refresh_search(cx);
1304 }
1305
1306 fn fetch_extensions_debounced(
1307 &mut self,
1308 on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
1309 cx: &mut Context<ExtensionsPage>,
1310 ) {
1311 self.extension_fetch_task = Some(cx.spawn(async move |this, cx| {
1312 let search = this
1313 .update(cx, |this, cx| this.search_query(cx))
1314 .ok()
1315 .flatten();
1316
1317 // Only debounce the fetching of extensions if we have a search
1318 // query.
1319 //
1320 // If the search was just cleared then we can just reload the list
1321 // of extensions without a debounce, which allows us to avoid seeing
1322 // an intermittent flash of a "no extensions" state.
1323 if search.is_some() {
1324 cx.background_executor()
1325 .timer(Duration::from_millis(250))
1326 .await;
1327 };
1328
1329 this.update(cx, |this, cx| {
1330 this.fetch_extensions(
1331 search,
1332 Some(BTreeSet::from_iter(this.provides_filter)),
1333 on_complete,
1334 cx,
1335 );
1336 })
1337 .ok();
1338 }));
1339 }
1340
1341 pub fn search_query(&self, cx: &mut App) -> Option<String> {
1342 let search = self.query_editor.read(cx).text(cx);
1343 if search.trim().is_empty() {
1344 None
1345 } else {
1346 Some(search)
1347 }
1348 }
1349
1350 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1351 let has_search = self.search_query(cx).is_some();
1352
1353 let message = if self.is_fetching_extensions {
1354 "Loading extensions…"
1355 } else if self.fetch_failed {
1356 "Failed to load extensions. Please check your connection and try again."
1357 } else {
1358 match self.filter {
1359 ExtensionFilter::All => {
1360 if has_search {
1361 "No extensions that match your search."
1362 } else {
1363 "No extensions."
1364 }
1365 }
1366 ExtensionFilter::Installed => {
1367 if has_search {
1368 "No installed extensions that match your search."
1369 } else {
1370 "No installed extensions."
1371 }
1372 }
1373 ExtensionFilter::NotInstalled => {
1374 if has_search {
1375 "No not installed extensions that match your search."
1376 } else {
1377 "No not installed extensions."
1378 }
1379 }
1380 }
1381 };
1382
1383 h_flex()
1384 .py_4()
1385 .gap_1p5()
1386 .when(self.fetch_failed, |this| {
1387 this.child(
1388 Icon::new(IconName::Warning)
1389 .size(IconSize::Small)
1390 .color(Color::Warning),
1391 )
1392 })
1393 .child(Label::new(message))
1394 }
1395
1396 fn update_settings(
1397 &mut self,
1398 selection: &ToggleState,
1399
1400 cx: &mut Context<Self>,
1401 callback: impl 'static + Send + Fn(&mut SettingsContent, bool),
1402 ) {
1403 if let Some(workspace) = self.workspace.upgrade() {
1404 let fs = workspace.read(cx).app_state().fs.clone();
1405 let selection = *selection;
1406 settings::update_settings_file(fs, cx, move |settings, _| {
1407 let value = match selection {
1408 ToggleState::Unselected => false,
1409 ToggleState::Selected => true,
1410 _ => return,
1411 };
1412
1413 callback(settings, value)
1414 });
1415 }
1416 }
1417
1418 fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1419 let Some(search) = self.search_query(cx) else {
1420 self.upsells.clear();
1421 self.show_acp_registry_upsell = false;
1422 return;
1423 };
1424
1425 if let Some(id) = search.strip_prefix("id:") {
1426 self.upsells.clear();
1427 self.show_acp_registry_upsell = false;
1428
1429 let upsell = match id.to_lowercase().as_str() {
1430 "ruff" => Some(Feature::ExtensionRuff),
1431 "basedpyright" => Some(Feature::ExtensionBasedpyright),
1432 "ty" => Some(Feature::ExtensionTy),
1433 _ => None,
1434 };
1435
1436 if let Some(upsell) = upsell {
1437 self.upsells.insert(upsell);
1438 }
1439
1440 return;
1441 }
1442
1443 let search = search.to_lowercase();
1444 let search_terms = search
1445 .split_whitespace()
1446 .map(|term| term.trim())
1447 .collect::<Vec<_>>();
1448
1449 for (feature, keywords) in keywords_by_feature() {
1450 if keywords
1451 .iter()
1452 .any(|keyword| search_terms.contains(keyword))
1453 {
1454 self.upsells.insert(*feature);
1455 } else {
1456 self.upsells.remove(feature);
1457 }
1458 }
1459
1460 self.show_acp_registry_upsell = acp_registry_upsell_keywords()
1461 .iter()
1462 .any(|keyword| search_terms.iter().any(|term| keyword.contains(term)));
1463 }
1464
1465 fn render_acp_registry_upsell(&self, cx: &mut Context<Self>) -> impl IntoElement {
1466 let registry_url = zed_urls::acp_registry_blog(cx);
1467
1468 let view_registry = Button::new("view_registry", "View Registry")
1469 .style(ButtonStyle::Tinted(ui::TintColor::Warning))
1470 .on_click({
1471 let registry_url = registry_url.clone();
1472 move |_, window, cx| {
1473 telemetry::event!(
1474 "ACP Registry Opened from Extensions",
1475 source = "ACP Registry Upsell",
1476 url = registry_url,
1477 );
1478 window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
1479 }
1480 });
1481 let open_registry_button = Button::new("open_registry", "Learn More")
1482 .icon(IconName::ArrowUpRight)
1483 .icon_size(IconSize::Small)
1484 .icon_position(IconPosition::End)
1485 .icon_color(Color::Muted)
1486 .on_click({
1487 move |_event, _window, cx| {
1488 telemetry::event!(
1489 "ACP Registry Viewed",
1490 source = "ACP Registry Upsell",
1491 url = registry_url,
1492 );
1493 cx.open_url(®istry_url)
1494 }
1495 });
1496
1497 div().pt_4().px_4().child(
1498 Banner::new()
1499 .severity(Severity::Warning)
1500 .child(
1501 Label::new(
1502 "Agent Server extensions will be deprecated in favor of the ACP registry.",
1503 )
1504 .mt_0p5(),
1505 )
1506 .action_slot(
1507 h_flex()
1508 .gap_1()
1509 .child(open_registry_button)
1510 .child(view_registry),
1511 ),
1512 )
1513 }
1514
1515 fn render_feature_upsell_banner(
1516 &self,
1517 label: SharedString,
1518 docs_url: SharedString,
1519 vim: bool,
1520 cx: &mut Context<Self>,
1521 ) -> impl IntoElement {
1522 let docs_url_button = Button::new("open_docs", "View Documentation")
1523 .icon(IconName::ArrowUpRight)
1524 .icon_size(IconSize::Small)
1525 .icon_position(IconPosition::End)
1526 .on_click({
1527 move |_event, _window, cx| {
1528 telemetry::event!(
1529 "Documentation Viewed",
1530 source = "Feature Upsell",
1531 url = docs_url,
1532 );
1533 cx.open_url(&docs_url)
1534 }
1535 });
1536
1537 div()
1538 .pt_4()
1539 .px_4()
1540 .child(
1541 Banner::new()
1542 .severity(Severity::Success)
1543 .child(Label::new(label).mt_0p5())
1544 .map(|this| {
1545 if vim {
1546 this.action_slot(
1547 h_flex()
1548 .gap_1()
1549 .child(docs_url_button)
1550 .child(Divider::vertical().color(ui::DividerColor::Border))
1551 .child(
1552 h_flex()
1553 .pl_1()
1554 .gap_1()
1555 .child(Label::new("Enable Vim mode"))
1556 .child(
1557 Switch::new(
1558 "enable-vim",
1559 if VimModeSetting::get_global(cx).0 {
1560 ui::ToggleState::Selected
1561 } else {
1562 ui::ToggleState::Unselected
1563 },
1564 )
1565 .on_click(cx.listener(
1566 move |this, selection, _, cx| {
1567 telemetry::event!(
1568 "Vim Mode Toggled",
1569 source = "Feature Upsell"
1570 );
1571 this.update_settings(
1572 selection,
1573 cx,
1574 |setting, value| {
1575 setting.vim_mode = Some(value)
1576 },
1577 );
1578 },
1579 )),
1580 ),
1581 ),
1582 )
1583 } else {
1584 this.action_slot(docs_url_button)
1585 }
1586 }),
1587 )
1588 .into_any_element()
1589 }
1590
1591 fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1592 let mut container = v_flex();
1593
1594 for feature in &self.upsells {
1595 let banner = match feature {
1596 Feature::AgentClaude => self.render_feature_upsell_banner(
1597 "Claude Agent support is built-in to Zed!".into(),
1598 "https://zed.dev/docs/ai/external-agents#claude-agent".into(),
1599 false,
1600 cx,
1601 ),
1602 Feature::AgentCodex => self.render_feature_upsell_banner(
1603 "Codex CLI support is built-in to Zed!".into(),
1604 "https://zed.dev/docs/ai/external-agents#codex-cli".into(),
1605 false,
1606 cx,
1607 ),
1608 Feature::AgentGemini => self.render_feature_upsell_banner(
1609 "Gemini CLI support is built-in to Zed!".into(),
1610 "https://zed.dev/docs/ai/external-agents#gemini-cli".into(),
1611 false,
1612 cx,
1613 ),
1614 Feature::ExtensionBasedpyright => self.render_feature_upsell_banner(
1615 "Basedpyright (Python language server) support is built-in to Zed!".into(),
1616 "https://zed.dev/docs/languages/python#basedpyright".into(),
1617 false,
1618 cx,
1619 ),
1620 Feature::ExtensionRuff => self.render_feature_upsell_banner(
1621 "Ruff (linter for Python) support is built-in to Zed!".into(),
1622 "https://zed.dev/docs/languages/python#code-formatting--linting".into(),
1623 false,
1624 cx,
1625 ),
1626 Feature::ExtensionTailwind => self.render_feature_upsell_banner(
1627 "Tailwind CSS support is built-in to Zed!".into(),
1628 "https://zed.dev/docs/languages/tailwindcss".into(),
1629 false,
1630 cx,
1631 ),
1632 Feature::ExtensionTy => self.render_feature_upsell_banner(
1633 "Ty (Python language server) support is built-in to Zed!".into(),
1634 "https://zed.dev/docs/languages/python".into(),
1635 false,
1636 cx,
1637 ),
1638 Feature::Git => self.render_feature_upsell_banner(
1639 "Zed comes with basic Git support—more features are coming in the future."
1640 .into(),
1641 "https://zed.dev/docs/git".into(),
1642 false,
1643 cx,
1644 ),
1645 Feature::LanguageBash => self.render_feature_upsell_banner(
1646 "Shell support is built-in to Zed!".into(),
1647 "https://zed.dev/docs/languages/bash".into(),
1648 false,
1649 cx,
1650 ),
1651 Feature::LanguageC => self.render_feature_upsell_banner(
1652 "C support is built-in to Zed!".into(),
1653 "https://zed.dev/docs/languages/c".into(),
1654 false,
1655 cx,
1656 ),
1657 Feature::LanguageCpp => self.render_feature_upsell_banner(
1658 "C++ support is built-in to Zed!".into(),
1659 "https://zed.dev/docs/languages/cpp".into(),
1660 false,
1661 cx,
1662 ),
1663 Feature::LanguageGo => self.render_feature_upsell_banner(
1664 "Go support is built-in to Zed!".into(),
1665 "https://zed.dev/docs/languages/go".into(),
1666 false,
1667 cx,
1668 ),
1669 Feature::LanguagePython => self.render_feature_upsell_banner(
1670 "Python support is built-in to Zed!".into(),
1671 "https://zed.dev/docs/languages/python".into(),
1672 false,
1673 cx,
1674 ),
1675 Feature::LanguageReact => self.render_feature_upsell_banner(
1676 "React support is built-in to Zed!".into(),
1677 "https://zed.dev/docs/languages/typescript".into(),
1678 false,
1679 cx,
1680 ),
1681 Feature::LanguageRust => self.render_feature_upsell_banner(
1682 "Rust support is built-in to Zed!".into(),
1683 "https://zed.dev/docs/languages/rust".into(),
1684 false,
1685 cx,
1686 ),
1687 Feature::LanguageTypescript => self.render_feature_upsell_banner(
1688 "Typescript support is built-in to Zed!".into(),
1689 "https://zed.dev/docs/languages/typescript".into(),
1690 false,
1691 cx,
1692 ),
1693 Feature::OpenIn => self.render_feature_upsell_banner(
1694 "Zed supports linking to a source line on GitHub and others.".into(),
1695 "https://zed.dev/docs/git#git-integrations".into(),
1696 false,
1697 cx,
1698 ),
1699 Feature::Vim => self.render_feature_upsell_banner(
1700 "Vim support is built-in to Zed!".into(),
1701 "https://zed.dev/docs/vim".into(),
1702 true,
1703 cx,
1704 ),
1705 };
1706 container = container.child(banner);
1707 }
1708
1709 container
1710 }
1711}
1712
1713impl Render for ExtensionsPage {
1714 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1715 v_flex()
1716 .size_full()
1717 .bg(cx.theme().colors().editor_background)
1718 .child(
1719 v_flex()
1720 .gap_4()
1721 .pt_4()
1722 .px_4()
1723 .bg(cx.theme().colors().editor_background)
1724 .child(
1725 h_flex()
1726 .w_full()
1727 .gap_1p5()
1728 .justify_between()
1729 .child(Headline::new("Extensions").size(HeadlineSize::Large))
1730 .child(
1731 Button::new("install-dev-extension", "Install Dev Extension")
1732 .style(ButtonStyle::Outlined)
1733 .size(ButtonSize::Medium)
1734 .on_click(|_event, window, cx| {
1735 window.dispatch_action(Box::new(InstallDevExtension), cx)
1736 }),
1737 ),
1738 )
1739 .child(
1740 h_flex()
1741 .w_full()
1742 .flex_wrap()
1743 .gap_2()
1744 .child(self.render_search(cx))
1745 .child(
1746 div().child(
1747 ToggleButtonGroup::single_row(
1748 "filter-buttons",
1749 [
1750 ToggleButtonSimple::new(
1751 "All",
1752 cx.listener(|this, _event, _, cx| {
1753 this.filter = ExtensionFilter::All;
1754 this.filter_extension_entries(cx);
1755 this.scroll_to_top(cx);
1756 }),
1757 ),
1758 ToggleButtonSimple::new(
1759 "Installed",
1760 cx.listener(|this, _event, _, cx| {
1761 this.filter = ExtensionFilter::Installed;
1762 this.filter_extension_entries(cx);
1763 this.scroll_to_top(cx);
1764 }),
1765 ),
1766 ToggleButtonSimple::new(
1767 "Not Installed",
1768 cx.listener(|this, _event, _, cx| {
1769 this.filter = ExtensionFilter::NotInstalled;
1770 this.filter_extension_entries(cx);
1771 this.scroll_to_top(cx);
1772 }),
1773 ),
1774 ],
1775 )
1776 .style(ToggleButtonGroupStyle::Outlined)
1777 .size(ToggleButtonGroupSize::Custom(rems_from_px(30.))) // Perfectly matches the input
1778 .label_size(LabelSize::Default)
1779 .auto_width()
1780 .selected_index(match self.filter {
1781 ExtensionFilter::All => 0,
1782 ExtensionFilter::Installed => 1,
1783 ExtensionFilter::NotInstalled => 2,
1784 })
1785 .into_any_element(),
1786 ),
1787 ),
1788 ),
1789 )
1790 .child(
1791 h_flex()
1792 .id("filter-row")
1793 .gap_2()
1794 .py_2p5()
1795 .px_4()
1796 .border_b_1()
1797 .border_color(cx.theme().colors().border_variant)
1798 .overflow_x_scroll()
1799 .child(
1800 Button::new("filter-all-categories", "All")
1801 .when(self.provides_filter.is_none(), |button| {
1802 button.style(ButtonStyle::Filled)
1803 })
1804 .when(self.provides_filter.is_some(), |button| {
1805 button.style(ButtonStyle::Subtle)
1806 })
1807 .toggle_state(self.provides_filter.is_none())
1808 .on_click(cx.listener(|this, _event, _, cx| {
1809 this.change_provides_filter(None, cx);
1810 })),
1811 )
1812 .children(ExtensionProvides::iter().filter_map(|provides| {
1813 match provides {
1814 ExtensionProvides::SlashCommands
1815 | ExtensionProvides::IndexedDocsProviders => return None,
1816 _ => {}
1817 }
1818
1819 let label = extension_provides_label(provides);
1820 let button_id = SharedString::from(format!("filter-category-{}", label));
1821
1822 Some(
1823 Button::new(button_id, label)
1824 .style(if self.provides_filter == Some(provides) {
1825 ButtonStyle::Filled
1826 } else {
1827 ButtonStyle::Subtle
1828 })
1829 .toggle_state(self.provides_filter == Some(provides))
1830 .on_click({
1831 cx.listener(move |this, _event, _, cx| {
1832 this.change_provides_filter(Some(provides), cx);
1833 })
1834 }),
1835 )
1836 })),
1837 )
1838 .when(
1839 self.provides_filter == Some(ExtensionProvides::AgentServers)
1840 || self.show_acp_registry_upsell,
1841 |this| this.child(self.render_acp_registry_upsell(cx)),
1842 )
1843 .child(self.render_feature_upsells(cx))
1844 .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
1845 let mut count = self.filtered_remote_extension_indices.len();
1846 if self.filter.include_dev_extensions() {
1847 count += self.filtered_dev_extension_indices.len();
1848 }
1849
1850 if count == 0 {
1851 this.child(self.render_empty_state(cx)).into_any_element()
1852 } else {
1853 let scroll_handle = &self.list;
1854 this.child(
1855 uniform_list("entries", count, cx.processor(Self::render_extensions))
1856 .flex_grow()
1857 .pb_4()
1858 .track_scroll(scroll_handle),
1859 )
1860 .vertical_scrollbar_for(scroll_handle, window, cx)
1861 .into_any_element()
1862 }
1863 }))
1864 }
1865}
1866
1867impl EventEmitter<ItemEvent> for ExtensionsPage {}
1868
1869impl Focusable for ExtensionsPage {
1870 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1871 self.query_editor.read(cx).focus_handle(cx)
1872 }
1873}
1874
1875impl Item for ExtensionsPage {
1876 type Event = ItemEvent;
1877
1878 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1879 "Extensions".into()
1880 }
1881
1882 fn telemetry_event_text(&self) -> Option<&'static str> {
1883 Some("Extensions Page Opened")
1884 }
1885
1886 fn show_toolbar(&self) -> bool {
1887 false
1888 }
1889
1890 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
1891 f(*event)
1892 }
1893}