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