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