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 client::{ExtensionMetadata, ExtensionProvides};
10use collections::{BTreeMap, BTreeSet};
11use editor::{Editor, EditorElement, EditorStyle};
12use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore};
13use fuzzy::{match_strings, StringMatchCandidate};
14use gpui::{
15 actions, uniform_list, Action, App, ClipboardItem, Context, Entity, EventEmitter, Flatten,
16 Focusable, InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle,
17 UniformListScrollHandle, WeakEntity, Window,
18};
19use num_format::{Locale, ToFormattedString};
20use project::DirectoryLister;
21use release_channel::ReleaseChannel;
22use settings::Settings;
23use strum::IntoEnumIterator as _;
24use theme::ThemeSettings;
25use ui::{prelude::*, CheckboxWithLabel, ContextMenu, PopoverMenu, ToggleButton, Tooltip};
26use vim_mode_setting::VimModeSetting;
27use workspace::{
28 item::{Item, ItemEvent},
29 Workspace, WorkspaceId,
30};
31use zed_actions::ExtensionCategoryFilter;
32
33use crate::components::{ExtensionCard, FeatureUpsell};
34use crate::extension_version_selector::{
35 ExtensionVersionSelector, ExtensionVersionSelectorDelegate,
36};
37
38actions!(zed, [InstallDevExtension]);
39
40pub fn init(cx: &mut App) {
41 cx.observe_new(move |workspace: &mut Workspace, window, cx| {
42 let Some(window) = window else {
43 return;
44 };
45 workspace
46 .register_action(
47 move |workspace, action: &zed_actions::Extensions, window, cx| {
48 let provides_filter = action.category_filter.map(|category| match category {
49 ExtensionCategoryFilter::Themes => ExtensionProvides::Themes,
50 ExtensionCategoryFilter::IconThemes => ExtensionProvides::IconThemes,
51 ExtensionCategoryFilter::Languages => ExtensionProvides::Languages,
52 ExtensionCategoryFilter::Grammars => ExtensionProvides::Grammars,
53 ExtensionCategoryFilter::LanguageServers => {
54 ExtensionProvides::LanguageServers
55 }
56 ExtensionCategoryFilter::ContextServers => {
57 ExtensionProvides::ContextServers
58 }
59 ExtensionCategoryFilter::SlashCommands => ExtensionProvides::SlashCommands,
60 ExtensionCategoryFilter::IndexedDocsProviders => {
61 ExtensionProvides::IndexedDocsProviders
62 }
63 ExtensionCategoryFilter::Snippets => ExtensionProvides::Snippets,
64 });
65
66 let existing = workspace
67 .active_pane()
68 .read(cx)
69 .items()
70 .find_map(|item| item.downcast::<ExtensionsPage>());
71
72 if let Some(existing) = existing {
73 if provides_filter.is_some() {
74 existing.update(cx, |extensions_page, cx| {
75 extensions_page.change_provides_filter(provides_filter, cx);
76 });
77 }
78
79 workspace.activate_item(&existing, true, true, window, cx);
80 } else {
81 let extensions_page =
82 ExtensionsPage::new(workspace, provides_filter, window, cx);
83 workspace.add_item_to_active_pane(
84 Box::new(extensions_page),
85 None,
86 true,
87 window,
88 cx,
89 )
90 }
91 },
92 )
93 .register_action(move |workspace, _: &InstallDevExtension, window, cx| {
94 let store = ExtensionStore::global(cx);
95 let prompt = workspace.prompt_for_open_path(
96 gpui::PathPromptOptions {
97 files: false,
98 directories: true,
99 multiple: false,
100 },
101 DirectoryLister::Local(workspace.app_state().fs.clone()),
102 window,
103 cx,
104 );
105
106 let workspace_handle = cx.entity().downgrade();
107 window
108 .spawn(cx, async move |cx| {
109 let extension_path =
110 match Flatten::flatten(prompt.await.map_err(|e| e.into())) {
111 Ok(Some(mut paths)) => paths.pop()?,
112 Ok(None) => return None,
113 Err(err) => {
114 workspace_handle
115 .update(cx, |workspace, cx| {
116 workspace.show_portal_error(err.to_string(), cx);
117 })
118 .ok();
119 return None;
120 }
121 };
122
123 let install_task = store
124 .update(cx, |store, cx| {
125 store.install_dev_extension(extension_path, cx)
126 })
127 .ok()?;
128
129 match install_task.await {
130 Ok(_) => {}
131 Err(err) => {
132 workspace_handle
133 .update(cx, |workspace, cx| {
134 workspace.show_error(
135 &err.context("failed to install dev extension"),
136 cx,
137 );
138 })
139 .ok();
140 }
141 }
142
143 Some(())
144 })
145 .detach();
146 });
147
148 cx.subscribe_in(workspace.project(), window, |_, _, event, window, cx| {
149 if let project::Event::LanguageNotFound(buffer) = event {
150 extension_suggest::suggest(buffer.clone(), window, cx);
151 }
152 })
153 .detach();
154 })
155 .detach();
156}
157
158fn extension_provides_label(provides: ExtensionProvides) -> &'static str {
159 match provides {
160 ExtensionProvides::Themes => "Themes",
161 ExtensionProvides::IconThemes => "Icon Themes",
162 ExtensionProvides::Languages => "Languages",
163 ExtensionProvides::Grammars => "Grammars",
164 ExtensionProvides::LanguageServers => "Language Servers",
165 ExtensionProvides::ContextServers => "Context Servers",
166 ExtensionProvides::SlashCommands => "Slash Commands",
167 ExtensionProvides::IndexedDocsProviders => "Indexed Docs Providers",
168 ExtensionProvides::Snippets => "Snippets",
169 }
170}
171
172#[derive(Clone)]
173pub enum ExtensionStatus {
174 NotInstalled,
175 Installing,
176 Upgrading,
177 Installed(Arc<str>),
178 Removing,
179}
180
181#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
182enum ExtensionFilter {
183 All,
184 Installed,
185 NotInstalled,
186}
187
188impl ExtensionFilter {
189 pub fn include_dev_extensions(&self) -> bool {
190 match self {
191 Self::All | Self::Installed => true,
192 Self::NotInstalled => false,
193 }
194 }
195}
196
197#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
198enum Feature {
199 Git,
200 OpenIn,
201 Vim,
202 LanguageBash,
203 LanguageC,
204 LanguageCpp,
205 LanguageGo,
206 LanguagePython,
207 LanguageReact,
208 LanguageRust,
209 LanguageTypescript,
210}
211
212fn keywords_by_feature() -> &'static BTreeMap<Feature, Vec<&'static str>> {
213 static KEYWORDS_BY_FEATURE: OnceLock<BTreeMap<Feature, Vec<&'static str>>> = OnceLock::new();
214 KEYWORDS_BY_FEATURE.get_or_init(|| {
215 BTreeMap::from_iter([
216 (Feature::Git, vec!["git"]),
217 (
218 Feature::OpenIn,
219 vec![
220 "github",
221 "gitlab",
222 "bitbucket",
223 "codeberg",
224 "sourcehut",
225 "permalink",
226 "link",
227 "open in",
228 ],
229 ),
230 (Feature::Vim, vec!["vim"]),
231 (Feature::LanguageBash, vec!["sh", "bash"]),
232 (Feature::LanguageC, vec!["c", "clang"]),
233 (Feature::LanguageCpp, vec!["c++", "cpp", "clang"]),
234 (Feature::LanguageGo, vec!["go", "golang"]),
235 (Feature::LanguagePython, vec!["python", "py"]),
236 (Feature::LanguageReact, vec!["react"]),
237 (Feature::LanguageRust, vec!["rust", "rs"]),
238 (
239 Feature::LanguageTypescript,
240 vec!["type", "typescript", "ts"],
241 ),
242 ])
243 })
244}
245
246pub struct ExtensionsPage {
247 workspace: WeakEntity<Workspace>,
248 list: UniformListScrollHandle,
249 is_fetching_extensions: bool,
250 filter: ExtensionFilter,
251 remote_extension_entries: Vec<ExtensionMetadata>,
252 dev_extension_entries: Vec<Arc<ExtensionManifest>>,
253 filtered_remote_extension_indices: Vec<usize>,
254 query_editor: Entity<Editor>,
255 query_contains_error: bool,
256 provides_filter: Option<ExtensionProvides>,
257 _subscriptions: [gpui::Subscription; 2],
258 extension_fetch_task: Option<Task<()>>,
259 upsells: BTreeSet<Feature>,
260}
261
262impl ExtensionsPage {
263 pub fn new(
264 workspace: &Workspace,
265 provides_filter: Option<ExtensionProvides>,
266 window: &mut Window,
267 cx: &mut Context<Workspace>,
268 ) -> Entity<Self> {
269 cx.new(|cx| {
270 let store = ExtensionStore::global(cx);
271 let workspace_handle = workspace.weak_handle();
272 let subscriptions = [
273 cx.observe(&store, |_: &mut Self, _, cx| cx.notify()),
274 cx.subscribe_in(
275 &store,
276 window,
277 move |this, _, event, window, cx| match event {
278 extension_host::Event::ExtensionsUpdated => {
279 this.fetch_extensions_debounced(cx)
280 }
281 extension_host::Event::ExtensionInstalled(extension_id) => this
282 .on_extension_installed(
283 workspace_handle.clone(),
284 extension_id,
285 window,
286 cx,
287 ),
288 _ => {}
289 },
290 ),
291 ];
292
293 let query_editor = cx.new(|cx| {
294 let mut input = Editor::single_line(window, cx);
295 input.set_placeholder_text("Search extensions...", cx);
296 input
297 });
298 cx.subscribe(&query_editor, Self::on_query_change).detach();
299
300 let mut this = Self {
301 workspace: workspace.weak_handle(),
302 list: UniformListScrollHandle::new(),
303 is_fetching_extensions: false,
304 filter: ExtensionFilter::All,
305 dev_extension_entries: Vec::new(),
306 filtered_remote_extension_indices: Vec::new(),
307 remote_extension_entries: Vec::new(),
308 query_contains_error: false,
309 provides_filter,
310 extension_fetch_task: None,
311 _subscriptions: subscriptions,
312 query_editor,
313 upsells: BTreeSet::default(),
314 };
315 this.fetch_extensions(None, Some(BTreeSet::from_iter(this.provides_filter)), cx);
316 this
317 })
318 }
319
320 fn on_extension_installed(
321 &mut self,
322 workspace: WeakEntity<Workspace>,
323 extension_id: &str,
324 window: &mut Window,
325 cx: &mut Context<Self>,
326 ) {
327 let extension_store = ExtensionStore::global(cx).read(cx);
328 let themes = extension_store
329 .extension_themes(extension_id)
330 .map(|name| name.to_string())
331 .collect::<Vec<_>>();
332 if !themes.is_empty() {
333 workspace
334 .update(cx, |_workspace, cx| {
335 window.dispatch_action(
336 zed_actions::theme_selector::Toggle {
337 themes_filter: Some(themes),
338 }
339 .boxed_clone(),
340 cx,
341 );
342 })
343 .ok();
344 return;
345 }
346
347 let icon_themes = extension_store
348 .extension_icon_themes(extension_id)
349 .map(|name| name.to_string())
350 .collect::<Vec<_>>();
351 if !icon_themes.is_empty() {
352 workspace
353 .update(cx, |_workspace, cx| {
354 window.dispatch_action(
355 zed_actions::icon_theme_selector::Toggle {
356 themes_filter: Some(icon_themes),
357 }
358 .boxed_clone(),
359 cx,
360 );
361 })
362 .ok();
363 }
364 }
365
366 /// Returns whether a dev extension currently exists for the extension with the given ID.
367 fn dev_extension_exists(extension_id: &str, cx: &mut Context<Self>) -> bool {
368 let extension_store = ExtensionStore::global(cx).read(cx);
369
370 extension_store
371 .dev_extensions()
372 .any(|dev_extension| dev_extension.id.as_ref() == extension_id)
373 }
374
375 fn extension_status(extension_id: &str, cx: &mut Context<Self>) -> ExtensionStatus {
376 let extension_store = ExtensionStore::global(cx).read(cx);
377
378 match extension_store.outstanding_operations().get(extension_id) {
379 Some(ExtensionOperation::Install) => ExtensionStatus::Installing,
380 Some(ExtensionOperation::Remove) => ExtensionStatus::Removing,
381 Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading,
382 None => match extension_store.installed_extensions().get(extension_id) {
383 Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()),
384 None => ExtensionStatus::NotInstalled,
385 },
386 }
387 }
388
389 fn filter_extension_entries(&mut self, cx: &mut Context<Self>) {
390 self.filtered_remote_extension_indices.clear();
391 self.filtered_remote_extension_indices.extend(
392 self.remote_extension_entries
393 .iter()
394 .enumerate()
395 .filter(|(_, extension)| match self.filter {
396 ExtensionFilter::All => true,
397 ExtensionFilter::Installed => {
398 let status = Self::extension_status(&extension.id, cx);
399 matches!(status, ExtensionStatus::Installed(_))
400 }
401 ExtensionFilter::NotInstalled => {
402 let status = Self::extension_status(&extension.id, cx);
403
404 matches!(status, ExtensionStatus::NotInstalled)
405 }
406 })
407 .map(|(ix, _)| ix),
408 );
409 cx.notify();
410 }
411
412 fn fetch_extensions(
413 &mut self,
414 search: Option<String>,
415 provides_filter: Option<BTreeSet<ExtensionProvides>>,
416 cx: &mut Context<Self>,
417 ) {
418 self.is_fetching_extensions = true;
419 cx.notify();
420
421 let extension_store = ExtensionStore::global(cx);
422
423 let dev_extensions = extension_store.update(cx, |store, _| {
424 store.dev_extensions().cloned().collect::<Vec<_>>()
425 });
426
427 let remote_extensions = extension_store.update(cx, |store, cx| {
428 store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx)
429 });
430
431 cx.spawn(async move |this, cx| {
432 let dev_extensions = if let Some(search) = search {
433 let match_candidates = dev_extensions
434 .iter()
435 .enumerate()
436 .map(|(ix, manifest)| StringMatchCandidate::new(ix, &manifest.name))
437 .collect::<Vec<_>>();
438
439 let matches = match_strings(
440 &match_candidates,
441 &search,
442 false,
443 match_candidates.len(),
444 &Default::default(),
445 cx.background_executor().clone(),
446 )
447 .await;
448 matches
449 .into_iter()
450 .map(|mat| dev_extensions[mat.candidate_id].clone())
451 .collect()
452 } else {
453 dev_extensions
454 };
455
456 let fetch_result = remote_extensions.await;
457 this.update(cx, |this, cx| {
458 cx.notify();
459 this.dev_extension_entries = dev_extensions;
460 this.is_fetching_extensions = false;
461 this.remote_extension_entries = fetch_result?;
462 this.filter_extension_entries(cx);
463 anyhow::Ok(())
464 })?
465 })
466 .detach_and_log_err(cx);
467 }
468
469 fn render_extensions(
470 &mut self,
471 range: Range<usize>,
472 _: &mut Window,
473 cx: &mut Context<Self>,
474 ) -> Vec<ExtensionCard> {
475 let dev_extension_entries_len = if self.filter.include_dev_extensions() {
476 self.dev_extension_entries.len()
477 } else {
478 0
479 };
480 range
481 .map(|ix| {
482 if ix < dev_extension_entries_len {
483 let extension = &self.dev_extension_entries[ix];
484 self.render_dev_extension(extension, cx)
485 } else {
486 let extension_ix =
487 self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
488 let extension = &self.remote_extension_entries[extension_ix];
489 self.render_remote_extension(extension, cx)
490 }
491 })
492 .collect()
493 }
494
495 fn render_dev_extension(
496 &self,
497 extension: &ExtensionManifest,
498 cx: &mut Context<Self>,
499 ) -> ExtensionCard {
500 let status = Self::extension_status(&extension.id, cx);
501
502 let repository_url = extension.repository.clone();
503
504 ExtensionCard::new()
505 .child(
506 h_flex()
507 .justify_between()
508 .child(
509 h_flex()
510 .gap_2()
511 .items_end()
512 .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
513 .child(
514 Headline::new(format!("v{}", extension.version))
515 .size(HeadlineSize::XSmall),
516 ),
517 )
518 .child(
519 h_flex()
520 .gap_2()
521 .justify_between()
522 .child(
523 Button::new(
524 SharedString::from(format!("rebuild-{}", extension.id)),
525 "Rebuild",
526 )
527 .on_click({
528 let extension_id = extension.id.clone();
529 move |_, _, cx| {
530 ExtensionStore::global(cx).update(cx, |store, cx| {
531 store.rebuild_dev_extension(extension_id.clone(), cx)
532 });
533 }
534 })
535 .color(Color::Accent)
536 .disabled(matches!(status, ExtensionStatus::Upgrading)),
537 )
538 .child(
539 Button::new(SharedString::from(extension.id.clone()), "Uninstall")
540 .on_click({
541 let extension_id = extension.id.clone();
542 move |_, _, cx| {
543 ExtensionStore::global(cx).update(cx, |store, cx| {
544 store.uninstall_extension(extension_id.clone(), cx)
545 });
546 }
547 })
548 .color(Color::Accent)
549 .disabled(matches!(status, ExtensionStatus::Removing)),
550 ),
551 ),
552 )
553 .child(
554 h_flex()
555 .gap_2()
556 .justify_between()
557 .child(
558 Label::new(format!(
559 "{}: {}",
560 if extension.authors.len() > 1 {
561 "Authors"
562 } else {
563 "Author"
564 },
565 extension.authors.join(", ")
566 ))
567 .size(LabelSize::Small)
568 .truncate(),
569 )
570 .child(Label::new("<>").size(LabelSize::Small)),
571 )
572 .child(
573 h_flex()
574 .gap_2()
575 .justify_between()
576 .children(extension.description.as_ref().map(|description| {
577 Label::new(description.clone())
578 .size(LabelSize::Small)
579 .color(Color::Default)
580 .truncate()
581 }))
582 .children(repository_url.map(|repository_url| {
583 IconButton::new(
584 SharedString::from(format!("repository-{}", extension.id)),
585 IconName::Github,
586 )
587 .icon_color(Color::Accent)
588 .icon_size(IconSize::Small)
589 .style(ButtonStyle::Filled)
590 .on_click(cx.listener({
591 let repository_url = repository_url.clone();
592 move |_, _, _, cx| {
593 cx.open_url(&repository_url);
594 }
595 }))
596 .tooltip(Tooltip::text(repository_url.clone()))
597 })),
598 )
599 }
600
601 fn render_remote_extension(
602 &self,
603 extension: &ExtensionMetadata,
604 cx: &mut Context<Self>,
605 ) -> ExtensionCard {
606 let this = cx.entity().clone();
607 let status = Self::extension_status(&extension.id, cx);
608 let has_dev_extension = Self::dev_extension_exists(&extension.id, cx);
609
610 let extension_id = extension.id.clone();
611 let (install_or_uninstall_button, upgrade_button) =
612 self.buttons_for_entry(extension, &status, has_dev_extension, cx);
613 let version = extension.manifest.version.clone();
614 let repository_url = extension.manifest.repository.clone();
615 let authors = extension.manifest.authors.clone();
616
617 let installed_version = match status {
618 ExtensionStatus::Installed(installed_version) => Some(installed_version),
619 _ => None,
620 };
621
622 ExtensionCard::new()
623 .overridden_by_dev_extension(has_dev_extension)
624 .child(
625 h_flex()
626 .justify_between()
627 .child(
628 h_flex()
629 .gap_2()
630 .child(
631 Headline::new(extension.manifest.name.clone())
632 .size(HeadlineSize::Medium),
633 )
634 .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
635 .children(
636 installed_version
637 .filter(|installed_version| *installed_version != version)
638 .map(|installed_version| {
639 Headline::new(format!("(v{installed_version} installed)",))
640 .size(HeadlineSize::XSmall)
641 }),
642 )
643 .map(|parent| {
644 if extension.manifest.provides.is_empty() {
645 return parent;
646 }
647
648 parent.child(
649 h_flex().gap_2().children(
650 extension
651 .manifest
652 .provides
653 .iter()
654 .map(|provides| {
655 div()
656 .bg(cx.theme().colors().element_background)
657 .px_0p5()
658 .border_1()
659 .border_color(cx.theme().colors().border)
660 .rounded_sm()
661 .child(
662 Label::new(extension_provides_label(
663 *provides,
664 ))
665 .size(LabelSize::XSmall),
666 )
667 })
668 .collect::<Vec<_>>(),
669 ),
670 )
671 }),
672 )
673 .child(
674 h_flex()
675 .gap_2()
676 .justify_between()
677 .children(upgrade_button)
678 .child(install_or_uninstall_button),
679 ),
680 )
681 .child(
682 h_flex()
683 .gap_2()
684 .justify_between()
685 .child(
686 Label::new(format!(
687 "{}: {}",
688 if extension.manifest.authors.len() > 1 {
689 "Authors"
690 } else {
691 "Author"
692 },
693 extension.manifest.authors.join(", ")
694 ))
695 .size(LabelSize::Small)
696 .truncate(),
697 )
698 .child(
699 Label::new(format!(
700 "Downloads: {}",
701 extension.download_count.to_formatted_string(&Locale::en)
702 ))
703 .size(LabelSize::Small),
704 ),
705 )
706 .child(
707 h_flex()
708 .gap_2()
709 .justify_between()
710 .children(extension.manifest.description.as_ref().map(|description| {
711 Label::new(description.clone())
712 .size(LabelSize::Small)
713 .color(Color::Default)
714 .truncate()
715 }))
716 .child(
717 h_flex()
718 .gap_2()
719 .child(
720 IconButton::new(
721 SharedString::from(format!("repository-{}", extension.id)),
722 IconName::Github,
723 )
724 .icon_color(Color::Accent)
725 .icon_size(IconSize::Small)
726 .style(ButtonStyle::Filled)
727 .on_click(cx.listener({
728 let repository_url = repository_url.clone();
729 move |_, _, _, cx| {
730 cx.open_url(&repository_url);
731 }
732 }))
733 .tooltip(Tooltip::text(repository_url.clone())),
734 )
735 .child(
736 PopoverMenu::new(SharedString::from(format!(
737 "more-{}",
738 extension.id
739 )))
740 .trigger(
741 IconButton::new(
742 SharedString::from(format!("more-{}", extension.id)),
743 IconName::Ellipsis,
744 )
745 .icon_color(Color::Accent)
746 .icon_size(IconSize::Small)
747 .style(ButtonStyle::Filled),
748 )
749 .menu(move |window, cx| {
750 Some(Self::render_remote_extension_context_menu(
751 &this,
752 extension_id.clone(),
753 authors.clone(),
754 window,
755 cx,
756 ))
757 }),
758 ),
759 ),
760 )
761 }
762
763 fn render_remote_extension_context_menu(
764 this: &Entity<Self>,
765 extension_id: Arc<str>,
766 authors: Vec<String>,
767 window: &mut Window,
768 cx: &mut App,
769 ) -> Entity<ContextMenu> {
770 let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| {
771 context_menu
772 .entry(
773 "Install Another Version...",
774 None,
775 window.handler_for(this, {
776 let extension_id = extension_id.clone();
777 move |this, window, cx| {
778 this.show_extension_version_list(extension_id.clone(), window, cx)
779 }
780 }),
781 )
782 .entry("Copy Extension ID", None, {
783 let extension_id = extension_id.clone();
784 move |_, cx| {
785 cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
786 }
787 })
788 .entry("Copy Author Info", None, {
789 let authors = authors.clone();
790 move |_, cx| {
791 cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", ")));
792 }
793 })
794 });
795
796 context_menu
797 }
798
799 fn show_extension_version_list(
800 &mut self,
801 extension_id: Arc<str>,
802 window: &mut Window,
803 cx: &mut Context<Self>,
804 ) {
805 let Some(workspace) = self.workspace.upgrade() else {
806 return;
807 };
808
809 cx.spawn_in(window, async move |this, cx| {
810 let extension_versions_task = this.update(cx, |_, cx| {
811 let extension_store = ExtensionStore::global(cx);
812
813 extension_store.update(cx, |store, cx| {
814 store.fetch_extension_versions(&extension_id, cx)
815 })
816 })?;
817
818 let extension_versions = extension_versions_task.await?;
819
820 workspace.update_in(cx, |workspace, window, cx| {
821 let fs = workspace.project().read(cx).fs().clone();
822 workspace.toggle_modal(window, cx, |window, cx| {
823 let delegate = ExtensionVersionSelectorDelegate::new(
824 fs,
825 cx.entity().downgrade(),
826 extension_versions,
827 );
828
829 ExtensionVersionSelector::new(delegate, window, cx)
830 });
831 })?;
832
833 anyhow::Ok(())
834 })
835 .detach_and_log_err(cx);
836 }
837
838 fn buttons_for_entry(
839 &self,
840 extension: &ExtensionMetadata,
841 status: &ExtensionStatus,
842 has_dev_extension: bool,
843 cx: &mut Context<Self>,
844 ) -> (Button, Option<Button>) {
845 let is_compatible =
846 extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
847
848 if has_dev_extension {
849 // If we have a dev extension for the given extension, just treat it as uninstalled.
850 // The button here is a placeholder, as it won't be interactable anyways.
851 return (
852 Button::new(SharedString::from(extension.id.clone()), "Install"),
853 None,
854 );
855 }
856
857 match status.clone() {
858 ExtensionStatus::NotInstalled => (
859 Button::new(SharedString::from(extension.id.clone()), "Install").on_click({
860 let extension_id = extension.id.clone();
861 move |_, _, cx| {
862 telemetry::event!("Extension Installed");
863 ExtensionStore::global(cx).update(cx, |store, cx| {
864 store.install_latest_extension(extension_id.clone(), cx)
865 });
866 }
867 }),
868 None,
869 ),
870 ExtensionStatus::Installing => (
871 Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
872 None,
873 ),
874 ExtensionStatus::Upgrading => (
875 Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
876 Some(
877 Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
878 ),
879 ),
880 ExtensionStatus::Installed(installed_version) => (
881 Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click({
882 let extension_id = extension.id.clone();
883 move |_, _, cx| {
884 telemetry::event!("Extension Uninstalled", extension_id);
885 ExtensionStore::global(cx).update(cx, |store, cx| {
886 store.uninstall_extension(extension_id.clone(), cx)
887 });
888 }
889 }),
890 if installed_version == extension.manifest.version {
891 None
892 } else {
893 Some(
894 Button::new(SharedString::from(extension.id.clone()), "Upgrade")
895 .when(!is_compatible, |upgrade_button| {
896 upgrade_button.disabled(true).tooltip({
897 let version = extension.manifest.version.clone();
898 move |_, cx| {
899 Tooltip::simple(
900 format!(
901 "v{version} is not compatible with this version of Zed.",
902 ),
903 cx,
904 )
905 }
906 })
907 })
908 .disabled(!is_compatible)
909 .on_click({
910 let extension_id = extension.id.clone();
911 let version = extension.manifest.version.clone();
912 move |_, _, cx| {
913 telemetry::event!("Extension Installed", extension_id, version);
914 ExtensionStore::global(cx).update(cx, |store, cx| {
915 store
916 .upgrade_extension(
917 extension_id.clone(),
918 version.clone(),
919 cx,
920 )
921 .detach_and_log_err(cx)
922 });
923 }
924 }),
925 )
926 },
927 ),
928 ExtensionStatus::Removing => (
929 Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
930 None,
931 ),
932 }
933 }
934
935 fn render_search(&self, cx: &mut Context<Self>) -> Div {
936 let mut key_context = KeyContext::new_with_defaults();
937 key_context.add("BufferSearchBar");
938
939 let editor_border = if self.query_contains_error {
940 Color::Error.color(cx)
941 } else {
942 cx.theme().colors().border
943 };
944
945 h_flex().w_full().gap_2().key_context(key_context).child(
946 h_flex()
947 .flex_1()
948 .px_2()
949 .py_1()
950 .gap_2()
951 .border_1()
952 .border_color(editor_border)
953 .min_w(rems_from_px(384.))
954 .rounded_lg()
955 .child(Icon::new(IconName::MagnifyingGlass))
956 .child(self.render_text_input(&self.query_editor, cx)),
957 )
958 }
959
960 fn render_text_input(
961 &self,
962 editor: &Entity<Editor>,
963 cx: &mut Context<Self>,
964 ) -> impl IntoElement {
965 let settings = ThemeSettings::get_global(cx);
966 let text_style = TextStyle {
967 color: if editor.read(cx).read_only(cx) {
968 cx.theme().colors().text_disabled
969 } else {
970 cx.theme().colors().text
971 },
972 font_family: settings.ui_font.family.clone(),
973 font_features: settings.ui_font.features.clone(),
974 font_fallbacks: settings.ui_font.fallbacks.clone(),
975 font_size: rems(0.875).into(),
976 font_weight: settings.ui_font.weight,
977 line_height: relative(1.3),
978 ..Default::default()
979 };
980
981 EditorElement::new(
982 editor,
983 EditorStyle {
984 background: cx.theme().colors().editor_background,
985 local_player: cx.theme().players().local(),
986 text: text_style,
987 ..Default::default()
988 },
989 )
990 }
991
992 fn on_query_change(
993 &mut self,
994 _: Entity<Editor>,
995 event: &editor::EditorEvent,
996 cx: &mut Context<Self>,
997 ) {
998 if let editor::EditorEvent::Edited { .. } = event {
999 self.query_contains_error = false;
1000 self.refresh_search(cx);
1001 }
1002 }
1003
1004 fn refresh_search(&mut self, cx: &mut Context<Self>) {
1005 self.fetch_extensions_debounced(cx);
1006 self.refresh_feature_upsells(cx);
1007 }
1008
1009 pub fn change_provides_filter(
1010 &mut self,
1011 provides_filter: Option<ExtensionProvides>,
1012 cx: &mut Context<Self>,
1013 ) {
1014 self.provides_filter = provides_filter;
1015 self.refresh_search(cx);
1016 }
1017
1018 fn fetch_extensions_debounced(&mut self, cx: &mut Context<ExtensionsPage>) {
1019 self.extension_fetch_task = Some(cx.spawn(async move |this, cx| {
1020 let search = this
1021 .update(cx, |this, cx| this.search_query(cx))
1022 .ok()
1023 .flatten();
1024
1025 // Only debounce the fetching of extensions if we have a search
1026 // query.
1027 //
1028 // If the search was just cleared then we can just reload the list
1029 // of extensions without a debounce, which allows us to avoid seeing
1030 // an intermittent flash of a "no extensions" state.
1031 if search.is_some() {
1032 cx.background_executor()
1033 .timer(Duration::from_millis(250))
1034 .await;
1035 };
1036
1037 this.update(cx, |this, cx| {
1038 this.fetch_extensions(search, Some(BTreeSet::from_iter(this.provides_filter)), cx);
1039 })
1040 .ok();
1041 }));
1042 }
1043
1044 pub fn search_query(&self, cx: &mut App) -> Option<String> {
1045 let search = self.query_editor.read(cx).text(cx);
1046 if search.trim().is_empty() {
1047 None
1048 } else {
1049 Some(search)
1050 }
1051 }
1052
1053 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1054 let has_search = self.search_query(cx).is_some();
1055
1056 let message = if self.is_fetching_extensions {
1057 "Loading extensions..."
1058 } else {
1059 match self.filter {
1060 ExtensionFilter::All => {
1061 if has_search {
1062 "No extensions that match your search."
1063 } else {
1064 "No extensions."
1065 }
1066 }
1067 ExtensionFilter::Installed => {
1068 if has_search {
1069 "No installed extensions that match your search."
1070 } else {
1071 "No installed extensions."
1072 }
1073 }
1074 ExtensionFilter::NotInstalled => {
1075 if has_search {
1076 "No not installed extensions that match your search."
1077 } else {
1078 "No not installed extensions."
1079 }
1080 }
1081 }
1082 };
1083
1084 Label::new(message)
1085 }
1086
1087 fn update_settings<T: Settings>(
1088 &mut self,
1089 selection: &ToggleState,
1090
1091 cx: &mut Context<Self>,
1092 callback: impl 'static + Send + Fn(&mut T::FileContent, bool),
1093 ) {
1094 if let Some(workspace) = self.workspace.upgrade() {
1095 let fs = workspace.read(cx).app_state().fs.clone();
1096 let selection = *selection;
1097 settings::update_settings_file::<T>(fs, cx, move |settings, _| {
1098 let value = match selection {
1099 ToggleState::Unselected => false,
1100 ToggleState::Selected => true,
1101 _ => return,
1102 };
1103
1104 callback(settings, value)
1105 });
1106 }
1107 }
1108
1109 fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1110 let Some(search) = self.search_query(cx) else {
1111 self.upsells.clear();
1112 return;
1113 };
1114
1115 let search = search.to_lowercase();
1116 let search_terms = search
1117 .split_whitespace()
1118 .map(|term| term.trim())
1119 .collect::<Vec<_>>();
1120
1121 for (feature, keywords) in keywords_by_feature() {
1122 if keywords
1123 .iter()
1124 .any(|keyword| search_terms.contains(keyword))
1125 {
1126 self.upsells.insert(*feature);
1127 } else {
1128 self.upsells.remove(feature);
1129 }
1130 }
1131 }
1132
1133 fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1134 let upsells_count = self.upsells.len();
1135
1136 v_flex().children(self.upsells.iter().enumerate().map(|(ix, feature)| {
1137 let upsell = match feature {
1138 Feature::Git => FeatureUpsell::new(
1139 "Zed comes with basic Git support. More Git features are coming in the future.",
1140 )
1141 .docs_url("https://zed.dev/docs/git"),
1142 Feature::OpenIn => FeatureUpsell::new(
1143 "Zed supports linking to a source line on GitHub and others.",
1144 )
1145 .docs_url("https://zed.dev/docs/git#git-integrations"),
1146 Feature::Vim => FeatureUpsell::new("Vim support is built-in to Zed!")
1147 .docs_url("https://zed.dev/docs/vim")
1148 .child(CheckboxWithLabel::new(
1149 "enable-vim",
1150 Label::new("Enable vim mode"),
1151 if VimModeSetting::get_global(cx).0 {
1152 ui::ToggleState::Selected
1153 } else {
1154 ui::ToggleState::Unselected
1155 },
1156 cx.listener(move |this, selection, _, cx| {
1157 telemetry::event!("Vim Mode Toggled", source = "Feature Upsell");
1158 this.update_settings::<VimModeSetting>(
1159 selection,
1160 cx,
1161 |setting, value| *setting = Some(value),
1162 );
1163 }),
1164 )),
1165 Feature::LanguageBash => FeatureUpsell::new("Shell support is built-in to Zed!")
1166 .docs_url("https://zed.dev/docs/languages/bash"),
1167 Feature::LanguageC => FeatureUpsell::new("C support is built-in to Zed!")
1168 .docs_url("https://zed.dev/docs/languages/c"),
1169 Feature::LanguageCpp => FeatureUpsell::new("C++ support is built-in to Zed!")
1170 .docs_url("https://zed.dev/docs/languages/cpp"),
1171 Feature::LanguageGo => FeatureUpsell::new("Go support is built-in to Zed!")
1172 .docs_url("https://zed.dev/docs/languages/go"),
1173 Feature::LanguagePython => FeatureUpsell::new("Python support is built-in to Zed!")
1174 .docs_url("https://zed.dev/docs/languages/python"),
1175 Feature::LanguageReact => FeatureUpsell::new("React support is built-in to Zed!")
1176 .docs_url("https://zed.dev/docs/languages/typescript"),
1177 Feature::LanguageRust => FeatureUpsell::new("Rust support is built-in to Zed!")
1178 .docs_url("https://zed.dev/docs/languages/rust"),
1179 Feature::LanguageTypescript => {
1180 FeatureUpsell::new("Typescript support is built-in to Zed!")
1181 .docs_url("https://zed.dev/docs/languages/typescript")
1182 }
1183 };
1184
1185 upsell.when(ix < upsells_count, |upsell| upsell.border_b_1())
1186 }))
1187 }
1188
1189 fn build_extension_provides_filter_menu(
1190 &self,
1191 window: &mut Window,
1192 cx: &mut Context<Self>,
1193 ) -> Entity<ContextMenu> {
1194 let this = cx.entity();
1195 ContextMenu::build(window, cx, |mut menu, _window, _cx| {
1196 menu = menu.header("Extension Category").toggleable_entry(
1197 "All",
1198 self.provides_filter.is_none(),
1199 IconPosition::End,
1200 None,
1201 {
1202 let this = this.clone();
1203 move |_window, cx| {
1204 this.update(cx, |this, cx| {
1205 this.change_provides_filter(None, cx);
1206 });
1207 }
1208 },
1209 );
1210
1211 for provides in ExtensionProvides::iter() {
1212 let label = extension_provides_label(provides);
1213
1214 menu = menu.toggleable_entry(
1215 label,
1216 self.provides_filter == Some(provides),
1217 IconPosition::End,
1218 None,
1219 {
1220 let this = this.clone();
1221 move |_window, cx| {
1222 this.update(cx, |this, cx| {
1223 this.change_provides_filter(Some(provides), cx);
1224 this.provides_filter = Some(provides);
1225 });
1226 }
1227 },
1228 )
1229 }
1230
1231 menu
1232 })
1233 }
1234}
1235
1236impl Render for ExtensionsPage {
1237 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1238 v_flex()
1239 .size_full()
1240 .bg(cx.theme().colors().editor_background)
1241 .child(
1242 v_flex()
1243 .gap_4()
1244 .p_4()
1245 .border_b_1()
1246 .border_color(cx.theme().colors().border)
1247 .bg(cx.theme().colors().editor_background)
1248 .child(
1249 h_flex()
1250 .w_full()
1251 .gap_2()
1252 .justify_between()
1253 .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
1254 .child(
1255 Button::new("install-dev-extension", "Install Dev Extension")
1256 .style(ButtonStyle::Filled)
1257 .size(ButtonSize::Large)
1258 .on_click(|_event, window, cx| {
1259 window.dispatch_action(Box::new(InstallDevExtension), cx)
1260 }),
1261 ),
1262 )
1263 .child(
1264 h_flex()
1265 .w_full()
1266 .gap_2()
1267 .justify_between()
1268 .child(h_flex().gap_2().child(self.render_search(cx)).child({
1269 let this = cx.entity().clone();
1270 PopoverMenu::new("extension-provides-filter")
1271 .menu(move |window, cx| {
1272 Some(this.update(cx, |this, cx| {
1273 this.build_extension_provides_filter_menu(window, cx)
1274 }))
1275 })
1276 .trigger_with_tooltip(
1277 Button::new(
1278 "extension-provides-filter-button",
1279 self.provides_filter
1280 .map(extension_provides_label)
1281 .unwrap_or("All"),
1282 )
1283 .icon(IconName::Filter)
1284 .icon_position(IconPosition::Start),
1285 Tooltip::text("Filter extensions by category"),
1286 )
1287 .anchor(gpui::Corner::TopLeft)
1288 }))
1289 .child(
1290 h_flex()
1291 .child(
1292 ToggleButton::new("filter-all", "All")
1293 .style(ButtonStyle::Filled)
1294 .size(ButtonSize::Large)
1295 .toggle_state(self.filter == ExtensionFilter::All)
1296 .on_click(cx.listener(|this, _event, _, cx| {
1297 this.filter = ExtensionFilter::All;
1298 this.filter_extension_entries(cx);
1299 }))
1300 .tooltip(move |_, cx| {
1301 Tooltip::simple("Show all extensions", cx)
1302 })
1303 .first(),
1304 )
1305 .child(
1306 ToggleButton::new("filter-installed", "Installed")
1307 .style(ButtonStyle::Filled)
1308 .size(ButtonSize::Large)
1309 .toggle_state(self.filter == ExtensionFilter::Installed)
1310 .on_click(cx.listener(|this, _event, _, cx| {
1311 this.filter = ExtensionFilter::Installed;
1312 this.filter_extension_entries(cx);
1313 }))
1314 .tooltip(move |_, cx| {
1315 Tooltip::simple("Show installed extensions", cx)
1316 })
1317 .middle(),
1318 )
1319 .child(
1320 ToggleButton::new("filter-not-installed", "Not Installed")
1321 .style(ButtonStyle::Filled)
1322 .size(ButtonSize::Large)
1323 .toggle_state(
1324 self.filter == ExtensionFilter::NotInstalled,
1325 )
1326 .on_click(cx.listener(|this, _event, _, cx| {
1327 this.filter = ExtensionFilter::NotInstalled;
1328 this.filter_extension_entries(cx);
1329 }))
1330 .tooltip(move |_, cx| {
1331 Tooltip::simple("Show not installed extensions", cx)
1332 })
1333 .last(),
1334 ),
1335 ),
1336 ),
1337 )
1338 .child(self.render_feature_upsells(cx))
1339 .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
1340 let mut count = self.filtered_remote_extension_indices.len();
1341 if self.filter.include_dev_extensions() {
1342 count += self.dev_extension_entries.len();
1343 }
1344
1345 if count == 0 {
1346 return this.py_4().child(self.render_empty_state(cx));
1347 }
1348
1349 let extensions_page = cx.entity().clone();
1350 let scroll_handle = self.list.clone();
1351 this.child(
1352 uniform_list(extensions_page, "entries", count, Self::render_extensions)
1353 .flex_grow()
1354 .pb_4()
1355 .track_scroll(scroll_handle),
1356 )
1357 }))
1358 }
1359}
1360
1361impl EventEmitter<ItemEvent> for ExtensionsPage {}
1362
1363impl Focusable for ExtensionsPage {
1364 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1365 self.query_editor.read(cx).focus_handle(cx)
1366 }
1367}
1368
1369impl Item for ExtensionsPage {
1370 type Event = ItemEvent;
1371
1372 fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option<SharedString> {
1373 Some("Extensions".into())
1374 }
1375
1376 fn telemetry_event_text(&self) -> Option<&'static str> {
1377 Some("Extensions Page Opened")
1378 }
1379
1380 fn show_toolbar(&self) -> bool {
1381 false
1382 }
1383
1384 fn clone_on_split(
1385 &self,
1386 _workspace_id: Option<WorkspaceId>,
1387 _window: &mut Window,
1388 _: &mut Context<Self>,
1389 ) -> Option<Entity<Self>> {
1390 None
1391 }
1392
1393 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
1394 f(*event)
1395 }
1396}