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