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