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