using System; using System.Collections.Generic; using System.Collections.Immutable; using System.ComponentModel; using System.Diagnostics.Contracts; using System.Linq; using System.Reactive.Disposables; using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; using AsyncAwaitBestPractices; using Avalonia.Controls; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DynamicData; using DynamicData.Binding; using FluentAvalonia.UI.Controls; using StabilityMatrix.Avalonia.Collections; using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.Views.PackageManager; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.PackageModification; using StabilityMatrix.Core.Models.Packages.Extensions; using StabilityMatrix.Core.Services; namespace StabilityMatrix.Avalonia.ViewModels.PackageManager; [View(typeof(PackageExtensionBrowserView))] [Transient] [ManagedService] public partial class PackageExtensionBrowserViewModel : ViewModelBase, IDisposable { private readonly INotificationService notificationService; private readonly ISettingsManager settingsManager; private readonly ServiceManager vmFactory; private readonly CompositeDisposable cleanUp; public PackagePair? PackagePair { get; set; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowNoExtensionsFoundMessage))] private bool isLoading; private SourceCache availableExtensionsSource = new(ext => ext.Author + ext.Title + ext.Reference); public IObservableCollection> SelectedAvailableItems { get; } = new ObservableCollectionExtended>(); public SearchCollection< SelectableItem, string, string > AvailableItemsSearchCollection { get; } private SourceCache installedExtensionsSource = new( ext => ext.Paths.FirstOrDefault()?.ToString() ?? ext.GitRepositoryUrl ?? ext.GetHashCode().ToString() ); public IObservableCollection> SelectedInstalledItems { get; } = new ObservableCollectionExtended>(); public SearchCollection< SelectableItem, string, string > InstalledItemsSearchCollection { get; } public IObservableCollection InstalledExtensions { get; } = new ObservableCollectionExtended(); [ObservableProperty] private bool showNoExtensionsFoundMessage; public PackageExtensionBrowserViewModel( INotificationService notificationService, ISettingsManager settingsManager, ServiceManager vmFactory ) { this.notificationService = notificationService; this.settingsManager = settingsManager; this.vmFactory = vmFactory; var availableItemsChangeSet = availableExtensionsSource .Connect() .Transform(ext => new SelectableItem(ext)) .ObserveOn(SynchronizationContext.Current!) .Publish(); availableItemsChangeSet .AutoRefresh(item => item.IsSelected) .Filter(item => item.IsSelected) .Bind(SelectedAvailableItems) .Subscribe(); var installedItemsChangeSet = installedExtensionsSource .Connect() .Transform(ext => new SelectableItem(ext)) .ObserveOn(SynchronizationContext.Current!) .Publish(); installedItemsChangeSet .AutoRefresh(item => item.IsSelected) .Filter(item => item.IsSelected) .Bind(SelectedInstalledItems) .Subscribe(); cleanUp = new CompositeDisposable( AvailableItemsSearchCollection = new SearchCollection< SelectableItem, string, string >( availableItemsChangeSet, query => string.IsNullOrWhiteSpace(query) ? _ => true : x => x.Item.Title.Contains(query, StringComparison.OrdinalIgnoreCase) ), availableItemsChangeSet.Connect(), InstalledItemsSearchCollection = new SearchCollection< SelectableItem, string, string >( installedItemsChangeSet, query => string.IsNullOrWhiteSpace(query) ? _ => true : x => x.Item.Title.Contains(query, StringComparison.OrdinalIgnoreCase) ), installedItemsChangeSet.Connect() ); } public void AddExtensions( IEnumerable packageExtensions, IEnumerable installedExtensions ) { availableExtensionsSource.AddOrUpdate(packageExtensions); installedExtensionsSource.AddOrUpdate(installedExtensions); } [RelayCommand] public async Task InstallSelectedExtensions() { if (!await BeforeInstallCheck()) return; var extensions = SelectedAvailableItems .Select(item => item.Item) .Where(extension => !extension.IsInstalled) .ToArray(); if (extensions.Length == 0) return; var steps = extensions .Select( ext => new InstallExtensionStep( PackagePair!.BasePackage.ExtensionManager!, PackagePair.InstalledPackage, ext ) ) .Cast() .ToArray(); var runner = new PackageModificationRunner { ShowDialogOnStart = true }; EventManager.Instance.OnPackageInstallProgressAdded(runner); await runner.ExecuteSteps(steps); ClearSelection(); RefreshBackground(); } [RelayCommand] public async Task UpdateSelectedExtensions() { var extensions = SelectedInstalledItems.Select(x => x.Item).ToArray(); if (extensions.Length == 0) return; var steps = extensions .Select( ext => new UpdateExtensionStep( PackagePair!.BasePackage.ExtensionManager!, PackagePair.InstalledPackage, ext ) ) .Cast() .ToArray(); var runner = new PackageModificationRunner { ShowDialogOnStart = true }; EventManager.Instance.OnPackageInstallProgressAdded(runner); await runner.ExecuteSteps(steps); ClearSelection(); RefreshBackground(); } [RelayCommand] public async Task UninstallSelectedExtensions() { var extensions = SelectedInstalledItems.Select(x => x.Item).ToArray(); if (extensions.Length == 0) return; var steps = extensions .Select( ext => new UninstallExtensionStep( PackagePair!.BasePackage.ExtensionManager!, PackagePair.InstalledPackage, ext ) ) .Cast() .ToArray(); var runner = new PackageModificationRunner { ShowDialogOnStart = true }; EventManager.Instance.OnPackageInstallProgressAdded(runner); await runner.ExecuteSteps(steps); ClearSelection(); RefreshBackground(); } [RelayCommand] public async Task OpenExtensionsSettingsDialog() { if (PackagePair is null) return; var grid = new ExtensionSettingsPropertyGrid { ManifestUrls = new BindingList( PackagePair?.InstalledPackage.ExtraExtensionManifestUrls ?? [] ) }; var dialog = vmFactory .Get(vm => { vm.Title = $"{Resources.Label_Settings}"; vm.SelectedObject = grid; vm.IncludeCategories = ["Base"]; }) .GetSaveDialog(); dialog.MinDialogWidth = 750; dialog.MaxDialogWidth = 750; if (await dialog.ShowAsync() == ContentDialogResult.Primary) { await using var _ = settingsManager.BeginTransaction(); PackagePair!.InstalledPackage.ExtraExtensionManifestUrls = grid.ManifestUrls.ToList(); } } /// public override async Task OnLoadedAsync() { await base.OnLoadedAsync(); await Refresh(); } [RelayCommand] public async Task Refresh() { if (PackagePair is null) return; IsLoading = true; try { if (Design.IsDesignMode) { var (availableExtensions, installedExtensions) = SynchronizeExtensions( availableExtensionsSource.Items, installedExtensionsSource.Items ); availableExtensionsSource.EditDiff(availableExtensions); installedExtensionsSource.EditDiff(installedExtensions); await Task.Delay(250); } else { await RefreshCore(); } } finally { IsLoading = false; ShowNoExtensionsFoundMessage = AvailableItemsSearchCollection.FilteredItems.Count == 0; } } public void RefreshBackground() { RefreshCore() .SafeFireAndForget(ex => { notificationService.ShowPersistent("Failed to refresh extensions", ex.ToString()); }); } private async Task RefreshCore() { using var _ = CodeTimer.StartDebug(); if (PackagePair?.BasePackage.ExtensionManager is not { } extensionManager) { throw new NotSupportedException( $"The package {PackagePair?.BasePackage} does not support extensions." ); } var availableExtensions = ( await extensionManager.GetManifestExtensionsAsync( extensionManager.GetManifests(PackagePair.InstalledPackage) ) ).ToArray(); var installedExtensions = ( await extensionManager.GetInstalledExtensionsAsync(PackagePair.InstalledPackage) ).ToArray(); // Synchronize SynchronizeExtensions(availableExtensions, installedExtensions); await Task.Run(() => { availableExtensionsSource.Edit(updater => { updater.Load(availableExtensions); }); installedExtensionsSource.Edit(updater => { updater.Load(installedExtensions); }); }); } public void ClearSelection() { foreach (var item in SelectedAvailableItems.ToImmutableArray()) { item.IsSelected = false; } foreach (var item in SelectedInstalledItems.ToImmutableArray()) { item.IsSelected = false; } } private async Task BeforeInstallCheck() { if ( !settingsManager.Settings.SeenTeachingTips.Contains( Core.Models.Settings.TeachingTip.PackageExtensionsInstallNotice ) ) { var dialog = new BetterContentDialog { Title = "Installing Extensions", Content = """ Extensions, the extension index, and their dependencies are community provided and not verified by the Stability Matrix team. The install process may invoke external programs and scripts. Please review the extension's source code and applicable licenses before installing. """, PrimaryButtonText = Resources.Action_Continue, CloseButtonText = Resources.Action_Cancel, DefaultButton = ContentDialogButton.Primary, MaxDialogWidth = 400 }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) { return false; } settingsManager.Transaction( s => s.SeenTeachingTips.Add(Core.Models.Settings.TeachingTip.PackageExtensionsInstallNotice) ); } return true; } [Pure] private static ( IEnumerable extensions, IEnumerable installedExtensions ) SynchronizeExtensions( IEnumerable extensions, IEnumerable installedExtensions ) { var availableArr = extensions.ToArray(); var installedArr = installedExtensions.ToArray(); SynchronizeExtensions(availableArr, installedArr); return (availableArr, installedArr); } private static void SynchronizeExtensions( IList extensions, IList installedExtensions ) { // For extensions, map their file paths for lookup var repoToExtension = extensions .SelectMany(ext => ext.Files.Select(path => (path, ext))) .ToLookup(kv => kv.path.ToString().StripEnd(".git")) .ToDictionary(group => group.Key, x => x.First().ext); // For installed extensions, add remote repo if available var extensionsInstalled = new HashSet(); foreach (var (i, installedExt) in installedExtensions.Enumerate()) { if ( installedExt.GitRepositoryUrl is not null && repoToExtension.TryGetValue( installedExt.GitRepositoryUrl.StripEnd(".git"), out var mappedExt ) ) { extensionsInstalled.Add(mappedExt); installedExtensions[i] = installedExt with { Definition = mappedExt }; } } // For available extensions, add installed status if available foreach (var (i, ext) in extensions.Enumerate()) { if (extensionsInstalled.Contains(ext)) { extensions[i] = ext with { IsInstalled = true }; } } } /// public void Dispose() { availableExtensionsSource.Dispose(); installedExtensionsSource.Dispose(); cleanUp.Dispose(); GC.SuppressFinalize(this); } private class ExtensionSettingsPropertyGrid : AbstractNotifyPropertyChanged { [Category("Base")] [DisplayName("Extension Manifest Sources")] public BindingList ManifestUrls { get; init; } = []; } }