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