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