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