extensions_ui.rs

  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}