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