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