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