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