extensions_ui.rs

  1use client::telemetry::Telemetry;
  2use editor::{Editor, EditorElement, EditorStyle};
  3use extension::{Extension, ExtensionStatus, ExtensionStore};
  4use gpui::{
  5    actions, canvas, uniform_list, AnyElement, AppContext, AvailableSpace, EventEmitter,
  6    FocusableView, FontStyle, FontWeight, InteractiveElement, KeyContext, ParentElement, Render,
  7    Styled, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, WhiteSpace,
  8    WindowContext,
  9};
 10use settings::Settings;
 11use std::time::Duration;
 12use std::{ops::Range, sync::Arc};
 13use theme::ThemeSettings;
 14use ui::{prelude::*, CheckboxWithLabel, Tooltip};
 15
 16use workspace::{
 17    item::{Item, ItemEvent},
 18    Workspace, WorkspaceId,
 19};
 20
 21actions!(zed, [Extensions]);
 22
 23pub fn init(cx: &mut AppContext) {
 24    cx.observe_new_views(move |workspace: &mut Workspace, _cx| {
 25        workspace.register_action(move |workspace, _: &Extensions, cx| {
 26            let extensions_page = ExtensionsPage::new(workspace, cx);
 27            workspace.add_item(Box::new(extensions_page), cx)
 28        });
 29    })
 30    .detach();
 31}
 32
 33pub struct ExtensionsPage {
 34    list: UniformListScrollHandle,
 35    telemetry: Arc<Telemetry>,
 36    is_fetching_extensions: bool,
 37    is_only_showing_installed_extensions: bool,
 38    extension_entries: Vec<Extension>,
 39    query_editor: View<Editor>,
 40    query_contains_error: bool,
 41    _subscription: gpui::Subscription,
 42    extension_fetch_task: Option<Task<()>>,
 43}
 44
 45impl ExtensionsPage {
 46    pub fn new(workspace: &Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
 47        cx.new_view(|cx: &mut ViewContext<Self>| {
 48            let store = ExtensionStore::global(cx);
 49            let subscription = cx.observe(&store, |_, _, cx| cx.notify());
 50
 51            let query_editor = cx.new_view(|cx| Editor::single_line(cx));
 52            cx.subscribe(&query_editor, Self::on_query_change).detach();
 53
 54            let mut this = Self {
 55                list: UniformListScrollHandle::new(),
 56                telemetry: workspace.client().telemetry().clone(),
 57                is_fetching_extensions: false,
 58                is_only_showing_installed_extensions: false,
 59                extension_entries: Vec::new(),
 60                query_contains_error: false,
 61                extension_fetch_task: None,
 62                _subscription: subscription,
 63                query_editor,
 64            };
 65            this.fetch_extensions(None, cx);
 66            this
 67        })
 68    }
 69
 70    fn filtered_extension_entries(&self, cx: &mut ViewContext<Self>) -> Vec<Extension> {
 71        let extension_store = ExtensionStore::global(cx).read(cx);
 72
 73        self.extension_entries
 74            .iter()
 75            .filter(|extension| {
 76                if self.is_only_showing_installed_extensions {
 77                    let status = extension_store.extension_status(&extension.id);
 78
 79                    matches!(status, ExtensionStatus::Installed(_))
 80                } else {
 81                    true
 82                }
 83            })
 84            .cloned()
 85            .collect::<Vec<_>>()
 86    }
 87
 88    fn install_extension(
 89        &self,
 90        extension_id: Arc<str>,
 91        version: Arc<str>,
 92        cx: &mut ViewContext<Self>,
 93    ) {
 94        ExtensionStore::global(cx).update(cx, |store, cx| {
 95            store.install_extension(extension_id, version, cx)
 96        });
 97        cx.notify();
 98    }
 99
100    fn uninstall_extension(&self, extension_id: Arc<str>, cx: &mut ViewContext<Self>) {
101        ExtensionStore::global(cx)
102            .update(cx, |store, cx| store.uninstall_extension(extension_id, cx));
103        cx.notify();
104    }
105
106    fn fetch_extensions(&mut self, search: Option<&str>, cx: &mut ViewContext<Self>) {
107        self.is_fetching_extensions = true;
108        cx.notify();
109
110        let extensions =
111            ExtensionStore::global(cx).update(cx, |store, cx| store.fetch_extensions(search, cx));
112
113        cx.spawn(move |this, mut cx| async move {
114            let fetch_result = extensions.await;
115            match fetch_result {
116                Ok(extensions) => this.update(&mut cx, |this, cx| {
117                    this.extension_entries = extensions;
118                    this.is_fetching_extensions = false;
119                    cx.notify();
120                }),
121                Err(err) => {
122                    this.update(&mut cx, |this, cx| {
123                        this.is_fetching_extensions = false;
124                        cx.notify();
125                    })
126                    .ok();
127
128                    Err(err)
129                }
130            }
131        })
132        .detach_and_log_err(cx);
133    }
134
135    fn render_extensions(&mut self, range: Range<usize>, cx: &mut ViewContext<Self>) -> Vec<Div> {
136        self.filtered_extension_entries(cx)[range]
137            .iter()
138            .map(|extension| self.render_entry(extension, cx))
139            .collect()
140    }
141
142    fn render_entry(&self, extension: &Extension, cx: &mut ViewContext<Self>) -> Div {
143        let status = ExtensionStore::global(cx)
144            .read(cx)
145            .extension_status(&extension.id);
146
147        let upgrade_button = match status.clone() {
148            ExtensionStatus::NotInstalled
149            | ExtensionStatus::Installing
150            | ExtensionStatus::Removing => None,
151            ExtensionStatus::Installed(installed_version) => {
152                if installed_version != extension.version {
153                    Some(
154                        Button::new(
155                            SharedString::from(format!("upgrade-{}", extension.id)),
156                            "Upgrade",
157                        )
158                        .on_click(cx.listener({
159                            let extension_id = extension.id.clone();
160                            let version = extension.version.clone();
161                            move |this, _, cx| {
162                                this.telemetry
163                                    .report_app_event("extensions: install extension".to_string());
164                                this.install_extension(extension_id.clone(), version.clone(), cx);
165                            }
166                        }))
167                        .color(Color::Accent),
168                    )
169                } else {
170                    None
171                }
172            }
173            ExtensionStatus::Upgrading => Some(
174                Button::new(
175                    SharedString::from(format!("upgrade-{}", extension.id)),
176                    "Upgrade",
177                )
178                .color(Color::Accent)
179                .disabled(true),
180            ),
181        };
182
183        let install_or_uninstall_button = match status {
184            ExtensionStatus::NotInstalled | ExtensionStatus::Installing => Button::new(
185                SharedString::from(extension.id.clone()),
186                if status.is_installing() {
187                    "Installing..."
188                } else {
189                    "Install"
190                },
191            )
192            .on_click(cx.listener({
193                let extension_id = extension.id.clone();
194                let version = extension.version.clone();
195                move |this, _, cx| {
196                    this.telemetry
197                        .report_app_event("extensions: install extension".to_string());
198                    this.install_extension(extension_id.clone(), version.clone(), cx);
199                }
200            }))
201            .disabled(status.is_installing()),
202            ExtensionStatus::Installed(_)
203            | ExtensionStatus::Upgrading
204            | ExtensionStatus::Removing => Button::new(
205                SharedString::from(extension.id.clone()),
206                if status.is_upgrading() {
207                    "Upgrading..."
208                } else if status.is_removing() {
209                    "Removing..."
210                } else {
211                    "Uninstall"
212                },
213            )
214            .on_click(cx.listener({
215                let extension_id = extension.id.clone();
216                move |this, _, cx| {
217                    this.telemetry
218                        .report_app_event("extensions: uninstall extension".to_string());
219                    this.uninstall_extension(extension_id.clone(), cx);
220                }
221            }))
222            .disabled(matches!(
223                status,
224                ExtensionStatus::Upgrading | ExtensionStatus::Removing
225            )),
226        }
227        .color(Color::Accent);
228
229        let repository_url = extension.repository.clone();
230        let tooltip_text = Tooltip::text(repository_url.clone(), cx);
231
232        div().w_full().child(
233            v_flex()
234                .w_full()
235                .h(rems(7.))
236                .p_3()
237                .mt_4()
238                .gap_2()
239                .bg(cx.theme().colors().elevated_surface_background)
240                .border_1()
241                .border_color(cx.theme().colors().border)
242                .rounded_md()
243                .child(
244                    h_flex()
245                        .justify_between()
246                        .child(
247                            h_flex()
248                                .gap_2()
249                                .items_end()
250                                .child(
251                                    Headline::new(extension.name.clone())
252                                        .size(HeadlineSize::Medium),
253                                )
254                                .child(
255                                    Headline::new(format!("v{}", extension.version))
256                                        .size(HeadlineSize::XSmall),
257                                ),
258                        )
259                        .child(
260                            h_flex()
261                                .gap_2()
262                                .justify_between()
263                                .children(upgrade_button)
264                                .child(install_or_uninstall_button),
265                        ),
266                )
267                .child(
268                    h_flex()
269                        .justify_between()
270                        .child(
271                            Label::new(format!(
272                                "{}: {}",
273                                if extension.authors.len() > 1 {
274                                    "Authors"
275                                } else {
276                                    "Author"
277                                },
278                                extension.authors.join(", ")
279                            ))
280                            .size(LabelSize::Small),
281                        )
282                        .child(
283                            Label::new(format!("Downloads: {}", extension.download_count))
284                                .size(LabelSize::Small),
285                        ),
286                )
287                .child(
288                    h_flex()
289                        .justify_between()
290                        .children(extension.description.as_ref().map(|description| {
291                            Label::new(description.clone())
292                                .size(LabelSize::Small)
293                                .color(Color::Default)
294                        }))
295                        .child(
296                            IconButton::new(
297                                SharedString::from(format!("repository-{}", extension.id)),
298                                IconName::Github,
299                            )
300                            .icon_color(Color::Accent)
301                            .icon_size(IconSize::Small)
302                            .style(ButtonStyle::Filled)
303                            .on_click(cx.listener(move |_, _, cx| {
304                                cx.open_url(&repository_url);
305                            }))
306                            .tooltip(move |_| tooltip_text.clone()),
307                        ),
308                ),
309        )
310    }
311
312    fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
313        let mut key_context = KeyContext::default();
314        key_context.add("BufferSearchBar");
315
316        let editor_border = if self.query_contains_error {
317            Color::Error.color(cx)
318        } else {
319            cx.theme().colors().border
320        };
321
322        h_flex()
323            .w_full()
324            .gap_2()
325            .key_context(key_context)
326            // .capture_action(cx.listener(Self::tab))
327            // .on_action(cx.listener(Self::dismiss))
328            .child(
329                h_flex()
330                    .flex_1()
331                    .px_2()
332                    .py_1()
333                    .gap_2()
334                    .border_1()
335                    .border_color(editor_border)
336                    .min_w(rems(384. / 16.))
337                    .rounded_lg()
338                    .child(Icon::new(IconName::MagnifyingGlass))
339                    .child(self.render_text_input(&self.query_editor, cx)),
340            )
341    }
342
343    fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
344        let settings = ThemeSettings::get_global(cx);
345        let text_style = TextStyle {
346            color: if editor.read(cx).read_only(cx) {
347                cx.theme().colors().text_disabled
348            } else {
349                cx.theme().colors().text
350            },
351            font_family: settings.ui_font.family.clone(),
352            font_features: settings.ui_font.features,
353            font_size: rems(0.875).into(),
354            font_weight: FontWeight::NORMAL,
355            font_style: FontStyle::Normal,
356            line_height: relative(1.3).into(),
357            background_color: None,
358            underline: None,
359            strikethrough: None,
360            white_space: WhiteSpace::Normal,
361        };
362
363        EditorElement::new(
364            &editor,
365            EditorStyle {
366                background: cx.theme().colors().editor_background,
367                local_player: cx.theme().players().local(),
368                text: text_style,
369                ..Default::default()
370            },
371        )
372    }
373
374    fn on_query_change(
375        &mut self,
376        _: View<Editor>,
377        event: &editor::EditorEvent,
378        cx: &mut ViewContext<Self>,
379    ) {
380        if let editor::EditorEvent::Edited = event {
381            self.query_contains_error = false;
382            self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
383                let search = this
384                    .update(&mut cx, |this, cx| this.search_query(cx))
385                    .ok()
386                    .flatten();
387
388                // Only debounce the fetching of extensions if we have a search
389                // query.
390                //
391                // If the search was just cleared then we can just reload the list
392                // of extensions without a debounce, which allows us to avoid seeing
393                // an intermittent flash of a "no extensions" state.
394                if let Some(_) = search {
395                    cx.background_executor()
396                        .timer(Duration::from_millis(250))
397                        .await;
398                };
399
400                this.update(&mut cx, |this, cx| {
401                    this.fetch_extensions(search.as_deref(), cx);
402                })
403                .ok();
404            }));
405        }
406    }
407
408    pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
409        let search = self.query_editor.read(cx).text(cx);
410        if search.trim().is_empty() {
411            None
412        } else {
413            Some(search)
414        }
415    }
416
417    fn render_empty_state(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
418        let has_search = self.search_query(cx).is_some();
419
420        let message = if self.is_fetching_extensions {
421            "Loading extensions..."
422        } else if self.is_only_showing_installed_extensions {
423            if has_search {
424                "No installed extensions that match your search."
425            } else {
426                "No installed extensions."
427            }
428        } else {
429            if has_search {
430                "No extensions that match your search."
431            } else {
432                "No extensions."
433            }
434        };
435
436        Label::new(message)
437    }
438}
439
440impl Render for ExtensionsPage {
441    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
442        v_flex()
443            .size_full()
444            .p_4()
445            .gap_4()
446            .bg(cx.theme().colors().editor_background)
447            .child(
448                h_flex()
449                    .w_full()
450                    .child(Headline::new("Extensions").size(HeadlineSize::XLarge)),
451            )
452            .child(
453                h_flex()
454                    .w_full()
455                    .gap_2()
456                    .child(h_flex().child(self.render_search(cx)))
457                    .child(CheckboxWithLabel::new(
458                        "installed",
459                        Label::new("Only show installed"),
460                        if self.is_only_showing_installed_extensions {
461                            Selection::Selected
462                        } else {
463                            Selection::Unselected
464                        },
465                        cx.listener(|this, selection, _cx| {
466                            this.is_only_showing_installed_extensions = match selection {
467                                Selection::Selected => true,
468                                Selection::Unselected => false,
469                                Selection::Indeterminate => return,
470                            }
471                        }),
472                    )),
473            )
474            .child(v_flex().size_full().overflow_y_hidden().map(|this| {
475                let entries = self.filtered_extension_entries(cx);
476                if entries.is_empty() {
477                    return this.child(self.render_empty_state(cx));
478                }
479
480                this.child(
481                    canvas({
482                        let view = cx.view().clone();
483                        let scroll_handle = self.list.clone();
484                        let item_count = entries.len();
485                        move |bounds, cx| {
486                            uniform_list::<_, Div, _>(
487                                view,
488                                "entries",
489                                item_count,
490                                Self::render_extensions,
491                            )
492                            .size_full()
493                            .track_scroll(scroll_handle)
494                            .into_any_element()
495                            .draw(
496                                bounds.origin,
497                                bounds.size.map(AvailableSpace::Definite),
498                                cx,
499                            )
500                        }
501                    })
502                    .size_full(),
503                )
504            }))
505    }
506}
507
508impl EventEmitter<ItemEvent> for ExtensionsPage {}
509
510impl FocusableView for ExtensionsPage {
511    fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
512        self.query_editor.read(cx).focus_handle(cx)
513    }
514}
515
516impl Item for ExtensionsPage {
517    type Event = ItemEvent;
518
519    fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
520        Label::new("Extensions")
521            .color(if selected {
522                Color::Default
523            } else {
524                Color::Muted
525            })
526            .into_any_element()
527    }
528
529    fn telemetry_event_text(&self) -> Option<&'static str> {
530        Some("extensions page")
531    }
532
533    fn show_toolbar(&self) -> bool {
534        false
535    }
536
537    fn clone_on_split(
538        &self,
539        _workspace_id: WorkspaceId,
540        _: &mut ViewContext<Self>,
541    ) -> Option<View<Self>> {
542        None
543    }
544
545    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
546        f(*event)
547    }
548}