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