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