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