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                        let version = extension.manifest.version.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_extension(extension_id.clone(), version.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("add-dev-extension", "Add 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, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
930        Label::new("Extensions")
931            .color(if 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}