1mod components;
2mod extension_suggest;
3mod extension_version_selector;
4
5use std::ops::DerefMut;
6use std::sync::OnceLock;
7use std::time::Duration;
8use std::{ops::Range, sync::Arc};
9
10use client::telemetry::Telemetry;
11use client::ExtensionMetadata;
12use collections::{BTreeMap, BTreeSet};
13use editor::{Editor, EditorElement, EditorStyle};
14use extension::{ExtensionManifest, ExtensionOperation, ExtensionStore};
15use fuzzy::{match_strings, StringMatchCandidate};
16use gpui::{
17 actions, uniform_list, AnyElement, AppContext, EventEmitter, Flatten, FocusableView, FontStyle,
18 InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle,
19 UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, WhiteSpace, WindowContext,
20};
21use num_format::{Locale, ToFormattedString};
22use release_channel::ReleaseChannel;
23use settings::Settings;
24use theme::ThemeSettings;
25use ui::{prelude::*, CheckboxWithLabel, ContextMenu, PopoverMenu, ToggleButton, Tooltip};
26use vim::VimModeSetting;
27use workspace::item::TabContentParams;
28use workspace::{
29 item::{Item, ItemEvent},
30 Workspace, WorkspaceId,
31};
32
33use crate::components::{ExtensionCard, FeatureUpsell};
34use crate::extension_version_selector::{
35 ExtensionVersionSelector, ExtensionVersionSelectorDelegate,
36};
37
38actions!(zed, [Extensions, InstallDevExtension]);
39
40pub fn init(cx: &mut AppContext) {
41 cx.observe_new_views(move |workspace: &mut Workspace, cx| {
42 workspace
43 .register_action(move |workspace, _: &Extensions, cx| {
44 let existing = workspace
45 .active_pane()
46 .read(cx)
47 .items()
48 .find_map(|item| item.downcast::<ExtensionsPage>());
49
50 if let Some(existing) = existing {
51 workspace.activate_item(&existing, cx);
52 } else {
53 let extensions_page = ExtensionsPage::new(workspace, cx);
54 workspace.add_item_to_active_pane(Box::new(extensions_page), None, cx)
55 }
56 })
57 .register_action(move |_, _: &InstallDevExtension, cx| {
58 let store = ExtensionStore::global(cx);
59 let prompt = cx.prompt_for_paths(gpui::PathPromptOptions {
60 files: false,
61 directories: true,
62 multiple: false,
63 });
64
65 let workspace_handle = cx.view().downgrade();
66 cx.deref_mut()
67 .spawn(|mut cx| async move {
68 let extension_path =
69 match Flatten::flatten(prompt.await.map_err(|e| e.into())) {
70 Ok(Some(mut paths)) => paths.pop()?,
71 Ok(None) => return None,
72 Err(err) => {
73 workspace_handle
74 .update(&mut cx, |workspace, cx| {
75 workspace.show_portal_error(err.to_string(), cx);
76 })
77 .ok();
78 return None;
79 }
80 };
81
82 store
83 .update(&mut cx, |store, cx| {
84 store
85 .install_dev_extension(extension_path, cx)
86 .detach_and_log_err(cx)
87 })
88 .ok()?;
89 Some(())
90 })
91 .detach();
92 });
93
94 cx.subscribe(workspace.project(), |_, _, event, cx| match event {
95 project::Event::LanguageNotFound(buffer) => {
96 extension_suggest::suggest(buffer.clone(), cx);
97 }
98 _ => {}
99 })
100 .detach();
101 })
102 .detach();
103}
104
105#[derive(Clone)]
106pub enum ExtensionStatus {
107 NotInstalled,
108 Installing,
109 Upgrading,
110 Installed(Arc<str>),
111 Removing,
112}
113
114#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
115enum ExtensionFilter {
116 All,
117 Installed,
118 NotInstalled,
119}
120
121impl ExtensionFilter {
122 pub fn include_dev_extensions(&self) -> bool {
123 match self {
124 Self::All | Self::Installed => true,
125 Self::NotInstalled => false,
126 }
127 }
128}
129
130#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
131enum Feature {
132 Git,
133 Vim,
134 LanguageC,
135 LanguageCpp,
136 LanguagePython,
137 LanguageRust,
138}
139
140fn keywords_by_feature() -> &'static BTreeMap<Feature, Vec<&'static str>> {
141 static KEYWORDS_BY_FEATURE: OnceLock<BTreeMap<Feature, Vec<&'static str>>> = OnceLock::new();
142 KEYWORDS_BY_FEATURE.get_or_init(|| {
143 BTreeMap::from_iter([
144 (Feature::Git, vec!["git"]),
145 (Feature::Vim, vec!["vim"]),
146 (Feature::LanguageC, vec!["c", "clang"]),
147 (Feature::LanguageCpp, vec!["c++", "cpp", "clang"]),
148 (Feature::LanguagePython, vec!["python", "py"]),
149 (Feature::LanguageRust, vec!["rust", "rs"]),
150 ])
151 })
152}
153
154pub struct ExtensionsPage {
155 workspace: WeakView<Workspace>,
156 list: UniformListScrollHandle,
157 telemetry: Arc<Telemetry>,
158 is_fetching_extensions: bool,
159 filter: ExtensionFilter,
160 remote_extension_entries: Vec<ExtensionMetadata>,
161 dev_extension_entries: Vec<Arc<ExtensionManifest>>,
162 filtered_remote_extension_indices: Vec<usize>,
163 query_editor: View<Editor>,
164 query_contains_error: bool,
165 _subscriptions: [gpui::Subscription; 2],
166 extension_fetch_task: Option<Task<()>>,
167 upsells: BTreeSet<Feature>,
168}
169
170impl ExtensionsPage {
171 pub fn new(workspace: &Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
172 cx.new_view(|cx: &mut ViewContext<Self>| {
173 let store = ExtensionStore::global(cx);
174 let workspace_handle = workspace.weak_handle();
175 let subscriptions = [
176 cx.observe(&store, |_, _, cx| cx.notify()),
177 cx.subscribe(&store, move |this, _, event, cx| match event {
178 extension::Event::ExtensionsUpdated => this.fetch_extensions_debounced(cx),
179 extension::Event::ExtensionInstalled(extension_id) => {
180 this.on_extension_installed(workspace_handle.clone(), extension_id, cx)
181 }
182 _ => {}
183 }),
184 ];
185
186 let query_editor = cx.new_view(|cx| {
187 let mut input = Editor::single_line(cx);
188 input.set_placeholder_text("Search extensions...", cx);
189 input
190 });
191 cx.subscribe(&query_editor, Self::on_query_change).detach();
192
193 let mut this = Self {
194 workspace: workspace.weak_handle(),
195 list: UniformListScrollHandle::new(),
196 telemetry: workspace.client().telemetry().clone(),
197 is_fetching_extensions: false,
198 filter: ExtensionFilter::All,
199 dev_extension_entries: Vec::new(),
200 filtered_remote_extension_indices: Vec::new(),
201 remote_extension_entries: Vec::new(),
202 query_contains_error: false,
203 extension_fetch_task: None,
204 _subscriptions: subscriptions,
205 query_editor,
206 upsells: BTreeSet::default(),
207 };
208 this.fetch_extensions(None, cx);
209 this
210 })
211 }
212
213 fn on_extension_installed(
214 &mut self,
215 workspace: WeakView<Workspace>,
216 extension_id: &str,
217 cx: &mut ViewContext<Self>,
218 ) {
219 let extension_store = ExtensionStore::global(cx).read(cx);
220 let themes = extension_store
221 .extension_themes(extension_id)
222 .map(|name| name.to_string())
223 .collect::<Vec<_>>();
224 if !themes.is_empty() {
225 workspace
226 .update(cx, |workspace, cx| {
227 theme_selector::toggle(
228 workspace,
229 &theme_selector::Toggle {
230 themes_filter: Some(themes),
231 },
232 cx,
233 )
234 })
235 .ok();
236 }
237 }
238
239 /// Returns whether a dev extension currently exists for the extension with the given ID.
240 fn dev_extension_exists(extension_id: &str, cx: &mut ViewContext<Self>) -> bool {
241 let extension_store = ExtensionStore::global(cx).read(cx);
242
243 extension_store
244 .dev_extensions()
245 .any(|dev_extension| dev_extension.id.as_ref() == extension_id)
246 }
247
248 fn extension_status(extension_id: &str, cx: &mut ViewContext<Self>) -> ExtensionStatus {
249 let extension_store = ExtensionStore::global(cx).read(cx);
250
251 match extension_store.outstanding_operations().get(extension_id) {
252 Some(ExtensionOperation::Install) => ExtensionStatus::Installing,
253 Some(ExtensionOperation::Remove) => ExtensionStatus::Removing,
254 Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading,
255 None => match extension_store.installed_extensions().get(extension_id) {
256 Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()),
257 None => ExtensionStatus::NotInstalled,
258 },
259 }
260 }
261
262 fn filter_extension_entries(&mut self, cx: &mut ViewContext<Self>) {
263 self.filtered_remote_extension_indices.clear();
264 self.filtered_remote_extension_indices.extend(
265 self.remote_extension_entries
266 .iter()
267 .enumerate()
268 .filter(|(_, extension)| match self.filter {
269 ExtensionFilter::All => true,
270 ExtensionFilter::Installed => {
271 let status = Self::extension_status(&extension.id, cx);
272 matches!(status, ExtensionStatus::Installed(_))
273 }
274 ExtensionFilter::NotInstalled => {
275 let status = Self::extension_status(&extension.id, cx);
276
277 matches!(status, ExtensionStatus::NotInstalled)
278 }
279 })
280 .map(|(ix, _)| ix),
281 );
282 cx.notify();
283 }
284
285 fn fetch_extensions(&mut self, search: Option<String>, cx: &mut ViewContext<Self>) {
286 self.is_fetching_extensions = true;
287 cx.notify();
288
289 let extension_store = ExtensionStore::global(cx);
290
291 let dev_extensions = extension_store.update(cx, |store, _| {
292 store.dev_extensions().cloned().collect::<Vec<_>>()
293 });
294
295 let remote_extensions = extension_store.update(cx, |store, cx| {
296 store.fetch_extensions(search.as_deref(), cx)
297 });
298
299 cx.spawn(move |this, mut cx| async move {
300 let dev_extensions = if let Some(search) = search {
301 let match_candidates = dev_extensions
302 .iter()
303 .enumerate()
304 .map(|(ix, manifest)| StringMatchCandidate {
305 id: ix,
306 string: manifest.name.clone(),
307 char_bag: manifest.name.as_str().into(),
308 })
309 .collect::<Vec<_>>();
310
311 let matches = match_strings(
312 &match_candidates,
313 &search,
314 false,
315 match_candidates.len(),
316 &Default::default(),
317 cx.background_executor().clone(),
318 )
319 .await;
320 matches
321 .into_iter()
322 .map(|mat| dev_extensions[mat.candidate_id].clone())
323 .collect()
324 } else {
325 dev_extensions
326 };
327
328 let fetch_result = remote_extensions.await;
329 this.update(&mut cx, |this, cx| {
330 cx.notify();
331 this.dev_extension_entries = dev_extensions;
332 this.is_fetching_extensions = false;
333 this.remote_extension_entries = fetch_result?;
334 this.filter_extension_entries(cx);
335 anyhow::Ok(())
336 })?
337 })
338 .detach_and_log_err(cx);
339 }
340
341 fn render_extensions(
342 &mut self,
343 range: Range<usize>,
344 cx: &mut ViewContext<Self>,
345 ) -> Vec<ExtensionCard> {
346 let dev_extension_entries_len = if self.filter.include_dev_extensions() {
347 self.dev_extension_entries.len()
348 } else {
349 0
350 };
351 range
352 .map(|ix| {
353 if ix < dev_extension_entries_len {
354 let extension = &self.dev_extension_entries[ix];
355 self.render_dev_extension(extension, cx)
356 } else {
357 let extension_ix =
358 self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
359 let extension = &self.remote_extension_entries[extension_ix];
360 self.render_remote_extension(extension, cx)
361 }
362 })
363 .collect()
364 }
365
366 fn render_dev_extension(
367 &self,
368 extension: &ExtensionManifest,
369 cx: &mut ViewContext<Self>,
370 ) -> ExtensionCard {
371 let status = Self::extension_status(&extension.id, cx);
372
373 let repository_url = extension.repository.clone();
374
375 ExtensionCard::new()
376 .child(
377 h_flex()
378 .justify_between()
379 .child(
380 h_flex()
381 .gap_2()
382 .items_end()
383 .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
384 .child(
385 Headline::new(format!("v{}", extension.version))
386 .size(HeadlineSize::XSmall),
387 ),
388 )
389 .child(
390 h_flex()
391 .gap_2()
392 .justify_between()
393 .child(
394 Button::new(
395 SharedString::from(format!("rebuild-{}", extension.id)),
396 "Rebuild",
397 )
398 .on_click({
399 let extension_id = extension.id.clone();
400 move |_, cx| {
401 ExtensionStore::global(cx).update(cx, |store, cx| {
402 store.rebuild_dev_extension(extension_id.clone(), cx)
403 });
404 }
405 })
406 .color(Color::Accent)
407 .disabled(matches!(status, ExtensionStatus::Upgrading)),
408 )
409 .child(
410 Button::new(SharedString::from(extension.id.clone()), "Uninstall")
411 .on_click({
412 let extension_id = extension.id.clone();
413 move |_, cx| {
414 ExtensionStore::global(cx).update(cx, |store, cx| {
415 store.uninstall_extension(extension_id.clone(), cx)
416 });
417 }
418 })
419 .color(Color::Accent)
420 .disabled(matches!(status, ExtensionStatus::Removing)),
421 ),
422 ),
423 )
424 .child(
425 h_flex()
426 .justify_between()
427 .child(
428 Label::new(format!(
429 "{}: {}",
430 if extension.authors.len() > 1 {
431 "Authors"
432 } else {
433 "Author"
434 },
435 extension.authors.join(", ")
436 ))
437 .size(LabelSize::Small),
438 )
439 .child(Label::new("<>").size(LabelSize::Small)),
440 )
441 .child(
442 h_flex()
443 .justify_between()
444 .children(extension.description.as_ref().map(|description| {
445 Label::new(description.clone())
446 .size(LabelSize::Small)
447 .color(Color::Default)
448 }))
449 .children(repository_url.map(|repository_url| {
450 IconButton::new(
451 SharedString::from(format!("repository-{}", extension.id)),
452 IconName::Github,
453 )
454 .icon_color(Color::Accent)
455 .icon_size(IconSize::Small)
456 .style(ButtonStyle::Filled)
457 .on_click(cx.listener({
458 let repository_url = repository_url.clone();
459 move |_, _, cx| {
460 cx.open_url(&repository_url);
461 }
462 }))
463 .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx))
464 })),
465 )
466 }
467
468 fn render_remote_extension(
469 &self,
470 extension: &ExtensionMetadata,
471 cx: &mut ViewContext<Self>,
472 ) -> ExtensionCard {
473 let this = cx.view().clone();
474 let status = Self::extension_status(&extension.id, cx);
475 let has_dev_extension = Self::dev_extension_exists(&extension.id, cx);
476
477 let extension_id = extension.id.clone();
478 let (install_or_uninstall_button, upgrade_button) =
479 self.buttons_for_entry(extension, &status, has_dev_extension, cx);
480 let version = extension.manifest.version.clone();
481 let repository_url = extension.manifest.repository.clone();
482
483 let installed_version = match status {
484 ExtensionStatus::Installed(installed_version) => Some(installed_version),
485 _ => None,
486 };
487
488 ExtensionCard::new()
489 .overridden_by_dev_extension(has_dev_extension)
490 .child(
491 h_flex()
492 .justify_between()
493 .child(
494 h_flex()
495 .gap_2()
496 .items_end()
497 .child(
498 Headline::new(extension.manifest.name.clone())
499 .size(HeadlineSize::Medium),
500 )
501 .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
502 .children(
503 installed_version
504 .filter(|installed_version| *installed_version != version)
505 .map(|installed_version| {
506 Headline::new(format!("(v{installed_version} installed)",))
507 .size(HeadlineSize::XSmall)
508 }),
509 ),
510 )
511 .child(
512 h_flex()
513 .gap_2()
514 .justify_between()
515 .children(upgrade_button)
516 .child(install_or_uninstall_button),
517 ),
518 )
519 .child(
520 h_flex()
521 .justify_between()
522 .child(
523 Label::new(format!(
524 "{}: {}",
525 if extension.manifest.authors.len() > 1 {
526 "Authors"
527 } else {
528 "Author"
529 },
530 extension.manifest.authors.join(", ")
531 ))
532 .size(LabelSize::Small),
533 )
534 .child(
535 Label::new(format!(
536 "Downloads: {}",
537 extension.download_count.to_formatted_string(&Locale::en)
538 ))
539 .size(LabelSize::Small),
540 ),
541 )
542 .child(
543 h_flex()
544 .gap_2()
545 .justify_between()
546 .children(extension.manifest.description.as_ref().map(|description| {
547 h_flex().overflow_x_hidden().child(
548 Label::new(description.clone())
549 .size(LabelSize::Small)
550 .color(Color::Default),
551 )
552 }))
553 .child(
554 h_flex()
555 .gap_2()
556 .child(
557 IconButton::new(
558 SharedString::from(format!("repository-{}", extension.id)),
559 IconName::Github,
560 )
561 .icon_color(Color::Accent)
562 .icon_size(IconSize::Small)
563 .style(ButtonStyle::Filled)
564 .on_click(cx.listener({
565 let repository_url = repository_url.clone();
566 move |_, _, cx| {
567 cx.open_url(&repository_url);
568 }
569 }))
570 .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx)),
571 )
572 .child(
573 PopoverMenu::new(SharedString::from(format!(
574 "more-{}",
575 extension.id
576 )))
577 .trigger(
578 IconButton::new(
579 SharedString::from(format!("more-{}", extension.id)),
580 IconName::Ellipsis,
581 )
582 .icon_color(Color::Accent)
583 .icon_size(IconSize::Small)
584 .style(ButtonStyle::Filled),
585 )
586 .menu(move |cx| {
587 Some(Self::render_remote_extension_context_menu(
588 &this,
589 extension_id.clone(),
590 cx,
591 ))
592 }),
593 ),
594 ),
595 )
596 }
597
598 fn render_remote_extension_context_menu(
599 this: &View<Self>,
600 extension_id: Arc<str>,
601 cx: &mut WindowContext,
602 ) -> View<ContextMenu> {
603 let context_menu = ContextMenu::build(cx, |context_menu, cx| {
604 context_menu.entry(
605 "Install Another Version...",
606 None,
607 cx.handler_for(&this, move |this, cx| {
608 this.show_extension_version_list(extension_id.clone(), cx)
609 }),
610 )
611 });
612
613 context_menu
614 }
615
616 fn show_extension_version_list(&mut self, extension_id: Arc<str>, cx: &mut ViewContext<Self>) {
617 let Some(workspace) = self.workspace.upgrade() else {
618 return;
619 };
620
621 cx.spawn(move |this, mut cx| async move {
622 let extension_versions_task = this.update(&mut cx, |_, cx| {
623 let extension_store = ExtensionStore::global(cx);
624
625 extension_store.update(cx, |store, cx| {
626 store.fetch_extension_versions(&extension_id, cx)
627 })
628 })?;
629
630 let extension_versions = extension_versions_task.await?;
631
632 workspace.update(&mut cx, |workspace, cx| {
633 let fs = workspace.project().read(cx).fs().clone();
634 workspace.toggle_modal(cx, |cx| {
635 let delegate = ExtensionVersionSelectorDelegate::new(
636 fs,
637 cx.view().downgrade(),
638 extension_versions,
639 );
640
641 ExtensionVersionSelector::new(delegate, cx)
642 });
643 })?;
644
645 anyhow::Ok(())
646 })
647 .detach_and_log_err(cx);
648 }
649
650 fn buttons_for_entry(
651 &self,
652 extension: &ExtensionMetadata,
653 status: &ExtensionStatus,
654 has_dev_extension: bool,
655 cx: &mut ViewContext<Self>,
656 ) -> (Button, Option<Button>) {
657 let is_compatible =
658 extension::is_version_compatible(ReleaseChannel::global(cx), &extension);
659
660 if has_dev_extension {
661 // If we have a dev extension for the given extension, just treat it as uninstalled.
662 // The button here is a placeholder, as it won't be interactable anyways.
663 return (
664 Button::new(SharedString::from(extension.id.clone()), "Install"),
665 None,
666 );
667 }
668
669 match status.clone() {
670 ExtensionStatus::NotInstalled => (
671 Button::new(SharedString::from(extension.id.clone()), "Install").on_click(
672 cx.listener({
673 let extension_id = extension.id.clone();
674 move |this, _, cx| {
675 this.telemetry
676 .report_app_event("extensions: install extension".to_string());
677 ExtensionStore::global(cx).update(cx, |store, cx| {
678 store.install_latest_extension(extension_id.clone(), cx)
679 });
680 }
681 }),
682 ),
683 None,
684 ),
685 ExtensionStatus::Installing => (
686 Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
687 None,
688 ),
689 ExtensionStatus::Upgrading => (
690 Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
691 Some(
692 Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
693 ),
694 ),
695 ExtensionStatus::Installed(installed_version) => (
696 Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click(
697 cx.listener({
698 let extension_id = extension.id.clone();
699 move |this, _, cx| {
700 this.telemetry
701 .report_app_event("extensions: uninstall extension".to_string());
702 ExtensionStore::global(cx).update(cx, |store, cx| {
703 store.uninstall_extension(extension_id.clone(), cx)
704 });
705 }
706 }),
707 ),
708 if installed_version == extension.manifest.version {
709 None
710 } else {
711 Some(
712 Button::new(SharedString::from(extension.id.clone()), "Upgrade")
713 .when(!is_compatible, |upgrade_button| {
714 upgrade_button.disabled(true).tooltip({
715 let version = extension.manifest.version.clone();
716 move |cx| {
717 Tooltip::text(
718 format!(
719 "v{version} is not compatible with this version of Zed.",
720 ),
721 cx,
722 )
723 }
724 })
725 })
726 .disabled(!is_compatible)
727 .on_click(cx.listener({
728 let extension_id = extension.id.clone();
729 let version = extension.manifest.version.clone();
730 move |this, _, cx| {
731 this.telemetry.report_app_event(
732 "extensions: install extension".to_string(),
733 );
734 ExtensionStore::global(cx).update(cx, |store, cx| {
735 store
736 .upgrade_extension(
737 extension_id.clone(),
738 version.clone(),
739 cx,
740 )
741 .detach_and_log_err(cx)
742 });
743 }
744 })),
745 )
746 },
747 ),
748 ExtensionStatus::Removing => (
749 Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
750 None,
751 ),
752 }
753 }
754
755 fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
756 let mut key_context = KeyContext::new_with_defaults();
757 key_context.add("BufferSearchBar");
758
759 let editor_border = if self.query_contains_error {
760 Color::Error.color(cx)
761 } else {
762 cx.theme().colors().border
763 };
764
765 h_flex().w_full().gap_2().key_context(key_context).child(
766 h_flex()
767 .flex_1()
768 .px_2()
769 .py_1()
770 .gap_2()
771 .border_1()
772 .border_color(editor_border)
773 .min_w(rems_from_px(384.))
774 .rounded_lg()
775 .child(Icon::new(IconName::MagnifyingGlass))
776 .child(self.render_text_input(&self.query_editor, cx)),
777 )
778 }
779
780 fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
781 let settings = ThemeSettings::get_global(cx);
782 let text_style = TextStyle {
783 color: if editor.read(cx).read_only(cx) {
784 cx.theme().colors().text_disabled
785 } else {
786 cx.theme().colors().text
787 },
788 font_family: settings.ui_font.family.clone(),
789 font_features: settings.ui_font.features.clone(),
790 font_size: rems(0.875).into(),
791 font_weight: settings.ui_font.weight,
792 font_style: FontStyle::Normal,
793 line_height: relative(1.3),
794 background_color: None,
795 underline: None,
796 strikethrough: None,
797 white_space: WhiteSpace::Normal,
798 };
799
800 EditorElement::new(
801 &editor,
802 EditorStyle {
803 background: cx.theme().colors().editor_background,
804 local_player: cx.theme().players().local(),
805 text: text_style,
806 ..Default::default()
807 },
808 )
809 }
810
811 fn on_query_change(
812 &mut self,
813 _: View<Editor>,
814 event: &editor::EditorEvent,
815 cx: &mut ViewContext<Self>,
816 ) {
817 if let editor::EditorEvent::Edited { .. } = event {
818 self.query_contains_error = false;
819 self.fetch_extensions_debounced(cx);
820 self.refresh_feature_upsells(cx);
821 }
822 }
823
824 fn fetch_extensions_debounced(&mut self, cx: &mut ViewContext<'_, ExtensionsPage>) {
825 self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
826 let search = this
827 .update(&mut cx, |this, cx| this.search_query(cx))
828 .ok()
829 .flatten();
830
831 // Only debounce the fetching of extensions if we have a search
832 // query.
833 //
834 // If the search was just cleared then we can just reload the list
835 // of extensions without a debounce, which allows us to avoid seeing
836 // an intermittent flash of a "no extensions" state.
837 if let Some(_) = search {
838 cx.background_executor()
839 .timer(Duration::from_millis(250))
840 .await;
841 };
842
843 this.update(&mut cx, |this, cx| {
844 this.fetch_extensions(search, cx);
845 })
846 .ok();
847 }));
848 }
849
850 pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
851 let search = self.query_editor.read(cx).text(cx);
852 if search.trim().is_empty() {
853 None
854 } else {
855 Some(search)
856 }
857 }
858
859 fn render_empty_state(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
860 let has_search = self.search_query(cx).is_some();
861
862 let message = if self.is_fetching_extensions {
863 "Loading extensions..."
864 } else {
865 match self.filter {
866 ExtensionFilter::All => {
867 if has_search {
868 "No extensions that match your search."
869 } else {
870 "No extensions."
871 }
872 }
873 ExtensionFilter::Installed => {
874 if has_search {
875 "No installed extensions that match your search."
876 } else {
877 "No installed extensions."
878 }
879 }
880 ExtensionFilter::NotInstalled => {
881 if has_search {
882 "No not installed extensions that match your search."
883 } else {
884 "No not installed extensions."
885 }
886 }
887 }
888 };
889
890 Label::new(message)
891 }
892
893 fn update_settings<T: Settings>(
894 &mut self,
895 selection: &Selection,
896 cx: &mut ViewContext<Self>,
897 callback: impl 'static + Send + Fn(&mut T::FileContent, bool),
898 ) {
899 if let Some(workspace) = self.workspace.upgrade() {
900 let fs = workspace.read(cx).app_state().fs.clone();
901 let selection = *selection;
902 settings::update_settings_file::<T>(fs, cx, move |settings| {
903 let value = match selection {
904 Selection::Unselected => false,
905 Selection::Selected => true,
906 _ => return,
907 };
908
909 callback(settings, value)
910 });
911 }
912 }
913
914 fn refresh_feature_upsells(&mut self, cx: &mut ViewContext<Self>) {
915 let Some(search) = self.search_query(cx) else {
916 self.upsells.clear();
917 return;
918 };
919
920 let search = search.to_lowercase();
921 let search_terms = search
922 .split_whitespace()
923 .map(|term| term.trim())
924 .collect::<Vec<_>>();
925
926 for (feature, keywords) in keywords_by_feature() {
927 if keywords
928 .iter()
929 .any(|keyword| search_terms.contains(keyword))
930 {
931 self.upsells.insert(*feature);
932 } else {
933 self.upsells.remove(&feature);
934 }
935 }
936 }
937
938 fn render_feature_upsells(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
939 let upsells_count = self.upsells.len();
940
941 v_flex().children(self.upsells.iter().enumerate().map(|(ix, feature)| {
942 let upsell = match feature {
943 Feature::Git => FeatureUpsell::new("Zed comes with basic Git support for diffs and branches. More Git features are coming in the future."),
944 Feature::Vim => FeatureUpsell::new("Vim support is built-in to Zed!")
945 .docs_url("https://zed.dev/docs/vim")
946 .child(CheckboxWithLabel::new(
947 "enable-vim",
948 Label::new("Enable vim mode"),
949 if VimModeSetting::get_global(cx).0 {
950 ui::Selection::Selected
951 } else {
952 ui::Selection::Unselected
953 },
954 cx.listener(move |this, selection, cx| {
955 this.telemetry
956 .report_app_event("extensions: toggle vim".to_string());
957 this.update_settings::<VimModeSetting>(
958 selection,
959 cx,
960 |setting, value| *setting = Some(value),
961 );
962 }),
963 )),
964 Feature::LanguageC => FeatureUpsell::new("C support is built-in to Zed!")
965 .docs_url("https://zed.dev/docs/languages/c"),
966 Feature::LanguageCpp => FeatureUpsell::new("C++ support is built-in to Zed!")
967 .docs_url("https://zed.dev/docs/languages/cpp"),
968 Feature::LanguagePython => FeatureUpsell::new("Python support is built-in to Zed!")
969 .docs_url("https://zed.dev/docs/languages/python"),
970 Feature::LanguageRust => FeatureUpsell::new("Rust support is built-in to Zed!")
971 .docs_url("https://zed.dev/docs/languages/rust"),
972 };
973
974 upsell.when(ix < upsells_count, |upsell| upsell.border_b_1())
975 }))
976 }
977}
978
979impl Render for ExtensionsPage {
980 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
981 v_flex()
982 .size_full()
983 .bg(cx.theme().colors().editor_background)
984 .child(
985 v_flex()
986 .gap_4()
987 .p_4()
988 .border_b_1()
989 .border_color(cx.theme().colors().border)
990 .bg(cx.theme().colors().editor_background)
991 .child(
992 h_flex()
993 .w_full()
994 .gap_2()
995 .justify_between()
996 .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
997 .child(
998 Button::new("install-dev-extension", "Install Dev Extension")
999 .style(ButtonStyle::Filled)
1000 .size(ButtonSize::Large)
1001 .on_click(|_event, cx| {
1002 cx.dispatch_action(Box::new(InstallDevExtension))
1003 }),
1004 ),
1005 )
1006 .child(
1007 h_flex()
1008 .w_full()
1009 .gap_2()
1010 .justify_between()
1011 .child(h_flex().child(self.render_search(cx)))
1012 .child(
1013 h_flex()
1014 .child(
1015 ToggleButton::new("filter-all", "All")
1016 .style(ButtonStyle::Filled)
1017 .size(ButtonSize::Large)
1018 .selected(self.filter == ExtensionFilter::All)
1019 .on_click(cx.listener(|this, _event, cx| {
1020 this.filter = ExtensionFilter::All;
1021 this.filter_extension_entries(cx);
1022 }))
1023 .tooltip(move |cx| {
1024 Tooltip::text("Show all extensions", cx)
1025 })
1026 .first(),
1027 )
1028 .child(
1029 ToggleButton::new("filter-installed", "Installed")
1030 .style(ButtonStyle::Filled)
1031 .size(ButtonSize::Large)
1032 .selected(self.filter == ExtensionFilter::Installed)
1033 .on_click(cx.listener(|this, _event, cx| {
1034 this.filter = ExtensionFilter::Installed;
1035 this.filter_extension_entries(cx);
1036 }))
1037 .tooltip(move |cx| {
1038 Tooltip::text("Show installed extensions", cx)
1039 })
1040 .middle(),
1041 )
1042 .child(
1043 ToggleButton::new("filter-not-installed", "Not Installed")
1044 .style(ButtonStyle::Filled)
1045 .size(ButtonSize::Large)
1046 .selected(self.filter == ExtensionFilter::NotInstalled)
1047 .on_click(cx.listener(|this, _event, cx| {
1048 this.filter = ExtensionFilter::NotInstalled;
1049 this.filter_extension_entries(cx);
1050 }))
1051 .tooltip(move |cx| {
1052 Tooltip::text("Show not installed extensions", cx)
1053 })
1054 .last(),
1055 ),
1056 ),
1057 ),
1058 )
1059 .child(self.render_feature_upsells(cx))
1060 .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
1061 let mut count = self.filtered_remote_extension_indices.len();
1062 if self.filter.include_dev_extensions() {
1063 count += self.dev_extension_entries.len();
1064 }
1065
1066 if count == 0 {
1067 return this.py_4().child(self.render_empty_state(cx));
1068 }
1069
1070 let view = cx.view().clone();
1071 let scroll_handle = self.list.clone();
1072 this.child(
1073 uniform_list(view, "entries", count, Self::render_extensions)
1074 .flex_grow()
1075 .pb_4()
1076 .track_scroll(scroll_handle),
1077 )
1078 }))
1079 }
1080}
1081
1082impl EventEmitter<ItemEvent> for ExtensionsPage {}
1083
1084impl FocusableView for ExtensionsPage {
1085 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
1086 self.query_editor.read(cx).focus_handle(cx)
1087 }
1088}
1089
1090impl Item for ExtensionsPage {
1091 type Event = ItemEvent;
1092
1093 fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement {
1094 Label::new("Extensions")
1095 .color(if params.selected {
1096 Color::Default
1097 } else {
1098 Color::Muted
1099 })
1100 .into_any_element()
1101 }
1102
1103 fn telemetry_event_text(&self) -> Option<&'static str> {
1104 Some("extensions page")
1105 }
1106
1107 fn show_toolbar(&self) -> bool {
1108 false
1109 }
1110
1111 fn clone_on_split(
1112 &self,
1113 _workspace_id: Option<WorkspaceId>,
1114 _: &mut ViewContext<Self>,
1115 ) -> Option<View<Self>> {
1116 None
1117 }
1118
1119 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
1120 f(*event)
1121 }
1122}