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