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