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