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