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