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.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 Some(Self::render_remote_extension_context_menu(
893 &this,
894 extension_id.clone(),
895 authors.clone(),
896 window,
897 cx,
898 ))
899 }),
900 ),
901 ),
902 )
903 }
904
905 fn render_remote_extension_context_menu(
906 this: &Entity<Self>,
907 extension_id: Arc<str>,
908 authors: Vec<String>,
909 window: &mut Window,
910 cx: &mut App,
911 ) -> Entity<ContextMenu> {
912 ContextMenu::build(window, cx, |context_menu, window, _| {
913 context_menu
914 .entry(
915 "Install Another Version...",
916 None,
917 window.handler_for(this, {
918 let extension_id = extension_id.clone();
919 move |this, window, cx| {
920 this.show_extension_version_list(extension_id.clone(), window, cx)
921 }
922 }),
923 )
924 .entry("Copy Extension ID", None, {
925 let extension_id = extension_id.clone();
926 move |_, cx| {
927 cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
928 }
929 })
930 .entry("Copy Author Info", None, {
931 let authors = authors.clone();
932 move |_, cx| {
933 cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", ")));
934 }
935 })
936 })
937 }
938
939 fn show_extension_version_list(
940 &mut self,
941 extension_id: Arc<str>,
942 window: &mut Window,
943 cx: &mut Context<Self>,
944 ) {
945 let Some(workspace) = self.workspace.upgrade() else {
946 return;
947 };
948
949 cx.spawn_in(window, async move |this, cx| {
950 let extension_versions_task = this.update(cx, |_, cx| {
951 let extension_store = ExtensionStore::global(cx);
952
953 extension_store.update(cx, |store, cx| {
954 store.fetch_extension_versions(&extension_id, cx)
955 })
956 })?;
957
958 let extension_versions = extension_versions_task.await?;
959
960 workspace.update_in(cx, |workspace, window, cx| {
961 let fs = workspace.project().read(cx).fs().clone();
962 workspace.toggle_modal(window, cx, |window, cx| {
963 let delegate = ExtensionVersionSelectorDelegate::new(
964 fs,
965 cx.entity().downgrade(),
966 extension_versions,
967 );
968
969 ExtensionVersionSelector::new(delegate, window, cx)
970 });
971 })?;
972
973 anyhow::Ok(())
974 })
975 .detach_and_log_err(cx);
976 }
977
978 fn buttons_for_entry(
979 &self,
980 extension: &ExtensionMetadata,
981 status: &ExtensionStatus,
982 has_dev_extension: bool,
983 cx: &mut Context<Self>,
984 ) -> ExtensionCardButtons {
985 let is_compatible =
986 extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
987
988 if has_dev_extension {
989 // If we have a dev extension for the given extension, just treat it as uninstalled.
990 // The button here is a placeholder, as it won't be interactable anyways.
991 return ExtensionCardButtons {
992 install_or_uninstall: Button::new(
993 SharedString::from(extension.id.clone()),
994 "Install",
995 ),
996 configure: None,
997 upgrade: None,
998 };
999 }
1000
1001 let is_configurable = extension
1002 .manifest
1003 .provides
1004 .contains(&ExtensionProvides::ContextServers);
1005
1006 match status.clone() {
1007 ExtensionStatus::NotInstalled => ExtensionCardButtons {
1008 install_or_uninstall: Button::new(
1009 SharedString::from(extension.id.clone()),
1010 "Install",
1011 )
1012 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1013 .icon(IconName::Download)
1014 .icon_size(IconSize::Small)
1015 .icon_color(Color::Muted)
1016 .icon_position(IconPosition::Start)
1017 .on_click({
1018 let extension_id = extension.id.clone();
1019 move |_, _, cx| {
1020 telemetry::event!("Extension Installed");
1021 ExtensionStore::global(cx).update(cx, |store, cx| {
1022 store.install_latest_extension(extension_id.clone(), cx)
1023 });
1024 }
1025 }),
1026 configure: None,
1027 upgrade: None,
1028 },
1029 ExtensionStatus::Installing => ExtensionCardButtons {
1030 install_or_uninstall: Button::new(
1031 SharedString::from(extension.id.clone()),
1032 "Install",
1033 )
1034 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1035 .icon(IconName::Download)
1036 .icon_size(IconSize::Small)
1037 .icon_color(Color::Muted)
1038 .icon_position(IconPosition::Start)
1039 .disabled(true),
1040 configure: None,
1041 upgrade: None,
1042 },
1043 ExtensionStatus::Upgrading => ExtensionCardButtons {
1044 install_or_uninstall: Button::new(
1045 SharedString::from(extension.id.clone()),
1046 "Uninstall",
1047 )
1048 .style(ButtonStyle::OutlinedGhost)
1049 .disabled(true),
1050 configure: is_configurable.then(|| {
1051 Button::new(
1052 SharedString::from(format!("configure-{}", extension.id)),
1053 "Configure",
1054 )
1055 .disabled(true)
1056 }),
1057 upgrade: Some(
1058 Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
1059 ),
1060 },
1061 ExtensionStatus::Installed(installed_version) => ExtensionCardButtons {
1062 install_or_uninstall: Button::new(
1063 SharedString::from(extension.id.clone()),
1064 "Uninstall",
1065 )
1066 .style(ButtonStyle::OutlinedGhost)
1067 .on_click({
1068 let extension_id = extension.id.clone();
1069 move |_, _, cx| {
1070 telemetry::event!("Extension Uninstalled", extension_id);
1071 ExtensionStore::global(cx).update(cx, |store, cx| {
1072 store
1073 .uninstall_extension(extension_id.clone(), cx)
1074 .detach_and_log_err(cx);
1075 });
1076 }
1077 }),
1078 configure: is_configurable.then(|| {
1079 Button::new(
1080 SharedString::from(format!("configure-{}", extension.id)),
1081 "Configure",
1082 )
1083 .style(ButtonStyle::OutlinedGhost)
1084 .on_click({
1085 let extension_id = extension.id.clone();
1086 move |_, _, cx| {
1087 if let Some(manifest) = ExtensionStore::global(cx)
1088 .read(cx)
1089 .extension_manifest_for_id(&extension_id)
1090 .cloned()
1091 && let Some(events) = extension::ExtensionEvents::try_global(cx)
1092 {
1093 events.update(cx, |this, cx| {
1094 this.emit(
1095 extension::Event::ConfigureExtensionRequested(manifest),
1096 cx,
1097 )
1098 });
1099 }
1100 }
1101 })
1102 }),
1103 upgrade: if installed_version == extension.manifest.version {
1104 None
1105 } else {
1106 Some(
1107 Button::new(SharedString::from(extension.id.clone()), "Upgrade")
1108 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1109 .when(!is_compatible, |upgrade_button| {
1110 upgrade_button.disabled(true).tooltip({
1111 let version = extension.manifest.version.clone();
1112 move |_, cx| {
1113 Tooltip::simple(
1114 format!(
1115 "v{version} is not compatible with this version of Zed.",
1116 ),
1117 cx,
1118 )
1119 }
1120 })
1121 })
1122 .disabled(!is_compatible)
1123 .on_click({
1124 let extension_id = extension.id.clone();
1125 let version = extension.manifest.version.clone();
1126 move |_, _, cx| {
1127 telemetry::event!("Extension Installed", extension_id, version);
1128 ExtensionStore::global(cx).update(cx, |store, cx| {
1129 store
1130 .upgrade_extension(
1131 extension_id.clone(),
1132 version.clone(),
1133 cx,
1134 )
1135 .detach_and_log_err(cx)
1136 });
1137 }
1138 }),
1139 )
1140 },
1141 },
1142 ExtensionStatus::Removing => ExtensionCardButtons {
1143 install_or_uninstall: Button::new(
1144 SharedString::from(extension.id.clone()),
1145 "Uninstall",
1146 )
1147 .style(ButtonStyle::OutlinedGhost)
1148 .disabled(true),
1149 configure: is_configurable.then(|| {
1150 Button::new(
1151 SharedString::from(format!("configure-{}", extension.id)),
1152 "Configure",
1153 )
1154 .disabled(true)
1155 }),
1156 upgrade: None,
1157 },
1158 }
1159 }
1160
1161 fn render_search(&self, cx: &mut Context<Self>) -> Div {
1162 let mut key_context = KeyContext::new_with_defaults();
1163 key_context.add("BufferSearchBar");
1164
1165 let editor_border = if self.query_contains_error {
1166 Color::Error.color(cx)
1167 } else {
1168 cx.theme().colors().border
1169 };
1170
1171 h_flex()
1172 .key_context(key_context)
1173 .h_8()
1174 .min_w(rems_from_px(384.))
1175 .flex_1()
1176 .pl_1p5()
1177 .pr_2()
1178 .gap_2()
1179 .border_1()
1180 .border_color(editor_border)
1181 .rounded_md()
1182 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
1183 .child(self.render_text_input(&self.query_editor, cx))
1184 }
1185
1186 fn render_text_input(
1187 &self,
1188 editor: &Entity<Editor>,
1189 cx: &mut Context<Self>,
1190 ) -> impl IntoElement {
1191 let settings = ThemeSettings::get_global(cx);
1192 let text_style = TextStyle {
1193 color: if editor.read(cx).read_only(cx) {
1194 cx.theme().colors().text_disabled
1195 } else {
1196 cx.theme().colors().text
1197 },
1198 font_family: settings.ui_font.family.clone(),
1199 font_features: settings.ui_font.features.clone(),
1200 font_fallbacks: settings.ui_font.fallbacks.clone(),
1201 font_size: rems(0.875).into(),
1202 font_weight: settings.ui_font.weight,
1203 line_height: relative(1.3),
1204 ..Default::default()
1205 };
1206
1207 EditorElement::new(
1208 editor,
1209 EditorStyle {
1210 background: cx.theme().colors().editor_background,
1211 local_player: cx.theme().players().local(),
1212 text: text_style,
1213 ..Default::default()
1214 },
1215 )
1216 }
1217
1218 fn on_query_change(
1219 &mut self,
1220 _: Entity<Editor>,
1221 event: &editor::EditorEvent,
1222 cx: &mut Context<Self>,
1223 ) {
1224 if let editor::EditorEvent::Edited { .. } = event {
1225 self.query_contains_error = false;
1226 self.refresh_search(cx);
1227 }
1228 }
1229
1230 fn refresh_search(&mut self, cx: &mut Context<Self>) {
1231 self.fetch_extensions_debounced(
1232 Some(Box::new(|this, cx| {
1233 this.scroll_to_top(cx);
1234 })),
1235 cx,
1236 );
1237 self.refresh_feature_upsells(cx);
1238 }
1239
1240 pub fn focus_extension(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
1241 self.query_editor.update(cx, |editor, cx| {
1242 editor.set_text(format!("id:{id}"), window, cx)
1243 });
1244 self.refresh_search(cx);
1245 }
1246
1247 pub fn change_provides_filter(
1248 &mut self,
1249 provides_filter: Option<ExtensionProvides>,
1250 cx: &mut Context<Self>,
1251 ) {
1252 self.provides_filter = provides_filter;
1253 self.refresh_search(cx);
1254 }
1255
1256 fn fetch_extensions_debounced(
1257 &mut self,
1258 on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
1259 cx: &mut Context<ExtensionsPage>,
1260 ) {
1261 self.extension_fetch_task = Some(cx.spawn(async move |this, cx| {
1262 let search = this
1263 .update(cx, |this, cx| this.search_query(cx))
1264 .ok()
1265 .flatten();
1266
1267 // Only debounce the fetching of extensions if we have a search
1268 // query.
1269 //
1270 // If the search was just cleared then we can just reload the list
1271 // of extensions without a debounce, which allows us to avoid seeing
1272 // an intermittent flash of a "no extensions" state.
1273 if search.is_some() {
1274 cx.background_executor()
1275 .timer(Duration::from_millis(250))
1276 .await;
1277 };
1278
1279 this.update(cx, |this, cx| {
1280 this.fetch_extensions(
1281 search,
1282 Some(BTreeSet::from_iter(this.provides_filter)),
1283 on_complete,
1284 cx,
1285 );
1286 })
1287 .ok();
1288 }));
1289 }
1290
1291 pub fn search_query(&self, cx: &mut App) -> Option<String> {
1292 let search = self.query_editor.read(cx).text(cx);
1293 if search.trim().is_empty() {
1294 None
1295 } else {
1296 Some(search)
1297 }
1298 }
1299
1300 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1301 let has_search = self.search_query(cx).is_some();
1302
1303 let message = if self.is_fetching_extensions {
1304 "Loading extensions…"
1305 } else if self.fetch_failed {
1306 "Failed to load extensions. Please check your connection and try again."
1307 } else {
1308 match self.filter {
1309 ExtensionFilter::All => {
1310 if has_search {
1311 "No extensions that match your search."
1312 } else {
1313 "No extensions."
1314 }
1315 }
1316 ExtensionFilter::Installed => {
1317 if has_search {
1318 "No installed extensions that match your search."
1319 } else {
1320 "No installed extensions."
1321 }
1322 }
1323 ExtensionFilter::NotInstalled => {
1324 if has_search {
1325 "No not installed extensions that match your search."
1326 } else {
1327 "No not installed extensions."
1328 }
1329 }
1330 }
1331 };
1332
1333 h_flex()
1334 .py_4()
1335 .gap_1p5()
1336 .when(self.fetch_failed, |this| {
1337 this.child(
1338 Icon::new(IconName::Warning)
1339 .size(IconSize::Small)
1340 .color(Color::Warning),
1341 )
1342 })
1343 .child(Label::new(message))
1344 }
1345
1346 fn update_settings(
1347 &mut self,
1348 selection: &ToggleState,
1349
1350 cx: &mut Context<Self>,
1351 callback: impl 'static + Send + Fn(&mut SettingsContent, bool),
1352 ) {
1353 if let Some(workspace) = self.workspace.upgrade() {
1354 let fs = workspace.read(cx).app_state().fs.clone();
1355 let selection = *selection;
1356 settings::update_settings_file(fs, cx, move |settings, _| {
1357 let value = match selection {
1358 ToggleState::Unselected => false,
1359 ToggleState::Selected => true,
1360 _ => return,
1361 };
1362
1363 callback(settings, value)
1364 });
1365 }
1366 }
1367
1368 fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1369 let Some(search) = self.search_query(cx) else {
1370 self.upsells.clear();
1371 return;
1372 };
1373
1374 if let Some(id) = search.strip_prefix("id:") {
1375 self.upsells.clear();
1376
1377 let upsell = match id.to_lowercase().as_str() {
1378 "ruff" => Some(Feature::ExtensionRuff),
1379 "basedpyright" => Some(Feature::ExtensionBasedpyright),
1380 "ty" => Some(Feature::ExtensionTy),
1381 _ => None,
1382 };
1383
1384 if let Some(upsell) = upsell {
1385 self.upsells.insert(upsell);
1386 }
1387
1388 return;
1389 }
1390
1391 let search = search.to_lowercase();
1392 let search_terms = search
1393 .split_whitespace()
1394 .map(|term| term.trim())
1395 .collect::<Vec<_>>();
1396
1397 for (feature, keywords) in keywords_by_feature() {
1398 if keywords
1399 .iter()
1400 .any(|keyword| search_terms.contains(keyword))
1401 {
1402 self.upsells.insert(*feature);
1403 } else {
1404 self.upsells.remove(feature);
1405 }
1406 }
1407 }
1408
1409 fn render_feature_upsell_banner(
1410 &self,
1411 label: SharedString,
1412 docs_url: SharedString,
1413 vim: bool,
1414 cx: &mut Context<Self>,
1415 ) -> impl IntoElement {
1416 let docs_url_button = Button::new("open_docs", "View Documentation")
1417 .icon(IconName::ArrowUpRight)
1418 .icon_size(IconSize::Small)
1419 .icon_position(IconPosition::End)
1420 .on_click({
1421 move |_event, _window, cx| {
1422 telemetry::event!(
1423 "Documentation Viewed",
1424 source = "Feature Upsell",
1425 url = docs_url,
1426 );
1427 cx.open_url(&docs_url)
1428 }
1429 });
1430
1431 div()
1432 .pt_4()
1433 .px_4()
1434 .child(
1435 Banner::new()
1436 .severity(Severity::Success)
1437 .child(Label::new(label).mt_0p5())
1438 .map(|this| {
1439 if vim {
1440 this.action_slot(
1441 h_flex()
1442 .gap_1()
1443 .child(docs_url_button)
1444 .child(Divider::vertical().color(ui::DividerColor::Border))
1445 .child(
1446 h_flex()
1447 .pl_1()
1448 .gap_1()
1449 .child(Label::new("Enable Vim mode"))
1450 .child(
1451 Switch::new(
1452 "enable-vim",
1453 if VimModeSetting::get_global(cx).0 {
1454 ui::ToggleState::Selected
1455 } else {
1456 ui::ToggleState::Unselected
1457 },
1458 )
1459 .on_click(cx.listener(
1460 move |this, selection, _, cx| {
1461 telemetry::event!(
1462 "Vim Mode Toggled",
1463 source = "Feature Upsell"
1464 );
1465 this.update_settings(
1466 selection,
1467 cx,
1468 |setting, value| {
1469 setting.vim_mode = Some(value)
1470 },
1471 );
1472 },
1473 ))
1474 .color(ui::SwitchColor::Accent),
1475 ),
1476 ),
1477 )
1478 } else {
1479 this.action_slot(docs_url_button)
1480 }
1481 }),
1482 )
1483 .into_any_element()
1484 }
1485
1486 fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1487 let mut container = v_flex();
1488
1489 for feature in &self.upsells {
1490 let banner = match feature {
1491 Feature::AgentClaude => self.render_feature_upsell_banner(
1492 "Claude Code support is built-in to Zed!".into(),
1493 "https://zed.dev/docs/ai/external-agents#claude-code".into(),
1494 false,
1495 cx,
1496 ),
1497 Feature::AgentCodex => self.render_feature_upsell_banner(
1498 "Codex CLI support is built-in to Zed!".into(),
1499 "https://zed.dev/docs/ai/external-agents#codex-cli".into(),
1500 false,
1501 cx,
1502 ),
1503 Feature::AgentGemini => self.render_feature_upsell_banner(
1504 "Gemini CLI support is built-in to Zed!".into(),
1505 "https://zed.dev/docs/ai/external-agents#gemini-cli".into(),
1506 false,
1507 cx,
1508 ),
1509 Feature::ExtensionBasedpyright => self.render_feature_upsell_banner(
1510 "Basedpyright (Python language server) support is built-in to Zed!".into(),
1511 "https://zed.dev/docs/languages/python#basedpyright".into(),
1512 false,
1513 cx,
1514 ),
1515 Feature::ExtensionRuff => self.render_feature_upsell_banner(
1516 "Ruff (linter for Python) support is built-in to Zed!".into(),
1517 "https://zed.dev/docs/languages/python#code-formatting--linting".into(),
1518 false,
1519 cx,
1520 ),
1521 Feature::ExtensionTailwind => self.render_feature_upsell_banner(
1522 "Tailwind CSS support is built-in to Zed!".into(),
1523 "https://zed.dev/docs/languages/tailwindcss".into(),
1524 false,
1525 cx,
1526 ),
1527 Feature::ExtensionTy => self.render_feature_upsell_banner(
1528 "Ty (Python language server) support is built-in to Zed!".into(),
1529 "https://zed.dev/docs/languages/python".into(),
1530 false,
1531 cx,
1532 ),
1533 Feature::Git => self.render_feature_upsell_banner(
1534 "Zed comes with basic Git support—more features are coming in the future."
1535 .into(),
1536 "https://zed.dev/docs/git".into(),
1537 false,
1538 cx,
1539 ),
1540 Feature::LanguageBash => self.render_feature_upsell_banner(
1541 "Shell support is built-in to Zed!".into(),
1542 "https://zed.dev/docs/languages/bash".into(),
1543 false,
1544 cx,
1545 ),
1546 Feature::LanguageC => self.render_feature_upsell_banner(
1547 "C support is built-in to Zed!".into(),
1548 "https://zed.dev/docs/languages/c".into(),
1549 false,
1550 cx,
1551 ),
1552 Feature::LanguageCpp => self.render_feature_upsell_banner(
1553 "C++ support is built-in to Zed!".into(),
1554 "https://zed.dev/docs/languages/cpp".into(),
1555 false,
1556 cx,
1557 ),
1558 Feature::LanguageGo => self.render_feature_upsell_banner(
1559 "Go support is built-in to Zed!".into(),
1560 "https://zed.dev/docs/languages/go".into(),
1561 false,
1562 cx,
1563 ),
1564 Feature::LanguagePython => self.render_feature_upsell_banner(
1565 "Python support is built-in to Zed!".into(),
1566 "https://zed.dev/docs/languages/python".into(),
1567 false,
1568 cx,
1569 ),
1570 Feature::LanguageReact => self.render_feature_upsell_banner(
1571 "React support is built-in to Zed!".into(),
1572 "https://zed.dev/docs/languages/typescript".into(),
1573 false,
1574 cx,
1575 ),
1576 Feature::LanguageRust => self.render_feature_upsell_banner(
1577 "Rust support is built-in to Zed!".into(),
1578 "https://zed.dev/docs/languages/rust".into(),
1579 false,
1580 cx,
1581 ),
1582 Feature::LanguageTypescript => self.render_feature_upsell_banner(
1583 "Typescript support is built-in to Zed!".into(),
1584 "https://zed.dev/docs/languages/typescript".into(),
1585 false,
1586 cx,
1587 ),
1588 Feature::OpenIn => self.render_feature_upsell_banner(
1589 "Zed supports linking to a source line on GitHub and others.".into(),
1590 "https://zed.dev/docs/git#git-integrations".into(),
1591 false,
1592 cx,
1593 ),
1594 Feature::Vim => self.render_feature_upsell_banner(
1595 "Vim support is built-in to Zed!".into(),
1596 "https://zed.dev/docs/vim".into(),
1597 true,
1598 cx,
1599 ),
1600 };
1601 container = container.child(banner);
1602 }
1603
1604 container
1605 }
1606}
1607
1608impl Render for ExtensionsPage {
1609 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1610 v_flex()
1611 .size_full()
1612 .bg(cx.theme().colors().editor_background)
1613 .child(
1614 v_flex()
1615 .gap_4()
1616 .pt_4()
1617 .px_4()
1618 .bg(cx.theme().colors().editor_background)
1619 .child(
1620 h_flex()
1621 .w_full()
1622 .gap_1p5()
1623 .justify_between()
1624 .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
1625 .child(
1626 Button::new("install-dev-extension", "Install Dev Extension")
1627 .style(ButtonStyle::Outlined)
1628 .size(ButtonSize::Medium)
1629 .on_click(|_event, window, cx| {
1630 window.dispatch_action(Box::new(InstallDevExtension), cx)
1631 }),
1632 ),
1633 )
1634 .child(
1635 h_flex()
1636 .w_full()
1637 .flex_wrap()
1638 .gap_2()
1639 .child(self.render_search(cx))
1640 .child(
1641 div().child(
1642 ToggleButtonGroup::single_row(
1643 "filter-buttons",
1644 [
1645 ToggleButtonSimple::new(
1646 "All",
1647 cx.listener(|this, _event, _, cx| {
1648 this.filter = ExtensionFilter::All;
1649 this.filter_extension_entries(cx);
1650 this.scroll_to_top(cx);
1651 }),
1652 ),
1653 ToggleButtonSimple::new(
1654 "Installed",
1655 cx.listener(|this, _event, _, cx| {
1656 this.filter = ExtensionFilter::Installed;
1657 this.filter_extension_entries(cx);
1658 this.scroll_to_top(cx);
1659 }),
1660 ),
1661 ToggleButtonSimple::new(
1662 "Not Installed",
1663 cx.listener(|this, _event, _, cx| {
1664 this.filter = ExtensionFilter::NotInstalled;
1665 this.filter_extension_entries(cx);
1666 this.scroll_to_top(cx);
1667 }),
1668 ),
1669 ],
1670 )
1671 .style(ToggleButtonGroupStyle::Outlined)
1672 .size(ToggleButtonGroupSize::Custom(rems_from_px(30.))) // Perfectly matches the input
1673 .label_size(LabelSize::Default)
1674 .auto_width()
1675 .selected_index(match self.filter {
1676 ExtensionFilter::All => 0,
1677 ExtensionFilter::Installed => 1,
1678 ExtensionFilter::NotInstalled => 2,
1679 })
1680 .into_any_element(),
1681 ),
1682 ),
1683 ),
1684 )
1685 .child(
1686 h_flex()
1687 .id("filter-row")
1688 .gap_2()
1689 .py_2p5()
1690 .px_4()
1691 .border_b_1()
1692 .border_color(cx.theme().colors().border_variant)
1693 .overflow_x_scroll()
1694 .child(
1695 Button::new("filter-all-categories", "All")
1696 .when(self.provides_filter.is_none(), |button| {
1697 button.style(ButtonStyle::Filled)
1698 })
1699 .when(self.provides_filter.is_some(), |button| {
1700 button.style(ButtonStyle::Subtle)
1701 })
1702 .toggle_state(self.provides_filter.is_none())
1703 .on_click(cx.listener(|this, _event, _, cx| {
1704 this.change_provides_filter(None, cx);
1705 })),
1706 )
1707 .children(ExtensionProvides::iter().filter_map(|provides| {
1708 match provides {
1709 ExtensionProvides::SlashCommands
1710 | ExtensionProvides::IndexedDocsProviders => return None,
1711 _ => {}
1712 }
1713
1714 let label = extension_provides_label(provides);
1715 let button_id = SharedString::from(format!("filter-category-{}", label));
1716
1717 Some(
1718 Button::new(button_id, label)
1719 .style(if self.provides_filter == Some(provides) {
1720 ButtonStyle::Filled
1721 } else {
1722 ButtonStyle::Subtle
1723 })
1724 .toggle_state(self.provides_filter == Some(provides))
1725 .on_click({
1726 cx.listener(move |this, _event, _, cx| {
1727 this.change_provides_filter(Some(provides), cx);
1728 })
1729 }),
1730 )
1731 })),
1732 )
1733 .child(self.render_feature_upsells(cx))
1734 .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
1735 let mut count = self.filtered_remote_extension_indices.len();
1736 if self.filter.include_dev_extensions() {
1737 count += self.dev_extension_entries.len();
1738 }
1739
1740 if count == 0 {
1741 this.child(self.render_empty_state(cx)).into_any_element()
1742 } else {
1743 let scroll_handle = &self.list;
1744 this.child(
1745 uniform_list("entries", count, cx.processor(Self::render_extensions))
1746 .flex_grow()
1747 .pb_4()
1748 .track_scroll(scroll_handle),
1749 )
1750 .vertical_scrollbar_for(scroll_handle, window, cx)
1751 .into_any_element()
1752 }
1753 }))
1754 }
1755}
1756
1757impl EventEmitter<ItemEvent> for ExtensionsPage {}
1758
1759impl Focusable for ExtensionsPage {
1760 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1761 self.query_editor.read(cx).focus_handle(cx)
1762 }
1763}
1764
1765impl Item for ExtensionsPage {
1766 type Event = ItemEvent;
1767
1768 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1769 "Extensions".into()
1770 }
1771
1772 fn telemetry_event_text(&self) -> Option<&'static str> {
1773 Some("Extensions Page Opened")
1774 }
1775
1776 fn show_toolbar(&self) -> bool {
1777 false
1778 }
1779
1780 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
1781 f(*event)
1782 }
1783}