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