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