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