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 repository_url = extension.manifest.repository.clone();
425
426        ExtensionCard::new()
427            .child(
428                h_flex()
429                    .justify_between()
430                    .child(
431                        h_flex()
432                            .gap_2()
433                            .items_end()
434                            .child(
435                                Headline::new(extension.manifest.name.clone())
436                                    .size(HeadlineSize::Medium),
437                            )
438                            .child(
439                                Headline::new(format!("v{}", extension.manifest.version))
440                                    .size(HeadlineSize::XSmall),
441                            ),
442                    )
443                    .child(
444                        h_flex()
445                            .gap_2()
446                            .justify_between()
447                            .children(upgrade_button)
448                            .child(install_or_uninstall_button),
449                    ),
450            )
451            .child(
452                h_flex()
453                    .justify_between()
454                    .child(
455                        Label::new(format!(
456                            "{}: {}",
457                            if extension.manifest.authors.len() > 1 {
458                                "Authors"
459                            } else {
460                                "Author"
461                            },
462                            extension.manifest.authors.join(", ")
463                        ))
464                        .size(LabelSize::Small),
465                    )
466                    .child(
467                        Label::new(format!("Downloads: {}", extension.download_count))
468                            .size(LabelSize::Small),
469                    ),
470            )
471            .child(
472                h_flex()
473                    .gap_2()
474                    .justify_between()
475                    .children(extension.manifest.description.as_ref().map(|description| {
476                        h_flex().overflow_x_hidden().child(
477                            Label::new(description.clone())
478                                .size(LabelSize::Small)
479                                .color(Color::Default),
480                        )
481                    }))
482                    .child(
483                        h_flex()
484                            .gap_2()
485                            .child(
486                                IconButton::new(
487                                    SharedString::from(format!("repository-{}", extension.id)),
488                                    IconName::Github,
489                                )
490                                .icon_color(Color::Accent)
491                                .icon_size(IconSize::Small)
492                                .style(ButtonStyle::Filled)
493                                .on_click(cx.listener({
494                                    let repository_url = repository_url.clone();
495                                    move |_, _, cx| {
496                                        cx.open_url(&repository_url);
497                                    }
498                                }))
499                                .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx)),
500                            )
501                            .child(
502                                popover_menu(SharedString::from(format!("more-{}", extension.id)))
503                                    .trigger(
504                                        IconButton::new(
505                                            SharedString::from(format!("more-{}", extension.id)),
506                                            IconName::Ellipsis,
507                                        )
508                                        .icon_color(Color::Accent)
509                                        .icon_size(IconSize::Small)
510                                        .style(ButtonStyle::Filled),
511                                    )
512                                    .menu(move |cx| {
513                                        Some(Self::render_remote_extension_context_menu(
514                                            &this,
515                                            extension_id.clone(),
516                                            cx,
517                                        ))
518                                    }),
519                            ),
520                    ),
521            )
522    }
523
524    fn render_remote_extension_context_menu(
525        this: &View<Self>,
526        extension_id: Arc<str>,
527        cx: &mut WindowContext,
528    ) -> View<ContextMenu> {
529        let context_menu = ContextMenu::build(cx, |context_menu, cx| {
530            context_menu.entry(
531                "Install Another Version...",
532                None,
533                cx.handler_for(&this, move |this, cx| {
534                    this.show_extension_version_list(extension_id.clone(), cx)
535                }),
536            )
537        });
538
539        context_menu
540    }
541
542    fn show_extension_version_list(&mut self, extension_id: Arc<str>, cx: &mut ViewContext<Self>) {
543        let Some(workspace) = self.workspace.upgrade() else {
544            return;
545        };
546
547        cx.spawn(move |this, mut cx| async move {
548            let extension_versions_task = this.update(&mut cx, |_, cx| {
549                let extension_store = ExtensionStore::global(cx);
550
551                extension_store.update(cx, |store, cx| {
552                    store.fetch_extension_versions(&extension_id, cx)
553                })
554            })?;
555
556            let extension_versions = extension_versions_task.await?;
557
558            workspace.update(&mut cx, |workspace, cx| {
559                let fs = workspace.project().read(cx).fs().clone();
560                workspace.toggle_modal(cx, |cx| {
561                    let delegate = ExtensionVersionSelectorDelegate::new(
562                        fs,
563                        cx.view().downgrade(),
564                        extension_versions,
565                    );
566
567                    ExtensionVersionSelector::new(delegate, cx)
568                });
569            })?;
570
571            anyhow::Ok(())
572        })
573        .detach_and_log_err(cx);
574    }
575
576    fn buttons_for_entry(
577        &self,
578        extension: &ExtensionMetadata,
579        status: &ExtensionStatus,
580        cx: &mut ViewContext<Self>,
581    ) -> (Button, Option<Button>) {
582        let is_compatible = extension::is_version_compatible(&extension);
583        let disabled = !is_compatible;
584
585        match status.clone() {
586            ExtensionStatus::NotInstalled => (
587                Button::new(SharedString::from(extension.id.clone()), "Install")
588                    .disabled(disabled)
589                    .on_click(cx.listener({
590                        let extension_id = extension.id.clone();
591                        move |this, _, cx| {
592                            this.telemetry
593                                .report_app_event("extensions: install extension".to_string());
594                            ExtensionStore::global(cx).update(cx, |store, cx| {
595                                store.install_latest_extension(extension_id.clone(), cx)
596                            });
597                        }
598                    })),
599                None,
600            ),
601            ExtensionStatus::Installing => (
602                Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
603                None,
604            ),
605            ExtensionStatus::Upgrading => (
606                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
607                Some(
608                    Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
609                ),
610            ),
611            ExtensionStatus::Installed(installed_version) => (
612                Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click(
613                    cx.listener({
614                        let extension_id = extension.id.clone();
615                        move |this, _, cx| {
616                            this.telemetry
617                                .report_app_event("extensions: uninstall extension".to_string());
618                            ExtensionStore::global(cx).update(cx, |store, cx| {
619                                store.uninstall_extension(extension_id.clone(), cx)
620                            });
621                        }
622                    }),
623                ),
624                if installed_version == extension.manifest.version {
625                    None
626                } else {
627                    Some(
628                        Button::new(SharedString::from(extension.id.clone()), "Upgrade")
629                            .disabled(disabled)
630                            .on_click(cx.listener({
631                                let extension_id = extension.id.clone();
632                                let version = extension.manifest.version.clone();
633                                move |this, _, cx| {
634                                    this.telemetry.report_app_event(
635                                        "extensions: install extension".to_string(),
636                                    );
637                                    ExtensionStore::global(cx).update(cx, |store, cx| {
638                                        store
639                                            .upgrade_extension(
640                                                extension_id.clone(),
641                                                version.clone(),
642                                                cx,
643                                            )
644                                            .detach_and_log_err(cx)
645                                    });
646                                }
647                            })),
648                    )
649                },
650            ),
651            ExtensionStatus::Removing => (
652                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
653                None,
654            ),
655        }
656    }
657
658    fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
659        let mut key_context = KeyContext::default();
660        key_context.add("BufferSearchBar");
661
662        let editor_border = if self.query_contains_error {
663            Color::Error.color(cx)
664        } else {
665            cx.theme().colors().border
666        };
667
668        h_flex()
669            .w_full()
670            .gap_2()
671            .key_context(key_context)
672            // .capture_action(cx.listener(Self::tab))
673            // .on_action(cx.listener(Self::dismiss))
674            .child(
675                h_flex()
676                    .flex_1()
677                    .px_2()
678                    .py_1()
679                    .gap_2()
680                    .border_1()
681                    .border_color(editor_border)
682                    .min_w(rems_from_px(384.))
683                    .rounded_lg()
684                    .child(Icon::new(IconName::MagnifyingGlass))
685                    .child(self.render_text_input(&self.query_editor, cx)),
686            )
687    }
688
689    fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
690        let settings = ThemeSettings::get_global(cx);
691        let text_style = TextStyle {
692            color: if editor.read(cx).read_only(cx) {
693                cx.theme().colors().text_disabled
694            } else {
695                cx.theme().colors().text
696            },
697            font_family: settings.ui_font.family.clone(),
698            font_features: settings.ui_font.features,
699            font_size: rems(0.875).into(),
700            font_weight: FontWeight::NORMAL,
701            font_style: FontStyle::Normal,
702            line_height: relative(1.3),
703            background_color: None,
704            underline: None,
705            strikethrough: None,
706            white_space: WhiteSpace::Normal,
707        };
708
709        EditorElement::new(
710            &editor,
711            EditorStyle {
712                background: cx.theme().colors().editor_background,
713                local_player: cx.theme().players().local(),
714                text: text_style,
715                ..Default::default()
716            },
717        )
718    }
719
720    fn on_query_change(
721        &mut self,
722        _: View<Editor>,
723        event: &editor::EditorEvent,
724        cx: &mut ViewContext<Self>,
725    ) {
726        if let editor::EditorEvent::Edited = event {
727            self.query_contains_error = false;
728            self.fetch_extensions_debounced(cx);
729        }
730    }
731
732    fn fetch_extensions_debounced(&mut self, cx: &mut ViewContext<'_, ExtensionsPage>) {
733        self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
734            let search = this
735                .update(&mut cx, |this, cx| this.search_query(cx))
736                .ok()
737                .flatten();
738
739            // Only debounce the fetching of extensions if we have a search
740            // query.
741            //
742            // If the search was just cleared then we can just reload the list
743            // of extensions without a debounce, which allows us to avoid seeing
744            // an intermittent flash of a "no extensions" state.
745            if let Some(_) = search {
746                cx.background_executor()
747                    .timer(Duration::from_millis(250))
748                    .await;
749            };
750
751            this.update(&mut cx, |this, cx| {
752                this.fetch_extensions(search, cx);
753            })
754            .ok();
755        }));
756    }
757
758    pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
759        let search = self.query_editor.read(cx).text(cx);
760        if search.trim().is_empty() {
761            None
762        } else {
763            Some(search)
764        }
765    }
766
767    fn render_empty_state(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
768        let has_search = self.search_query(cx).is_some();
769
770        let message = if self.is_fetching_extensions {
771            "Loading extensions..."
772        } else {
773            match self.filter {
774                ExtensionFilter::All => {
775                    if has_search {
776                        "No extensions that match your search."
777                    } else {
778                        "No extensions."
779                    }
780                }
781                ExtensionFilter::Installed => {
782                    if has_search {
783                        "No installed extensions that match your search."
784                    } else {
785                        "No installed extensions."
786                    }
787                }
788                ExtensionFilter::NotInstalled => {
789                    if has_search {
790                        "No not installed extensions that match your search."
791                    } else {
792                        "No not installed extensions."
793                    }
794                }
795            }
796        };
797
798        Label::new(message)
799    }
800}
801
802impl Render for ExtensionsPage {
803    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
804        v_flex()
805            .size_full()
806            .bg(cx.theme().colors().editor_background)
807            .child(
808                v_flex()
809                    .gap_4()
810                    .p_4()
811                    .border_b()
812                    .border_color(cx.theme().colors().border)
813                    .bg(cx.theme().colors().editor_background)
814                    .child(
815                        h_flex()
816                            .w_full()
817                            .gap_2()
818                            .justify_between()
819                            .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
820                            .child(
821                                Button::new("install-dev-extension", "Install Dev Extension")
822                                    .style(ButtonStyle::Filled)
823                                    .size(ButtonSize::Large)
824                                    .on_click(|_event, cx| {
825                                        cx.dispatch_action(Box::new(InstallDevExtension))
826                                    }),
827                            ),
828                    )
829                    .child(
830                        h_flex()
831                            .w_full()
832                            .gap_2()
833                            .justify_between()
834                            .child(h_flex().child(self.render_search(cx)))
835                            .child(
836                                h_flex()
837                                    .child(
838                                        ToggleButton::new("filter-all", "All")
839                                            .style(ButtonStyle::Filled)
840                                            .size(ButtonSize::Large)
841                                            .selected(self.filter == ExtensionFilter::All)
842                                            .on_click(cx.listener(|this, _event, cx| {
843                                                this.filter = ExtensionFilter::All;
844                                                this.filter_extension_entries(cx);
845                                            }))
846                                            .tooltip(move |cx| {
847                                                Tooltip::text("Show all extensions", cx)
848                                            })
849                                            .first(),
850                                    )
851                                    .child(
852                                        ToggleButton::new("filter-installed", "Installed")
853                                            .style(ButtonStyle::Filled)
854                                            .size(ButtonSize::Large)
855                                            .selected(self.filter == ExtensionFilter::Installed)
856                                            .on_click(cx.listener(|this, _event, cx| {
857                                                this.filter = ExtensionFilter::Installed;
858                                                this.filter_extension_entries(cx);
859                                            }))
860                                            .tooltip(move |cx| {
861                                                Tooltip::text("Show installed extensions", cx)
862                                            })
863                                            .middle(),
864                                    )
865                                    .child(
866                                        ToggleButton::new("filter-not-installed", "Not Installed")
867                                            .style(ButtonStyle::Filled)
868                                            .size(ButtonSize::Large)
869                                            .selected(self.filter == ExtensionFilter::NotInstalled)
870                                            .on_click(cx.listener(|this, _event, cx| {
871                                                this.filter = ExtensionFilter::NotInstalled;
872                                                this.filter_extension_entries(cx);
873                                            }))
874                                            .tooltip(move |cx| {
875                                                Tooltip::text("Show not installed extensions", cx)
876                                            })
877                                            .last(),
878                                    ),
879                            ),
880                    ),
881            )
882            .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
883                let mut count = self.filtered_remote_extension_indices.len();
884                if self.filter.include_dev_extensions() {
885                    count += self.dev_extension_entries.len();
886                }
887
888                if count == 0 {
889                    return this.py_4().child(self.render_empty_state(cx));
890                }
891
892                let view = cx.view().clone();
893                let scroll_handle = self.list.clone();
894                this.child(
895                    canvas(
896                        move |bounds, cx| {
897                            let mut list = uniform_list::<_, ExtensionCard, _>(
898                                view,
899                                "entries",
900                                count,
901                                Self::render_extensions,
902                            )
903                            .size_full()
904                            .pb_4()
905                            .track_scroll(scroll_handle)
906                            .into_any_element();
907                            list.layout(bounds.origin, bounds.size.into(), cx);
908                            list
909                        },
910                        |_bounds, mut list, cx| list.paint(cx),
911                    )
912                    .size_full(),
913                )
914            }))
915    }
916}
917
918impl EventEmitter<ItemEvent> for ExtensionsPage {}
919
920impl FocusableView for ExtensionsPage {
921    fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
922        self.query_editor.read(cx).focus_handle(cx)
923    }
924}
925
926impl Item for ExtensionsPage {
927    type Event = ItemEvent;
928
929    fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement {
930        Label::new("Extensions")
931            .color(if params.selected {
932                Color::Default
933            } else {
934                Color::Muted
935            })
936            .into_any_element()
937    }
938
939    fn telemetry_event_text(&self) -> Option<&'static str> {
940        Some("extensions page")
941    }
942
943    fn show_toolbar(&self) -> bool {
944        false
945    }
946
947    fn clone_on_split(
948        &self,
949        _workspace_id: WorkspaceId,
950        _: &mut ViewContext<Self>,
951    ) -> Option<View<Self>> {
952        None
953    }
954
955    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
956        f(*event)
957    }
958}