1mod components;
2
3use crate::components::ExtensionCard;
4use client::telemetry::Telemetry;
5use editor::{Editor, EditorElement, EditorStyle};
6use extension::{ExtensionApiResponse, ExtensionManifest, ExtensionStatus, ExtensionStore};
7use fuzzy::{match_strings, StringMatchCandidate};
8use gpui::{
9 actions, canvas, uniform_list, AnyElement, AppContext, EventEmitter, FocusableView, FontStyle,
10 FontWeight, InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle,
11 UniformListScrollHandle, View, ViewContext, VisualContext, WhiteSpace, WindowContext,
12};
13use settings::Settings;
14use std::ops::DerefMut;
15use std::time::Duration;
16use std::{ops::Range, sync::Arc};
17use theme::ThemeSettings;
18use ui::{prelude::*, ToggleButton, Tooltip};
19use util::ResultExt as _;
20use workspace::{
21 item::{Item, ItemEvent},
22 Workspace, WorkspaceId,
23};
24
25actions!(zed, [Extensions, InstallDevExtension]);
26
27pub fn init(cx: &mut AppContext) {
28 cx.observe_new_views(move |workspace: &mut Workspace, _cx| {
29 workspace
30 .register_action(move |workspace, _: &Extensions, cx| {
31 let extensions_page = ExtensionsPage::new(workspace, cx);
32 workspace.add_item_to_active_pane(Box::new(extensions_page), cx)
33 })
34 .register_action(move |_, _: &InstallDevExtension, cx| {
35 let store = ExtensionStore::global(cx);
36 let prompt = cx.prompt_for_paths(gpui::PathPromptOptions {
37 files: false,
38 directories: true,
39 multiple: false,
40 });
41
42 cx.deref_mut()
43 .spawn(|mut cx| async move {
44 let extension_path = prompt.await.log_err()??.pop()?;
45 store
46 .update(&mut cx, |store, cx| {
47 store
48 .install_dev_extension(extension_path, cx)
49 .detach_and_log_err(cx)
50 })
51 .ok()?;
52 Some(())
53 })
54 .detach();
55 });
56 })
57 .detach();
58}
59
60#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
61enum ExtensionFilter {
62 All,
63 Installed,
64 NotInstalled,
65}
66
67impl ExtensionFilter {
68 pub fn include_dev_extensions(&self) -> bool {
69 match self {
70 Self::All | Self::Installed => true,
71 Self::NotInstalled => false,
72 }
73 }
74}
75
76pub struct ExtensionsPage {
77 list: UniformListScrollHandle,
78 telemetry: Arc<Telemetry>,
79 is_fetching_extensions: bool,
80 filter: ExtensionFilter,
81 remote_extension_entries: Vec<ExtensionApiResponse>,
82 dev_extension_entries: Vec<Arc<ExtensionManifest>>,
83 filtered_remote_extension_indices: Vec<usize>,
84 query_editor: View<Editor>,
85 query_contains_error: bool,
86 _subscriptions: [gpui::Subscription; 2],
87 extension_fetch_task: Option<Task<()>>,
88}
89
90impl ExtensionsPage {
91 pub fn new(workspace: &Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
92 cx.new_view(|cx: &mut ViewContext<Self>| {
93 let store = ExtensionStore::global(cx);
94 let subscriptions = [
95 cx.observe(&store, |_, _, cx| cx.notify()),
96 cx.subscribe(&store, |this, _, event, cx| match event {
97 extension::Event::ExtensionsUpdated => this.fetch_extensions_debounced(cx),
98 _ => {}
99 }),
100 ];
101
102 let query_editor = cx.new_view(|cx| {
103 let mut input = Editor::single_line(cx);
104 input.set_placeholder_text("Search extensions...", cx);
105 input
106 });
107 cx.subscribe(&query_editor, Self::on_query_change).detach();
108
109 let mut this = Self {
110 list: UniformListScrollHandle::new(),
111 telemetry: workspace.client().telemetry().clone(),
112 is_fetching_extensions: false,
113 filter: ExtensionFilter::All,
114 dev_extension_entries: Vec::new(),
115 filtered_remote_extension_indices: Vec::new(),
116 remote_extension_entries: Vec::new(),
117 query_contains_error: false,
118 extension_fetch_task: None,
119 _subscriptions: subscriptions,
120 query_editor,
121 };
122 this.fetch_extensions(None, cx);
123 this
124 })
125 }
126
127 fn filter_extension_entries(&mut self, cx: &mut ViewContext<Self>) {
128 let extension_store = ExtensionStore::global(cx).read(cx);
129
130 self.filtered_remote_extension_indices.clear();
131 self.filtered_remote_extension_indices.extend(
132 self.remote_extension_entries
133 .iter()
134 .enumerate()
135 .filter(|(_, extension)| match self.filter {
136 ExtensionFilter::All => true,
137 ExtensionFilter::Installed => {
138 let status = extension_store.extension_status(&extension.id);
139 matches!(status, ExtensionStatus::Installed(_))
140 }
141 ExtensionFilter::NotInstalled => {
142 let status = extension_store.extension_status(&extension.id);
143
144 matches!(status, ExtensionStatus::NotInstalled)
145 }
146 })
147 .map(|(ix, _)| ix),
148 );
149 cx.notify();
150 }
151
152 fn fetch_extensions(&mut self, search: Option<String>, cx: &mut ViewContext<Self>) {
153 self.is_fetching_extensions = true;
154 cx.notify();
155
156 let extension_store = ExtensionStore::global(cx);
157
158 let dev_extensions = extension_store.update(cx, |store, _| {
159 store.dev_extensions().cloned().collect::<Vec<_>>()
160 });
161
162 let remote_extensions = extension_store.update(cx, |store, cx| {
163 store.fetch_extensions(search.as_deref(), cx)
164 });
165
166 cx.spawn(move |this, mut cx| async move {
167 let dev_extensions = if let Some(search) = search {
168 let match_candidates = dev_extensions
169 .iter()
170 .enumerate()
171 .map(|(ix, manifest)| StringMatchCandidate {
172 id: ix,
173 string: manifest.name.clone(),
174 char_bag: manifest.name.as_str().into(),
175 })
176 .collect::<Vec<_>>();
177
178 let matches = match_strings(
179 &match_candidates,
180 &search,
181 false,
182 match_candidates.len(),
183 &Default::default(),
184 cx.background_executor().clone(),
185 )
186 .await;
187 matches
188 .into_iter()
189 .map(|mat| dev_extensions[mat.candidate_id].clone())
190 .collect()
191 } else {
192 dev_extensions
193 };
194
195 let fetch_result = remote_extensions.await;
196 this.update(&mut cx, |this, cx| {
197 cx.notify();
198 this.dev_extension_entries = dev_extensions;
199 this.is_fetching_extensions = false;
200 this.remote_extension_entries = fetch_result?;
201 this.filter_extension_entries(cx);
202 anyhow::Ok(())
203 })?
204 })
205 .detach_and_log_err(cx);
206 }
207
208 fn render_extensions(
209 &mut self,
210 range: Range<usize>,
211 cx: &mut ViewContext<Self>,
212 ) -> Vec<ExtensionCard> {
213 let dev_extension_entries_len = if self.filter.include_dev_extensions() {
214 self.dev_extension_entries.len()
215 } else {
216 0
217 };
218 range
219 .map(|ix| {
220 if ix < dev_extension_entries_len {
221 let extension = &self.dev_extension_entries[ix];
222 self.render_dev_extension(extension, cx)
223 } else {
224 let extension_ix =
225 self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
226 let extension = &self.remote_extension_entries[extension_ix];
227 self.render_remote_extension(extension, cx)
228 }
229 })
230 .collect()
231 }
232
233 fn render_dev_extension(
234 &self,
235 extension: &ExtensionManifest,
236 cx: &mut ViewContext<Self>,
237 ) -> ExtensionCard {
238 let status = ExtensionStore::global(cx)
239 .read(cx)
240 .extension_status(&extension.id);
241
242 let repository_url = extension.repository.clone();
243
244 ExtensionCard::new()
245 .child(
246 h_flex()
247 .justify_between()
248 .child(
249 h_flex()
250 .gap_2()
251 .items_end()
252 .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
253 .child(
254 Headline::new(format!("v{}", extension.version))
255 .size(HeadlineSize::XSmall),
256 ),
257 )
258 .child(
259 h_flex()
260 .gap_2()
261 .justify_between()
262 .child(
263 Button::new(
264 SharedString::from(format!("rebuild-{}", extension.id)),
265 "Rebuild",
266 )
267 .on_click({
268 let extension_id = extension.id.clone();
269 move |_, cx| {
270 ExtensionStore::global(cx).update(cx, |store, cx| {
271 store.rebuild_dev_extension(extension_id.clone(), cx)
272 });
273 }
274 })
275 .color(Color::Accent)
276 .disabled(matches!(status, ExtensionStatus::Upgrading)),
277 )
278 .child(
279 Button::new(SharedString::from(extension.id.clone()), "Uninstall")
280 .on_click({
281 let extension_id = extension.id.clone();
282 move |_, cx| {
283 ExtensionStore::global(cx).update(cx, |store, cx| {
284 store.uninstall_extension(extension_id.clone(), cx)
285 });
286 }
287 })
288 .color(Color::Accent)
289 .disabled(matches!(status, ExtensionStatus::Removing)),
290 ),
291 ),
292 )
293 .child(
294 h_flex()
295 .justify_between()
296 .child(
297 Label::new(format!(
298 "{}: {}",
299 if extension.authors.len() > 1 {
300 "Authors"
301 } else {
302 "Author"
303 },
304 extension.authors.join(", ")
305 ))
306 .size(LabelSize::Small),
307 )
308 .child(Label::new("<>").size(LabelSize::Small)),
309 )
310 .child(
311 h_flex()
312 .justify_between()
313 .children(extension.description.as_ref().map(|description| {
314 Label::new(description.clone())
315 .size(LabelSize::Small)
316 .color(Color::Default)
317 }))
318 .children(repository_url.map(|repository_url| {
319 IconButton::new(
320 SharedString::from(format!("repository-{}", extension.id)),
321 IconName::Github,
322 )
323 .icon_color(Color::Accent)
324 .icon_size(IconSize::Small)
325 .style(ButtonStyle::Filled)
326 .on_click(cx.listener({
327 let repository_url = repository_url.clone();
328 move |_, _, cx| {
329 cx.open_url(&repository_url);
330 }
331 }))
332 .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx))
333 })),
334 )
335 }
336
337 fn render_remote_extension(
338 &self,
339 extension: &ExtensionApiResponse,
340 cx: &mut ViewContext<Self>,
341 ) -> ExtensionCard {
342 let status = ExtensionStore::global(cx)
343 .read(cx)
344 .extension_status(&extension.id);
345
346 let (install_or_uninstall_button, upgrade_button) =
347 self.buttons_for_entry(extension, &status, cx);
348 let repository_url = extension.repository.clone();
349
350 ExtensionCard::new()
351 .child(
352 h_flex()
353 .justify_between()
354 .child(
355 h_flex()
356 .gap_2()
357 .items_end()
358 .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
359 .child(
360 Headline::new(format!("v{}", extension.version))
361 .size(HeadlineSize::XSmall),
362 ),
363 )
364 .child(
365 h_flex()
366 .gap_2()
367 .justify_between()
368 .children(upgrade_button)
369 .child(install_or_uninstall_button),
370 ),
371 )
372 .child(
373 h_flex()
374 .justify_between()
375 .child(
376 Label::new(format!(
377 "{}: {}",
378 if extension.authors.len() > 1 {
379 "Authors"
380 } else {
381 "Author"
382 },
383 extension.authors.join(", ")
384 ))
385 .size(LabelSize::Small),
386 )
387 .child(
388 Label::new(format!("Downloads: {}", extension.download_count))
389 .size(LabelSize::Small),
390 ),
391 )
392 .child(
393 h_flex()
394 .gap_2()
395 .justify_between()
396 .children(extension.description.as_ref().map(|description| {
397 h_flex().overflow_x_hidden().child(
398 Label::new(description.clone())
399 .size(LabelSize::Small)
400 .color(Color::Default),
401 )
402 }))
403 .child(
404 IconButton::new(
405 SharedString::from(format!("repository-{}", extension.id)),
406 IconName::Github,
407 )
408 .icon_color(Color::Accent)
409 .icon_size(IconSize::Small)
410 .style(ButtonStyle::Filled)
411 .on_click(cx.listener({
412 let repository_url = repository_url.clone();
413 move |_, _, cx| {
414 cx.open_url(&repository_url);
415 }
416 }))
417 .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx)),
418 ),
419 )
420 }
421
422 fn buttons_for_entry(
423 &self,
424 extension: &ExtensionApiResponse,
425 status: &ExtensionStatus,
426 cx: &mut ViewContext<Self>,
427 ) -> (Button, Option<Button>) {
428 match status.clone() {
429 ExtensionStatus::NotInstalled => (
430 Button::new(SharedString::from(extension.id.clone()), "Install").on_click(
431 cx.listener({
432 let extension_id = extension.id.clone();
433 let version = extension.version.clone();
434 move |this, _, cx| {
435 this.telemetry
436 .report_app_event("extensions: install extension".to_string());
437 ExtensionStore::global(cx).update(cx, |store, cx| {
438 store.install_extension(extension_id.clone(), version.clone(), cx)
439 });
440 }
441 }),
442 ),
443 None,
444 ),
445 ExtensionStatus::Installing => (
446 Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
447 None,
448 ),
449 ExtensionStatus::Upgrading => (
450 Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
451 Some(
452 Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
453 ),
454 ),
455 ExtensionStatus::Installed(installed_version) => (
456 Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click(
457 cx.listener({
458 let extension_id = extension.id.clone();
459 move |this, _, cx| {
460 this.telemetry
461 .report_app_event("extensions: uninstall extension".to_string());
462 ExtensionStore::global(cx).update(cx, |store, cx| {
463 store.uninstall_extension(extension_id.clone(), cx)
464 });
465 }
466 }),
467 ),
468 if installed_version == extension.version {
469 None
470 } else {
471 Some(
472 Button::new(SharedString::from(extension.id.clone()), "Upgrade").on_click(
473 cx.listener({
474 let extension_id = extension.id.clone();
475 let version = extension.version.clone();
476 move |this, _, cx| {
477 this.telemetry.report_app_event(
478 "extensions: install extension".to_string(),
479 );
480 ExtensionStore::global(cx).update(cx, |store, cx| {
481 store.upgrade_extension(
482 extension_id.clone(),
483 version.clone(),
484 cx,
485 )
486 });
487 }
488 }),
489 ),
490 )
491 },
492 ),
493 ExtensionStatus::Removing => (
494 Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
495 None,
496 ),
497 }
498 }
499
500 fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
501 let mut key_context = KeyContext::default();
502 key_context.add("BufferSearchBar");
503
504 let editor_border = if self.query_contains_error {
505 Color::Error.color(cx)
506 } else {
507 cx.theme().colors().border
508 };
509
510 h_flex()
511 .w_full()
512 .gap_2()
513 .key_context(key_context)
514 // .capture_action(cx.listener(Self::tab))
515 // .on_action(cx.listener(Self::dismiss))
516 .child(
517 h_flex()
518 .flex_1()
519 .px_2()
520 .py_1()
521 .gap_2()
522 .border_1()
523 .border_color(editor_border)
524 .min_w(rems_from_px(384.))
525 .rounded_lg()
526 .child(Icon::new(IconName::MagnifyingGlass))
527 .child(self.render_text_input(&self.query_editor, cx)),
528 )
529 }
530
531 fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
532 let settings = ThemeSettings::get_global(cx);
533 let text_style = TextStyle {
534 color: if editor.read(cx).read_only(cx) {
535 cx.theme().colors().text_disabled
536 } else {
537 cx.theme().colors().text
538 },
539 font_family: settings.ui_font.family.clone(),
540 font_features: settings.ui_font.features,
541 font_size: rems(0.875).into(),
542 font_weight: FontWeight::NORMAL,
543 font_style: FontStyle::Normal,
544 line_height: relative(1.3),
545 background_color: None,
546 underline: None,
547 strikethrough: None,
548 white_space: WhiteSpace::Normal,
549 };
550
551 EditorElement::new(
552 &editor,
553 EditorStyle {
554 background: cx.theme().colors().editor_background,
555 local_player: cx.theme().players().local(),
556 text: text_style,
557 ..Default::default()
558 },
559 )
560 }
561
562 fn on_query_change(
563 &mut self,
564 _: View<Editor>,
565 event: &editor::EditorEvent,
566 cx: &mut ViewContext<Self>,
567 ) {
568 if let editor::EditorEvent::Edited = event {
569 self.query_contains_error = false;
570 self.fetch_extensions_debounced(cx);
571 }
572 }
573
574 fn fetch_extensions_debounced(&mut self, cx: &mut ViewContext<'_, ExtensionsPage>) {
575 self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
576 let search = this
577 .update(&mut cx, |this, cx| this.search_query(cx))
578 .ok()
579 .flatten();
580
581 // Only debounce the fetching of extensions if we have a search
582 // query.
583 //
584 // If the search was just cleared then we can just reload the list
585 // of extensions without a debounce, which allows us to avoid seeing
586 // an intermittent flash of a "no extensions" state.
587 if let Some(_) = search {
588 cx.background_executor()
589 .timer(Duration::from_millis(250))
590 .await;
591 };
592
593 this.update(&mut cx, |this, cx| {
594 this.fetch_extensions(search, cx);
595 })
596 .ok();
597 }));
598 }
599
600 pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
601 let search = self.query_editor.read(cx).text(cx);
602 if search.trim().is_empty() {
603 None
604 } else {
605 Some(search)
606 }
607 }
608
609 fn render_empty_state(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
610 let has_search = self.search_query(cx).is_some();
611
612 let message = if self.is_fetching_extensions {
613 "Loading extensions..."
614 } else {
615 match self.filter {
616 ExtensionFilter::All => {
617 if has_search {
618 "No extensions that match your search."
619 } else {
620 "No extensions."
621 }
622 }
623 ExtensionFilter::Installed => {
624 if has_search {
625 "No installed extensions that match your search."
626 } else {
627 "No installed extensions."
628 }
629 }
630 ExtensionFilter::NotInstalled => {
631 if has_search {
632 "No not installed extensions that match your search."
633 } else {
634 "No not installed extensions."
635 }
636 }
637 }
638 };
639
640 Label::new(message)
641 }
642}
643
644impl Render for ExtensionsPage {
645 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
646 v_flex()
647 .size_full()
648 .bg(cx.theme().colors().editor_background)
649 .child(
650 v_flex()
651 .gap_4()
652 .p_4()
653 .border_b()
654 .border_color(cx.theme().colors().border)
655 .bg(cx.theme().colors().editor_background)
656 .child(
657 h_flex()
658 .w_full()
659 .gap_2()
660 .justify_between()
661 .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
662 .child(
663 Button::new("add-dev-extension", "Add Dev Extension")
664 .style(ButtonStyle::Filled)
665 .size(ButtonSize::Large)
666 .on_click(|_event, cx| {
667 cx.dispatch_action(Box::new(InstallDevExtension))
668 }),
669 ),
670 )
671 .child(
672 h_flex()
673 .w_full()
674 .gap_2()
675 .justify_between()
676 .child(h_flex().child(self.render_search(cx)))
677 .child(
678 h_flex()
679 .child(
680 ToggleButton::new("filter-all", "All")
681 .style(ButtonStyle::Filled)
682 .size(ButtonSize::Large)
683 .selected(self.filter == ExtensionFilter::All)
684 .on_click(cx.listener(|this, _event, cx| {
685 this.filter = ExtensionFilter::All;
686 this.filter_extension_entries(cx);
687 }))
688 .tooltip(move |cx| {
689 Tooltip::text("Show all extensions", cx)
690 })
691 .first(),
692 )
693 .child(
694 ToggleButton::new("filter-installed", "Installed")
695 .style(ButtonStyle::Filled)
696 .size(ButtonSize::Large)
697 .selected(self.filter == ExtensionFilter::Installed)
698 .on_click(cx.listener(|this, _event, cx| {
699 this.filter = ExtensionFilter::Installed;
700 this.filter_extension_entries(cx);
701 }))
702 .tooltip(move |cx| {
703 Tooltip::text("Show installed extensions", cx)
704 })
705 .middle(),
706 )
707 .child(
708 ToggleButton::new("filter-not-installed", "Not Installed")
709 .style(ButtonStyle::Filled)
710 .size(ButtonSize::Large)
711 .selected(self.filter == ExtensionFilter::NotInstalled)
712 .on_click(cx.listener(|this, _event, cx| {
713 this.filter = ExtensionFilter::NotInstalled;
714 this.filter_extension_entries(cx);
715 }))
716 .tooltip(move |cx| {
717 Tooltip::text("Show not installed extensions", cx)
718 })
719 .last(),
720 ),
721 ),
722 ),
723 )
724 .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
725 let mut count = self.filtered_remote_extension_indices.len();
726 if self.filter.include_dev_extensions() {
727 count += self.dev_extension_entries.len();
728 }
729
730 if count == 0 {
731 return this.py_4().child(self.render_empty_state(cx));
732 }
733
734 let view = cx.view().clone();
735 let scroll_handle = self.list.clone();
736 this.child(
737 canvas(
738 move |bounds, cx| {
739 let mut list = uniform_list::<_, ExtensionCard, _>(
740 view,
741 "entries",
742 count,
743 Self::render_extensions,
744 )
745 .size_full()
746 .pb_4()
747 .track_scroll(scroll_handle)
748 .into_any_element();
749 list.layout(bounds.origin, bounds.size.into(), cx);
750 list
751 },
752 |_bounds, mut list, cx| list.paint(cx),
753 )
754 .size_full(),
755 )
756 }))
757 }
758}
759
760impl EventEmitter<ItemEvent> for ExtensionsPage {}
761
762impl FocusableView for ExtensionsPage {
763 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
764 self.query_editor.read(cx).focus_handle(cx)
765 }
766}
767
768impl Item for ExtensionsPage {
769 type Event = ItemEvent;
770
771 fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
772 Label::new("Extensions")
773 .color(if selected {
774 Color::Default
775 } else {
776 Color::Muted
777 })
778 .into_any_element()
779 }
780
781 fn telemetry_event_text(&self) -> Option<&'static str> {
782 Some("extensions page")
783 }
784
785 fn show_toolbar(&self) -> bool {
786 false
787 }
788
789 fn clone_on_split(
790 &self,
791 _workspace_id: WorkspaceId,
792 _: &mut ViewContext<Self>,
793 ) -> Option<View<Self>> {
794 None
795 }
796
797 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
798 f(*event)
799 }
800}