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