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