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::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        let disabled = !is_compatible;
595
596        match status.clone() {
597            ExtensionStatus::NotInstalled => (
598                Button::new(SharedString::from(extension.id.clone()), "Install")
599                    .disabled(disabled)
600                    .on_click(cx.listener({
601                        let extension_id = extension.id.clone();
602                        move |this, _, cx| {
603                            this.telemetry
604                                .report_app_event("extensions: install extension".to_string());
605                            ExtensionStore::global(cx).update(cx, |store, cx| {
606                                store.install_latest_extension(extension_id.clone(), cx)
607                            });
608                        }
609                    })),
610                None,
611            ),
612            ExtensionStatus::Installing => (
613                Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
614                None,
615            ),
616            ExtensionStatus::Upgrading => (
617                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
618                Some(
619                    Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
620                ),
621            ),
622            ExtensionStatus::Installed(installed_version) => (
623                Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click(
624                    cx.listener({
625                        let extension_id = extension.id.clone();
626                        move |this, _, cx| {
627                            this.telemetry
628                                .report_app_event("extensions: uninstall extension".to_string());
629                            ExtensionStore::global(cx).update(cx, |store, cx| {
630                                store.uninstall_extension(extension_id.clone(), cx)
631                            });
632                        }
633                    }),
634                ),
635                if installed_version == extension.manifest.version {
636                    None
637                } else {
638                    Some(
639                        Button::new(SharedString::from(extension.id.clone()), "Upgrade")
640                            .disabled(disabled)
641                            .on_click(cx.listener({
642                                let extension_id = extension.id.clone();
643                                let version = extension.manifest.version.clone();
644                                move |this, _, cx| {
645                                    this.telemetry.report_app_event(
646                                        "extensions: install extension".to_string(),
647                                    );
648                                    ExtensionStore::global(cx).update(cx, |store, cx| {
649                                        store
650                                            .upgrade_extension(
651                                                extension_id.clone(),
652                                                version.clone(),
653                                                cx,
654                                            )
655                                            .detach_and_log_err(cx)
656                                    });
657                                }
658                            })),
659                    )
660                },
661            ),
662            ExtensionStatus::Removing => (
663                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
664                None,
665            ),
666        }
667    }
668
669    fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
670        let mut key_context = KeyContext::default();
671        key_context.add("BufferSearchBar");
672
673        let editor_border = if self.query_contains_error {
674            Color::Error.color(cx)
675        } else {
676            cx.theme().colors().border
677        };
678
679        h_flex()
680            .w_full()
681            .gap_2()
682            .key_context(key_context)
683            // .capture_action(cx.listener(Self::tab))
684            // .on_action(cx.listener(Self::dismiss))
685            .child(
686                h_flex()
687                    .flex_1()
688                    .px_2()
689                    .py_1()
690                    .gap_2()
691                    .border_1()
692                    .border_color(editor_border)
693                    .min_w(rems_from_px(384.))
694                    .rounded_lg()
695                    .child(Icon::new(IconName::MagnifyingGlass))
696                    .child(self.render_text_input(&self.query_editor, cx)),
697            )
698    }
699
700    fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
701        let settings = ThemeSettings::get_global(cx);
702        let text_style = TextStyle {
703            color: if editor.read(cx).read_only(cx) {
704                cx.theme().colors().text_disabled
705            } else {
706                cx.theme().colors().text
707            },
708            font_family: settings.ui_font.family.clone(),
709            font_features: settings.ui_font.features,
710            font_size: rems(0.875).into(),
711            font_weight: FontWeight::NORMAL,
712            font_style: FontStyle::Normal,
713            line_height: relative(1.3),
714            background_color: None,
715            underline: None,
716            strikethrough: None,
717            white_space: WhiteSpace::Normal,
718        };
719
720        EditorElement::new(
721            &editor,
722            EditorStyle {
723                background: cx.theme().colors().editor_background,
724                local_player: cx.theme().players().local(),
725                text: text_style,
726                ..Default::default()
727            },
728        )
729    }
730
731    fn on_query_change(
732        &mut self,
733        _: View<Editor>,
734        event: &editor::EditorEvent,
735        cx: &mut ViewContext<Self>,
736    ) {
737        if let editor::EditorEvent::Edited = event {
738            self.query_contains_error = false;
739            self.fetch_extensions_debounced(cx);
740        }
741    }
742
743    fn fetch_extensions_debounced(&mut self, cx: &mut ViewContext<'_, ExtensionsPage>) {
744        self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
745            let search = this
746                .update(&mut cx, |this, cx| this.search_query(cx))
747                .ok()
748                .flatten();
749
750            // Only debounce the fetching of extensions if we have a search
751            // query.
752            //
753            // If the search was just cleared then we can just reload the list
754            // of extensions without a debounce, which allows us to avoid seeing
755            // an intermittent flash of a "no extensions" state.
756            if let Some(_) = search {
757                cx.background_executor()
758                    .timer(Duration::from_millis(250))
759                    .await;
760            };
761
762            this.update(&mut cx, |this, cx| {
763                this.fetch_extensions(search, cx);
764            })
765            .ok();
766        }));
767    }
768
769    pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
770        let search = self.query_editor.read(cx).text(cx);
771        if search.trim().is_empty() {
772            None
773        } else {
774            Some(search)
775        }
776    }
777
778    fn render_empty_state(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
779        let has_search = self.search_query(cx).is_some();
780
781        let message = if self.is_fetching_extensions {
782            "Loading extensions..."
783        } else {
784            match self.filter {
785                ExtensionFilter::All => {
786                    if has_search {
787                        "No extensions that match your search."
788                    } else {
789                        "No extensions."
790                    }
791                }
792                ExtensionFilter::Installed => {
793                    if has_search {
794                        "No installed extensions that match your search."
795                    } else {
796                        "No installed extensions."
797                    }
798                }
799                ExtensionFilter::NotInstalled => {
800                    if has_search {
801                        "No not installed extensions that match your search."
802                    } else {
803                        "No not installed extensions."
804                    }
805                }
806            }
807        };
808
809        Label::new(message)
810    }
811}
812
813impl Render for ExtensionsPage {
814    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
815        v_flex()
816            .size_full()
817            .bg(cx.theme().colors().editor_background)
818            .child(
819                v_flex()
820                    .gap_4()
821                    .p_4()
822                    .border_b()
823                    .border_color(cx.theme().colors().border)
824                    .bg(cx.theme().colors().editor_background)
825                    .child(
826                        h_flex()
827                            .w_full()
828                            .gap_2()
829                            .justify_between()
830                            .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
831                            .child(
832                                Button::new("install-dev-extension", "Install Dev Extension")
833                                    .style(ButtonStyle::Filled)
834                                    .size(ButtonSize::Large)
835                                    .on_click(|_event, cx| {
836                                        cx.dispatch_action(Box::new(InstallDevExtension))
837                                    }),
838                            ),
839                    )
840                    .child(
841                        h_flex()
842                            .w_full()
843                            .gap_2()
844                            .justify_between()
845                            .child(h_flex().child(self.render_search(cx)))
846                            .child(
847                                h_flex()
848                                    .child(
849                                        ToggleButton::new("filter-all", "All")
850                                            .style(ButtonStyle::Filled)
851                                            .size(ButtonSize::Large)
852                                            .selected(self.filter == ExtensionFilter::All)
853                                            .on_click(cx.listener(|this, _event, cx| {
854                                                this.filter = ExtensionFilter::All;
855                                                this.filter_extension_entries(cx);
856                                            }))
857                                            .tooltip(move |cx| {
858                                                Tooltip::text("Show all extensions", cx)
859                                            })
860                                            .first(),
861                                    )
862                                    .child(
863                                        ToggleButton::new("filter-installed", "Installed")
864                                            .style(ButtonStyle::Filled)
865                                            .size(ButtonSize::Large)
866                                            .selected(self.filter == ExtensionFilter::Installed)
867                                            .on_click(cx.listener(|this, _event, cx| {
868                                                this.filter = ExtensionFilter::Installed;
869                                                this.filter_extension_entries(cx);
870                                            }))
871                                            .tooltip(move |cx| {
872                                                Tooltip::text("Show installed extensions", cx)
873                                            })
874                                            .middle(),
875                                    )
876                                    .child(
877                                        ToggleButton::new("filter-not-installed", "Not Installed")
878                                            .style(ButtonStyle::Filled)
879                                            .size(ButtonSize::Large)
880                                            .selected(self.filter == ExtensionFilter::NotInstalled)
881                                            .on_click(cx.listener(|this, _event, cx| {
882                                                this.filter = ExtensionFilter::NotInstalled;
883                                                this.filter_extension_entries(cx);
884                                            }))
885                                            .tooltip(move |cx| {
886                                                Tooltip::text("Show not installed extensions", cx)
887                                            })
888                                            .last(),
889                                    ),
890                            ),
891                    ),
892            )
893            .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
894                let mut count = self.filtered_remote_extension_indices.len();
895                if self.filter.include_dev_extensions() {
896                    count += self.dev_extension_entries.len();
897                }
898
899                if count == 0 {
900                    return this.py_4().child(self.render_empty_state(cx));
901                }
902
903                let view = cx.view().clone();
904                let scroll_handle = self.list.clone();
905                this.child(
906                    canvas(
907                        move |bounds, cx| {
908                            let mut list = uniform_list::<_, ExtensionCard, _>(
909                                view,
910                                "entries",
911                                count,
912                                Self::render_extensions,
913                            )
914                            .size_full()
915                            .pb_4()
916                            .track_scroll(scroll_handle)
917                            .into_any_element();
918                            list.layout(bounds.origin, bounds.size.into(), cx);
919                            list
920                        },
921                        |_bounds, mut list, cx| list.paint(cx),
922                    )
923                    .size_full(),
924                )
925            }))
926    }
927}
928
929impl EventEmitter<ItemEvent> for ExtensionsPage {}
930
931impl FocusableView for ExtensionsPage {
932    fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
933        self.query_editor.read(cx).focus_handle(cx)
934    }
935}
936
937impl Item for ExtensionsPage {
938    type Event = ItemEvent;
939
940    fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement {
941        Label::new("Extensions")
942            .color(if params.selected {
943                Color::Default
944            } else {
945                Color::Muted
946            })
947            .into_any_element()
948    }
949
950    fn telemetry_event_text(&self) -> Option<&'static str> {
951        Some("extensions page")
952    }
953
954    fn show_toolbar(&self) -> bool {
955        false
956    }
957
958    fn clone_on_split(
959        &self,
960        _workspace_id: WorkspaceId,
961        _: &mut ViewContext<Self>,
962    ) -> Option<View<Self>> {
963        None
964    }
965
966    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
967        f(*event)
968    }
969}