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