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