extensions_ui.rs

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