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