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