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