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