Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:33:54.252Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

extensions_ui.rs

2091 lines · 83.4 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.