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