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