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