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