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_1().children(
722 extension
723 .manifest
724 .provides
725 .iter()
726 .filter_map(|provides| {
727 match provides {
728 ExtensionProvides::SlashCommands
729 | ExtensionProvides::IndexedDocsProviders => {
730 return None;
731 }
732 _ => {}
733 }
734
735 Some(
736 div()
737 .px_1()
738 .border_1()
739 .rounded_sm()
740 .border_color(cx.theme().colors().border)
741 .bg(cx.theme().colors().element_background)
742 .child(
743 Label::new(extension_provides_label(
744 *provides,
745 ))
746 .size(LabelSize::XSmall),
747 ),
748 )
749 })
750 .collect::<Vec<_>>(),
751 ),
752 )
753 }),
754 )
755 .child(
756 h_flex()
757 .gap_1()
758 .children(buttons.upgrade)
759 .children(buttons.configure)
760 .child(buttons.install_or_uninstall),
761 ),
762 )
763 .child(
764 h_flex()
765 .gap_2()
766 .justify_between()
767 .child(
768 Label::new(format!(
769 "{}: {}",
770 if extension.manifest.authors.len() > 1 {
771 "Authors"
772 } else {
773 "Author"
774 },
775 extension.manifest.authors.join(", ")
776 ))
777 .size(LabelSize::Small)
778 .color(Color::Muted)
779 .truncate(),
780 )
781 .child(
782 Label::new(format!(
783 "Downloads: {}",
784 extension.download_count.to_formatted_string(&Locale::en)
785 ))
786 .size(LabelSize::Small),
787 ),
788 )
789 .child(
790 h_flex()
791 .gap_2()
792 .justify_between()
793 .children(extension.manifest.description.as_ref().map(|description| {
794 Label::new(description.clone())
795 .size(LabelSize::Small)
796 .color(Color::Default)
797 .truncate()
798 }))
799 .child(
800 h_flex()
801 .gap_2()
802 .child(
803 IconButton::new(
804 SharedString::from(format!("repository-{}", extension.id)),
805 IconName::Github,
806 )
807 .icon_color(Color::Accent)
808 .icon_size(IconSize::Small)
809 .on_click(cx.listener({
810 let repository_url = repository_url.clone();
811 move |_, _, _, cx| {
812 cx.open_url(&repository_url);
813 }
814 }))
815 .tooltip(Tooltip::text(repository_url.clone())),
816 )
817 .child(
818 PopoverMenu::new(SharedString::from(format!(
819 "more-{}",
820 extension.id
821 )))
822 .trigger(
823 IconButton::new(
824 SharedString::from(format!("more-{}", extension.id)),
825 IconName::Ellipsis,
826 )
827 .icon_color(Color::Accent)
828 .icon_size(IconSize::Small),
829 )
830 .menu(move |window, cx| {
831 Some(Self::render_remote_extension_context_menu(
832 &this,
833 extension_id.clone(),
834 authors.clone(),
835 window,
836 cx,
837 ))
838 }),
839 ),
840 ),
841 )
842 }
843
844 fn render_remote_extension_context_menu(
845 this: &Entity<Self>,
846 extension_id: Arc<str>,
847 authors: Vec<String>,
848 window: &mut Window,
849 cx: &mut App,
850 ) -> Entity<ContextMenu> {
851 let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| {
852 context_menu
853 .entry(
854 "Install Another Version...",
855 None,
856 window.handler_for(this, {
857 let extension_id = extension_id.clone();
858 move |this, window, cx| {
859 this.show_extension_version_list(extension_id.clone(), window, cx)
860 }
861 }),
862 )
863 .entry("Copy Extension ID", None, {
864 let extension_id = extension_id.clone();
865 move |_, cx| {
866 cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
867 }
868 })
869 .entry("Copy Author Info", None, {
870 let authors = authors.clone();
871 move |_, cx| {
872 cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", ")));
873 }
874 })
875 });
876
877 context_menu
878 }
879
880 fn show_extension_version_list(
881 &mut self,
882 extension_id: Arc<str>,
883 window: &mut Window,
884 cx: &mut Context<Self>,
885 ) {
886 let Some(workspace) = self.workspace.upgrade() else {
887 return;
888 };
889
890 cx.spawn_in(window, async move |this, cx| {
891 let extension_versions_task = this.update(cx, |_, cx| {
892 let extension_store = ExtensionStore::global(cx);
893
894 extension_store.update(cx, |store, cx| {
895 store.fetch_extension_versions(&extension_id, cx)
896 })
897 })?;
898
899 let extension_versions = extension_versions_task.await?;
900
901 workspace.update_in(cx, |workspace, window, cx| {
902 let fs = workspace.project().read(cx).fs().clone();
903 workspace.toggle_modal(window, cx, |window, cx| {
904 let delegate = ExtensionVersionSelectorDelegate::new(
905 fs,
906 cx.entity().downgrade(),
907 extension_versions,
908 );
909
910 ExtensionVersionSelector::new(delegate, window, cx)
911 });
912 })?;
913
914 anyhow::Ok(())
915 })
916 .detach_and_log_err(cx);
917 }
918
919 fn buttons_for_entry(
920 &self,
921 extension: &ExtensionMetadata,
922 status: &ExtensionStatus,
923 has_dev_extension: bool,
924 cx: &mut Context<Self>,
925 ) -> ExtensionCardButtons {
926 let is_compatible =
927 extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
928
929 if has_dev_extension {
930 // If we have a dev extension for the given extension, just treat it as uninstalled.
931 // The button here is a placeholder, as it won't be interactable anyways.
932 return ExtensionCardButtons {
933 install_or_uninstall: Button::new(
934 SharedString::from(extension.id.clone()),
935 "Install",
936 ),
937 configure: None,
938 upgrade: None,
939 };
940 }
941
942 let is_configurable = extension
943 .manifest
944 .provides
945 .contains(&ExtensionProvides::ContextServers);
946
947 match status.clone() {
948 ExtensionStatus::NotInstalled => ExtensionCardButtons {
949 install_or_uninstall: Button::new(
950 SharedString::from(extension.id.clone()),
951 "Install",
952 )
953 .on_click({
954 let extension_id = extension.id.clone();
955 move |_, _, cx| {
956 telemetry::event!("Extension Installed");
957 ExtensionStore::global(cx).update(cx, |store, cx| {
958 store.install_latest_extension(extension_id.clone(), cx)
959 });
960 }
961 }),
962 configure: None,
963 upgrade: None,
964 },
965 ExtensionStatus::Installing => ExtensionCardButtons {
966 install_or_uninstall: Button::new(
967 SharedString::from(extension.id.clone()),
968 "Install",
969 )
970 .disabled(true),
971 configure: None,
972 upgrade: None,
973 },
974 ExtensionStatus::Upgrading => ExtensionCardButtons {
975 install_or_uninstall: Button::new(
976 SharedString::from(extension.id.clone()),
977 "Uninstall",
978 )
979 .disabled(true),
980 configure: is_configurable.then(|| {
981 Button::new(
982 SharedString::from(format!("configure-{}", extension.id)),
983 "Configure",
984 )
985 .disabled(true)
986 }),
987 upgrade: Some(
988 Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
989 ),
990 },
991 ExtensionStatus::Installed(installed_version) => ExtensionCardButtons {
992 install_or_uninstall: Button::new(
993 SharedString::from(extension.id.clone()),
994 "Uninstall",
995 )
996 .on_click({
997 let extension_id = extension.id.clone();
998 move |_, _, cx| {
999 telemetry::event!("Extension Uninstalled", extension_id);
1000 ExtensionStore::global(cx).update(cx, |store, cx| {
1001 store
1002 .uninstall_extension(extension_id.clone(), cx)
1003 .detach_and_log_err(cx);
1004 });
1005 }
1006 }),
1007 configure: is_configurable.then(|| {
1008 Button::new(
1009 SharedString::from(format!("configure-{}", extension.id)),
1010 "Configure",
1011 )
1012 .on_click({
1013 let extension_id = extension.id.clone();
1014 move |_, _, cx| {
1015 if let Some(manifest) = ExtensionStore::global(cx)
1016 .read(cx)
1017 .extension_manifest_for_id(&extension_id)
1018 .cloned()
1019 {
1020 if let Some(events) = extension::ExtensionEvents::try_global(cx) {
1021 events.update(cx, |this, cx| {
1022 this.emit(
1023 extension::Event::ConfigureExtensionRequested(manifest),
1024 cx,
1025 )
1026 });
1027 }
1028 }
1029 }
1030 })
1031 }),
1032 upgrade: if installed_version == extension.manifest.version {
1033 None
1034 } else {
1035 Some(
1036 Button::new(SharedString::from(extension.id.clone()), "Upgrade")
1037 .when(!is_compatible, |upgrade_button| {
1038 upgrade_button.disabled(true).tooltip({
1039 let version = extension.manifest.version.clone();
1040 move |_, cx| {
1041 Tooltip::simple(
1042 format!(
1043 "v{version} is not compatible with this version of Zed.",
1044 ),
1045 cx,
1046 )
1047 }
1048 })
1049 })
1050 .disabled(!is_compatible)
1051 .on_click({
1052 let extension_id = extension.id.clone();
1053 let version = extension.manifest.version.clone();
1054 move |_, _, cx| {
1055 telemetry::event!("Extension Installed", extension_id, version);
1056 ExtensionStore::global(cx).update(cx, |store, cx| {
1057 store
1058 .upgrade_extension(
1059 extension_id.clone(),
1060 version.clone(),
1061 cx,
1062 )
1063 .detach_and_log_err(cx)
1064 });
1065 }
1066 }),
1067 )
1068 },
1069 },
1070 ExtensionStatus::Removing => ExtensionCardButtons {
1071 install_or_uninstall: Button::new(
1072 SharedString::from(extension.id.clone()),
1073 "Uninstall",
1074 )
1075 .disabled(true),
1076 configure: is_configurable.then(|| {
1077 Button::new(
1078 SharedString::from(format!("configure-{}", extension.id)),
1079 "Configure",
1080 )
1081 .disabled(true)
1082 }),
1083 upgrade: None,
1084 },
1085 }
1086 }
1087
1088 fn render_search(&self, cx: &mut Context<Self>) -> Div {
1089 let mut key_context = KeyContext::new_with_defaults();
1090 key_context.add("BufferSearchBar");
1091
1092 let editor_border = if self.query_contains_error {
1093 Color::Error.color(cx)
1094 } else {
1095 cx.theme().colors().border
1096 };
1097
1098 h_flex()
1099 .key_context(key_context)
1100 .h_8()
1101 .flex_1()
1102 .min_w(rems_from_px(384.))
1103 .pl_1p5()
1104 .pr_2()
1105 .py_1()
1106 .gap_2()
1107 .border_1()
1108 .border_color(editor_border)
1109 .rounded_lg()
1110 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
1111 .child(self.render_text_input(&self.query_editor, cx))
1112 }
1113
1114 fn render_text_input(
1115 &self,
1116 editor: &Entity<Editor>,
1117 cx: &mut Context<Self>,
1118 ) -> impl IntoElement {
1119 let settings = ThemeSettings::get_global(cx);
1120 let text_style = TextStyle {
1121 color: if editor.read(cx).read_only(cx) {
1122 cx.theme().colors().text_disabled
1123 } else {
1124 cx.theme().colors().text
1125 },
1126 font_family: settings.ui_font.family.clone(),
1127 font_features: settings.ui_font.features.clone(),
1128 font_fallbacks: settings.ui_font.fallbacks.clone(),
1129 font_size: rems(0.875).into(),
1130 font_weight: settings.ui_font.weight,
1131 line_height: relative(1.3),
1132 ..Default::default()
1133 };
1134
1135 EditorElement::new(
1136 editor,
1137 EditorStyle {
1138 background: cx.theme().colors().editor_background,
1139 local_player: cx.theme().players().local(),
1140 text: text_style,
1141 ..Default::default()
1142 },
1143 )
1144 }
1145
1146 fn on_query_change(
1147 &mut self,
1148 _: Entity<Editor>,
1149 event: &editor::EditorEvent,
1150 cx: &mut Context<Self>,
1151 ) {
1152 if let editor::EditorEvent::Edited { .. } = event {
1153 self.query_contains_error = false;
1154 self.refresh_search(cx);
1155 }
1156 }
1157
1158 fn refresh_search(&mut self, cx: &mut Context<Self>) {
1159 self.fetch_extensions_debounced(
1160 Some(Box::new(|this, cx| {
1161 this.scroll_to_top(cx);
1162 })),
1163 cx,
1164 );
1165 self.refresh_feature_upsells(cx);
1166 }
1167
1168 pub fn change_provides_filter(
1169 &mut self,
1170 provides_filter: Option<ExtensionProvides>,
1171 cx: &mut Context<Self>,
1172 ) {
1173 self.provides_filter = provides_filter;
1174 self.refresh_search(cx);
1175 }
1176
1177 fn fetch_extensions_debounced(
1178 &mut self,
1179 on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
1180 cx: &mut Context<ExtensionsPage>,
1181 ) {
1182 self.extension_fetch_task = Some(cx.spawn(async move |this, cx| {
1183 let search = this
1184 .update(cx, |this, cx| this.search_query(cx))
1185 .ok()
1186 .flatten();
1187
1188 // Only debounce the fetching of extensions if we have a search
1189 // query.
1190 //
1191 // If the search was just cleared then we can just reload the list
1192 // of extensions without a debounce, which allows us to avoid seeing
1193 // an intermittent flash of a "no extensions" state.
1194 if search.is_some() {
1195 cx.background_executor()
1196 .timer(Duration::from_millis(250))
1197 .await;
1198 };
1199
1200 this.update(cx, |this, cx| {
1201 this.fetch_extensions(
1202 search,
1203 Some(BTreeSet::from_iter(this.provides_filter)),
1204 on_complete,
1205 cx,
1206 );
1207 })
1208 .ok();
1209 }));
1210 }
1211
1212 pub fn search_query(&self, cx: &mut App) -> Option<String> {
1213 let search = self.query_editor.read(cx).text(cx);
1214 if search.trim().is_empty() {
1215 None
1216 } else {
1217 Some(search)
1218 }
1219 }
1220
1221 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1222 let has_search = self.search_query(cx).is_some();
1223
1224 let message = if self.is_fetching_extensions {
1225 "Loading extensions..."
1226 } else {
1227 match self.filter {
1228 ExtensionFilter::All => {
1229 if has_search {
1230 "No extensions that match your search."
1231 } else {
1232 "No extensions."
1233 }
1234 }
1235 ExtensionFilter::Installed => {
1236 if has_search {
1237 "No installed extensions that match your search."
1238 } else {
1239 "No installed extensions."
1240 }
1241 }
1242 ExtensionFilter::NotInstalled => {
1243 if has_search {
1244 "No not installed extensions that match your search."
1245 } else {
1246 "No not installed extensions."
1247 }
1248 }
1249 }
1250 };
1251
1252 Label::new(message)
1253 }
1254
1255 fn update_settings<T: Settings>(
1256 &mut self,
1257 selection: &ToggleState,
1258
1259 cx: &mut Context<Self>,
1260 callback: impl 'static + Send + Fn(&mut T::FileContent, bool),
1261 ) {
1262 if let Some(workspace) = self.workspace.upgrade() {
1263 let fs = workspace.read(cx).app_state().fs.clone();
1264 let selection = *selection;
1265 settings::update_settings_file::<T>(fs, cx, move |settings, _| {
1266 let value = match selection {
1267 ToggleState::Unselected => false,
1268 ToggleState::Selected => true,
1269 _ => return,
1270 };
1271
1272 callback(settings, value)
1273 });
1274 }
1275 }
1276
1277 fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1278 let Some(search) = self.search_query(cx) else {
1279 self.upsells.clear();
1280 return;
1281 };
1282
1283 let search = search.to_lowercase();
1284 let search_terms = search
1285 .split_whitespace()
1286 .map(|term| term.trim())
1287 .collect::<Vec<_>>();
1288
1289 for (feature, keywords) in keywords_by_feature() {
1290 if keywords
1291 .iter()
1292 .any(|keyword| search_terms.contains(keyword))
1293 {
1294 self.upsells.insert(*feature);
1295 } else {
1296 self.upsells.remove(feature);
1297 }
1298 }
1299 }
1300
1301 fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1302 let upsells_count = self.upsells.len();
1303
1304 v_flex().children(self.upsells.iter().enumerate().map(|(ix, feature)| {
1305 let upsell = match feature {
1306 Feature::Git => FeatureUpsell::new(
1307 "Zed comes with basic Git support. More Git features are coming in the future.",
1308 )
1309 .docs_url("https://zed.dev/docs/git"),
1310 Feature::OpenIn => FeatureUpsell::new(
1311 "Zed supports linking to a source line on GitHub and others.",
1312 )
1313 .docs_url("https://zed.dev/docs/git#git-integrations"),
1314 Feature::Vim => FeatureUpsell::new("Vim support is built-in to Zed!")
1315 .docs_url("https://zed.dev/docs/vim")
1316 .child(CheckboxWithLabel::new(
1317 "enable-vim",
1318 Label::new("Enable vim mode"),
1319 if VimModeSetting::get_global(cx).0 {
1320 ui::ToggleState::Selected
1321 } else {
1322 ui::ToggleState::Unselected
1323 },
1324 cx.listener(move |this, selection, _, cx| {
1325 telemetry::event!("Vim Mode Toggled", source = "Feature Upsell");
1326 this.update_settings::<VimModeSetting>(
1327 selection,
1328 cx,
1329 |setting, value| *setting = Some(value),
1330 );
1331 }),
1332 )),
1333 Feature::LanguageBash => FeatureUpsell::new("Shell support is built-in to Zed!")
1334 .docs_url("https://zed.dev/docs/languages/bash"),
1335 Feature::LanguageC => FeatureUpsell::new("C support is built-in to Zed!")
1336 .docs_url("https://zed.dev/docs/languages/c"),
1337 Feature::LanguageCpp => FeatureUpsell::new("C++ support is built-in to Zed!")
1338 .docs_url("https://zed.dev/docs/languages/cpp"),
1339 Feature::LanguageGo => FeatureUpsell::new("Go support is built-in to Zed!")
1340 .docs_url("https://zed.dev/docs/languages/go"),
1341 Feature::LanguagePython => FeatureUpsell::new("Python support is built-in to Zed!")
1342 .docs_url("https://zed.dev/docs/languages/python"),
1343 Feature::LanguageReact => FeatureUpsell::new("React support is built-in to Zed!")
1344 .docs_url("https://zed.dev/docs/languages/typescript"),
1345 Feature::LanguageRust => FeatureUpsell::new("Rust support is built-in to Zed!")
1346 .docs_url("https://zed.dev/docs/languages/rust"),
1347 Feature::LanguageTypescript => {
1348 FeatureUpsell::new("Typescript support is built-in to Zed!")
1349 .docs_url("https://zed.dev/docs/languages/typescript")
1350 }
1351 };
1352
1353 upsell.when(ix < upsells_count, |upsell| upsell.border_b_1())
1354 }))
1355 }
1356}
1357
1358impl Render for ExtensionsPage {
1359 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1360 v_flex()
1361 .size_full()
1362 .bg(cx.theme().colors().editor_background)
1363 .child(
1364 v_flex()
1365 .gap_4()
1366 .pt_4()
1367 .px_4()
1368 .bg(cx.theme().colors().editor_background)
1369 .child(
1370 h_flex()
1371 .w_full()
1372 .gap_2()
1373 .justify_between()
1374 .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
1375 .child(
1376 Button::new("install-dev-extension", "Install Dev Extension")
1377 .style(ButtonStyle::Filled)
1378 .size(ButtonSize::Large)
1379 .on_click(|_event, window, cx| {
1380 window.dispatch_action(Box::new(InstallDevExtension), cx)
1381 }),
1382 ),
1383 )
1384 .child(
1385 h_flex()
1386 .w_full()
1387 .gap_4()
1388 .flex_wrap()
1389 .child(self.render_search(cx))
1390 .child(
1391 h_flex()
1392 .child(
1393 ToggleButton::new("filter-all", "All")
1394 .style(ButtonStyle::Filled)
1395 .size(ButtonSize::Large)
1396 .toggle_state(self.filter == ExtensionFilter::All)
1397 .on_click(cx.listener(|this, _event, _, cx| {
1398 this.filter = ExtensionFilter::All;
1399 this.filter_extension_entries(cx);
1400 this.scroll_to_top(cx);
1401 }))
1402 .tooltip(move |_, cx| {
1403 Tooltip::simple("Show all extensions", cx)
1404 })
1405 .first(),
1406 )
1407 .child(
1408 ToggleButton::new("filter-installed", "Installed")
1409 .style(ButtonStyle::Filled)
1410 .size(ButtonSize::Large)
1411 .toggle_state(self.filter == ExtensionFilter::Installed)
1412 .on_click(cx.listener(|this, _event, _, cx| {
1413 this.filter = ExtensionFilter::Installed;
1414 this.filter_extension_entries(cx);
1415 this.scroll_to_top(cx);
1416 }))
1417 .tooltip(move |_, cx| {
1418 Tooltip::simple("Show installed extensions", cx)
1419 })
1420 .middle(),
1421 )
1422 .child(
1423 ToggleButton::new("filter-not-installed", "Not Installed")
1424 .style(ButtonStyle::Filled)
1425 .size(ButtonSize::Large)
1426 .toggle_state(
1427 self.filter == ExtensionFilter::NotInstalled,
1428 )
1429 .on_click(cx.listener(|this, _event, _, cx| {
1430 this.filter = ExtensionFilter::NotInstalled;
1431 this.filter_extension_entries(cx);
1432 this.scroll_to_top(cx);
1433 }))
1434 .tooltip(move |_, cx| {
1435 Tooltip::simple("Show not installed extensions", cx)
1436 })
1437 .last(),
1438 ),
1439 ),
1440 ),
1441 )
1442 .child(
1443 h_flex()
1444 .id("filter-row")
1445 .gap_2()
1446 .py_2p5()
1447 .px_4()
1448 .border_b_1()
1449 .border_color(cx.theme().colors().border_variant)
1450 .overflow_x_scroll()
1451 .child(
1452 Button::new("filter-all-categories", "All")
1453 .when(self.provides_filter.is_none(), |button| {
1454 button.style(ButtonStyle::Filled)
1455 })
1456 .when(self.provides_filter.is_some(), |button| {
1457 button.style(ButtonStyle::Subtle)
1458 })
1459 .toggle_state(self.provides_filter.is_none())
1460 .on_click(cx.listener(|this, _event, _, cx| {
1461 this.change_provides_filter(None, cx);
1462 })),
1463 )
1464 .children(ExtensionProvides::iter().filter_map(|provides| {
1465 match provides {
1466 ExtensionProvides::SlashCommands
1467 | ExtensionProvides::IndexedDocsProviders => return None,
1468 _ => {}
1469 }
1470
1471 let label = extension_provides_label(provides);
1472 let button_id = SharedString::from(format!("filter-category-{}", label));
1473
1474 Some(
1475 Button::new(button_id, label)
1476 .style(if self.provides_filter == Some(provides) {
1477 ButtonStyle::Filled
1478 } else {
1479 ButtonStyle::Subtle
1480 })
1481 .toggle_state(self.provides_filter == Some(provides))
1482 .on_click({
1483 cx.listener(move |this, _event, _, cx| {
1484 this.change_provides_filter(Some(provides), cx);
1485 })
1486 }),
1487 )
1488 })),
1489 )
1490 .child(self.render_feature_upsells(cx))
1491 .child(
1492 v_flex()
1493 .pl_4()
1494 .pr_6()
1495 .size_full()
1496 .overflow_y_hidden()
1497 .map(|this| {
1498 let mut count = self.filtered_remote_extension_indices.len();
1499 if self.filter.include_dev_extensions() {
1500 count += self.dev_extension_entries.len();
1501 }
1502
1503 if count == 0 {
1504 return this.py_4().child(self.render_empty_state(cx));
1505 }
1506
1507 let scroll_handle = self.list.clone();
1508 this.child(
1509 uniform_list("entries", count, cx.processor(Self::render_extensions))
1510 .flex_grow()
1511 .pb_4()
1512 .track_scroll(scroll_handle),
1513 )
1514 .child(
1515 div()
1516 .absolute()
1517 .right_1()
1518 .top_0()
1519 .bottom_0()
1520 .w(px(12.))
1521 .children(Scrollbar::vertical(self.scrollbar_state.clone())),
1522 )
1523 }),
1524 )
1525 }
1526}
1527
1528impl EventEmitter<ItemEvent> for ExtensionsPage {}
1529
1530impl Focusable for ExtensionsPage {
1531 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1532 self.query_editor.read(cx).focus_handle(cx)
1533 }
1534}
1535
1536impl Item for ExtensionsPage {
1537 type Event = ItemEvent;
1538
1539 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1540 "Extensions".into()
1541 }
1542
1543 fn telemetry_event_text(&self) -> Option<&'static str> {
1544 Some("Extensions Page Opened")
1545 }
1546
1547 fn show_toolbar(&self) -> bool {
1548 false
1549 }
1550
1551 fn clone_on_split(
1552 &self,
1553 _workspace_id: Option<WorkspaceId>,
1554 _window: &mut Window,
1555 _: &mut Context<Self>,
1556 ) -> Option<Entity<Self>> {
1557 None
1558 }
1559
1560 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
1561 f(*event)
1562 }
1563}