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, ExtensionProvides};
10use collections::{BTreeMap, BTreeSet};
11use editor::{Editor, EditorElement, EditorStyle};
12use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore};
13use fuzzy::{StringMatchCandidate, match_strings};
14use gpui::{
15 Action, App, ClipboardItem, Context, Entity, EventEmitter, Flatten, Focusable,
16 InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle,
17 UniformListScrollHandle, WeakEntity, Window, actions, point, uniform_list,
18};
19use num_format::{Locale, ToFormattedString};
20use project::DirectoryLister;
21use release_channel::ReleaseChannel;
22use settings::Settings;
23use strum::IntoEnumIterator as _;
24use theme::ThemeSettings;
25use ui::{
26 CheckboxWithLabel, ContextMenu, PopoverMenu, ScrollableHandle, Scrollbar, ScrollbarState,
27 ToggleButton, Tooltip, prelude::*,
28};
29use vim_mode_setting::VimModeSetting;
30use workspace::{
31 Workspace, WorkspaceId,
32 item::{Item, ItemEvent},
33};
34use zed_actions::ExtensionCategoryFilter;
35
36use crate::components::{ExtensionCard, FeatureUpsell};
37use crate::extension_version_selector::{
38 ExtensionVersionSelector, ExtensionVersionSelectorDelegate,
39};
40
41actions!(zed, [InstallDevExtension]);
42
43pub fn init(cx: &mut App) {
44 cx.observe_new(move |workspace: &mut Workspace, window, cx| {
45 let Some(window) = window else {
46 return;
47 };
48 workspace
49 .register_action(
50 move |workspace, action: &zed_actions::Extensions, window, cx| {
51 let provides_filter = action.category_filter.map(|category| match category {
52 ExtensionCategoryFilter::Themes => ExtensionProvides::Themes,
53 ExtensionCategoryFilter::IconThemes => ExtensionProvides::IconThemes,
54 ExtensionCategoryFilter::Languages => ExtensionProvides::Languages,
55 ExtensionCategoryFilter::Grammars => ExtensionProvides::Grammars,
56 ExtensionCategoryFilter::LanguageServers => {
57 ExtensionProvides::LanguageServers
58 }
59 ExtensionCategoryFilter::ContextServers => {
60 ExtensionProvides::ContextServers
61 }
62 ExtensionCategoryFilter::SlashCommands => ExtensionProvides::SlashCommands,
63 ExtensionCategoryFilter::IndexedDocsProviders => {
64 ExtensionProvides::IndexedDocsProviders
65 }
66 ExtensionCategoryFilter::Snippets => ExtensionProvides::Snippets,
67 });
68
69 let existing = workspace
70 .active_pane()
71 .read(cx)
72 .items()
73 .find_map(|item| item.downcast::<ExtensionsPage>());
74
75 if let Some(existing) = existing {
76 if provides_filter.is_some() {
77 existing.update(cx, |extensions_page, cx| {
78 extensions_page.change_provides_filter(provides_filter, cx);
79 });
80 }
81
82 workspace.activate_item(&existing, true, true, window, cx);
83 } else {
84 let extensions_page =
85 ExtensionsPage::new(workspace, provides_filter, window, cx);
86 workspace.add_item_to_active_pane(
87 Box::new(extensions_page),
88 None,
89 true,
90 window,
91 cx,
92 )
93 }
94 },
95 )
96 .register_action(move |workspace, _: &InstallDevExtension, window, cx| {
97 let store = ExtensionStore::global(cx);
98 let prompt = workspace.prompt_for_open_path(
99 gpui::PathPromptOptions {
100 files: false,
101 directories: true,
102 multiple: false,
103 },
104 DirectoryLister::Local(workspace.app_state().fs.clone()),
105 window,
106 cx,
107 );
108
109 let workspace_handle = cx.entity().downgrade();
110 window
111 .spawn(cx, async move |cx| {
112 let extension_path =
113 match Flatten::flatten(prompt.await.map_err(|e| e.into())) {
114 Ok(Some(mut paths)) => paths.pop()?,
115 Ok(None) => return None,
116 Err(err) => {
117 workspace_handle
118 .update(cx, |workspace, cx| {
119 workspace.show_portal_error(err.to_string(), cx);
120 })
121 .ok();
122 return None;
123 }
124 };
125
126 let install_task = store
127 .update(cx, |store, cx| {
128 store.install_dev_extension(extension_path, cx)
129 })
130 .ok()?;
131
132 match install_task.await {
133 Ok(_) => {}
134 Err(err) => {
135 log::error!("Failed to install dev extension: {:?}", err);
136 workspace_handle
137 .update(cx, |workspace, cx| {
138 workspace.show_error(
139 // NOTE: using `anyhow::context` here ends up not printing
140 // the error
141 &format!("Failed to install dev extension: {}", err),
142 cx,
143 );
144 })
145 .ok();
146 }
147 }
148
149 Some(())
150 })
151 .detach();
152 });
153
154 cx.subscribe_in(workspace.project(), window, |_, _, event, window, cx| {
155 if let project::Event::LanguageNotFound(buffer) = event {
156 extension_suggest::suggest(buffer.clone(), window, cx);
157 }
158 })
159 .detach();
160 })
161 .detach();
162}
163
164fn extension_provides_label(provides: ExtensionProvides) -> &'static str {
165 match provides {
166 ExtensionProvides::Themes => "Themes",
167 ExtensionProvides::IconThemes => "Icon Themes",
168 ExtensionProvides::Languages => "Languages",
169 ExtensionProvides::Grammars => "Grammars",
170 ExtensionProvides::LanguageServers => "Language Servers",
171 ExtensionProvides::ContextServers => "MCP Servers",
172 ExtensionProvides::SlashCommands => "Slash Commands",
173 ExtensionProvides::IndexedDocsProviders => "Indexed Docs Providers",
174 ExtensionProvides::Snippets => "Snippets",
175 }
176}
177
178#[derive(Clone)]
179pub enum ExtensionStatus {
180 NotInstalled,
181 Installing,
182 Upgrading,
183 Installed(Arc<str>),
184 Removing,
185}
186
187#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
188enum ExtensionFilter {
189 All,
190 Installed,
191 NotInstalled,
192}
193
194impl ExtensionFilter {
195 pub fn include_dev_extensions(&self) -> bool {
196 match self {
197 Self::All | Self::Installed => true,
198 Self::NotInstalled => false,
199 }
200 }
201}
202
203#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
204enum Feature {
205 Git,
206 OpenIn,
207 Vim,
208 LanguageBash,
209 LanguageC,
210 LanguageCpp,
211 LanguageGo,
212 LanguagePython,
213 LanguageReact,
214 LanguageRust,
215 LanguageTypescript,
216}
217
218fn keywords_by_feature() -> &'static BTreeMap<Feature, Vec<&'static str>> {
219 static KEYWORDS_BY_FEATURE: OnceLock<BTreeMap<Feature, Vec<&'static str>>> = OnceLock::new();
220 KEYWORDS_BY_FEATURE.get_or_init(|| {
221 BTreeMap::from_iter([
222 (Feature::Git, vec!["git"]),
223 (
224 Feature::OpenIn,
225 vec![
226 "github",
227 "gitlab",
228 "bitbucket",
229 "codeberg",
230 "sourcehut",
231 "permalink",
232 "link",
233 "open in",
234 ],
235 ),
236 (Feature::Vim, vec!["vim"]),
237 (Feature::LanguageBash, vec!["sh", "bash"]),
238 (Feature::LanguageC, vec!["c", "clang"]),
239 (Feature::LanguageCpp, vec!["c++", "cpp", "clang"]),
240 (Feature::LanguageGo, vec!["go", "golang"]),
241 (Feature::LanguagePython, vec!["python", "py"]),
242 (Feature::LanguageReact, vec!["react"]),
243 (Feature::LanguageRust, vec!["rust", "rs"]),
244 (
245 Feature::LanguageTypescript,
246 vec!["type", "typescript", "ts"],
247 ),
248 ])
249 })
250}
251
252struct ExtensionCardButtons {
253 install_or_uninstall: Button,
254 upgrade: Option<Button>,
255 configure: Option<Button>,
256}
257
258pub struct ExtensionsPage {
259 workspace: WeakEntity<Workspace>,
260 list: UniformListScrollHandle,
261 is_fetching_extensions: bool,
262 filter: ExtensionFilter,
263 remote_extension_entries: Vec<ExtensionMetadata>,
264 dev_extension_entries: Vec<Arc<ExtensionManifest>>,
265 filtered_remote_extension_indices: Vec<usize>,
266 query_editor: Entity<Editor>,
267 query_contains_error: bool,
268 provides_filter: Option<ExtensionProvides>,
269 _subscriptions: [gpui::Subscription; 2],
270 extension_fetch_task: Option<Task<()>>,
271 upsells: BTreeSet<Feature>,
272 scrollbar_state: ScrollbarState,
273}
274
275impl ExtensionsPage {
276 pub fn new(
277 workspace: &Workspace,
278 provides_filter: Option<ExtensionProvides>,
279 window: &mut Window,
280 cx: &mut Context<Workspace>,
281 ) -> Entity<Self> {
282 cx.new(|cx| {
283 let store = ExtensionStore::global(cx);
284 let workspace_handle = workspace.weak_handle();
285 let subscriptions = [
286 cx.observe(&store, |_: &mut Self, _, cx| cx.notify()),
287 cx.subscribe_in(
288 &store,
289 window,
290 move |this, _, event, window, cx| match event {
291 extension_host::Event::ExtensionsUpdated => {
292 this.fetch_extensions_debounced(None, cx)
293 }
294 extension_host::Event::ExtensionInstalled(extension_id) => this
295 .on_extension_installed(
296 workspace_handle.clone(),
297 extension_id,
298 window,
299 cx,
300 ),
301 _ => {}
302 },
303 ),
304 ];
305
306 let query_editor = cx.new(|cx| {
307 let mut input = Editor::single_line(window, cx);
308 input.set_placeholder_text("Search extensions...", cx);
309 input
310 });
311 cx.subscribe(&query_editor, Self::on_query_change).detach();
312
313 let scroll_handle = UniformListScrollHandle::new();
314
315 let mut this = Self {
316 workspace: workspace.weak_handle(),
317 list: scroll_handle.clone(),
318 is_fetching_extensions: false,
319 filter: ExtensionFilter::All,
320 dev_extension_entries: Vec::new(),
321 filtered_remote_extension_indices: Vec::new(),
322 remote_extension_entries: Vec::new(),
323 query_contains_error: false,
324 provides_filter,
325 extension_fetch_task: None,
326 _subscriptions: subscriptions,
327 query_editor,
328 upsells: BTreeSet::default(),
329 scrollbar_state: ScrollbarState::new(scroll_handle),
330 };
331 this.fetch_extensions(
332 None,
333 Some(BTreeSet::from_iter(this.provides_filter)),
334 None,
335 cx,
336 );
337 this
338 })
339 }
340
341 fn on_extension_installed(
342 &mut self,
343 workspace: WeakEntity<Workspace>,
344 extension_id: &str,
345 window: &mut Window,
346 cx: &mut Context<Self>,
347 ) {
348 let extension_store = ExtensionStore::global(cx).read(cx);
349 let themes = extension_store
350 .extension_themes(extension_id)
351 .map(|name| name.to_string())
352 .collect::<Vec<_>>();
353 if !themes.is_empty() {
354 workspace
355 .update(cx, |_workspace, cx| {
356 window.dispatch_action(
357 zed_actions::theme_selector::Toggle {
358 themes_filter: Some(themes),
359 }
360 .boxed_clone(),
361 cx,
362 );
363 })
364 .ok();
365 return;
366 }
367
368 let icon_themes = extension_store
369 .extension_icon_themes(extension_id)
370 .map(|name| name.to_string())
371 .collect::<Vec<_>>();
372 if !icon_themes.is_empty() {
373 workspace
374 .update(cx, |_workspace, cx| {
375 window.dispatch_action(
376 zed_actions::icon_theme_selector::Toggle {
377 themes_filter: Some(icon_themes),
378 }
379 .boxed_clone(),
380 cx,
381 );
382 })
383 .ok();
384 }
385 }
386
387 /// Returns whether a dev extension currently exists for the extension with the given ID.
388 fn dev_extension_exists(extension_id: &str, cx: &mut Context<Self>) -> bool {
389 let extension_store = ExtensionStore::global(cx).read(cx);
390
391 extension_store
392 .dev_extensions()
393 .any(|dev_extension| dev_extension.id.as_ref() == extension_id)
394 }
395
396 fn extension_status(extension_id: &str, cx: &mut Context<Self>) -> ExtensionStatus {
397 let extension_store = ExtensionStore::global(cx).read(cx);
398
399 match extension_store.outstanding_operations().get(extension_id) {
400 Some(ExtensionOperation::Install) => ExtensionStatus::Installing,
401 Some(ExtensionOperation::Remove) => ExtensionStatus::Removing,
402 Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading,
403 None => match extension_store.installed_extensions().get(extension_id) {
404 Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()),
405 None => ExtensionStatus::NotInstalled,
406 },
407 }
408 }
409
410 fn filter_extension_entries(&mut self, cx: &mut Context<Self>) {
411 self.filtered_remote_extension_indices.clear();
412 self.filtered_remote_extension_indices.extend(
413 self.remote_extension_entries
414 .iter()
415 .enumerate()
416 .filter(|(_, extension)| match self.filter {
417 ExtensionFilter::All => true,
418 ExtensionFilter::Installed => {
419 let status = Self::extension_status(&extension.id, cx);
420 matches!(status, ExtensionStatus::Installed(_))
421 }
422 ExtensionFilter::NotInstalled => {
423 let status = Self::extension_status(&extension.id, cx);
424
425 matches!(status, ExtensionStatus::NotInstalled)
426 }
427 })
428 .map(|(ix, _)| ix),
429 );
430 cx.notify();
431 }
432
433 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
434 self.list.set_offset(point(px(0.), px(0.)));
435 cx.notify();
436 }
437
438 fn fetch_extensions(
439 &mut self,
440 search: Option<String>,
441 provides_filter: Option<BTreeSet<ExtensionProvides>>,
442 on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
443 cx: &mut Context<Self>,
444 ) {
445 self.is_fetching_extensions = true;
446 cx.notify();
447
448 let extension_store = ExtensionStore::global(cx);
449
450 let dev_extensions = extension_store
451 .read(cx)
452 .dev_extensions()
453 .cloned()
454 .collect::<Vec<_>>();
455
456 let remote_extensions = extension_store.update(cx, |store, cx| {
457 store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx)
458 });
459
460 cx.spawn(async move |this, cx| {
461 let dev_extensions = if let Some(search) = search {
462 let match_candidates = dev_extensions
463 .iter()
464 .enumerate()
465 .map(|(ix, manifest)| StringMatchCandidate::new(ix, &manifest.name))
466 .collect::<Vec<_>>();
467
468 let matches = match_strings(
469 &match_candidates,
470 &search,
471 false,
472 match_candidates.len(),
473 &Default::default(),
474 cx.background_executor().clone(),
475 )
476 .await;
477 matches
478 .into_iter()
479 .map(|mat| dev_extensions[mat.candidate_id].clone())
480 .collect()
481 } else {
482 dev_extensions
483 };
484
485 let fetch_result = remote_extensions.await;
486 this.update(cx, |this, cx| {
487 cx.notify();
488 this.dev_extension_entries = dev_extensions;
489 this.is_fetching_extensions = false;
490 this.remote_extension_entries = fetch_result?;
491 this.filter_extension_entries(cx);
492 if let Some(callback) = on_complete {
493 callback(this, cx);
494 }
495 anyhow::Ok(())
496 })?
497 })
498 .detach_and_log_err(cx);
499 }
500
501 fn render_extensions(
502 &mut self,
503 range: Range<usize>,
504 _: &mut Window,
505 cx: &mut Context<Self>,
506 ) -> Vec<ExtensionCard> {
507 let dev_extension_entries_len = if self.filter.include_dev_extensions() {
508 self.dev_extension_entries.len()
509 } else {
510 0
511 };
512 range
513 .map(|ix| {
514 if ix < dev_extension_entries_len {
515 let extension = &self.dev_extension_entries[ix];
516 self.render_dev_extension(extension, cx)
517 } else {
518 let extension_ix =
519 self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
520 let extension = &self.remote_extension_entries[extension_ix];
521 self.render_remote_extension(extension, cx)
522 }
523 })
524 .collect()
525 }
526
527 fn render_dev_extension(
528 &self,
529 extension: &ExtensionManifest,
530 cx: &mut Context<Self>,
531 ) -> ExtensionCard {
532 let status = Self::extension_status(&extension.id, cx);
533
534 let repository_url = extension.repository.clone();
535
536 let can_configure = !extension.context_servers.is_empty();
537
538 ExtensionCard::new()
539 .child(
540 h_flex()
541 .justify_between()
542 .child(
543 h_flex()
544 .gap_2()
545 .items_end()
546 .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
547 .child(
548 Headline::new(format!("v{}", extension.version))
549 .size(HeadlineSize::XSmall),
550 ),
551 )
552 .child(
553 h_flex()
554 .gap_2()
555 .justify_between()
556 .child(
557 Button::new(
558 SharedString::from(format!("rebuild-{}", extension.id)),
559 "Rebuild",
560 )
561 .on_click({
562 let extension_id = extension.id.clone();
563 move |_, _, cx| {
564 ExtensionStore::global(cx).update(cx, |store, cx| {
565 store.rebuild_dev_extension(extension_id.clone(), cx)
566 });
567 }
568 })
569 .color(Color::Accent)
570 .disabled(matches!(status, ExtensionStatus::Upgrading)),
571 )
572 .child(
573 Button::new(SharedString::from(extension.id.clone()), "Uninstall")
574 .on_click({
575 let extension_id = extension.id.clone();
576 move |_, _, cx| {
577 ExtensionStore::global(cx).update(cx, |store, cx| {
578 store.uninstall_extension(extension_id.clone(), cx)
579 });
580 }
581 })
582 .color(Color::Accent)
583 .disabled(matches!(status, ExtensionStatus::Removing)),
584 )
585 .when(can_configure, |this| {
586 this.child(
587 Button::new(
588 SharedString::from(format!("configure-{}", extension.id)),
589 "Configure",
590 )
591
592
593 .on_click({
594 let manifest = Arc::new(extension.clone());
595 move |_, _, cx| {
596 if let Some(events) =
597 extension::ExtensionEvents::try_global(cx)
598 {
599 events.update(cx, |this, cx| {
600 this.emit(
601 extension::Event::ConfigureExtensionRequested(
602 manifest.clone(),
603 ),
604 cx,
605 )
606 });
607 }
608 }
609 })
610 .color(Color::Accent)
611 .disabled(matches!(status, ExtensionStatus::Installing)),
612 )
613 }),
614 ),
615 )
616 .child(
617 h_flex()
618 .gap_2()
619 .justify_between()
620 .child(
621 Label::new(format!(
622 "{}: {}",
623 if extension.authors.len() > 1 {
624 "Authors"
625 } else {
626 "Author"
627 },
628 extension.authors.join(", ")
629 ))
630 .size(LabelSize::Small)
631 .color(Color::Muted)
632 .truncate(),
633 )
634 .child(Label::new("<>").size(LabelSize::Small)),
635 )
636 .child(
637 h_flex()
638 .gap_2()
639 .justify_between()
640 .children(extension.description.as_ref().map(|description| {
641 Label::new(description.clone())
642 .size(LabelSize::Small)
643 .color(Color::Default)
644 .truncate()
645 }))
646 .children(repository_url.map(|repository_url| {
647 IconButton::new(
648 SharedString::from(format!("repository-{}", extension.id)),
649 IconName::Github,
650 )
651 .icon_color(Color::Accent)
652 .icon_size(IconSize::Small)
653 .on_click(cx.listener({
654 let repository_url = repository_url.clone();
655 move |_, _, _, cx| {
656 cx.open_url(&repository_url);
657 }
658 }))
659 .tooltip(Tooltip::text(repository_url.clone()))
660 })),
661 )
662 }
663
664 fn render_remote_extension(
665 &self,
666 extension: &ExtensionMetadata,
667 cx: &mut Context<Self>,
668 ) -> ExtensionCard {
669 let this = cx.entity().clone();
670 let status = Self::extension_status(&extension.id, cx);
671 let has_dev_extension = Self::dev_extension_exists(&extension.id, cx);
672
673 let extension_id = extension.id.clone();
674 let buttons = self.buttons_for_entry(extension, &status, has_dev_extension, cx);
675 let version = extension.manifest.version.clone();
676 let repository_url = extension.manifest.repository.clone();
677 let authors = extension.manifest.authors.clone();
678
679 let installed_version = match status {
680 ExtensionStatus::Installed(installed_version) => Some(installed_version),
681 _ => None,
682 };
683
684 ExtensionCard::new()
685 .overridden_by_dev_extension(has_dev_extension)
686 .child(
687 h_flex()
688 .justify_between()
689 .child(
690 h_flex()
691 .gap_2()
692 .child(
693 Headline::new(extension.manifest.name.clone())
694 .size(HeadlineSize::Medium),
695 )
696 .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
697 .children(
698 installed_version
699 .filter(|installed_version| *installed_version != version)
700 .map(|installed_version| {
701 Headline::new(format!("(v{installed_version} installed)",))
702 .size(HeadlineSize::XSmall)
703 }),
704 )
705 .map(|parent| {
706 if extension.manifest.provides.is_empty() {
707 return parent;
708 }
709
710 parent.child(
711 h_flex().gap_2().children(
712 extension
713 .manifest
714 .provides
715 .iter()
716 .map(|provides| {
717 div()
718 .bg(cx.theme().colors().element_background)
719 .px_0p5()
720 .border_1()
721 .border_color(cx.theme().colors().border)
722 .rounded_sm()
723 .child(
724 Label::new(extension_provides_label(
725 *provides,
726 ))
727 .size(LabelSize::XSmall),
728 )
729 })
730 .collect::<Vec<_>>(),
731 ),
732 )
733 }),
734 )
735 .child(
736 h_flex()
737 .gap_2()
738 .justify_between()
739 .children(buttons.upgrade)
740 .children(buttons.configure)
741 .child(buttons.install_or_uninstall),
742 ),
743 )
744 .child(
745 h_flex()
746 .gap_2()
747 .justify_between()
748 .child(
749 Label::new(format!(
750 "{}: {}",
751 if extension.manifest.authors.len() > 1 {
752 "Authors"
753 } else {
754 "Author"
755 },
756 extension.manifest.authors.join(", ")
757 ))
758 .size(LabelSize::Small)
759 .color(Color::Muted)
760 .truncate(),
761 )
762 .child(
763 Label::new(format!(
764 "Downloads: {}",
765 extension.download_count.to_formatted_string(&Locale::en)
766 ))
767 .size(LabelSize::Small),
768 ),
769 )
770 .child(
771 h_flex()
772 .gap_2()
773 .justify_between()
774 .children(extension.manifest.description.as_ref().map(|description| {
775 Label::new(description.clone())
776 .size(LabelSize::Small)
777 .color(Color::Default)
778 .truncate()
779 }))
780 .child(
781 h_flex()
782 .gap_2()
783 .child(
784 IconButton::new(
785 SharedString::from(format!("repository-{}", extension.id)),
786 IconName::Github,
787 )
788 .icon_color(Color::Accent)
789 .icon_size(IconSize::Small)
790 .on_click(cx.listener({
791 let repository_url = repository_url.clone();
792 move |_, _, _, cx| {
793 cx.open_url(&repository_url);
794 }
795 }))
796 .tooltip(Tooltip::text(repository_url.clone())),
797 )
798 .child(
799 PopoverMenu::new(SharedString::from(format!(
800 "more-{}",
801 extension.id
802 )))
803 .trigger(
804 IconButton::new(
805 SharedString::from(format!("more-{}", extension.id)),
806 IconName::Ellipsis,
807 )
808 .icon_color(Color::Accent)
809 .icon_size(IconSize::Small),
810 )
811 .menu(move |window, cx| {
812 Some(Self::render_remote_extension_context_menu(
813 &this,
814 extension_id.clone(),
815 authors.clone(),
816 window,
817 cx,
818 ))
819 }),
820 ),
821 ),
822 )
823 }
824
825 fn render_remote_extension_context_menu(
826 this: &Entity<Self>,
827 extension_id: Arc<str>,
828 authors: Vec<String>,
829 window: &mut Window,
830 cx: &mut App,
831 ) -> Entity<ContextMenu> {
832 let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| {
833 context_menu
834 .entry(
835 "Install Another Version...",
836 None,
837 window.handler_for(this, {
838 let extension_id = extension_id.clone();
839 move |this, window, cx| {
840 this.show_extension_version_list(extension_id.clone(), window, cx)
841 }
842 }),
843 )
844 .entry("Copy Extension ID", None, {
845 let extension_id = extension_id.clone();
846 move |_, cx| {
847 cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
848 }
849 })
850 .entry("Copy Author Info", None, {
851 let authors = authors.clone();
852 move |_, cx| {
853 cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", ")));
854 }
855 })
856 });
857
858 context_menu
859 }
860
861 fn show_extension_version_list(
862 &mut self,
863 extension_id: Arc<str>,
864 window: &mut Window,
865 cx: &mut Context<Self>,
866 ) {
867 let Some(workspace) = self.workspace.upgrade() else {
868 return;
869 };
870
871 cx.spawn_in(window, async move |this, cx| {
872 let extension_versions_task = this.update(cx, |_, cx| {
873 let extension_store = ExtensionStore::global(cx);
874
875 extension_store.update(cx, |store, cx| {
876 store.fetch_extension_versions(&extension_id, cx)
877 })
878 })?;
879
880 let extension_versions = extension_versions_task.await?;
881
882 workspace.update_in(cx, |workspace, window, cx| {
883 let fs = workspace.project().read(cx).fs().clone();
884 workspace.toggle_modal(window, cx, |window, cx| {
885 let delegate = ExtensionVersionSelectorDelegate::new(
886 fs,
887 cx.entity().downgrade(),
888 extension_versions,
889 );
890
891 ExtensionVersionSelector::new(delegate, window, cx)
892 });
893 })?;
894
895 anyhow::Ok(())
896 })
897 .detach_and_log_err(cx);
898 }
899
900 fn buttons_for_entry(
901 &self,
902 extension: &ExtensionMetadata,
903 status: &ExtensionStatus,
904 has_dev_extension: bool,
905 cx: &mut Context<Self>,
906 ) -> ExtensionCardButtons {
907 let is_compatible =
908 extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
909
910 if has_dev_extension {
911 // If we have a dev extension for the given extension, just treat it as uninstalled.
912 // The button here is a placeholder, as it won't be interactable anyways.
913 return ExtensionCardButtons {
914 install_or_uninstall: Button::new(
915 SharedString::from(extension.id.clone()),
916 "Install",
917 ),
918 configure: None,
919 upgrade: None,
920 };
921 }
922
923 let is_configurable = extension
924 .manifest
925 .provides
926 .contains(&ExtensionProvides::ContextServers);
927
928 match status.clone() {
929 ExtensionStatus::NotInstalled => ExtensionCardButtons {
930 install_or_uninstall: Button::new(
931 SharedString::from(extension.id.clone()),
932 "Install",
933 )
934 .on_click({
935 let extension_id = extension.id.clone();
936 move |_, _, cx| {
937 telemetry::event!("Extension Installed");
938 ExtensionStore::global(cx).update(cx, |store, cx| {
939 store.install_latest_extension(extension_id.clone(), cx)
940 });
941 }
942 }),
943 configure: None,
944 upgrade: None,
945 },
946 ExtensionStatus::Installing => ExtensionCardButtons {
947 install_or_uninstall: Button::new(
948 SharedString::from(extension.id.clone()),
949 "Install",
950 )
951 .disabled(true),
952 configure: None,
953 upgrade: None,
954 },
955 ExtensionStatus::Upgrading => ExtensionCardButtons {
956 install_or_uninstall: Button::new(
957 SharedString::from(extension.id.clone()),
958 "Uninstall",
959 )
960 .disabled(true),
961 configure: is_configurable.then(|| {
962 Button::new(
963 SharedString::from(format!("configure-{}", extension.id)),
964 "Configure",
965 )
966 .disabled(true)
967 }),
968 upgrade: Some(
969 Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
970 ),
971 },
972 ExtensionStatus::Installed(installed_version) => ExtensionCardButtons {
973 install_or_uninstall: Button::new(
974 SharedString::from(extension.id.clone()),
975 "Uninstall",
976 )
977 .on_click({
978 let extension_id = extension.id.clone();
979 move |_, _, cx| {
980 telemetry::event!("Extension Uninstalled", extension_id);
981 ExtensionStore::global(cx).update(cx, |store, cx| {
982 store.uninstall_extension(extension_id.clone(), cx)
983 });
984 }
985 }),
986 configure: is_configurable.then(|| {
987 Button::new(
988 SharedString::from(format!("configure-{}", extension.id)),
989 "Configure",
990 )
991 .on_click({
992 let extension_id = extension.id.clone();
993 move |_, _, cx| {
994 if let Some(manifest) = ExtensionStore::global(cx)
995 .read(cx)
996 .extension_manifest_for_id(&extension_id)
997 .cloned()
998 {
999 if let Some(events) = extension::ExtensionEvents::try_global(cx) {
1000 events.update(cx, |this, cx| {
1001 this.emit(
1002 extension::Event::ConfigureExtensionRequested(manifest),
1003 cx,
1004 )
1005 });
1006 }
1007 }
1008 }
1009 })
1010 }),
1011 upgrade: if installed_version == extension.manifest.version {
1012 None
1013 } else {
1014 Some(
1015 Button::new(SharedString::from(extension.id.clone()), "Upgrade")
1016 .when(!is_compatible, |upgrade_button| {
1017 upgrade_button.disabled(true).tooltip({
1018 let version = extension.manifest.version.clone();
1019 move |_, cx| {
1020 Tooltip::simple(
1021 format!(
1022 "v{version} is not compatible with this version of Zed.",
1023 ),
1024 cx,
1025 )
1026 }
1027 })
1028 })
1029 .disabled(!is_compatible)
1030 .on_click({
1031 let extension_id = extension.id.clone();
1032 let version = extension.manifest.version.clone();
1033 move |_, _, cx| {
1034 telemetry::event!("Extension Installed", extension_id, version);
1035 ExtensionStore::global(cx).update(cx, |store, cx| {
1036 store
1037 .upgrade_extension(
1038 extension_id.clone(),
1039 version.clone(),
1040 cx,
1041 )
1042 .detach_and_log_err(cx)
1043 });
1044 }
1045 }),
1046 )
1047 },
1048 },
1049 ExtensionStatus::Removing => ExtensionCardButtons {
1050 install_or_uninstall: Button::new(
1051 SharedString::from(extension.id.clone()),
1052 "Uninstall",
1053 )
1054 .disabled(true),
1055 configure: is_configurable.then(|| {
1056 Button::new(
1057 SharedString::from(format!("configure-{}", extension.id)),
1058 "Configure",
1059 )
1060 .disabled(true)
1061 }),
1062 upgrade: None,
1063 },
1064 }
1065 }
1066
1067 fn render_search(&self, cx: &mut Context<Self>) -> Div {
1068 let mut key_context = KeyContext::new_with_defaults();
1069 key_context.add("BufferSearchBar");
1070
1071 let editor_border = if self.query_contains_error {
1072 Color::Error.color(cx)
1073 } else {
1074 cx.theme().colors().border
1075 };
1076
1077 h_flex()
1078 .key_context(key_context)
1079 .h_8()
1080 .flex_1()
1081 .min_w(rems_from_px(384.))
1082 .pl_1p5()
1083 .pr_2()
1084 .py_1()
1085 .gap_2()
1086 .border_1()
1087 .border_color(editor_border)
1088 .rounded_lg()
1089 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
1090 .child(self.render_text_input(&self.query_editor, cx))
1091 }
1092
1093 fn render_text_input(
1094 &self,
1095 editor: &Entity<Editor>,
1096 cx: &mut Context<Self>,
1097 ) -> impl IntoElement {
1098 let settings = ThemeSettings::get_global(cx);
1099 let text_style = TextStyle {
1100 color: if editor.read(cx).read_only(cx) {
1101 cx.theme().colors().text_disabled
1102 } else {
1103 cx.theme().colors().text
1104 },
1105 font_family: settings.ui_font.family.clone(),
1106 font_features: settings.ui_font.features.clone(),
1107 font_fallbacks: settings.ui_font.fallbacks.clone(),
1108 font_size: rems(0.875).into(),
1109 font_weight: settings.ui_font.weight,
1110 line_height: relative(1.3),
1111 ..Default::default()
1112 };
1113
1114 EditorElement::new(
1115 editor,
1116 EditorStyle {
1117 background: cx.theme().colors().editor_background,
1118 local_player: cx.theme().players().local(),
1119 text: text_style,
1120 ..Default::default()
1121 },
1122 )
1123 }
1124
1125 fn on_query_change(
1126 &mut self,
1127 _: Entity<Editor>,
1128 event: &editor::EditorEvent,
1129 cx: &mut Context<Self>,
1130 ) {
1131 if let editor::EditorEvent::Edited { .. } = event {
1132 self.query_contains_error = false;
1133 self.refresh_search(cx);
1134 }
1135 }
1136
1137 fn refresh_search(&mut self, cx: &mut Context<Self>) {
1138 self.fetch_extensions_debounced(
1139 Some(Box::new(|this, cx| {
1140 this.scroll_to_top(cx);
1141 })),
1142 cx,
1143 );
1144 self.refresh_feature_upsells(cx);
1145 }
1146
1147 pub fn change_provides_filter(
1148 &mut self,
1149 provides_filter: Option<ExtensionProvides>,
1150 cx: &mut Context<Self>,
1151 ) {
1152 self.provides_filter = provides_filter;
1153 self.refresh_search(cx);
1154 }
1155
1156 fn fetch_extensions_debounced(
1157 &mut self,
1158 on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
1159 cx: &mut Context<ExtensionsPage>,
1160 ) {
1161 self.extension_fetch_task = Some(cx.spawn(async move |this, cx| {
1162 let search = this
1163 .update(cx, |this, cx| this.search_query(cx))
1164 .ok()
1165 .flatten();
1166
1167 // Only debounce the fetching of extensions if we have a search
1168 // query.
1169 //
1170 // If the search was just cleared then we can just reload the list
1171 // of extensions without a debounce, which allows us to avoid seeing
1172 // an intermittent flash of a "no extensions" state.
1173 if search.is_some() {
1174 cx.background_executor()
1175 .timer(Duration::from_millis(250))
1176 .await;
1177 };
1178
1179 this.update(cx, |this, cx| {
1180 this.fetch_extensions(
1181 search,
1182 Some(BTreeSet::from_iter(this.provides_filter)),
1183 on_complete,
1184 cx,
1185 );
1186 })
1187 .ok();
1188 }));
1189 }
1190
1191 pub fn search_query(&self, cx: &mut App) -> Option<String> {
1192 let search = self.query_editor.read(cx).text(cx);
1193 if search.trim().is_empty() {
1194 None
1195 } else {
1196 Some(search)
1197 }
1198 }
1199
1200 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1201 let has_search = self.search_query(cx).is_some();
1202
1203 let message = if self.is_fetching_extensions {
1204 "Loading extensions..."
1205 } else {
1206 match self.filter {
1207 ExtensionFilter::All => {
1208 if has_search {
1209 "No extensions that match your search."
1210 } else {
1211 "No extensions."
1212 }
1213 }
1214 ExtensionFilter::Installed => {
1215 if has_search {
1216 "No installed extensions that match your search."
1217 } else {
1218 "No installed extensions."
1219 }
1220 }
1221 ExtensionFilter::NotInstalled => {
1222 if has_search {
1223 "No not installed extensions that match your search."
1224 } else {
1225 "No not installed extensions."
1226 }
1227 }
1228 }
1229 };
1230
1231 Label::new(message)
1232 }
1233
1234 fn update_settings<T: Settings>(
1235 &mut self,
1236 selection: &ToggleState,
1237
1238 cx: &mut Context<Self>,
1239 callback: impl 'static + Send + Fn(&mut T::FileContent, bool),
1240 ) {
1241 if let Some(workspace) = self.workspace.upgrade() {
1242 let fs = workspace.read(cx).app_state().fs.clone();
1243 let selection = *selection;
1244 settings::update_settings_file::<T>(fs, cx, move |settings, _| {
1245 let value = match selection {
1246 ToggleState::Unselected => false,
1247 ToggleState::Selected => true,
1248 _ => return,
1249 };
1250
1251 callback(settings, value)
1252 });
1253 }
1254 }
1255
1256 fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1257 let Some(search) = self.search_query(cx) else {
1258 self.upsells.clear();
1259 return;
1260 };
1261
1262 let search = search.to_lowercase();
1263 let search_terms = search
1264 .split_whitespace()
1265 .map(|term| term.trim())
1266 .collect::<Vec<_>>();
1267
1268 for (feature, keywords) in keywords_by_feature() {
1269 if keywords
1270 .iter()
1271 .any(|keyword| search_terms.contains(keyword))
1272 {
1273 self.upsells.insert(*feature);
1274 } else {
1275 self.upsells.remove(feature);
1276 }
1277 }
1278 }
1279
1280 fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1281 let upsells_count = self.upsells.len();
1282
1283 v_flex().children(self.upsells.iter().enumerate().map(|(ix, feature)| {
1284 let upsell = match feature {
1285 Feature::Git => FeatureUpsell::new(
1286 "Zed comes with basic Git support. More Git features are coming in the future.",
1287 )
1288 .docs_url("https://zed.dev/docs/git"),
1289 Feature::OpenIn => FeatureUpsell::new(
1290 "Zed supports linking to a source line on GitHub and others.",
1291 )
1292 .docs_url("https://zed.dev/docs/git#git-integrations"),
1293 Feature::Vim => FeatureUpsell::new("Vim support is built-in to Zed!")
1294 .docs_url("https://zed.dev/docs/vim")
1295 .child(CheckboxWithLabel::new(
1296 "enable-vim",
1297 Label::new("Enable vim mode"),
1298 if VimModeSetting::get_global(cx).0 {
1299 ui::ToggleState::Selected
1300 } else {
1301 ui::ToggleState::Unselected
1302 },
1303 cx.listener(move |this, selection, _, cx| {
1304 telemetry::event!("Vim Mode Toggled", source = "Feature Upsell");
1305 this.update_settings::<VimModeSetting>(
1306 selection,
1307 cx,
1308 |setting, value| *setting = Some(value),
1309 );
1310 }),
1311 )),
1312 Feature::LanguageBash => FeatureUpsell::new("Shell support is built-in to Zed!")
1313 .docs_url("https://zed.dev/docs/languages/bash"),
1314 Feature::LanguageC => FeatureUpsell::new("C support is built-in to Zed!")
1315 .docs_url("https://zed.dev/docs/languages/c"),
1316 Feature::LanguageCpp => FeatureUpsell::new("C++ support is built-in to Zed!")
1317 .docs_url("https://zed.dev/docs/languages/cpp"),
1318 Feature::LanguageGo => FeatureUpsell::new("Go support is built-in to Zed!")
1319 .docs_url("https://zed.dev/docs/languages/go"),
1320 Feature::LanguagePython => FeatureUpsell::new("Python support is built-in to Zed!")
1321 .docs_url("https://zed.dev/docs/languages/python"),
1322 Feature::LanguageReact => FeatureUpsell::new("React support is built-in to Zed!")
1323 .docs_url("https://zed.dev/docs/languages/typescript"),
1324 Feature::LanguageRust => FeatureUpsell::new("Rust support is built-in to Zed!")
1325 .docs_url("https://zed.dev/docs/languages/rust"),
1326 Feature::LanguageTypescript => {
1327 FeatureUpsell::new("Typescript support is built-in to Zed!")
1328 .docs_url("https://zed.dev/docs/languages/typescript")
1329 }
1330 };
1331
1332 upsell.when(ix < upsells_count, |upsell| upsell.border_b_1())
1333 }))
1334 }
1335}
1336
1337impl Render for ExtensionsPage {
1338 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1339 v_flex()
1340 .size_full()
1341 .bg(cx.theme().colors().editor_background)
1342 .child(
1343 v_flex()
1344 .gap_4()
1345 .pt_4()
1346 .px_4()
1347 .bg(cx.theme().colors().editor_background)
1348 .child(
1349 h_flex()
1350 .w_full()
1351 .gap_2()
1352 .justify_between()
1353 .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
1354 .child(
1355 Button::new("install-dev-extension", "Install Dev Extension")
1356 .style(ButtonStyle::Filled)
1357 .size(ButtonSize::Large)
1358 .on_click(|_event, window, cx| {
1359 window.dispatch_action(Box::new(InstallDevExtension), cx)
1360 }),
1361 ),
1362 )
1363 .child(
1364 h_flex()
1365 .w_full()
1366 .gap_4()
1367 .flex_wrap()
1368 .child(self.render_search(cx))
1369 .child(
1370 h_flex()
1371 .child(
1372 ToggleButton::new("filter-all", "All")
1373 .style(ButtonStyle::Filled)
1374 .size(ButtonSize::Large)
1375 .toggle_state(self.filter == ExtensionFilter::All)
1376 .on_click(cx.listener(|this, _event, _, cx| {
1377 this.filter = ExtensionFilter::All;
1378 this.filter_extension_entries(cx);
1379 this.scroll_to_top(cx);
1380 }))
1381 .tooltip(move |_, cx| {
1382 Tooltip::simple("Show all extensions", cx)
1383 })
1384 .first(),
1385 )
1386 .child(
1387 ToggleButton::new("filter-installed", "Installed")
1388 .style(ButtonStyle::Filled)
1389 .size(ButtonSize::Large)
1390 .toggle_state(self.filter == ExtensionFilter::Installed)
1391 .on_click(cx.listener(|this, _event, _, cx| {
1392 this.filter = ExtensionFilter::Installed;
1393 this.filter_extension_entries(cx);
1394 this.scroll_to_top(cx);
1395 }))
1396 .tooltip(move |_, cx| {
1397 Tooltip::simple("Show installed extensions", cx)
1398 })
1399 .middle(),
1400 )
1401 .child(
1402 ToggleButton::new("filter-not-installed", "Not Installed")
1403 .style(ButtonStyle::Filled)
1404 .size(ButtonSize::Large)
1405 .toggle_state(
1406 self.filter == ExtensionFilter::NotInstalled,
1407 )
1408 .on_click(cx.listener(|this, _event, _, cx| {
1409 this.filter = ExtensionFilter::NotInstalled;
1410 this.filter_extension_entries(cx);
1411 this.scroll_to_top(cx);
1412 }))
1413 .tooltip(move |_, cx| {
1414 Tooltip::simple("Show not installed extensions", cx)
1415 })
1416 .last(),
1417 ),
1418 ),
1419 ),
1420 )
1421 .child(
1422 h_flex()
1423 .id("filter-row")
1424 .gap_2()
1425 .py_2p5()
1426 .px_4()
1427 .border_b_1()
1428 .border_color(cx.theme().colors().border_variant)
1429 .overflow_x_scroll()
1430 .child(
1431 Button::new("filter-all-categories", "All")
1432 .when(self.provides_filter.is_none(), |button| {
1433 button.style(ButtonStyle::Filled)
1434 })
1435 .when(self.provides_filter.is_some(), |button| {
1436 button.style(ButtonStyle::Subtle)
1437 })
1438 .toggle_state(self.provides_filter.is_none())
1439 .on_click(cx.listener(|this, _event, _, cx| {
1440 this.change_provides_filter(None, cx);
1441 })),
1442 )
1443 .children(ExtensionProvides::iter().map(|provides| {
1444 let label = extension_provides_label(provides);
1445 Button::new(
1446 SharedString::from(format!("filter-category-{}", label)),
1447 label,
1448 )
1449 .style(if self.provides_filter == Some(provides) {
1450 ButtonStyle::Filled
1451 } else {
1452 ButtonStyle::Subtle
1453 })
1454 .toggle_state(self.provides_filter == Some(provides))
1455 .on_click({
1456 cx.listener(move |this, _event, _, cx| {
1457 this.change_provides_filter(Some(provides), cx);
1458 })
1459 })
1460 })),
1461 )
1462 .child(self.render_feature_upsells(cx))
1463 .child(
1464 v_flex()
1465 .pl_4()
1466 .pr_6()
1467 .size_full()
1468 .overflow_y_hidden()
1469 .map(|this| {
1470 let mut count = self.filtered_remote_extension_indices.len();
1471 if self.filter.include_dev_extensions() {
1472 count += self.dev_extension_entries.len();
1473 }
1474
1475 if count == 0 {
1476 return this.py_4().child(self.render_empty_state(cx));
1477 }
1478
1479 let extensions_page = cx.entity().clone();
1480 let scroll_handle = self.list.clone();
1481 this.child(
1482 uniform_list(
1483 extensions_page,
1484 "entries",
1485 count,
1486 Self::render_extensions,
1487 )
1488 .flex_grow()
1489 .pb_4()
1490 .track_scroll(scroll_handle),
1491 )
1492 .child(
1493 div()
1494 .absolute()
1495 .right_1()
1496 .top_0()
1497 .bottom_0()
1498 .w(px(12.))
1499 .children(Scrollbar::vertical(self.scrollbar_state.clone())),
1500 )
1501 }),
1502 )
1503 }
1504}
1505
1506impl EventEmitter<ItemEvent> for ExtensionsPage {}
1507
1508impl Focusable for ExtensionsPage {
1509 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1510 self.query_editor.read(cx).focus_handle(cx)
1511 }
1512}
1513
1514impl Item for ExtensionsPage {
1515 type Event = ItemEvent;
1516
1517 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1518 "Extensions".into()
1519 }
1520
1521 fn telemetry_event_text(&self) -> Option<&'static str> {
1522 Some("Extensions Page Opened")
1523 }
1524
1525 fn show_toolbar(&self) -> bool {
1526 false
1527 }
1528
1529 fn clone_on_split(
1530 &self,
1531 _workspace_id: Option<WorkspaceId>,
1532 _window: &mut Window,
1533 _: &mut Context<Self>,
1534 ) -> Option<Entity<Self>> {
1535 None
1536 }
1537
1538 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
1539 f(*event)
1540 }
1541}