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