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