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