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