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