extensions_ui.rs

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