Skip to repository content2091 lines · 83.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:34:33.632Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
extensions_ui.rs
1mod components;
2mod extension_version_selector;
3
4use std::sync::OnceLock;
5use std::time::Duration;
6use std::{any::TypeId, ops::Range, sync::Arc};
7
8use anyhow::Context as _;
9use cloud_api_types::{ExtensionMetadata, ExtensionProvides};
10use collections::{BTreeMap, BTreeSet};
11use command_palette_hooks::CommandPaletteFilter;
12use editor::{Editor, EditorElement, EditorStyle};
13use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore};
14use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
15use gpui::{
16 Action, Anchor, App, ClipboardItem, Context, DismissEvent, Entity, EventEmitter, Focusable,
17 InteractiveElement, KeyContext, ParentElement, Point, Render, Styled, Task, TaskExt, TextStyle,
18 UniformListScrollHandle, WeakEntity, Window, actions, point, uniform_list,
19};
20use num_format::{Locale, ToFormattedString};
21use picker::{Picker, PickerDelegate};
22use project::DirectoryLister;
23use release_channel::ReleaseChannel;
24use schemars::JsonSchema;
25use serde::Deserialize;
26use settings::{Settings, SettingsContent};
27use strum::IntoEnumIterator as _;
28use theme_settings::ThemeSettings;
29use ui::{
30 Banner, Chip, ContextMenu, Divider, ListItem, ListItemSpacing, PopoverMenu, ScrollableHandle,
31 Switch, ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonGroupStyle, ToggleButtonSimple,
32 Tooltip, WithScrollbar, prelude::*,
33};
34use util::ResultExt;
35use vim_mode_setting::VimModeSetting;
36use workspace::{
37 Workspace,
38 item::{Item, ItemEvent},
39 workspace_error::{ErrorAction, ErrorSeverity, WorkspaceError},
40};
41use zed_actions::ExtensionCategoryFilter;
42
43use crate::components::ExtensionCard;
44use crate::extension_version_selector::{
45 ExtensionVersionSelector, ExtensionVersionSelectorDelegate,
46};
47
48actions!(
49 omega,
50 [
51 /// Installs an extension from a local directory for development.
52 #[action(deprecated_aliases = ["zed::InstallDevExtension"])]
53 InstallDevExtension,
54 ]
55);
56
57/// Rebuilds an installed dev extension.
58#[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, gpui::Action)]
59#[action(namespace = omega, deprecated_aliases = ["zed::RebuildDevExtension"])]
60#[serde(deny_unknown_fields)]
61pub struct RebuildDevExtension {
62 /// The ID of the dev extension to rebuild.
63 ///
64 /// Default: opens a picker if multiple dev extensions are installed.
65 #[serde(default)]
66 pub extension_id: Option<String>,
67}
68
69#[derive(Default)]
70struct DevExtensionNotInstalledError {
71 extension_id: Option<SharedString>,
72}
73
74impl WorkspaceError for DevExtensionNotInstalledError {
75 fn primary_message(&self) -> SharedString {
76 match &self.extension_id {
77 Some(extension_id) => {
78 format!("Dev extension '{extension_id}' is not installed.").into()
79 }
80 None => "No dev extensions are installed.".into(),
81 }
82 }
83
84 fn primary_action(&self) -> ErrorAction {
85 ErrorAction::new("Install Dev Extension", InstallDevExtension)
86 }
87
88 fn severity(&self) -> ErrorSeverity {
89 ErrorSeverity::Warning
90 }
91}
92
93fn update_rebuild_dev_extension_visibility(store: &Entity<ExtensionStore>, cx: &mut App) {
94 let has_dev_extensions = store.read(cx).dev_extensions().next().is_some();
95 CommandPaletteFilter::update_global(cx, |filter, _cx| {
96 if has_dev_extensions {
97 filter.show_action_types(&[TypeId::of::<RebuildDevExtension>()]);
98 } else {
99 filter.hide_action_types(&[TypeId::of::<RebuildDevExtension>()]);
100 }
101 });
102}
103
104pub fn init(cx: &mut App) {
105 let store = ExtensionStore::global(cx);
106 update_rebuild_dev_extension_visibility(&store, cx);
107 cx.observe(&store, |store, cx| {
108 update_rebuild_dev_extension_visibility(&store, cx);
109 })
110 .detach();
111
112 cx.observe_new(move |workspace: &mut Workspace, window, _cx| {
113 let Some(_window) = window else {
114 return;
115 };
116 workspace
117 .register_action(
118 move |workspace, action: &zed_actions::Extensions, window, cx| {
119 let provides_filter = action.category_filter.map(|category| match category {
120 ExtensionCategoryFilter::Themes => ExtensionProvides::Themes,
121 ExtensionCategoryFilter::IconThemes => ExtensionProvides::IconThemes,
122 ExtensionCategoryFilter::Languages => ExtensionProvides::Languages,
123 ExtensionCategoryFilter::Grammars => ExtensionProvides::Grammars,
124 ExtensionCategoryFilter::LanguageServers => {
125 ExtensionProvides::LanguageServers
126 }
127 ExtensionCategoryFilter::ContextServers => {
128 ExtensionProvides::ContextServers
129 }
130 ExtensionCategoryFilter::Snippets => ExtensionProvides::Snippets,
131 ExtensionCategoryFilter::DebugAdapters => ExtensionProvides::DebugAdapters,
132 });
133
134 let existing = workspace
135 .active_pane()
136 .read(cx)
137 .items()
138 .find_map(|item| item.downcast::<ExtensionsPage>());
139
140 if let Some(existing) = existing {
141 existing.update(cx, |extensions_page, cx| {
142 if provides_filter.is_some() {
143 extensions_page.change_provides_filter(provides_filter, cx);
144 }
145 if let Some(id) = action.id.as_ref() {
146 extensions_page.focus_extension(id, window, cx);
147 }
148 });
149
150 workspace.activate_item(&existing, true, true, window, cx);
151 } else {
152 let extensions_page = ExtensionsPage::new(
153 workspace,
154 provides_filter,
155 action.id.as_deref(),
156 window,
157 cx,
158 );
159 workspace.add_item_to_active_pane(
160 Box::new(extensions_page),
161 None,
162 true,
163 window,
164 cx,
165 )
166 }
167 },
168 )
169 .register_action(move |workspace, _: &InstallDevExtension, window, cx| {
170 let store = ExtensionStore::global(cx);
171 let prompt = workspace.prompt_for_open_path(
172 gpui::PathPromptOptions {
173 files: false,
174 directories: true,
175 multiple: false,
176 prompt: None,
177 },
178 DirectoryLister::Local(
179 workspace.project().clone(),
180 workspace.app_state().fs.clone(),
181 ),
182 window,
183 cx,
184 );
185
186 let workspace_handle = cx.entity().downgrade();
187 window
188 .spawn(cx, async move |cx| {
189 let extension_path = match prompt.await.map_err(anyhow::Error::from) {
190 Ok(Some(mut paths)) => paths.pop()?,
191 Ok(None) => return None,
192 Err(err) => {
193 workspace_handle
194 .update(cx, |workspace, cx| {
195 workspace.show_error(
196 workspace::workspace_error::PortalError::new(
197 err.to_string(),
198 ),
199 cx,
200 );
201 })
202 .ok();
203 return None;
204 }
205 };
206
207 let install_task = store.update(cx, |store, cx| {
208 store.install_dev_extension(extension_path, cx)
209 });
210
211 match install_task.await {
212 Ok(_) => {}
213 Err(err) => {
214 log::error!("Failed to install dev extension: {:?}", err);
215 workspace_handle
216 .update(cx, |workspace, cx| {
217 // NOTE: using `anyhow::context` here ends up not printing
218 // the error
219 workspace.show_error(
220 format!("Failed to install dev extension: {}", err),
221 cx,
222 );
223 })
224 .ok();
225 }
226 }
227
228 Some(())
229 })
230 .detach();
231 })
232 .register_action(move |workspace, action: &RebuildDevExtension, window, cx| {
233 if let Some(target_id) = action.extension_id.as_deref() {
234 let extension_id = ExtensionStore::global(cx)
235 .read(cx)
236 .dev_extensions()
237 .find_map(|m| {
238 if m.id.as_ref() == target_id {
239 Some(m.id.clone())
240 } else {
241 None
242 }
243 });
244 if let Some(extension_id) = extension_id {
245 ExtensionStore::global(cx).update(cx, |store, cx| {
246 store.rebuild_dev_extension(extension_id, cx);
247 });
248 } else {
249 workspace.show_error(
250 DevExtensionNotInstalledError {
251 extension_id: Some(SharedString::from(target_id.to_owned())),
252 },
253 cx,
254 );
255 }
256 return;
257 }
258
259 let dev_extensions = ExtensionStore::global(cx)
260 .read(cx)
261 .dev_extensions()
262 .cloned()
263 .collect::<Vec<_>>();
264
265 match dev_extensions.len() {
266 0 => {
267 workspace.show_error(DevExtensionNotInstalledError::default(), cx);
268 }
269 1 => {
270 let extension_id = dev_extensions[0].id.clone();
271 ExtensionStore::global(cx).update(cx, |store, cx| {
272 store.rebuild_dev_extension(extension_id, cx);
273 });
274 }
275 _ => {
276 workspace.toggle_modal(window, cx, |window, cx| {
277 let delegate = DevExtensionRebuildPickerDelegate::new(dev_extensions);
278 Picker::uniform_list(delegate, window, cx)
279 });
280 }
281 }
282 });
283
284 })
285 .detach();
286}
287
288fn extension_provides_label(provides: ExtensionProvides) -> &'static str {
289 match provides {
290 ExtensionProvides::Themes => "Themes",
291 ExtensionProvides::IconThemes => "Icon Themes",
292 ExtensionProvides::Languages => "Languages",
293 ExtensionProvides::Grammars => "Grammars",
294 ExtensionProvides::LanguageServers => "Language Servers",
295 ExtensionProvides::ContextServers => "MCP Servers",
296 ExtensionProvides::AgentServers => "Agent Servers",
297 ExtensionProvides::SlashCommands => "Slash Commands",
298 ExtensionProvides::IndexedDocsProviders => "Indexed Docs Providers",
299 ExtensionProvides::Snippets => "Snippets",
300 ExtensionProvides::DebugAdapters => "Debug Adapters",
301 }
302}
303
304#[derive(Clone)]
305pub enum ExtensionStatus {
306 NotInstalled,
307 Installing,
308 Upgrading,
309 Installed(Arc<str>),
310 Removing,
311}
312
313#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
314enum ExtensionFilter {
315 All,
316 Installed,
317 NotInstalled,
318}
319
320impl ExtensionFilter {
321 pub fn include_dev_extensions(&self) -> bool {
322 match self {
323 Self::All | Self::Installed => true,
324 Self::NotInstalled => false,
325 }
326 }
327}
328
329#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
330enum Feature {
331 AgentClaude,
332 AgentCodex,
333 AgentGemini,
334 ExtensionBasedpyright,
335 ExtensionRuff,
336 ExtensionTailwind,
337 ExtensionTy,
338 Git,
339 LanguageBash,
340 LanguageC,
341 LanguageCpp,
342 LanguageGo,
343 LanguagePython,
344 LanguageReact,
345 LanguageRust,
346 LanguageTypescript,
347 OpenIn,
348 Vim,
349}
350
351fn keywords_by_feature() -> &'static BTreeMap<Feature, Vec<&'static str>> {
352 static KEYWORDS_BY_FEATURE: OnceLock<BTreeMap<Feature, Vec<&'static str>>> = OnceLock::new();
353 KEYWORDS_BY_FEATURE.get_or_init(|| {
354 BTreeMap::from_iter([
355 (
356 Feature::AgentClaude,
357 vec!["claude", "claude code", "claude agent"],
358 ),
359 (Feature::AgentCodex, vec!["codex", "codex cli"]),
360 (Feature::AgentGemini, vec!["gemini", "gemini cli"]),
361 (
362 Feature::ExtensionBasedpyright,
363 vec!["basedpyright", "pyright"],
364 ),
365 (Feature::ExtensionRuff, vec!["ruff"]),
366 (Feature::ExtensionTailwind, vec!["tail", "tailwind"]),
367 (Feature::ExtensionTy, vec!["ty"]),
368 (Feature::Git, vec!["git"]),
369 (Feature::LanguageBash, vec!["sh", "bash"]),
370 (Feature::LanguageC, vec!["c", "clang"]),
371 (Feature::LanguageCpp, vec!["c++", "cpp", "clang"]),
372 (Feature::LanguageGo, vec!["go", "golang"]),
373 (Feature::LanguagePython, vec!["python", "py"]),
374 (Feature::LanguageReact, vec!["react"]),
375 (Feature::LanguageRust, vec!["rust", "rs"]),
376 (
377 Feature::LanguageTypescript,
378 vec!["type", "typescript", "ts"],
379 ),
380 (
381 Feature::OpenIn,
382 vec![
383 "github",
384 "gitlab",
385 "bitbucket",
386 "codeberg",
387 "sourcehut",
388 "permalink",
389 "link",
390 "open in",
391 ],
392 ),
393 (Feature::Vim, vec!["vim"]),
394 ])
395 })
396}
397
398fn extension_button_id(extension_id: &Arc<str>, operation: ExtensionOperation) -> ElementId {
399 (SharedString::from(extension_id.clone()), operation as usize).into()
400}
401
402struct ExtensionCardButtons {
403 install_or_uninstall: Button,
404 upgrade: Option<Button>,
405 configure: Option<Button>,
406}
407
408pub struct ExtensionsPage {
409 workspace: WeakEntity<Workspace>,
410 list: UniformListScrollHandle,
411 is_fetching_extensions: bool,
412 fetch_failed: bool,
413 filter: ExtensionFilter,
414 remote_extension_entries: Vec<ExtensionMetadata>,
415 dev_extension_entries: Vec<Arc<ExtensionManifest>>,
416 filtered_remote_extension_indices: Vec<usize>,
417 filtered_dev_extension_indices: Vec<usize>,
418 query_editor: Entity<Editor>,
419 query_contains_error: bool,
420 provides_filter: Option<ExtensionProvides>,
421 _subscriptions: [gpui::Subscription; 2],
422 extension_fetch_task: Option<Task<()>>,
423 upsells: BTreeSet<Feature>,
424}
425
426impl ExtensionsPage {
427 pub fn new(
428 workspace: &Workspace,
429 provides_filter: Option<ExtensionProvides>,
430 focus_extension_id: Option<&str>,
431 window: &mut Window,
432 cx: &mut Context<Workspace>,
433 ) -> Entity<Self> {
434 cx.new(|cx| {
435 let store = ExtensionStore::global(cx);
436 let workspace_handle = workspace.weak_handle();
437 let subscriptions = [
438 cx.observe(&store, |_: &mut Self, _, cx| cx.notify()),
439 cx.subscribe_in(
440 &store,
441 window,
442 move |this, _, event, window, cx| match event {
443 extension_host::Event::ExtensionsUpdated => {
444 this.fetch_extensions_debounced(None, cx)
445 }
446 extension_host::Event::ExtensionInstalled(extension_id) => this
447 .on_extension_installed(
448 workspace_handle.clone(),
449 extension_id,
450 window,
451 cx,
452 ),
453 _ => {}
454 },
455 ),
456 ];
457
458 let query_editor = cx.new(|cx| {
459 let mut input = Editor::single_line(window, cx);
460 input.set_placeholder_text("Search extensions...", window, cx);
461 if let Some(id) = focus_extension_id {
462 input.set_text(format!("id:{id}"), window, cx);
463 }
464 input
465 });
466 cx.subscribe(&query_editor, Self::on_query_change).detach();
467
468 let scroll_handle = UniformListScrollHandle::new();
469
470 let mut this = Self {
471 workspace: workspace.weak_handle(),
472 list: scroll_handle,
473 is_fetching_extensions: false,
474 fetch_failed: false,
475 filter: ExtensionFilter::All,
476 dev_extension_entries: Vec::new(),
477 filtered_remote_extension_indices: Vec::new(),
478 filtered_dev_extension_indices: Vec::new(),
479 remote_extension_entries: Vec::new(),
480 query_contains_error: false,
481 provides_filter,
482 extension_fetch_task: None,
483 _subscriptions: subscriptions,
484 query_editor,
485 upsells: BTreeSet::default(),
486 };
487 this.fetch_extensions(
488 this.search_query(cx),
489 Some(BTreeSet::from_iter(this.provides_filter)),
490 None,
491 cx,
492 );
493 this
494 })
495 }
496
497 fn on_extension_installed(
498 &mut self,
499 workspace: WeakEntity<Workspace>,
500 extension_id: &str,
501 window: &mut Window,
502 cx: &mut Context<Self>,
503 ) {
504 let extension_store = ExtensionStore::global(cx).read(cx);
505 let themes = extension_store
506 .extension_themes(extension_id)
507 .map(|name| name.to_string())
508 .collect::<Vec<_>>();
509 if !themes.is_empty() {
510 workspace
511 .update(cx, |_workspace, cx| {
512 window.dispatch_action(
513 zed_actions::theme_selector::Toggle {
514 themes_filter: Some(themes),
515 }
516 .boxed_clone(),
517 cx,
518 );
519 })
520 .ok();
521 return;
522 }
523
524 let icon_themes = extension_store
525 .extension_icon_themes(extension_id)
526 .map(|name| name.to_string())
527 .collect::<Vec<_>>();
528 if !icon_themes.is_empty() {
529 workspace
530 .update(cx, |_workspace, cx| {
531 window.dispatch_action(
532 zed_actions::icon_theme_selector::Toggle {
533 themes_filter: Some(icon_themes),
534 }
535 .boxed_clone(),
536 cx,
537 );
538 })
539 .ok();
540 }
541 }
542
543 /// Returns whether a dev extension currently exists for the extension with the given ID.
544 fn dev_extension_exists(extension_id: &str, cx: &mut Context<Self>) -> bool {
545 let extension_store = ExtensionStore::global(cx).read(cx);
546
547 extension_store
548 .dev_extensions()
549 .any(|dev_extension| dev_extension.id.as_ref() == extension_id)
550 }
551
552 fn extension_status(extension_id: &str, cx: &mut Context<Self>) -> ExtensionStatus {
553 let extension_store = ExtensionStore::global(cx).read(cx);
554
555 match extension_store.outstanding_operations().get(extension_id) {
556 Some(ExtensionOperation::Install) => ExtensionStatus::Installing,
557 Some(ExtensionOperation::Remove) => ExtensionStatus::Removing,
558 Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading,
559 None => match extension_store.installed_extensions().get(extension_id) {
560 Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()),
561 None => ExtensionStatus::NotInstalled,
562 },
563 }
564 }
565
566 fn filter_extension_entries(&mut self, cx: &mut Context<Self>) {
567 self.filtered_remote_extension_indices.clear();
568 self.filtered_remote_extension_indices.extend(
569 self.remote_extension_entries
570 .iter()
571 .enumerate()
572 .filter(|(_, extension)| match self.filter {
573 ExtensionFilter::All => true,
574 ExtensionFilter::Installed => {
575 let status = Self::extension_status(&extension.id, cx);
576 matches!(status, ExtensionStatus::Installed(_))
577 }
578 ExtensionFilter::NotInstalled => {
579 let status = Self::extension_status(&extension.id, cx);
580
581 matches!(status, ExtensionStatus::NotInstalled)
582 }
583 })
584 .filter(|(_, extension)| match self.provides_filter {
585 Some(provides) => extension.manifest.provides.contains(&provides),
586 None => true,
587 })
588 .map(|(ix, _)| ix),
589 );
590
591 self.filtered_dev_extension_indices.clear();
592 self.filtered_dev_extension_indices.extend(
593 self.dev_extension_entries
594 .iter()
595 .enumerate()
596 .filter(|(_, manifest)| match self.provides_filter {
597 Some(provides) => manifest.provides().contains(&provides),
598 None => true,
599 })
600 .map(|(ix, _)| ix),
601 );
602
603 cx.notify();
604 }
605
606 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
607 self.list.set_offset(point(px(0.), px(0.)));
608 cx.notify();
609 }
610
611 fn fetch_extensions(
612 &mut self,
613 search: Option<String>,
614 provides_filter: Option<BTreeSet<ExtensionProvides>>,
615 on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
616 cx: &mut Context<Self>,
617 ) {
618 self.is_fetching_extensions = true;
619 self.fetch_failed = false;
620 cx.notify();
621
622 let extension_store = ExtensionStore::global(cx);
623
624 let dev_extensions = extension_store
625 .read(cx)
626 .dev_extensions()
627 .cloned()
628 .collect::<Vec<_>>();
629
630 let remote_extensions =
631 if let Some(id) = search.as_ref().and_then(|s| s.strip_prefix("id:")) {
632 let versions =
633 extension_store.update(cx, |store, cx| store.fetch_extension_versions(id, cx));
634 cx.foreground_executor().spawn(async move {
635 let versions = versions.await?;
636 let latest = versions
637 .into_iter()
638 .max_by_key(|v| v.published_at)
639 .context("no extension found")?;
640 Ok(vec![latest])
641 })
642 } else {
643 extension_store.update(cx, |store, cx| {
644 store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx)
645 })
646 };
647
648 cx.spawn(async move |this, cx| {
649 let dev_extensions = if let Some(search) = search {
650 let match_candidates = dev_extensions
651 .iter()
652 .enumerate()
653 .map(|(ix, manifest)| StringMatchCandidate::new(ix, &manifest.name))
654 .collect::<Vec<_>>();
655
656 let matches = match_strings(
657 &match_candidates,
658 &search,
659 false,
660 true,
661 match_candidates.len(),
662 &Default::default(),
663 cx.background_executor().clone(),
664 )
665 .await;
666 matches
667 .into_iter()
668 .map(|mat| dev_extensions[mat.candidate_id].clone())
669 .collect()
670 } else {
671 dev_extensions
672 };
673
674 let fetch_result = remote_extensions.await;
675
676 let result = this.update(cx, |this, cx| {
677 cx.notify();
678 this.dev_extension_entries = dev_extensions;
679 this.is_fetching_extensions = false;
680
681 match fetch_result {
682 Ok(extensions) => {
683 this.fetch_failed = false;
684 this.remote_extension_entries = extensions;
685 this.filter_extension_entries(cx);
686 if let Some(callback) = on_complete {
687 callback(this, cx);
688 }
689 Ok(())
690 }
691 Err(err) => {
692 this.fetch_failed = true;
693 this.filter_extension_entries(cx);
694 Err(err)
695 }
696 }
697 });
698
699 result?
700 })
701 .detach_and_log_err(cx);
702 }
703
704 fn render_extensions(
705 &mut self,
706 range: Range<usize>,
707 _: &mut Window,
708 cx: &mut Context<Self>,
709 ) -> Vec<ExtensionCard> {
710 let dev_extension_entries_len = if self.filter.include_dev_extensions() {
711 self.filtered_dev_extension_indices.len()
712 } else {
713 0
714 };
715 range
716 .map(|ix| {
717 if ix < dev_extension_entries_len {
718 let dev_ix = self.filtered_dev_extension_indices[ix];
719 let extension = &self.dev_extension_entries[dev_ix];
720 self.render_dev_extension(extension, cx)
721 } else {
722 let extension_ix =
723 self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
724 let extension = &self.remote_extension_entries[extension_ix];
725 self.render_remote_extension(extension, cx)
726 }
727 })
728 .collect()
729 }
730
731 fn render_dev_extension(
732 &self,
733 extension: &ExtensionManifest,
734 cx: &mut Context<Self>,
735 ) -> ExtensionCard {
736 let status = Self::extension_status(&extension.id, cx);
737
738 let repository_url = extension.repository.clone();
739
740 let can_configure = !extension.context_servers.is_empty();
741
742 ExtensionCard::new()
743 .child(
744 h_flex()
745 .justify_between()
746 .child(
747 h_flex()
748 .gap_2()
749 .items_end()
750 .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
751 .child(
752 Headline::new(format!("v{}", extension.version))
753 .size(HeadlineSize::XSmall),
754 ),
755 )
756 .child(
757 h_flex()
758 .gap_1()
759 .justify_between()
760 .child(
761 Button::new(
762 SharedString::from(format!("rebuild-{}", extension.id)),
763 "Rebuild",
764 )
765 .color(Color::Accent)
766 .disabled(matches!(status, ExtensionStatus::Upgrading))
767 .on_click({
768 let extension_id = extension.id.clone();
769 move |_, _, cx| {
770 ExtensionStore::global(cx).update(cx, |store, cx| {
771 store.rebuild_dev_extension(extension_id.clone(), cx)
772 });
773 }
774 }),
775 )
776 .child(
777 Button::new(extension_button_id(&extension.id, ExtensionOperation::Remove), "Uninstall")
778 .color(Color::Accent)
779 .disabled(matches!(status, ExtensionStatus::Removing))
780 .on_click({
781 let extension_id = extension.id.clone();
782 move |_, _, cx| {
783 ExtensionStore::global(cx).update(cx, |store, cx| {
784 store.uninstall_extension(extension_id.clone(), cx).detach_and_log_err(cx);
785 });
786 }
787 }),
788 )
789 .when(can_configure, |this| {
790 this.child(
791 Button::new(
792 SharedString::from(format!("configure-{}", extension.id)),
793 "Configure",
794 )
795 .color(Color::Accent)
796 .disabled(matches!(status, ExtensionStatus::Installing))
797 .on_click({
798 let manifest = Arc::new(extension.clone());
799 move |_, _, cx| {
800 if let Some(events) =
801 extension::ExtensionEvents::try_global(cx)
802 {
803 events.update(cx, |this, cx| {
804 this.emit(
805 extension::Event::ConfigureExtensionRequested(
806 manifest.clone(),
807 ),
808 cx,
809 )
810 });
811 }
812 }
813 }),
814 )
815 }),
816 ),
817 )
818 .child(
819 h_flex()
820 .gap_2()
821 .justify_between()
822 .child(
823 Label::new(format!(
824 "{}: {}",
825 if extension.authors.len() > 1 {
826 "Authors"
827 } else {
828 "Author"
829 },
830 extension.authors.join(", ")
831 ))
832 .size(LabelSize::Small)
833 .color(Color::Muted)
834 .truncate(),
835 )
836 .child(Label::new("<>").size(LabelSize::Small)),
837 )
838 .child(
839 h_flex()
840 .gap_2()
841 .justify_between()
842 .children(extension.description.as_ref().map(|description| {
843 Label::new(description.clone())
844 .size(LabelSize::Small)
845 .color(Color::Default)
846 .truncate()
847 }))
848 .children(repository_url.map(|repository_url| {
849 IconButton::new(
850 SharedString::from(format!("repository-{}", extension.id)),
851 IconName::Github,
852 )
853 .icon_color(Color::Accent)
854 .icon_size(IconSize::Small)
855 .on_click(cx.listener({
856 let repository_url = repository_url.clone();
857 move |_, _, _, cx| {
858 cx.open_url(&repository_url);
859 }
860 }))
861 .tooltip(Tooltip::text(repository_url))
862 })),
863 )
864 }
865
866 fn render_remote_extension(
867 &self,
868 extension: &ExtensionMetadata,
869 cx: &mut Context<Self>,
870 ) -> ExtensionCard {
871 let this = cx.weak_entity();
872 let status = Self::extension_status(&extension.id, cx);
873 let has_dev_extension = Self::dev_extension_exists(&extension.id, cx);
874
875 let extension_id = extension.id.clone();
876 let buttons = self.buttons_for_entry(extension, &status, has_dev_extension, cx);
877 let version = extension.manifest.version.clone();
878 let repository_url = extension.manifest.repository.clone();
879 let authors = extension.manifest.authors.clone();
880
881 let installed_version = match status {
882 ExtensionStatus::Installed(installed_version) => Some(installed_version),
883 _ => None,
884 };
885
886 ExtensionCard::new()
887 .overridden_by_dev_extension(has_dev_extension)
888 .child(
889 h_flex()
890 .justify_between()
891 .child(
892 h_flex()
893 .gap_2()
894 .child(
895 Headline::new(extension.manifest.name.clone())
896 .size(HeadlineSize::Small),
897 )
898 .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
899 .children(
900 installed_version
901 .filter(|installed_version| *installed_version != version)
902 .map(|installed_version| {
903 Headline::new(format!("(v{installed_version} installed)",))
904 .size(HeadlineSize::XSmall)
905 }),
906 )
907 .map(|parent| {
908 if extension.manifest.provides.is_empty() {
909 return parent;
910 }
911
912 parent.child(
913 h_flex().gap_1().children(
914 extension
915 .manifest
916 .provides
917 .iter()
918 .filter_map(|provides| {
919 match provides {
920 ExtensionProvides::AgentServers
921 | ExtensionProvides::SlashCommands
922 | ExtensionProvides::IndexedDocsProviders => {
923 return None;
924 }
925 _ => {}
926 }
927
928 Some(Chip::new(extension_provides_label(*provides)))
929 })
930 .collect::<Vec<_>>(),
931 ),
932 )
933 }),
934 )
935 .child(
936 h_flex()
937 .gap_1()
938 .children(buttons.upgrade)
939 .children(buttons.configure)
940 .child(buttons.install_or_uninstall),
941 ),
942 )
943 .child(
944 h_flex()
945 .gap_2()
946 .justify_between()
947 .children(extension.manifest.description.as_ref().map(|description| {
948 Label::new(description.clone())
949 .size(LabelSize::Small)
950 .color(Color::Default)
951 .truncate()
952 }))
953 .child(
954 Label::new(format!(
955 "Downloads: {}",
956 extension.download_count.to_formatted_string(&Locale::en)
957 ))
958 .size(LabelSize::Small),
959 ),
960 )
961 .child(
962 h_flex()
963 .min_w_0()
964 .w_full()
965 .justify_between()
966 .child(
967 h_flex()
968 .min_w_0()
969 .gap_1()
970 .child(
971 Icon::new(IconName::Person)
972 .size(IconSize::XSmall)
973 .color(Color::Muted),
974 )
975 .child(
976 Label::new(extension.manifest.authors.join(", "))
977 .size(LabelSize::Small)
978 .color(Color::Muted)
979 .truncate(),
980 ),
981 )
982 .child(
983 h_flex()
984 .gap_1()
985 .flex_shrink_0()
986 .child({
987 let repo_url_for_tooltip = repository_url.clone();
988
989 IconButton::new(
990 SharedString::from(format!("repository-{}", extension.id)),
991 IconName::Github,
992 )
993 .icon_size(IconSize::Small)
994 .tooltip(move |_, cx| {
995 Tooltip::with_meta(
996 "Visit Extension Repository",
997 None,
998 repo_url_for_tooltip.clone(),
999 cx,
1000 )
1001 })
1002 .on_click(cx.listener(
1003 move |_, _, _, cx| {
1004 cx.open_url(&repository_url);
1005 },
1006 ))
1007 })
1008 .child(
1009 PopoverMenu::new(SharedString::from(format!(
1010 "more-{}",
1011 extension.id
1012 )))
1013 .trigger(
1014 IconButton::new(
1015 SharedString::from(format!("more-{}", extension.id)),
1016 IconName::Ellipsis,
1017 )
1018 .icon_size(IconSize::Small),
1019 )
1020 .anchor(Anchor::TopRight)
1021 .offset(Point {
1022 x: px(0.0),
1023 y: px(2.0),
1024 })
1025 .menu(move |window, cx| {
1026 this.upgrade().map(|this| {
1027 Self::render_remote_extension_context_menu(
1028 &this,
1029 extension_id.clone(),
1030 authors.clone(),
1031 window,
1032 cx,
1033 )
1034 })
1035 }),
1036 ),
1037 ),
1038 )
1039 }
1040
1041 fn render_remote_extension_context_menu(
1042 this: &Entity<Self>,
1043 extension_id: Arc<str>,
1044 authors: Vec<String>,
1045 window: &mut Window,
1046 cx: &mut App,
1047 ) -> Entity<ContextMenu> {
1048 ContextMenu::build(window, cx, |context_menu, window, _| {
1049 context_menu
1050 .entry(
1051 "Install Another Version...",
1052 None,
1053 window.handler_for(this, {
1054 let extension_id = extension_id.clone();
1055 move |this, window, cx| {
1056 this.show_extension_version_list(extension_id.clone(), window, cx)
1057 }
1058 }),
1059 )
1060 .entry("Copy Extension ID", None, {
1061 let extension_id = extension_id.clone();
1062 move |_, cx| {
1063 cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
1064 }
1065 })
1066 .entry("Copy Author Info", None, {
1067 let authors = authors.clone();
1068 move |_, cx| {
1069 cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", ")));
1070 }
1071 })
1072 })
1073 }
1074
1075 fn show_extension_version_list(
1076 &mut self,
1077 extension_id: Arc<str>,
1078 window: &mut Window,
1079 cx: &mut Context<Self>,
1080 ) {
1081 let Some(workspace) = self.workspace.upgrade() else {
1082 return;
1083 };
1084
1085 cx.spawn_in(window, async move |this, cx| {
1086 let extension_versions_task = this.update(cx, |_, cx| {
1087 let extension_store = ExtensionStore::global(cx);
1088
1089 extension_store.update(cx, |store, cx| {
1090 store.fetch_extension_versions(&extension_id, cx)
1091 })
1092 })?;
1093
1094 let extension_versions = extension_versions_task.await?;
1095
1096 workspace.update_in(cx, |workspace, window, cx| {
1097 let fs = workspace.project().read(cx).fs().clone();
1098 workspace.toggle_modal(window, cx, |window, cx| {
1099 let delegate = ExtensionVersionSelectorDelegate::new(
1100 fs,
1101 cx.entity().downgrade(),
1102 extension_versions,
1103 );
1104
1105 ExtensionVersionSelector::new(delegate, window, cx)
1106 });
1107 })?;
1108
1109 anyhow::Ok(())
1110 })
1111 .detach_and_log_err(cx);
1112 }
1113
1114 fn buttons_for_entry(
1115 &self,
1116 extension: &ExtensionMetadata,
1117 status: &ExtensionStatus,
1118 has_dev_extension: bool,
1119 cx: &mut Context<Self>,
1120 ) -> ExtensionCardButtons {
1121 let is_compatible =
1122 extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
1123
1124 if has_dev_extension {
1125 // If we have a dev extension for the given extension, just treat it as uninstalled.
1126 // The button here is a placeholder, as it won't be interactable anyways.
1127 return ExtensionCardButtons {
1128 install_or_uninstall: Button::new(
1129 extension_button_id(&extension.id, ExtensionOperation::Install),
1130 "Install",
1131 ),
1132 configure: None,
1133 upgrade: None,
1134 };
1135 }
1136
1137 let is_configurable = extension
1138 .manifest
1139 .provides
1140 .contains(&ExtensionProvides::ContextServers);
1141
1142 match status.clone() {
1143 ExtensionStatus::NotInstalled => ExtensionCardButtons {
1144 install_or_uninstall: Button::new(
1145 extension_button_id(&extension.id, ExtensionOperation::Install),
1146 "Install",
1147 )
1148 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1149 .start_icon(
1150 Icon::new(IconName::Download)
1151 .size(IconSize::Small)
1152 .color(Color::Muted),
1153 )
1154 .on_click({
1155 let extension_id = extension.id.clone();
1156 move |_, _, cx| {
1157 telemetry::event!("Extension Installed");
1158 ExtensionStore::global(cx).update(cx, |store, cx| {
1159 store.install_latest_extension(extension_id.clone(), cx)
1160 });
1161 }
1162 }),
1163 configure: None,
1164 upgrade: None,
1165 },
1166 ExtensionStatus::Installing => ExtensionCardButtons {
1167 install_or_uninstall: Button::new(
1168 extension_button_id(&extension.id, ExtensionOperation::Install),
1169 "Install",
1170 )
1171 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1172 .start_icon(
1173 Icon::new(IconName::Download)
1174 .size(IconSize::Small)
1175 .color(Color::Muted),
1176 )
1177 .disabled(true),
1178 configure: None,
1179 upgrade: None,
1180 },
1181 ExtensionStatus::Upgrading => ExtensionCardButtons {
1182 install_or_uninstall: Button::new(
1183 extension_button_id(&extension.id, ExtensionOperation::Remove),
1184 "Uninstall",
1185 )
1186 .style(ButtonStyle::OutlinedGhost)
1187 .disabled(true),
1188 configure: is_configurable.then(|| {
1189 Button::new(
1190 SharedString::from(format!("configure-{}", extension.id)),
1191 "Configure",
1192 )
1193 .disabled(true)
1194 }),
1195 upgrade: Some(
1196 Button::new(
1197 extension_button_id(&extension.id, ExtensionOperation::Upgrade),
1198 "Upgrade",
1199 )
1200 .disabled(true),
1201 ),
1202 },
1203 ExtensionStatus::Installed(installed_version) => ExtensionCardButtons {
1204 install_or_uninstall: Button::new(
1205 extension_button_id(&extension.id, ExtensionOperation::Remove),
1206 "Uninstall",
1207 )
1208 .style(ButtonStyle::OutlinedGhost)
1209 .on_click({
1210 let extension_id = extension.id.clone();
1211 move |_, _, cx| {
1212 telemetry::event!("Extension Uninstalled", extension_id);
1213 ExtensionStore::global(cx).update(cx, |store, cx| {
1214 store
1215 .uninstall_extension(extension_id.clone(), cx)
1216 .detach_and_log_err(cx);
1217 });
1218 }
1219 }),
1220 configure: is_configurable.then(|| {
1221 Button::new(
1222 SharedString::from(format!("configure-{}", extension.id)),
1223 "Configure",
1224 )
1225 .style(ButtonStyle::OutlinedGhost)
1226 .on_click({
1227 let extension_id = extension.id.clone();
1228 move |_, _, cx| {
1229 if let Some(manifest) = ExtensionStore::global(cx)
1230 .read(cx)
1231 .extension_manifest_for_id(&extension_id)
1232 .cloned()
1233 && let Some(events) = extension::ExtensionEvents::try_global(cx)
1234 {
1235 events.update(cx, |this, cx| {
1236 this.emit(
1237 extension::Event::ConfigureExtensionRequested(manifest),
1238 cx,
1239 )
1240 });
1241 }
1242 }
1243 })
1244 }),
1245 upgrade: if installed_version == extension.manifest.version {
1246 None
1247 } else {
1248 Some(
1249 Button::new(extension_button_id(&extension.id, ExtensionOperation::Upgrade), "Upgrade")
1250 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1251 .when(!is_compatible, |upgrade_button| {
1252 upgrade_button.disabled(true).tooltip({
1253 let version = extension.manifest.version.clone();
1254 move |_, cx| {
1255 Tooltip::simple(
1256 format!(
1257 "v{version} is not compatible with this version of Omega.",
1258 ),
1259 cx,
1260 )
1261 }
1262 })
1263 })
1264 .disabled(!is_compatible)
1265 .on_click({
1266 let extension_id = extension.id.clone();
1267 let version = extension.manifest.version.clone();
1268 move |_, _, cx| {
1269 telemetry::event!("Extension Installed", extension_id, version);
1270 ExtensionStore::global(cx).update(cx, |store, cx| {
1271 store
1272 .upgrade_extension(
1273 extension_id.clone(),
1274 version.clone(),
1275 cx,
1276 )
1277 .detach_and_log_err(cx)
1278 });
1279 }
1280 }),
1281 )
1282 },
1283 },
1284 ExtensionStatus::Removing => ExtensionCardButtons {
1285 install_or_uninstall: Button::new(
1286 extension_button_id(&extension.id, ExtensionOperation::Remove),
1287 "Uninstall",
1288 )
1289 .style(ButtonStyle::OutlinedGhost)
1290 .disabled(true),
1291 configure: is_configurable.then(|| {
1292 Button::new(
1293 SharedString::from(format!("configure-{}", extension.id)),
1294 "Configure",
1295 )
1296 .disabled(true)
1297 }),
1298 upgrade: None,
1299 },
1300 }
1301 }
1302
1303 fn render_search(&self, cx: &mut Context<Self>) -> Div {
1304 let mut key_context = KeyContext::new_with_defaults();
1305 key_context.add("BufferSearchBar");
1306
1307 let editor_border = if self.query_contains_error {
1308 Color::Error.color(cx)
1309 } else {
1310 cx.theme().colors().border
1311 };
1312
1313 h_flex()
1314 .key_context(key_context)
1315 .h_8()
1316 .min_w(rems_from_px(384.))
1317 .flex_1()
1318 .pl_1p5()
1319 .pr_2()
1320 .gap_2()
1321 .border_1()
1322 .border_color(editor_border)
1323 .rounded_md()
1324 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
1325 .child(self.render_text_input(&self.query_editor, cx))
1326 }
1327
1328 fn render_text_input(
1329 &self,
1330 editor: &Entity<Editor>,
1331 cx: &mut Context<Self>,
1332 ) -> impl IntoElement {
1333 let settings = ThemeSettings::get_global(cx);
1334 let text_style = TextStyle {
1335 color: if editor.read(cx).read_only(cx) {
1336 cx.theme().colors().text_disabled
1337 } else {
1338 cx.theme().colors().text
1339 },
1340 font_family: settings.ui_font.family.clone(),
1341 font_features: settings.ui_font.features.clone(),
1342 font_fallbacks: settings.ui_font.fallbacks.clone(),
1343 font_size: rems(0.875).into(),
1344 font_weight: settings.ui_font.weight,
1345 line_height: relative(1.3),
1346 ..Default::default()
1347 };
1348
1349 EditorElement::new(
1350 editor,
1351 EditorStyle {
1352 background: cx.theme().colors().editor_background,
1353 local_player: cx.theme().players().local(),
1354 text: text_style,
1355 ..Default::default()
1356 },
1357 )
1358 }
1359
1360 fn on_query_change(
1361 &mut self,
1362 _: Entity<Editor>,
1363 event: &editor::EditorEvent,
1364 cx: &mut Context<Self>,
1365 ) {
1366 if let editor::EditorEvent::Edited { .. } = event {
1367 self.query_contains_error = false;
1368 self.refresh_search(cx);
1369 }
1370 }
1371
1372 fn refresh_search(&mut self, cx: &mut Context<Self>) {
1373 self.fetch_extensions_debounced(
1374 Some(Box::new(|this, cx| {
1375 this.scroll_to_top(cx);
1376 })),
1377 cx,
1378 );
1379 self.refresh_feature_upsells(cx);
1380 }
1381
1382 pub fn focus_extension(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
1383 self.query_editor.update(cx, |editor, cx| {
1384 editor.set_text(format!("id:{id}"), window, cx)
1385 });
1386 self.refresh_search(cx);
1387 }
1388
1389 pub fn change_provides_filter(
1390 &mut self,
1391 provides_filter: Option<ExtensionProvides>,
1392 cx: &mut Context<Self>,
1393 ) {
1394 self.provides_filter = provides_filter;
1395 self.refresh_search(cx);
1396 }
1397
1398 fn fetch_extensions_debounced(
1399 &mut self,
1400 on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
1401 cx: &mut Context<ExtensionsPage>,
1402 ) {
1403 self.extension_fetch_task = Some(cx.spawn(async move |this, cx| {
1404 let search = this
1405 .update(cx, |this, cx| this.search_query(cx))
1406 .ok()
1407 .flatten();
1408
1409 // Only debounce the fetching of extensions if we have a search
1410 // query.
1411 //
1412 // If the search was just cleared then we can just reload the list
1413 // of extensions without a debounce, which allows us to avoid seeing
1414 // an intermittent flash of a "no extensions" state.
1415 if search.is_some() {
1416 cx.background_executor()
1417 .timer(Duration::from_millis(250))
1418 .await;
1419 };
1420
1421 this.update(cx, |this, cx| {
1422 this.fetch_extensions(
1423 search,
1424 Some(BTreeSet::from_iter(this.provides_filter)),
1425 on_complete,
1426 cx,
1427 );
1428 })
1429 .ok();
1430 }));
1431 }
1432
1433 pub fn search_query(&self, cx: &mut App) -> Option<String> {
1434 let search = self.query_editor.read(cx).text(cx);
1435 if search.trim().is_empty() {
1436 None
1437 } else {
1438 Some(search)
1439 }
1440 }
1441
1442 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1443 let has_search = self.search_query(cx).is_some();
1444
1445 let message = if self.is_fetching_extensions {
1446 "Loading extensions…"
1447 } else if self.fetch_failed {
1448 "Failed to load extensions. Please check your connection and try again."
1449 } else {
1450 match self.filter {
1451 ExtensionFilter::All => {
1452 if has_search {
1453 "No extensions that match your search."
1454 } else {
1455 "No extensions."
1456 }
1457 }
1458 ExtensionFilter::Installed => {
1459 if has_search {
1460 "No installed extensions that match your search."
1461 } else {
1462 "No installed extensions."
1463 }
1464 }
1465 ExtensionFilter::NotInstalled => {
1466 if has_search {
1467 "No not installed extensions that match your search."
1468 } else {
1469 "No not installed extensions."
1470 }
1471 }
1472 }
1473 };
1474
1475 h_flex()
1476 .py_4()
1477 .gap_1p5()
1478 .when(self.fetch_failed, |this| {
1479 this.child(
1480 Icon::new(IconName::Warning)
1481 .size(IconSize::Small)
1482 .color(Color::Warning),
1483 )
1484 })
1485 .child(Label::new(message))
1486 }
1487
1488 fn update_settings(
1489 &mut self,
1490 selection: &ToggleState,
1491
1492 cx: &mut Context<Self>,
1493 callback: impl 'static + Send + Fn(&mut SettingsContent, bool),
1494 ) {
1495 if let Some(workspace) = self.workspace.upgrade() {
1496 let fs = workspace.read(cx).app_state().fs.clone();
1497 let selection = *selection;
1498 settings::update_settings_file(fs, cx, move |settings, _| {
1499 let value = match selection {
1500 ToggleState::Unselected => false,
1501 ToggleState::Selected => true,
1502 _ => return,
1503 };
1504
1505 callback(settings, value)
1506 });
1507 }
1508 }
1509
1510 fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1511 let Some(search) = self.search_query(cx) else {
1512 self.upsells.clear();
1513 return;
1514 };
1515
1516 if let Some(id) = search.strip_prefix("id:") {
1517 self.upsells.clear();
1518
1519 let upsell = match id.to_lowercase().as_str() {
1520 "ruff" => Some(Feature::ExtensionRuff),
1521 "basedpyright" => Some(Feature::ExtensionBasedpyright),
1522 "ty" => Some(Feature::ExtensionTy),
1523 _ => None,
1524 };
1525
1526 if let Some(upsell) = upsell {
1527 self.upsells.insert(upsell);
1528 }
1529
1530 return;
1531 }
1532
1533 let search = search.to_lowercase();
1534 let search_terms = search
1535 .split_whitespace()
1536 .map(|term| term.trim())
1537 .collect::<Vec<_>>();
1538
1539 for (feature, keywords) in keywords_by_feature() {
1540 if keywords
1541 .iter()
1542 .any(|keyword| search_terms.contains(keyword))
1543 {
1544 self.upsells.insert(*feature);
1545 } else {
1546 self.upsells.remove(feature);
1547 }
1548 }
1549 }
1550
1551 fn render_feature_upsell_banner(
1552 &self,
1553 label: SharedString,
1554 docs_url: SharedString,
1555 vim: bool,
1556 cx: &mut Context<Self>,
1557 ) -> impl IntoElement {
1558 let docs_url_button = Button::new("open_docs", "View Documentation")
1559 .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small))
1560 .on_click({
1561 move |_event, _window, cx| {
1562 telemetry::event!(
1563 "Documentation Viewed",
1564 source = "Feature Upsell",
1565 url = docs_url,
1566 );
1567 cx.open_url(&docs_url)
1568 }
1569 });
1570
1571 div()
1572 .pt_4()
1573 .px_4()
1574 .child(
1575 Banner::new()
1576 .severity(Severity::Success)
1577 .child(Label::new(label).mt_0p5())
1578 .map(|this| {
1579 if vim {
1580 this.action_slot(
1581 h_flex()
1582 .gap_1()
1583 .child(docs_url_button)
1584 .child(Divider::vertical().color(ui::DividerColor::Border))
1585 .child(
1586 h_flex()
1587 .pl_1()
1588 .gap_1()
1589 .child(Label::new("Enable Vim mode"))
1590 .child(
1591 Switch::new(
1592 "enable-vim",
1593 if VimModeSetting::get_global(cx).0 {
1594 ui::ToggleState::Selected
1595 } else {
1596 ui::ToggleState::Unselected
1597 },
1598 )
1599 .on_click(cx.listener(
1600 move |this, selection, _, cx| {
1601 telemetry::event!(
1602 "Vim Mode Toggled",
1603 source = "Feature Upsell"
1604 );
1605 this.update_settings(
1606 selection,
1607 cx,
1608 |setting, value| {
1609 setting.vim_mode = Some(value)
1610 },
1611 );
1612 },
1613 )),
1614 ),
1615 ),
1616 )
1617 } else {
1618 this.action_slot(docs_url_button)
1619 }
1620 }),
1621 )
1622 .into_any_element()
1623 }
1624
1625 fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1626 let mut container = v_flex();
1627
1628 for feature in &self.upsells {
1629 let banner = match feature {
1630 Feature::AgentClaude => self.render_feature_upsell_banner(
1631 "Claude Agent support is built-in to Omega!".into(),
1632 "https://zed.dev/docs/ai/external-agents#claude-agent".into(),
1633 false,
1634 cx,
1635 ),
1636 Feature::AgentCodex => self.render_feature_upsell_banner(
1637 "Codex CLI support is built-in to Omega!".into(),
1638 "https://zed.dev/docs/ai/external-agents#codex-cli".into(),
1639 false,
1640 cx,
1641 ),
1642 Feature::AgentGemini => self.render_feature_upsell_banner(
1643 "Gemini CLI support is built-in to Omega!".into(),
1644 "https://zed.dev/docs/ai/external-agents#gemini-cli".into(),
1645 false,
1646 cx,
1647 ),
1648 Feature::ExtensionBasedpyright => self.render_feature_upsell_banner(
1649 "Basedpyright (Python language server) support is built-in to Omega!".into(),
1650 "https://zed.dev/docs/languages/python#basedpyright".into(),
1651 false,
1652 cx,
1653 ),
1654 Feature::ExtensionRuff => self.render_feature_upsell_banner(
1655 "Ruff (linter for Python) support is built-in to Omega!".into(),
1656 "https://zed.dev/docs/languages/python#code-formatting--linting".into(),
1657 false,
1658 cx,
1659 ),
1660 Feature::ExtensionTailwind => self.render_feature_upsell_banner(
1661 "Tailwind CSS support is built-in to Omega!".into(),
1662 "https://zed.dev/docs/languages/tailwindcss".into(),
1663 false,
1664 cx,
1665 ),
1666 Feature::ExtensionTy => self.render_feature_upsell_banner(
1667 "Ty (Python language server) support is built-in to Omega!".into(),
1668 "https://zed.dev/docs/languages/python".into(),
1669 false,
1670 cx,
1671 ),
1672 Feature::Git => self.render_feature_upsell_banner(
1673 "Omega comes with basic Git support—more features are coming in the future."
1674 .into(),
1675 "https://zed.dev/docs/git".into(),
1676 false,
1677 cx,
1678 ),
1679 Feature::LanguageBash => self.render_feature_upsell_banner(
1680 "Shell support is built-in to Omega!".into(),
1681 "https://zed.dev/docs/languages/bash".into(),
1682 false,
1683 cx,
1684 ),
1685 Feature::LanguageC => self.render_feature_upsell_banner(
1686 "C support is built-in to Omega!".into(),
1687 "https://zed.dev/docs/languages/c".into(),
1688 false,
1689 cx,
1690 ),
1691 Feature::LanguageCpp => self.render_feature_upsell_banner(
1692 "C++ support is built-in to Omega!".into(),
1693 "https://zed.dev/docs/languages/cpp".into(),
1694 false,
1695 cx,
1696 ),
1697 Feature::LanguageGo => self.render_feature_upsell_banner(
1698 "Go support is built-in to Omega!".into(),
1699 "https://zed.dev/docs/languages/go".into(),
1700 false,
1701 cx,
1702 ),
1703 Feature::LanguagePython => self.render_feature_upsell_banner(
1704 "Python support is built-in to Omega!".into(),
1705 "https://zed.dev/docs/languages/python".into(),
1706 false,
1707 cx,
1708 ),
1709 Feature::LanguageReact => self.render_feature_upsell_banner(
1710 "React support is built-in to Omega!".into(),
1711 "https://zed.dev/docs/languages/typescript".into(),
1712 false,
1713 cx,
1714 ),
1715 Feature::LanguageRust => self.render_feature_upsell_banner(
1716 "Rust support is built-in to Omega!".into(),
1717 "https://zed.dev/docs/languages/rust".into(),
1718 false,
1719 cx,
1720 ),
1721 Feature::LanguageTypescript => self.render_feature_upsell_banner(
1722 "Typescript support is built-in to Omega!".into(),
1723 "https://zed.dev/docs/languages/typescript".into(),
1724 false,
1725 cx,
1726 ),
1727 Feature::OpenIn => self.render_feature_upsell_banner(
1728 "Omega supports linking to a source line on GitHub and others.".into(),
1729 "https://zed.dev/docs/git#git-integrations".into(),
1730 false,
1731 cx,
1732 ),
1733 Feature::Vim => self.render_feature_upsell_banner(
1734 "Vim support is built-in to Omega!".into(),
1735 "https://zed.dev/docs/vim".into(),
1736 true,
1737 cx,
1738 ),
1739 };
1740 container = container.child(banner);
1741 }
1742
1743 container
1744 }
1745}
1746
1747struct DevExtensionRebuildPickerDelegate {
1748 entries: Vec<Arc<ExtensionManifest>>,
1749 matches: Vec<StringMatch>,
1750 selected_index: usize,
1751}
1752
1753impl DevExtensionRebuildPickerDelegate {
1754 fn new(manifests: Vec<Arc<ExtensionManifest>>) -> Self {
1755 let matches = manifests
1756 .iter()
1757 .enumerate()
1758 .map(|(ix, manifest)| StringMatch {
1759 candidate_id: ix,
1760 score: 0.0,
1761 positions: Vec::new(),
1762 string: manifest.name.clone(),
1763 })
1764 .collect();
1765
1766 Self {
1767 entries: manifests,
1768 matches,
1769 selected_index: 0,
1770 }
1771 }
1772}
1773
1774impl PickerDelegate for DevExtensionRebuildPickerDelegate {
1775 type ListItem = ListItem;
1776
1777 fn name() -> &'static str {
1778 "dev-extension-rebuild"
1779 }
1780
1781 fn match_count(&self) -> usize {
1782 self.matches.len()
1783 }
1784
1785 fn selected_index(&self) -> usize {
1786 self.selected_index
1787 }
1788
1789 fn set_selected_index(
1790 &mut self,
1791 ix: usize,
1792 _window: &mut Window,
1793 _cx: &mut Context<Picker<Self>>,
1794 ) {
1795 self.selected_index = ix;
1796 }
1797
1798 fn selected_index_changed(
1799 &self,
1800 _ix: usize,
1801 _window: &mut Window,
1802 _cx: &mut Context<Picker<Self>>,
1803 ) -> Option<Box<dyn Fn(&mut Window, &mut App) + 'static>> {
1804 None
1805 }
1806
1807 fn update_matches(
1808 &mut self,
1809 query: String,
1810 window: &mut Window,
1811 cx: &mut Context<Picker<Self>>,
1812 ) -> Task<()> {
1813 let background = cx.background_executor().clone();
1814 let candidates = self
1815 .entries
1816 .iter()
1817 .enumerate()
1818 .map(|(ix, manifest)| StringMatchCandidate::new(ix, manifest.name.as_ref()))
1819 .collect::<Vec<_>>();
1820
1821 cx.spawn_in(window, async move |this, cx| {
1822 let matches = if query.is_empty() {
1823 candidates
1824 .into_iter()
1825 .enumerate()
1826 .map(|(index, candidate)| StringMatch {
1827 candidate_id: index,
1828 string: candidate.string,
1829 positions: Vec::new(),
1830 score: 0.0,
1831 })
1832 .collect()
1833 } else {
1834 match_strings(
1835 &candidates,
1836 &query,
1837 false,
1838 true,
1839 100,
1840 &Default::default(),
1841 background,
1842 )
1843 .await
1844 };
1845
1846 this.update(cx, |this, _cx| {
1847 this.delegate.matches = matches;
1848 this.delegate.selected_index = this
1849 .delegate
1850 .selected_index
1851 .min(this.delegate.matches.len().saturating_sub(1));
1852 })
1853 .log_err();
1854 })
1855 }
1856
1857 fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
1858 let Some(mat) = self.matches.get(self.selected_index) else {
1859 return;
1860 };
1861
1862 let extension_id = self.entries[mat.candidate_id].id.clone();
1863 ExtensionStore::global(cx).update(cx, |store, cx| {
1864 store.rebuild_dev_extension(extension_id, cx);
1865 });
1866
1867 cx.emit(DismissEvent);
1868 }
1869
1870 fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
1871
1872 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
1873 Arc::from("Rebuild dev extension…")
1874 }
1875
1876 fn render_match(
1877 &self,
1878 ix: usize,
1879 selected: bool,
1880 _window: &mut Window,
1881 _cx: &mut Context<Picker<Self>>,
1882 ) -> Option<Self::ListItem> {
1883 let mat = self.matches.get(ix)?;
1884 let entry = self.entries.get(mat.candidate_id)?;
1885
1886 let item = ListItem::new(("dev-extension-list-item", mat.candidate_id))
1887 .inset(true)
1888 .spacing(ListItemSpacing::Sparse)
1889 .toggle_state(selected)
1890 .child(
1891 h_flex()
1892 .w_full()
1893 .py_px()
1894 .justify_between()
1895 .gap_2()
1896 .child(Label::new(entry.name.clone()))
1897 .child(
1898 Label::new(format!("{} • v{}", entry.id, entry.version))
1899 .size(LabelSize::Small)
1900 .color(Color::Muted),
1901 ),
1902 );
1903
1904 Some(item)
1905 }
1906
1907 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1908 Some("No dev extensions found".into())
1909 }
1910}
1911
1912impl Render for ExtensionsPage {
1913 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1914 v_flex()
1915 .size_full()
1916 .bg(cx.theme().colors().editor_background)
1917 .child(
1918 v_flex()
1919 .gap_4()
1920 .pt_4()
1921 .px_4()
1922 .bg(cx.theme().colors().editor_background)
1923 .child(
1924 h_flex()
1925 .w_full()
1926 .gap_1p5()
1927 .justify_between()
1928 .child(Headline::new("Extensions").size(HeadlineSize::Large))
1929 .child(
1930 Button::new("install-dev-extension", "Install Dev Extension")
1931 .style(ButtonStyle::Outlined)
1932 .size(ButtonSize::Medium)
1933 .on_click(|_event, window, cx| {
1934 window.dispatch_action(Box::new(InstallDevExtension), cx)
1935 }),
1936 ),
1937 )
1938 .child(
1939 h_flex()
1940 .w_full()
1941 .flex_wrap()
1942 .gap_2()
1943 .child(self.render_search(cx))
1944 .child(
1945 div().child(
1946 ToggleButtonGroup::single_row(
1947 "filter-buttons",
1948 [
1949 ToggleButtonSimple::new(
1950 "All",
1951 cx.listener(|this, _event, _, cx| {
1952 this.filter = ExtensionFilter::All;
1953 this.filter_extension_entries(cx);
1954 this.scroll_to_top(cx);
1955 }),
1956 ),
1957 ToggleButtonSimple::new(
1958 "Installed",
1959 cx.listener(|this, _event, _, cx| {
1960 this.filter = ExtensionFilter::Installed;
1961 this.filter_extension_entries(cx);
1962 this.scroll_to_top(cx);
1963 }),
1964 ),
1965 ToggleButtonSimple::new(
1966 "Not Installed",
1967 cx.listener(|this, _event, _, cx| {
1968 this.filter = ExtensionFilter::NotInstalled;
1969 this.filter_extension_entries(cx);
1970 this.scroll_to_top(cx);
1971 }),
1972 ),
1973 ],
1974 )
1975 .style(ToggleButtonGroupStyle::Outlined)
1976 .size(ToggleButtonGroupSize::Custom(rems_from_px(30.))) // Perfectly matches the input
1977 .label_size(LabelSize::Default)
1978 .auto_width()
1979 .selected_index(match self.filter {
1980 ExtensionFilter::All => 0,
1981 ExtensionFilter::Installed => 1,
1982 ExtensionFilter::NotInstalled => 2,
1983 })
1984 .into_any_element(),
1985 ),
1986 ),
1987 ),
1988 )
1989 .child(
1990 h_flex()
1991 .id("filter-row")
1992 .gap_2()
1993 .py_2p5()
1994 .px_4()
1995 .border_b_1()
1996 .border_color(cx.theme().colors().border_variant)
1997 .overflow_x_scroll()
1998 .child(
1999 Button::new("filter-all-categories", "All")
2000 .when(self.provides_filter.is_none(), |button| {
2001 button.style(ButtonStyle::Filled)
2002 })
2003 .when(self.provides_filter.is_some(), |button| {
2004 button.style(ButtonStyle::Subtle)
2005 })
2006 .toggle_state(self.provides_filter.is_none())
2007 .on_click(cx.listener(|this, _event, _, cx| {
2008 this.change_provides_filter(None, cx);
2009 })),
2010 )
2011 .children(
2012 ExtensionProvides::iter()
2013 .filter(|provides| match provides {
2014 ExtensionProvides::AgentServers
2015 | ExtensionProvides::Grammars // grammars do not add anything of value to users currently
2016 | ExtensionProvides::IndexedDocsProviders
2017 | ExtensionProvides::SlashCommands => false,
2018 _ => true,
2019 })
2020 .map(|provides| {
2021 let label = extension_provides_label(provides);
2022 let button_id =
2023 SharedString::from(format!("filter-category-{}", label));
2024
2025 Button::new(button_id, label)
2026 .style(if self.provides_filter == Some(provides) {
2027 ButtonStyle::Filled
2028 } else {
2029 ButtonStyle::Subtle
2030 })
2031 .toggle_state(self.provides_filter == Some(provides))
2032 .on_click({
2033 cx.listener(move |this, _event, _, cx| {
2034 this.change_provides_filter(Some(provides), cx);
2035 })
2036 })
2037 }),
2038 ),
2039 )
2040 .child(self.render_feature_upsells(cx))
2041 .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
2042 let mut count = self.filtered_remote_extension_indices.len();
2043 if self.filter.include_dev_extensions() {
2044 count += self.filtered_dev_extension_indices.len();
2045 }
2046
2047 if count == 0 {
2048 this.child(self.render_empty_state(cx)).into_any_element()
2049 } else {
2050 let scroll_handle = &self.list;
2051 this.child(
2052 uniform_list("entries", count, cx.processor(Self::render_extensions))
2053 .flex_grow_1()
2054 .pb_4()
2055 .track_scroll(scroll_handle),
2056 )
2057 .vertical_scrollbar_for(scroll_handle, window, cx)
2058 .into_any_element()
2059 }
2060 }))
2061 }
2062}
2063
2064impl EventEmitter<ItemEvent> for ExtensionsPage {}
2065
2066impl Focusable for ExtensionsPage {
2067 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
2068 self.query_editor.read(cx).focus_handle(cx)
2069 }
2070}
2071
2072impl Item for ExtensionsPage {
2073 type Event = ItemEvent;
2074
2075 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
2076 "Extensions".into()
2077 }
2078
2079 fn telemetry_event_text(&self) -> Option<&'static str> {
2080 Some("Extensions Page Opened")
2081 }
2082
2083 fn show_toolbar(&self) -> bool {
2084 false
2085 }
2086
2087 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
2088 f(*event)
2089 }
2090}
2091