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