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