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