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