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