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