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