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