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                .h(rems(7.))
220                .p_3()
221                .mt_4()
222                .gap_2()
223                .bg(cx.theme().colors().elevated_surface_background)
224                .border_1()
225                .border_color(cx.theme().colors().border)
226                .rounded_md()
227                .child(
228                    h_flex()
229                        .justify_between()
230                        .child(
231                            h_flex()
232                                .gap_2()
233                                .items_end()
234                                .child(
235                                    Headline::new(extension.name.clone())
236                                        .size(HeadlineSize::Medium),
237                                )
238                                .child(
239                                    Headline::new(format!("v{}", extension.version))
240                                        .size(HeadlineSize::XSmall),
241                                ),
242                        )
243                        .child(
244                            h_flex()
245                                .gap_2()
246                                .justify_between()
247                                .children(upgrade_button)
248                                .child(install_or_uninstall_button),
249                        ),
250                )
251                .child(
252                    h_flex().justify_between().child(
253                        Label::new(format!(
254                            "{}: {}",
255                            if extension.authors.len() > 1 {
256                                "Authors"
257                            } else {
258                                "Author"
259                            },
260                            extension.authors.join(", ")
261                        ))
262                        .size(LabelSize::Small),
263                    ),
264                )
265                .child(
266                    h_flex()
267                        .justify_between()
268                        .children(extension.description.as_ref().map(|description| {
269                            Label::new(description.clone())
270                                .size(LabelSize::Small)
271                                .color(Color::Default)
272                        })),
273                ),
274        )
275    }
276
277    fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
278        let mut key_context = KeyContext::default();
279        key_context.add("BufferSearchBar");
280
281        let editor_border = if self.query_contains_error {
282            Color::Error.color(cx)
283        } else {
284            cx.theme().colors().border
285        };
286
287        h_flex()
288            .w_full()
289            .gap_2()
290            .key_context(key_context)
291            // .capture_action(cx.listener(Self::tab))
292            // .on_action(cx.listener(Self::dismiss))
293            .child(
294                h_flex()
295                    .flex_1()
296                    .px_2()
297                    .py_1()
298                    .gap_2()
299                    .border_1()
300                    .border_color(editor_border)
301                    .min_w(rems(384. / 16.))
302                    .rounded_lg()
303                    .child(Icon::new(IconName::MagnifyingGlass))
304                    .child(self.render_text_input(&self.query_editor, cx)),
305            )
306    }
307
308    fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
309        let settings = ThemeSettings::get_global(cx);
310        let text_style = TextStyle {
311            color: if editor.read(cx).read_only(cx) {
312                cx.theme().colors().text_disabled
313            } else {
314                cx.theme().colors().text
315            },
316            font_family: settings.ui_font.family.clone(),
317            font_features: settings.ui_font.features,
318            font_size: rems(0.875).into(),
319            font_weight: FontWeight::NORMAL,
320            font_style: FontStyle::Normal,
321            line_height: relative(1.3).into(),
322            background_color: None,
323            underline: None,
324            strikethrough: None,
325            white_space: WhiteSpace::Normal,
326        };
327
328        EditorElement::new(
329            &editor,
330            EditorStyle {
331                background: cx.theme().colors().editor_background,
332                local_player: cx.theme().players().local(),
333                text: text_style,
334                ..Default::default()
335            },
336        )
337    }
338
339    fn on_query_change(
340        &mut self,
341        _: View<Editor>,
342        event: &editor::EditorEvent,
343        cx: &mut ViewContext<Self>,
344    ) {
345        if let editor::EditorEvent::Edited = event {
346            self.query_contains_error = false;
347            self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
348                cx.background_executor()
349                    .timer(Duration::from_millis(250))
350                    .await;
351                this.update(&mut cx, |this, cx| {
352                    this.fetch_extensions(this.search_query(cx).as_deref(), cx);
353                })
354                .ok();
355            }));
356        }
357    }
358
359    pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
360        let search = self.query_editor.read(cx).text(cx);
361        if search.trim().is_empty() {
362            None
363        } else {
364            Some(search)
365        }
366    }
367}
368
369impl EventEmitter<ItemEvent> for ExtensionsPage {}
370
371impl FocusableView for ExtensionsPage {
372    fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
373        self.query_editor.read(cx).focus_handle(cx)
374    }
375}
376
377impl Item for ExtensionsPage {
378    type Event = ItemEvent;
379
380    fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
381        Label::new("Extensions")
382            .color(if selected {
383                Color::Default
384            } else {
385                Color::Muted
386            })
387            .into_any_element()
388    }
389
390    fn telemetry_event_text(&self) -> Option<&'static str> {
391        Some("extensions page")
392    }
393
394    fn show_toolbar(&self) -> bool {
395        false
396    }
397
398    fn clone_on_split(
399        &self,
400        _workspace_id: WorkspaceId,
401        cx: &mut ViewContext<Self>,
402    ) -> Option<View<Self>> {
403        Some(cx.new_view(|cx| {
404            let store = ExtensionStore::global(cx);
405            let subscription = cx.observe(&store, |_, _, cx| cx.notify());
406
407            ExtensionsPage {
408                fs: self.fs.clone(),
409                workspace: self.workspace.clone(),
410                list: UniformListScrollHandle::new(),
411                telemetry: self.telemetry.clone(),
412                extensions_entries: Default::default(),
413                query_editor: self.query_editor.clone(),
414                _subscription: subscription,
415                query_contains_error: false,
416                extension_fetch_task: None,
417            }
418        }))
419    }
420
421    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
422        f(*event)
423    }
424}