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::{ExtensionManifest, ExtensionOperation, ExtensionStore};
15use fuzzy::{match_strings, StringMatchCandidate};
16use gpui::{
17 actions, uniform_list, AppContext, EventEmitter, Flatten, FocusableView, InteractiveElement,
18 KeyContext, ParentElement, Render, Styled, Task, TextStyle, UniformListScrollHandle, View,
19 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::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, [Extensions, 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, _: &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| match event {
99 project::Event::LanguageNotFound(buffer) => {
100 extension_suggest::suggest(buffer.clone(), cx);
101 }
102 _ => {}
103 })
104 .detach();
105 })
106 .detach();
107}
108
109#[derive(Clone)]
110pub enum ExtensionStatus {
111 NotInstalled,
112 Installing,
113 Upgrading,
114 Installed(Arc<str>),
115 Removing,
116}
117
118#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
119enum ExtensionFilter {
120 All,
121 Installed,
122 NotInstalled,
123}
124
125impl ExtensionFilter {
126 pub fn include_dev_extensions(&self) -> bool {
127 match self {
128 Self::All | Self::Installed => true,
129 Self::NotInstalled => false,
130 }
131 }
132}
133
134#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
135enum Feature {
136 Git,
137 OpenIn,
138 Vim,
139 LanguageBash,
140 LanguageC,
141 LanguageCpp,
142 LanguageGo,
143 LanguagePython,
144 LanguageReact,
145 LanguageRust,
146 LanguageTypescript,
147}
148
149fn keywords_by_feature() -> &'static BTreeMap<Feature, Vec<&'static str>> {
150 static KEYWORDS_BY_FEATURE: OnceLock<BTreeMap<Feature, Vec<&'static str>>> = OnceLock::new();
151 KEYWORDS_BY_FEATURE.get_or_init(|| {
152 BTreeMap::from_iter([
153 (Feature::Git, vec!["git"]),
154 (
155 Feature::OpenIn,
156 vec![
157 "github",
158 "gitlab",
159 "bitbucket",
160 "codeberg",
161 "sourcehut",
162 "permalink",
163 "link",
164 "open in",
165 ],
166 ),
167 (Feature::Vim, vec!["vim"]),
168 (Feature::LanguageBash, vec!["sh", "bash"]),
169 (Feature::LanguageC, vec!["c", "clang"]),
170 (Feature::LanguageCpp, vec!["c++", "cpp", "clang"]),
171 (Feature::LanguageGo, vec!["go", "golang"]),
172 (Feature::LanguagePython, vec!["python", "py"]),
173 (Feature::LanguageReact, vec!["react"]),
174 (Feature::LanguageRust, vec!["rust", "rs"]),
175 (
176 Feature::LanguageTypescript,
177 vec!["type", "typescript", "ts"],
178 ),
179 ])
180 })
181}
182
183pub struct ExtensionsPage {
184 workspace: WeakView<Workspace>,
185 list: UniformListScrollHandle,
186 telemetry: Arc<Telemetry>,
187 is_fetching_extensions: bool,
188 filter: ExtensionFilter,
189 remote_extension_entries: Vec<ExtensionMetadata>,
190 dev_extension_entries: Vec<Arc<ExtensionManifest>>,
191 filtered_remote_extension_indices: Vec<usize>,
192 query_editor: View<Editor>,
193 query_contains_error: bool,
194 _subscriptions: [gpui::Subscription; 2],
195 extension_fetch_task: Option<Task<()>>,
196 upsells: BTreeSet<Feature>,
197}
198
199impl ExtensionsPage {
200 pub fn new(workspace: &Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
201 cx.new_view(|cx: &mut ViewContext<Self>| {
202 let store = ExtensionStore::global(cx);
203 let workspace_handle = workspace.weak_handle();
204 let subscriptions = [
205 cx.observe(&store, |_, _, cx| cx.notify()),
206 cx.subscribe(&store, move |this, _, event, cx| match event {
207 extension::Event::ExtensionsUpdated => this.fetch_extensions_debounced(cx),
208 extension::Event::ExtensionInstalled(extension_id) => {
209 this.on_extension_installed(workspace_handle.clone(), extension_id, cx)
210 }
211 _ => {}
212 }),
213 ];
214
215 let query_editor = cx.new_view(|cx| {
216 let mut input = Editor::single_line(cx);
217 input.set_placeholder_text("Search extensions...", cx);
218 input
219 });
220 cx.subscribe(&query_editor, Self::on_query_change).detach();
221
222 let mut this = Self {
223 workspace: workspace.weak_handle(),
224 list: UniformListScrollHandle::new(),
225 telemetry: workspace.client().telemetry().clone(),
226 is_fetching_extensions: false,
227 filter: ExtensionFilter::All,
228 dev_extension_entries: Vec::new(),
229 filtered_remote_extension_indices: Vec::new(),
230 remote_extension_entries: Vec::new(),
231 query_contains_error: false,
232 extension_fetch_task: None,
233 _subscriptions: subscriptions,
234 query_editor,
235 upsells: BTreeSet::default(),
236 };
237 this.fetch_extensions(None, cx);
238 this
239 })
240 }
241
242 fn on_extension_installed(
243 &mut self,
244 workspace: WeakView<Workspace>,
245 extension_id: &str,
246 cx: &mut ViewContext<Self>,
247 ) {
248 let extension_store = ExtensionStore::global(cx).read(cx);
249 let themes = extension_store
250 .extension_themes(extension_id)
251 .map(|name| name.to_string())
252 .collect::<Vec<_>>();
253 if !themes.is_empty() {
254 workspace
255 .update(cx, |workspace, cx| {
256 theme_selector::toggle(
257 workspace,
258 &theme_selector::Toggle {
259 themes_filter: Some(themes),
260 },
261 cx,
262 )
263 })
264 .ok();
265 }
266 }
267
268 /// Returns whether a dev extension currently exists for the extension with the given ID.
269 fn dev_extension_exists(extension_id: &str, cx: &mut ViewContext<Self>) -> bool {
270 let extension_store = ExtensionStore::global(cx).read(cx);
271
272 extension_store
273 .dev_extensions()
274 .any(|dev_extension| dev_extension.id.as_ref() == extension_id)
275 }
276
277 fn extension_status(extension_id: &str, cx: &mut ViewContext<Self>) -> ExtensionStatus {
278 let extension_store = ExtensionStore::global(cx).read(cx);
279
280 match extension_store.outstanding_operations().get(extension_id) {
281 Some(ExtensionOperation::Install) => ExtensionStatus::Installing,
282 Some(ExtensionOperation::Remove) => ExtensionStatus::Removing,
283 Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading,
284 None => match extension_store.installed_extensions().get(extension_id) {
285 Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()),
286 None => ExtensionStatus::NotInstalled,
287 },
288 }
289 }
290
291 fn filter_extension_entries(&mut self, cx: &mut ViewContext<Self>) {
292 self.filtered_remote_extension_indices.clear();
293 self.filtered_remote_extension_indices.extend(
294 self.remote_extension_entries
295 .iter()
296 .enumerate()
297 .filter(|(_, extension)| match self.filter {
298 ExtensionFilter::All => true,
299 ExtensionFilter::Installed => {
300 let status = Self::extension_status(&extension.id, cx);
301 matches!(status, ExtensionStatus::Installed(_))
302 }
303 ExtensionFilter::NotInstalled => {
304 let status = Self::extension_status(&extension.id, cx);
305
306 matches!(status, ExtensionStatus::NotInstalled)
307 }
308 })
309 .map(|(ix, _)| ix),
310 );
311 cx.notify();
312 }
313
314 fn fetch_extensions(&mut self, search: Option<String>, cx: &mut ViewContext<Self>) {
315 self.is_fetching_extensions = true;
316 cx.notify();
317
318 let extension_store = ExtensionStore::global(cx);
319
320 let dev_extensions = extension_store.update(cx, |store, _| {
321 store.dev_extensions().cloned().collect::<Vec<_>>()
322 });
323
324 let remote_extensions = extension_store.update(cx, |store, cx| {
325 store.fetch_extensions(search.as_deref(), cx)
326 });
327
328 cx.spawn(move |this, mut cx| async move {
329 let dev_extensions = if let Some(search) = search {
330 let match_candidates = dev_extensions
331 .iter()
332 .enumerate()
333 .map(|(ix, manifest)| StringMatchCandidate {
334 id: ix,
335 string: manifest.name.clone(),
336 char_bag: manifest.name.as_str().into(),
337 })
338 .collect::<Vec<_>>();
339
340 let matches = match_strings(
341 &match_candidates,
342 &search,
343 false,
344 match_candidates.len(),
345 &Default::default(),
346 cx.background_executor().clone(),
347 )
348 .await;
349 matches
350 .into_iter()
351 .map(|mat| dev_extensions[mat.candidate_id].clone())
352 .collect()
353 } else {
354 dev_extensions
355 };
356
357 let fetch_result = remote_extensions.await;
358 this.update(&mut cx, |this, cx| {
359 cx.notify();
360 this.dev_extension_entries = dev_extensions;
361 this.is_fetching_extensions = false;
362 this.remote_extension_entries = fetch_result?;
363 this.filter_extension_entries(cx);
364 anyhow::Ok(())
365 })?
366 })
367 .detach_and_log_err(cx);
368 }
369
370 fn render_extensions(
371 &mut self,
372 range: Range<usize>,
373 cx: &mut ViewContext<Self>,
374 ) -> Vec<ExtensionCard> {
375 let dev_extension_entries_len = if self.filter.include_dev_extensions() {
376 self.dev_extension_entries.len()
377 } else {
378 0
379 };
380 range
381 .map(|ix| {
382 if ix < dev_extension_entries_len {
383 let extension = &self.dev_extension_entries[ix];
384 self.render_dev_extension(extension, cx)
385 } else {
386 let extension_ix =
387 self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
388 let extension = &self.remote_extension_entries[extension_ix];
389 self.render_remote_extension(extension, cx)
390 }
391 })
392 .collect()
393 }
394
395 fn render_dev_extension(
396 &self,
397 extension: &ExtensionManifest,
398 cx: &mut ViewContext<Self>,
399 ) -> ExtensionCard {
400 let status = Self::extension_status(&extension.id, cx);
401
402 let repository_url = extension.repository.clone();
403
404 ExtensionCard::new()
405 .child(
406 h_flex()
407 .justify_between()
408 .child(
409 h_flex()
410 .gap_2()
411 .items_end()
412 .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
413 .child(
414 Headline::new(format!("v{}", extension.version))
415 .size(HeadlineSize::XSmall),
416 ),
417 )
418 .child(
419 h_flex()
420 .gap_2()
421 .justify_between()
422 .child(
423 Button::new(
424 SharedString::from(format!("rebuild-{}", extension.id)),
425 "Rebuild",
426 )
427 .on_click({
428 let extension_id = extension.id.clone();
429 move |_, cx| {
430 ExtensionStore::global(cx).update(cx, |store, cx| {
431 store.rebuild_dev_extension(extension_id.clone(), cx)
432 });
433 }
434 })
435 .color(Color::Accent)
436 .disabled(matches!(status, ExtensionStatus::Upgrading)),
437 )
438 .child(
439 Button::new(SharedString::from(extension.id.clone()), "Uninstall")
440 .on_click({
441 let extension_id = extension.id.clone();
442 move |_, cx| {
443 ExtensionStore::global(cx).update(cx, |store, cx| {
444 store.uninstall_extension(extension_id.clone(), cx)
445 });
446 }
447 })
448 .color(Color::Accent)
449 .disabled(matches!(status, ExtensionStatus::Removing)),
450 ),
451 ),
452 )
453 .child(
454 h_flex()
455 .gap_2()
456 .justify_between()
457 .child(
458 div().overflow_x_hidden().text_ellipsis().child(
459 Label::new(format!(
460 "{}: {}",
461 if extension.authors.len() > 1 {
462 "Authors"
463 } else {
464 "Author"
465 },
466 extension.authors.join(", ")
467 ))
468 .size(LabelSize::Small),
469 ),
470 )
471 .child(Label::new("<>").size(LabelSize::Small)),
472 )
473 .child(
474 h_flex()
475 .gap_2()
476 .justify_between()
477 .children(extension.description.as_ref().map(|description| {
478 div().overflow_x_hidden().text_ellipsis().child(
479 Label::new(description.clone())
480 .size(LabelSize::Small)
481 .color(Color::Default),
482 )
483 }))
484 .children(repository_url.map(|repository_url| {
485 IconButton::new(
486 SharedString::from(format!("repository-{}", extension.id)),
487 IconName::Github,
488 )
489 .icon_color(Color::Accent)
490 .icon_size(IconSize::Small)
491 .style(ButtonStyle::Filled)
492 .on_click(cx.listener({
493 let repository_url = repository_url.clone();
494 move |_, _, cx| {
495 cx.open_url(&repository_url);
496 }
497 }))
498 .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx))
499 })),
500 )
501 }
502
503 fn render_remote_extension(
504 &self,
505 extension: &ExtensionMetadata,
506 cx: &mut ViewContext<Self>,
507 ) -> ExtensionCard {
508 let this = cx.view().clone();
509 let status = Self::extension_status(&extension.id, cx);
510 let has_dev_extension = Self::dev_extension_exists(&extension.id, cx);
511
512 let extension_id = extension.id.clone();
513 let (install_or_uninstall_button, upgrade_button) =
514 self.buttons_for_entry(extension, &status, has_dev_extension, cx);
515 let version = extension.manifest.version.clone();
516 let repository_url = extension.manifest.repository.clone();
517
518 let installed_version = match status {
519 ExtensionStatus::Installed(installed_version) => Some(installed_version),
520 _ => None,
521 };
522
523 ExtensionCard::new()
524 .overridden_by_dev_extension(has_dev_extension)
525 .child(
526 h_flex()
527 .justify_between()
528 .child(
529 h_flex()
530 .gap_2()
531 .items_end()
532 .child(
533 Headline::new(extension.manifest.name.clone())
534 .size(HeadlineSize::Medium),
535 )
536 .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
537 .children(
538 installed_version
539 .filter(|installed_version| *installed_version != version)
540 .map(|installed_version| {
541 Headline::new(format!("(v{installed_version} installed)",))
542 .size(HeadlineSize::XSmall)
543 }),
544 ),
545 )
546 .child(
547 h_flex()
548 .gap_2()
549 .justify_between()
550 .children(upgrade_button)
551 .child(install_or_uninstall_button),
552 ),
553 )
554 .child(
555 h_flex()
556 .gap_2()
557 .justify_between()
558 .child(
559 div().overflow_x_hidden().text_ellipsis().child(
560 Label::new(format!(
561 "{}: {}",
562 if extension.manifest.authors.len() > 1 {
563 "Authors"
564 } else {
565 "Author"
566 },
567 extension.manifest.authors.join(", ")
568 ))
569 .size(LabelSize::Small),
570 ),
571 )
572 .child(
573 Label::new(format!(
574 "Downloads: {}",
575 extension.download_count.to_formatted_string(&Locale::en)
576 ))
577 .size(LabelSize::Small),
578 ),
579 )
580 .child(
581 h_flex()
582 .gap_2()
583 .justify_between()
584 .children(extension.manifest.description.as_ref().map(|description| {
585 div().overflow_x_hidden().text_ellipsis().child(
586 Label::new(description.clone())
587 .size(LabelSize::Small)
588 .color(Color::Default),
589 )
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.entry(
643 "Install Another Version...",
644 None,
645 cx.handler_for(&this, move |this, cx| {
646 this.show_extension_version_list(extension_id.clone(), cx)
647 }),
648 )
649 });
650
651 context_menu
652 }
653
654 fn show_extension_version_list(&mut self, extension_id: Arc<str>, cx: &mut ViewContext<Self>) {
655 let Some(workspace) = self.workspace.upgrade() else {
656 return;
657 };
658
659 cx.spawn(move |this, mut cx| async move {
660 let extension_versions_task = this.update(&mut cx, |_, cx| {
661 let extension_store = ExtensionStore::global(cx);
662
663 extension_store.update(cx, |store, cx| {
664 store.fetch_extension_versions(&extension_id, cx)
665 })
666 })?;
667
668 let extension_versions = extension_versions_task.await?;
669
670 workspace.update(&mut cx, |workspace, cx| {
671 let fs = workspace.project().read(cx).fs().clone();
672 workspace.toggle_modal(cx, |cx| {
673 let delegate = ExtensionVersionSelectorDelegate::new(
674 fs,
675 cx.view().downgrade(),
676 extension_versions,
677 );
678
679 ExtensionVersionSelector::new(delegate, cx)
680 });
681 })?;
682
683 anyhow::Ok(())
684 })
685 .detach_and_log_err(cx);
686 }
687
688 fn buttons_for_entry(
689 &self,
690 extension: &ExtensionMetadata,
691 status: &ExtensionStatus,
692 has_dev_extension: bool,
693 cx: &mut ViewContext<Self>,
694 ) -> (Button, Option<Button>) {
695 let is_compatible =
696 extension::is_version_compatible(ReleaseChannel::global(cx), &extension);
697
698 if has_dev_extension {
699 // If we have a dev extension for the given extension, just treat it as uninstalled.
700 // The button here is a placeholder, as it won't be interactable anyways.
701 return (
702 Button::new(SharedString::from(extension.id.clone()), "Install"),
703 None,
704 );
705 }
706
707 match status.clone() {
708 ExtensionStatus::NotInstalled => (
709 Button::new(SharedString::from(extension.id.clone()), "Install").on_click(
710 cx.listener({
711 let extension_id = extension.id.clone();
712 move |this, _, cx| {
713 this.telemetry
714 .report_app_event("extensions: install extension".to_string());
715 ExtensionStore::global(cx).update(cx, |store, cx| {
716 store.install_latest_extension(extension_id.clone(), cx)
717 });
718 }
719 }),
720 ),
721 None,
722 ),
723 ExtensionStatus::Installing => (
724 Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
725 None,
726 ),
727 ExtensionStatus::Upgrading => (
728 Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
729 Some(
730 Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
731 ),
732 ),
733 ExtensionStatus::Installed(installed_version) => (
734 Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click(
735 cx.listener({
736 let extension_id = extension.id.clone();
737 move |this, _, cx| {
738 this.telemetry
739 .report_app_event("extensions: uninstall extension".to_string());
740 ExtensionStore::global(cx).update(cx, |store, cx| {
741 store.uninstall_extension(extension_id.clone(), cx)
742 });
743 }
744 }),
745 ),
746 if installed_version == extension.manifest.version {
747 None
748 } else {
749 Some(
750 Button::new(SharedString::from(extension.id.clone()), "Upgrade")
751 .when(!is_compatible, |upgrade_button| {
752 upgrade_button.disabled(true).tooltip({
753 let version = extension.manifest.version.clone();
754 move |cx| {
755 Tooltip::text(
756 format!(
757 "v{version} is not compatible with this version of Zed.",
758 ),
759 cx,
760 )
761 }
762 })
763 })
764 .disabled(!is_compatible)
765 .on_click(cx.listener({
766 let extension_id = extension.id.clone();
767 let version = extension.manifest.version.clone();
768 move |this, _, cx| {
769 this.telemetry.report_app_event(
770 "extensions: install extension".to_string(),
771 );
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 let Some(_) = search {
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: &Selection,
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 Selection::Unselected => false,
940 Selection::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 telemetry = self.telemetry.clone();
978 let upsell = match feature {
979 Feature::Git => FeatureUpsell::new(
980 telemetry,
981 "Zed comes with basic Git support. More Git features are coming in the future.",
982 )
983 .docs_url("https://zed.dev/docs/git"),
984 Feature::OpenIn => FeatureUpsell::new(
985 telemetry,
986 "Zed supports linking to a source line on GitHub and others.",
987 )
988 .docs_url("https://zed.dev/docs/git#git-integrations"),
989 Feature::Vim => FeatureUpsell::new(telemetry, "Vim support is built-in to Zed!")
990 .docs_url("https://zed.dev/docs/vim")
991 .child(CheckboxWithLabel::new(
992 "enable-vim",
993 Label::new("Enable vim mode"),
994 if VimModeSetting::get_global(cx).0 {
995 ui::Selection::Selected
996 } else {
997 ui::Selection::Unselected
998 },
999 cx.listener(move |this, selection, cx| {
1000 this.telemetry
1001 .report_app_event("feature upsell: toggle vim".to_string());
1002 this.update_settings::<VimModeSetting>(
1003 selection,
1004 cx,
1005 |setting, value| *setting = Some(value),
1006 );
1007 }),
1008 )),
1009 Feature::LanguageBash => {
1010 FeatureUpsell::new(telemetry, "Shell support is built-in to Zed!")
1011 .docs_url("https://zed.dev/docs/languages/bash")
1012 }
1013 Feature::LanguageC => {
1014 FeatureUpsell::new(telemetry, "C support is built-in to Zed!")
1015 .docs_url("https://zed.dev/docs/languages/c")
1016 }
1017 Feature::LanguageCpp => {
1018 FeatureUpsell::new(telemetry, "C++ support is built-in to Zed!")
1019 .docs_url("https://zed.dev/docs/languages/cpp")
1020 }
1021 Feature::LanguageGo => {
1022 FeatureUpsell::new(telemetry, "Go support is built-in to Zed!")
1023 .docs_url("https://zed.dev/docs/languages/go")
1024 }
1025 Feature::LanguagePython => {
1026 FeatureUpsell::new(telemetry, "Python support is built-in to Zed!")
1027 .docs_url("https://zed.dev/docs/languages/python")
1028 }
1029 Feature::LanguageReact => {
1030 FeatureUpsell::new(telemetry, "React support is built-in to Zed!")
1031 .docs_url("https://zed.dev/docs/languages/typescript")
1032 }
1033 Feature::LanguageRust => {
1034 FeatureUpsell::new(telemetry, "Rust support is built-in to Zed!")
1035 .docs_url("https://zed.dev/docs/languages/rust")
1036 }
1037 Feature::LanguageTypescript => {
1038 FeatureUpsell::new(telemetry, "Typescript support is built-in to Zed!")
1039 .docs_url("https://zed.dev/docs/languages/typescript")
1040 }
1041 };
1042
1043 upsell.when(ix < upsells_count, |upsell| upsell.border_b_1())
1044 }))
1045 }
1046}
1047
1048impl Render for ExtensionsPage {
1049 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1050 v_flex()
1051 .size_full()
1052 .bg(cx.theme().colors().editor_background)
1053 .child(
1054 v_flex()
1055 .gap_4()
1056 .p_4()
1057 .border_b_1()
1058 .border_color(cx.theme().colors().border)
1059 .bg(cx.theme().colors().editor_background)
1060 .child(
1061 h_flex()
1062 .w_full()
1063 .gap_2()
1064 .justify_between()
1065 .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
1066 .child(
1067 Button::new("install-dev-extension", "Install Dev Extension")
1068 .style(ButtonStyle::Filled)
1069 .size(ButtonSize::Large)
1070 .on_click(|_event, cx| {
1071 cx.dispatch_action(Box::new(InstallDevExtension))
1072 }),
1073 ),
1074 )
1075 .child(
1076 h_flex()
1077 .w_full()
1078 .gap_2()
1079 .justify_between()
1080 .child(h_flex().child(self.render_search(cx)))
1081 .child(
1082 h_flex()
1083 .child(
1084 ToggleButton::new("filter-all", "All")
1085 .style(ButtonStyle::Filled)
1086 .size(ButtonSize::Large)
1087 .selected(self.filter == ExtensionFilter::All)
1088 .on_click(cx.listener(|this, _event, cx| {
1089 this.filter = ExtensionFilter::All;
1090 this.filter_extension_entries(cx);
1091 }))
1092 .tooltip(move |cx| {
1093 Tooltip::text("Show all extensions", cx)
1094 })
1095 .first(),
1096 )
1097 .child(
1098 ToggleButton::new("filter-installed", "Installed")
1099 .style(ButtonStyle::Filled)
1100 .size(ButtonSize::Large)
1101 .selected(self.filter == ExtensionFilter::Installed)
1102 .on_click(cx.listener(|this, _event, cx| {
1103 this.filter = ExtensionFilter::Installed;
1104 this.filter_extension_entries(cx);
1105 }))
1106 .tooltip(move |cx| {
1107 Tooltip::text("Show installed extensions", cx)
1108 })
1109 .middle(),
1110 )
1111 .child(
1112 ToggleButton::new("filter-not-installed", "Not Installed")
1113 .style(ButtonStyle::Filled)
1114 .size(ButtonSize::Large)
1115 .selected(self.filter == ExtensionFilter::NotInstalled)
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}