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.update(cx, |store, _| {
451 store.dev_extensions().cloned().collect::<Vec<_>>()
452 });
453
454 let remote_extensions = extension_store.update(cx, |store, cx| {
455 store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx)
456 });
457
458 cx.spawn(async move |this, cx| {
459 let dev_extensions = if let Some(search) = search {
460 let match_candidates = dev_extensions
461 .iter()
462 .enumerate()
463 .map(|(ix, manifest)| StringMatchCandidate::new(ix, &manifest.name))
464 .collect::<Vec<_>>();
465
466 let matches = match_strings(
467 &match_candidates,
468 &search,
469 false,
470 match_candidates.len(),
471 &Default::default(),
472 cx.background_executor().clone(),
473 )
474 .await;
475 matches
476 .into_iter()
477 .map(|mat| dev_extensions[mat.candidate_id].clone())
478 .collect()
479 } else {
480 dev_extensions
481 };
482
483 let fetch_result = remote_extensions.await;
484 this.update(cx, |this, cx| {
485 cx.notify();
486 this.dev_extension_entries = dev_extensions;
487 this.is_fetching_extensions = false;
488 this.remote_extension_entries = fetch_result?;
489 this.filter_extension_entries(cx);
490 if let Some(callback) = on_complete {
491 callback(this, cx);
492 }
493 anyhow::Ok(())
494 })?
495 })
496 .detach_and_log_err(cx);
497 }
498
499 fn render_extensions(
500 &mut self,
501 range: Range<usize>,
502 _: &mut Window,
503 cx: &mut Context<Self>,
504 ) -> Vec<ExtensionCard> {
505 let dev_extension_entries_len = if self.filter.include_dev_extensions() {
506 self.dev_extension_entries.len()
507 } else {
508 0
509 };
510 range
511 .map(|ix| {
512 if ix < dev_extension_entries_len {
513 let extension = &self.dev_extension_entries[ix];
514 self.render_dev_extension(extension, cx)
515 } else {
516 let extension_ix =
517 self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
518 let extension = &self.remote_extension_entries[extension_ix];
519 self.render_remote_extension(extension, cx)
520 }
521 })
522 .collect()
523 }
524
525 fn render_dev_extension(
526 &self,
527 extension: &ExtensionManifest,
528 cx: &mut Context<Self>,
529 ) -> ExtensionCard {
530 let status = Self::extension_status(&extension.id, cx);
531
532 let repository_url = extension.repository.clone();
533
534 let can_configure = !extension.context_servers.is_empty();
535
536 ExtensionCard::new()
537 .child(
538 h_flex()
539 .justify_between()
540 .child(
541 h_flex()
542 .gap_2()
543 .items_end()
544 .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
545 .child(
546 Headline::new(format!("v{}", extension.version))
547 .size(HeadlineSize::XSmall),
548 ),
549 )
550 .child(
551 h_flex()
552 .gap_2()
553 .justify_between()
554 .child(
555 Button::new(
556 SharedString::from(format!("rebuild-{}", extension.id)),
557 "Rebuild",
558 )
559 .on_click({
560 let extension_id = extension.id.clone();
561 move |_, _, cx| {
562 ExtensionStore::global(cx).update(cx, |store, cx| {
563 store.rebuild_dev_extension(extension_id.clone(), cx)
564 });
565 }
566 })
567 .color(Color::Accent)
568 .disabled(matches!(status, ExtensionStatus::Upgrading)),
569 )
570 .child(
571 Button::new(SharedString::from(extension.id.clone()), "Uninstall")
572 .on_click({
573 let extension_id = extension.id.clone();
574 move |_, _, cx| {
575 ExtensionStore::global(cx).update(cx, |store, cx| {
576 store.uninstall_extension(extension_id.clone(), cx)
577 });
578 }
579 })
580 .color(Color::Accent)
581 .disabled(matches!(status, ExtensionStatus::Removing)),
582 )
583 .when(can_configure, |this| {
584 this.child(
585 Button::new(
586 SharedString::from(format!("configure-{}", extension.id)),
587 "Configure",
588 )
589
590
591 .on_click({
592 let manifest = Arc::new(extension.clone());
593 move |_, _, cx| {
594 if let Some(events) =
595 extension::ExtensionEvents::try_global(cx)
596 {
597 events.update(cx, |this, cx| {
598 this.emit(
599 extension::Event::ConfigureExtensionRequested(
600 manifest.clone(),
601 ),
602 cx,
603 )
604 });
605 }
606 }
607 })
608 .color(Color::Accent)
609 .disabled(matches!(status, ExtensionStatus::Installing)),
610 )
611 }),
612 ),
613 )
614 .child(
615 h_flex()
616 .gap_2()
617 .justify_between()
618 .child(
619 Label::new(format!(
620 "{}: {}",
621 if extension.authors.len() > 1 {
622 "Authors"
623 } else {
624 "Author"
625 },
626 extension.authors.join(", ")
627 ))
628 .size(LabelSize::Small)
629 .color(Color::Muted)
630 .truncate(),
631 )
632 .child(Label::new("<>").size(LabelSize::Small)),
633 )
634 .child(
635 h_flex()
636 .gap_2()
637 .justify_between()
638 .children(extension.description.as_ref().map(|description| {
639 Label::new(description.clone())
640 .size(LabelSize::Small)
641 .color(Color::Default)
642 .truncate()
643 }))
644 .children(repository_url.map(|repository_url| {
645 IconButton::new(
646 SharedString::from(format!("repository-{}", extension.id)),
647 IconName::Github,
648 )
649 .icon_color(Color::Accent)
650 .icon_size(IconSize::Small)
651 .on_click(cx.listener({
652 let repository_url = repository_url.clone();
653 move |_, _, _, cx| {
654 cx.open_url(&repository_url);
655 }
656 }))
657 .tooltip(Tooltip::text(repository_url.clone()))
658 })),
659 )
660 }
661
662 fn render_remote_extension(
663 &self,
664 extension: &ExtensionMetadata,
665 cx: &mut Context<Self>,
666 ) -> ExtensionCard {
667 let this = cx.entity().clone();
668 let status = Self::extension_status(&extension.id, cx);
669 let has_dev_extension = Self::dev_extension_exists(&extension.id, cx);
670
671 let extension_id = extension.id.clone();
672 let buttons = self.buttons_for_entry(extension, &status, has_dev_extension, cx);
673 let version = extension.manifest.version.clone();
674 let repository_url = extension.manifest.repository.clone();
675 let authors = extension.manifest.authors.clone();
676
677 let installed_version = match status {
678 ExtensionStatus::Installed(installed_version) => Some(installed_version),
679 _ => None,
680 };
681
682 ExtensionCard::new()
683 .overridden_by_dev_extension(has_dev_extension)
684 .child(
685 h_flex()
686 .justify_between()
687 .child(
688 h_flex()
689 .gap_2()
690 .child(
691 Headline::new(extension.manifest.name.clone())
692 .size(HeadlineSize::Medium),
693 )
694 .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
695 .children(
696 installed_version
697 .filter(|installed_version| *installed_version != version)
698 .map(|installed_version| {
699 Headline::new(format!("(v{installed_version} installed)",))
700 .size(HeadlineSize::XSmall)
701 }),
702 )
703 .map(|parent| {
704 if extension.manifest.provides.is_empty() {
705 return parent;
706 }
707
708 parent.child(
709 h_flex().gap_2().children(
710 extension
711 .manifest
712 .provides
713 .iter()
714 .map(|provides| {
715 div()
716 .bg(cx.theme().colors().element_background)
717 .px_0p5()
718 .border_1()
719 .border_color(cx.theme().colors().border)
720 .rounded_sm()
721 .child(
722 Label::new(extension_provides_label(
723 *provides,
724 ))
725 .size(LabelSize::XSmall),
726 )
727 })
728 .collect::<Vec<_>>(),
729 ),
730 )
731 }),
732 )
733 .child(
734 h_flex()
735 .gap_2()
736 .justify_between()
737 .children(buttons.upgrade)
738 .children(buttons.configure)
739 .child(buttons.install_or_uninstall),
740 ),
741 )
742 .child(
743 h_flex()
744 .gap_2()
745 .justify_between()
746 .child(
747 Label::new(format!(
748 "{}: {}",
749 if extension.manifest.authors.len() > 1 {
750 "Authors"
751 } else {
752 "Author"
753 },
754 extension.manifest.authors.join(", ")
755 ))
756 .size(LabelSize::Small)
757 .color(Color::Muted)
758 .truncate(),
759 )
760 .child(
761 Label::new(format!(
762 "Downloads: {}",
763 extension.download_count.to_formatted_string(&Locale::en)
764 ))
765 .size(LabelSize::Small),
766 ),
767 )
768 .child(
769 h_flex()
770 .gap_2()
771 .justify_between()
772 .children(extension.manifest.description.as_ref().map(|description| {
773 Label::new(description.clone())
774 .size(LabelSize::Small)
775 .color(Color::Default)
776 .truncate()
777 }))
778 .child(
779 h_flex()
780 .gap_2()
781 .child(
782 IconButton::new(
783 SharedString::from(format!("repository-{}", extension.id)),
784 IconName::Github,
785 )
786 .icon_color(Color::Accent)
787 .icon_size(IconSize::Small)
788 .on_click(cx.listener({
789 let repository_url = repository_url.clone();
790 move |_, _, _, cx| {
791 cx.open_url(&repository_url);
792 }
793 }))
794 .tooltip(Tooltip::text(repository_url.clone())),
795 )
796 .child(
797 PopoverMenu::new(SharedString::from(format!(
798 "more-{}",
799 extension.id
800 )))
801 .trigger(
802 IconButton::new(
803 SharedString::from(format!("more-{}", extension.id)),
804 IconName::Ellipsis,
805 )
806 .icon_color(Color::Accent)
807 .icon_size(IconSize::Small),
808 )
809 .menu(move |window, cx| {
810 Some(Self::render_remote_extension_context_menu(
811 &this,
812 extension_id.clone(),
813 authors.clone(),
814 window,
815 cx,
816 ))
817 }),
818 ),
819 ),
820 )
821 }
822
823 fn render_remote_extension_context_menu(
824 this: &Entity<Self>,
825 extension_id: Arc<str>,
826 authors: Vec<String>,
827 window: &mut Window,
828 cx: &mut App,
829 ) -> Entity<ContextMenu> {
830 let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| {
831 context_menu
832 .entry(
833 "Install Another Version...",
834 None,
835 window.handler_for(this, {
836 let extension_id = extension_id.clone();
837 move |this, window, cx| {
838 this.show_extension_version_list(extension_id.clone(), window, cx)
839 }
840 }),
841 )
842 .entry("Copy Extension ID", None, {
843 let extension_id = extension_id.clone();
844 move |_, cx| {
845 cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
846 }
847 })
848 .entry("Copy Author Info", None, {
849 let authors = authors.clone();
850 move |_, cx| {
851 cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", ")));
852 }
853 })
854 });
855
856 context_menu
857 }
858
859 fn show_extension_version_list(
860 &mut self,
861 extension_id: Arc<str>,
862 window: &mut Window,
863 cx: &mut Context<Self>,
864 ) {
865 let Some(workspace) = self.workspace.upgrade() else {
866 return;
867 };
868
869 cx.spawn_in(window, async move |this, cx| {
870 let extension_versions_task = this.update(cx, |_, cx| {
871 let extension_store = ExtensionStore::global(cx);
872
873 extension_store.update(cx, |store, cx| {
874 store.fetch_extension_versions(&extension_id, cx)
875 })
876 })?;
877
878 let extension_versions = extension_versions_task.await?;
879
880 workspace.update_in(cx, |workspace, window, cx| {
881 let fs = workspace.project().read(cx).fs().clone();
882 workspace.toggle_modal(window, cx, |window, cx| {
883 let delegate = ExtensionVersionSelectorDelegate::new(
884 fs,
885 cx.entity().downgrade(),
886 extension_versions,
887 );
888
889 ExtensionVersionSelector::new(delegate, window, cx)
890 });
891 })?;
892
893 anyhow::Ok(())
894 })
895 .detach_and_log_err(cx);
896 }
897
898 fn buttons_for_entry(
899 &self,
900 extension: &ExtensionMetadata,
901 status: &ExtensionStatus,
902 has_dev_extension: bool,
903 cx: &mut Context<Self>,
904 ) -> ExtensionCardButtons {
905 let is_compatible =
906 extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
907
908 if has_dev_extension {
909 // If we have a dev extension for the given extension, just treat it as uninstalled.
910 // The button here is a placeholder, as it won't be interactable anyways.
911 return ExtensionCardButtons {
912 install_or_uninstall: Button::new(
913 SharedString::from(extension.id.clone()),
914 "Install",
915 ),
916 configure: None,
917 upgrade: None,
918 };
919 }
920
921 let is_configurable = extension
922 .manifest
923 .provides
924 .contains(&ExtensionProvides::ContextServers);
925
926 match status.clone() {
927 ExtensionStatus::NotInstalled => ExtensionCardButtons {
928 install_or_uninstall: Button::new(
929 SharedString::from(extension.id.clone()),
930 "Install",
931 )
932 .on_click({
933 let extension_id = extension.id.clone();
934 move |_, _, cx| {
935 telemetry::event!("Extension Installed");
936 ExtensionStore::global(cx).update(cx, |store, cx| {
937 store.install_latest_extension(extension_id.clone(), cx)
938 });
939 }
940 }),
941 configure: None,
942 upgrade: None,
943 },
944 ExtensionStatus::Installing => ExtensionCardButtons {
945 install_or_uninstall: Button::new(
946 SharedString::from(extension.id.clone()),
947 "Install",
948 )
949 .disabled(true),
950 configure: None,
951 upgrade: None,
952 },
953 ExtensionStatus::Upgrading => ExtensionCardButtons {
954 install_or_uninstall: Button::new(
955 SharedString::from(extension.id.clone()),
956 "Uninstall",
957 )
958 .disabled(true),
959 configure: is_configurable.then(|| {
960 Button::new(
961 SharedString::from(format!("configure-{}", extension.id)),
962 "Configure",
963 )
964 .disabled(true)
965 }),
966 upgrade: Some(
967 Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
968 ),
969 },
970 ExtensionStatus::Installed(installed_version) => ExtensionCardButtons {
971 install_or_uninstall: Button::new(
972 SharedString::from(extension.id.clone()),
973 "Uninstall",
974 )
975 .on_click({
976 let extension_id = extension.id.clone();
977 move |_, _, cx| {
978 telemetry::event!("Extension Uninstalled", extension_id);
979 ExtensionStore::global(cx).update(cx, |store, cx| {
980 store.uninstall_extension(extension_id.clone(), cx)
981 });
982 }
983 }),
984 configure: is_configurable.then(|| {
985 Button::new(
986 SharedString::from(format!("configure-{}", extension.id)),
987 "Configure",
988 )
989 .on_click({
990 let extension_id = extension.id.clone();
991 move |_, _, cx| {
992 if let Some(manifest) = ExtensionStore::global(cx)
993 .read(cx)
994 .extension_manifest_for_id(&extension_id)
995 .cloned()
996 {
997 if let Some(events) = extension::ExtensionEvents::try_global(cx) {
998 events.update(cx, |this, cx| {
999 this.emit(
1000 extension::Event::ConfigureExtensionRequested(manifest),
1001 cx,
1002 )
1003 });
1004 }
1005 }
1006 }
1007 })
1008 }),
1009 upgrade: if installed_version == extension.manifest.version {
1010 None
1011 } else {
1012 Some(
1013 Button::new(SharedString::from(extension.id.clone()), "Upgrade")
1014 .when(!is_compatible, |upgrade_button| {
1015 upgrade_button.disabled(true).tooltip({
1016 let version = extension.manifest.version.clone();
1017 move |_, cx| {
1018 Tooltip::simple(
1019 format!(
1020 "v{version} is not compatible with this version of Zed.",
1021 ),
1022 cx,
1023 )
1024 }
1025 })
1026 })
1027 .disabled(!is_compatible)
1028 .on_click({
1029 let extension_id = extension.id.clone();
1030 let version = extension.manifest.version.clone();
1031 move |_, _, cx| {
1032 telemetry::event!("Extension Installed", extension_id, version);
1033 ExtensionStore::global(cx).update(cx, |store, cx| {
1034 store
1035 .upgrade_extension(
1036 extension_id.clone(),
1037 version.clone(),
1038 cx,
1039 )
1040 .detach_and_log_err(cx)
1041 });
1042 }
1043 }),
1044 )
1045 },
1046 },
1047 ExtensionStatus::Removing => ExtensionCardButtons {
1048 install_or_uninstall: Button::new(
1049 SharedString::from(extension.id.clone()),
1050 "Uninstall",
1051 )
1052 .disabled(true),
1053 configure: is_configurable.then(|| {
1054 Button::new(
1055 SharedString::from(format!("configure-{}", extension.id)),
1056 "Configure",
1057 )
1058 .disabled(true)
1059 }),
1060 upgrade: None,
1061 },
1062 }
1063 }
1064
1065 fn render_search(&self, cx: &mut Context<Self>) -> Div {
1066 let mut key_context = KeyContext::new_with_defaults();
1067 key_context.add("BufferSearchBar");
1068
1069 let editor_border = if self.query_contains_error {
1070 Color::Error.color(cx)
1071 } else {
1072 cx.theme().colors().border
1073 };
1074
1075 h_flex()
1076 .key_context(key_context)
1077 .h_8()
1078 .flex_1()
1079 .min_w(rems_from_px(384.))
1080 .pl_1p5()
1081 .pr_2()
1082 .py_1()
1083 .gap_2()
1084 .border_1()
1085 .border_color(editor_border)
1086 .rounded_lg()
1087 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
1088 .child(self.render_text_input(&self.query_editor, cx))
1089 }
1090
1091 fn render_text_input(
1092 &self,
1093 editor: &Entity<Editor>,
1094 cx: &mut Context<Self>,
1095 ) -> impl IntoElement {
1096 let settings = ThemeSettings::get_global(cx);
1097 let text_style = TextStyle {
1098 color: if editor.read(cx).read_only(cx) {
1099 cx.theme().colors().text_disabled
1100 } else {
1101 cx.theme().colors().text
1102 },
1103 font_family: settings.ui_font.family.clone(),
1104 font_features: settings.ui_font.features.clone(),
1105 font_fallbacks: settings.ui_font.fallbacks.clone(),
1106 font_size: rems(0.875).into(),
1107 font_weight: settings.ui_font.weight,
1108 line_height: relative(1.3),
1109 ..Default::default()
1110 };
1111
1112 EditorElement::new(
1113 editor,
1114 EditorStyle {
1115 background: cx.theme().colors().editor_background,
1116 local_player: cx.theme().players().local(),
1117 text: text_style,
1118 ..Default::default()
1119 },
1120 )
1121 }
1122
1123 fn on_query_change(
1124 &mut self,
1125 _: Entity<Editor>,
1126 event: &editor::EditorEvent,
1127 cx: &mut Context<Self>,
1128 ) {
1129 if let editor::EditorEvent::Edited { .. } = event {
1130 self.query_contains_error = false;
1131 self.refresh_search(cx);
1132 }
1133 }
1134
1135 fn refresh_search(&mut self, cx: &mut Context<Self>) {
1136 self.fetch_extensions_debounced(
1137 Some(Box::new(|this, cx| {
1138 this.scroll_to_top(cx);
1139 })),
1140 cx,
1141 );
1142 self.refresh_feature_upsells(cx);
1143 }
1144
1145 pub fn change_provides_filter(
1146 &mut self,
1147 provides_filter: Option<ExtensionProvides>,
1148 cx: &mut Context<Self>,
1149 ) {
1150 self.provides_filter = provides_filter;
1151 self.refresh_search(cx);
1152 }
1153
1154 fn fetch_extensions_debounced(
1155 &mut self,
1156 on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
1157 cx: &mut Context<ExtensionsPage>,
1158 ) {
1159 self.extension_fetch_task = Some(cx.spawn(async move |this, cx| {
1160 let search = this
1161 .update(cx, |this, cx| this.search_query(cx))
1162 .ok()
1163 .flatten();
1164
1165 // Only debounce the fetching of extensions if we have a search
1166 // query.
1167 //
1168 // If the search was just cleared then we can just reload the list
1169 // of extensions without a debounce, which allows us to avoid seeing
1170 // an intermittent flash of a "no extensions" state.
1171 if search.is_some() {
1172 cx.background_executor()
1173 .timer(Duration::from_millis(250))
1174 .await;
1175 };
1176
1177 this.update(cx, |this, cx| {
1178 this.fetch_extensions(
1179 search,
1180 Some(BTreeSet::from_iter(this.provides_filter)),
1181 on_complete,
1182 cx,
1183 );
1184 })
1185 .ok();
1186 }));
1187 }
1188
1189 pub fn search_query(&self, cx: &mut App) -> Option<String> {
1190 let search = self.query_editor.read(cx).text(cx);
1191 if search.trim().is_empty() {
1192 None
1193 } else {
1194 Some(search)
1195 }
1196 }
1197
1198 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1199 let has_search = self.search_query(cx).is_some();
1200
1201 let message = if self.is_fetching_extensions {
1202 "Loading extensions..."
1203 } else {
1204 match self.filter {
1205 ExtensionFilter::All => {
1206 if has_search {
1207 "No extensions that match your search."
1208 } else {
1209 "No extensions."
1210 }
1211 }
1212 ExtensionFilter::Installed => {
1213 if has_search {
1214 "No installed extensions that match your search."
1215 } else {
1216 "No installed extensions."
1217 }
1218 }
1219 ExtensionFilter::NotInstalled => {
1220 if has_search {
1221 "No not installed extensions that match your search."
1222 } else {
1223 "No not installed extensions."
1224 }
1225 }
1226 }
1227 };
1228
1229 Label::new(message)
1230 }
1231
1232 fn update_settings<T: Settings>(
1233 &mut self,
1234 selection: &ToggleState,
1235
1236 cx: &mut Context<Self>,
1237 callback: impl 'static + Send + Fn(&mut T::FileContent, bool),
1238 ) {
1239 if let Some(workspace) = self.workspace.upgrade() {
1240 let fs = workspace.read(cx).app_state().fs.clone();
1241 let selection = *selection;
1242 settings::update_settings_file::<T>(fs, cx, move |settings, _| {
1243 let value = match selection {
1244 ToggleState::Unselected => false,
1245 ToggleState::Selected => true,
1246 _ => return,
1247 };
1248
1249 callback(settings, value)
1250 });
1251 }
1252 }
1253
1254 fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1255 let Some(search) = self.search_query(cx) else {
1256 self.upsells.clear();
1257 return;
1258 };
1259
1260 let search = search.to_lowercase();
1261 let search_terms = search
1262 .split_whitespace()
1263 .map(|term| term.trim())
1264 .collect::<Vec<_>>();
1265
1266 for (feature, keywords) in keywords_by_feature() {
1267 if keywords
1268 .iter()
1269 .any(|keyword| search_terms.contains(keyword))
1270 {
1271 self.upsells.insert(*feature);
1272 } else {
1273 self.upsells.remove(feature);
1274 }
1275 }
1276 }
1277
1278 fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1279 let upsells_count = self.upsells.len();
1280
1281 v_flex().children(self.upsells.iter().enumerate().map(|(ix, feature)| {
1282 let upsell = match feature {
1283 Feature::Git => FeatureUpsell::new(
1284 "Zed comes with basic Git support. More Git features are coming in the future.",
1285 )
1286 .docs_url("https://zed.dev/docs/git"),
1287 Feature::OpenIn => FeatureUpsell::new(
1288 "Zed supports linking to a source line on GitHub and others.",
1289 )
1290 .docs_url("https://zed.dev/docs/git#git-integrations"),
1291 Feature::Vim => FeatureUpsell::new("Vim support is built-in to Zed!")
1292 .docs_url("https://zed.dev/docs/vim")
1293 .child(CheckboxWithLabel::new(
1294 "enable-vim",
1295 Label::new("Enable vim mode"),
1296 if VimModeSetting::get_global(cx).0 {
1297 ui::ToggleState::Selected
1298 } else {
1299 ui::ToggleState::Unselected
1300 },
1301 cx.listener(move |this, selection, _, cx| {
1302 telemetry::event!("Vim Mode Toggled", source = "Feature Upsell");
1303 this.update_settings::<VimModeSetting>(
1304 selection,
1305 cx,
1306 |setting, value| *setting = Some(value),
1307 );
1308 }),
1309 )),
1310 Feature::LanguageBash => FeatureUpsell::new("Shell support is built-in to Zed!")
1311 .docs_url("https://zed.dev/docs/languages/bash"),
1312 Feature::LanguageC => FeatureUpsell::new("C support is built-in to Zed!")
1313 .docs_url("https://zed.dev/docs/languages/c"),
1314 Feature::LanguageCpp => FeatureUpsell::new("C++ support is built-in to Zed!")
1315 .docs_url("https://zed.dev/docs/languages/cpp"),
1316 Feature::LanguageGo => FeatureUpsell::new("Go support is built-in to Zed!")
1317 .docs_url("https://zed.dev/docs/languages/go"),
1318 Feature::LanguagePython => FeatureUpsell::new("Python support is built-in to Zed!")
1319 .docs_url("https://zed.dev/docs/languages/python"),
1320 Feature::LanguageReact => FeatureUpsell::new("React support is built-in to Zed!")
1321 .docs_url("https://zed.dev/docs/languages/typescript"),
1322 Feature::LanguageRust => FeatureUpsell::new("Rust support is built-in to Zed!")
1323 .docs_url("https://zed.dev/docs/languages/rust"),
1324 Feature::LanguageTypescript => {
1325 FeatureUpsell::new("Typescript support is built-in to Zed!")
1326 .docs_url("https://zed.dev/docs/languages/typescript")
1327 }
1328 };
1329
1330 upsell.when(ix < upsells_count, |upsell| upsell.border_b_1())
1331 }))
1332 }
1333}
1334
1335impl Render for ExtensionsPage {
1336 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1337 v_flex()
1338 .size_full()
1339 .bg(cx.theme().colors().editor_background)
1340 .child(
1341 v_flex()
1342 .gap_4()
1343 .pt_4()
1344 .px_4()
1345 .bg(cx.theme().colors().editor_background)
1346 .child(
1347 h_flex()
1348 .w_full()
1349 .gap_2()
1350 .justify_between()
1351 .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
1352 .child(
1353 Button::new("install-dev-extension", "Install Dev Extension")
1354 .style(ButtonStyle::Filled)
1355 .size(ButtonSize::Large)
1356 .on_click(|_event, window, cx| {
1357 window.dispatch_action(Box::new(InstallDevExtension), cx)
1358 }),
1359 ),
1360 )
1361 .child(
1362 h_flex()
1363 .w_full()
1364 .gap_4()
1365 .flex_wrap()
1366 .child(self.render_search(cx))
1367 .child(
1368 h_flex()
1369 .child(
1370 ToggleButton::new("filter-all", "All")
1371 .style(ButtonStyle::Filled)
1372 .size(ButtonSize::Large)
1373 .toggle_state(self.filter == ExtensionFilter::All)
1374 .on_click(cx.listener(|this, _event, _, cx| {
1375 this.filter = ExtensionFilter::All;
1376 this.filter_extension_entries(cx);
1377 this.scroll_to_top(cx);
1378 }))
1379 .tooltip(move |_, cx| {
1380 Tooltip::simple("Show all extensions", cx)
1381 })
1382 .first(),
1383 )
1384 .child(
1385 ToggleButton::new("filter-installed", "Installed")
1386 .style(ButtonStyle::Filled)
1387 .size(ButtonSize::Large)
1388 .toggle_state(self.filter == ExtensionFilter::Installed)
1389 .on_click(cx.listener(|this, _event, _, cx| {
1390 this.filter = ExtensionFilter::Installed;
1391 this.filter_extension_entries(cx);
1392 this.scroll_to_top(cx);
1393 }))
1394 .tooltip(move |_, cx| {
1395 Tooltip::simple("Show installed extensions", cx)
1396 })
1397 .middle(),
1398 )
1399 .child(
1400 ToggleButton::new("filter-not-installed", "Not Installed")
1401 .style(ButtonStyle::Filled)
1402 .size(ButtonSize::Large)
1403 .toggle_state(
1404 self.filter == ExtensionFilter::NotInstalled,
1405 )
1406 .on_click(cx.listener(|this, _event, _, cx| {
1407 this.filter = ExtensionFilter::NotInstalled;
1408 this.filter_extension_entries(cx);
1409 this.scroll_to_top(cx);
1410 }))
1411 .tooltip(move |_, cx| {
1412 Tooltip::simple("Show not installed extensions", cx)
1413 })
1414 .last(),
1415 ),
1416 ),
1417 ),
1418 )
1419 .child(
1420 h_flex()
1421 .id("filter-row")
1422 .gap_2()
1423 .py_2p5()
1424 .px_4()
1425 .border_b_1()
1426 .border_color(cx.theme().colors().border_variant)
1427 .overflow_x_scroll()
1428 .child(
1429 Button::new("filter-all-categories", "All")
1430 .when(self.provides_filter.is_none(), |button| {
1431 button.style(ButtonStyle::Filled)
1432 })
1433 .when(self.provides_filter.is_some(), |button| {
1434 button.style(ButtonStyle::Subtle)
1435 })
1436 .toggle_state(self.provides_filter.is_none())
1437 .on_click(cx.listener(|this, _event, _, cx| {
1438 this.change_provides_filter(None, cx);
1439 })),
1440 )
1441 .children(ExtensionProvides::iter().map(|provides| {
1442 let label = extension_provides_label(provides);
1443 Button::new(
1444 SharedString::from(format!("filter-category-{}", label)),
1445 label,
1446 )
1447 .style(if self.provides_filter == Some(provides) {
1448 ButtonStyle::Filled
1449 } else {
1450 ButtonStyle::Subtle
1451 })
1452 .toggle_state(self.provides_filter == Some(provides))
1453 .on_click({
1454 cx.listener(move |this, _event, _, cx| {
1455 this.change_provides_filter(Some(provides), cx);
1456 })
1457 })
1458 })),
1459 )
1460 .child(self.render_feature_upsells(cx))
1461 .child(
1462 v_flex()
1463 .pl_4()
1464 .pr_6()
1465 .size_full()
1466 .overflow_y_hidden()
1467 .map(|this| {
1468 let mut count = self.filtered_remote_extension_indices.len();
1469 if self.filter.include_dev_extensions() {
1470 count += self.dev_extension_entries.len();
1471 }
1472
1473 if count == 0 {
1474 return this.py_4().child(self.render_empty_state(cx));
1475 }
1476
1477 let extensions_page = cx.entity().clone();
1478 let scroll_handle = self.list.clone();
1479 this.child(
1480 uniform_list(
1481 extensions_page,
1482 "entries",
1483 count,
1484 Self::render_extensions,
1485 )
1486 .flex_grow()
1487 .pb_4()
1488 .track_scroll(scroll_handle),
1489 )
1490 .child(
1491 div()
1492 .absolute()
1493 .right_1()
1494 .top_0()
1495 .bottom_0()
1496 .w(px(12.))
1497 .children(Scrollbar::vertical(self.scrollbar_state.clone())),
1498 )
1499 }),
1500 )
1501 }
1502}
1503
1504impl EventEmitter<ItemEvent> for ExtensionsPage {}
1505
1506impl Focusable for ExtensionsPage {
1507 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1508 self.query_editor.read(cx).focus_handle(cx)
1509 }
1510}
1511
1512impl Item for ExtensionsPage {
1513 type Event = ItemEvent;
1514
1515 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1516 "Extensions".into()
1517 }
1518
1519 fn telemetry_event_text(&self) -> Option<&'static str> {
1520 Some("Extensions Page Opened")
1521 }
1522
1523 fn show_toolbar(&self) -> bool {
1524 false
1525 }
1526
1527 fn clone_on_split(
1528 &self,
1529 _workspace_id: Option<WorkspaceId>,
1530 _window: &mut Window,
1531 _: &mut Context<Self>,
1532 ) -> Option<Entity<Self>> {
1533 None
1534 }
1535
1536 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
1537 f(*event)
1538 }
1539}