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