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