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