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