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