extensions_ui.rs

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