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