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