diff --git a/docs/CLI.md b/docs/CLI.md index 84ec07427a..ad3d3b1dea 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -117,7 +117,7 @@ Related environment variables: | --- | --- | --- | --- | | `app status` | None | None | Returns app/session state such as headless mode, page, and supported UI actions. | | `app show` | None | None | Shows and focuses the window when a GUI session exists. | -| `app navigate` | `--page ` | `--manager `, `--help-attachment ` | Valid pages include `discover`, `updates`, `installed`, `bundles`, `settings`, `managers`, `own-log`, `manager-log`, `operation-history`, `help`, `release-notes`, and `about`. | +| `app navigate` | `--page ` | `--manager `, `--help-attachment ` | Valid pages include `discover`, `updates`, `installed`, `bundles`, `catalog`, `catalog-editor`, `settings`, `managers`, `own-log`, `manager-log`, `operation-history`, `help`, `release-notes`, and `about`. `catalog` and `catalog-editor` require Enable Software Catalog in Experimental settings and developer options, followed by an application restart. | | `app quit` | None | None | Gracefully shuts down the selected session, including headless daemons. | ### Operations diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index 9b9964f56f..f4cb59a246 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -1,4 +1,77 @@ { + "Catalog Editor": "Catalog Editor", + "Tiles": "Tiles", + "Rename catalog": "Rename catalog", + "Rename": "Rename", + "Enter a catalog name.": "Enter a catalog name.", + "Package unavailable": "Package unavailable", + "Enable this package's manager and source to view its details.": "Enable this package's manager and source to view its details.", + "Catalog filters": "Catalog filters", + "Hide packages with unavailable sources": "Hide packages with unavailable sources", + "Packages with unavailable sources are hidden. Change Filters to show them.": "Packages with unavailable sources are hidden. Change Filters to show them.", + "No packages are available with the current filters.": "No packages are available with the current filters.", + "Open Catalog Editor": "Open Catalog Editor", + "Select a catalog": "Select a catalog", + "Catalog ID": "Catalog ID", + "Add selection to catalog": "Add selection to catalog", + "Remove selection from catalog": "Remove selection from catalog", + "Add packages from Discover using Add selection to catalog.": "Add packages from Discover using Add selection to catalog.", + "No packages in this catalog": "No packages in this catalog", + "Loading catalog": "Loading catalog", + "No packages match your filters": "No packages match your filters", + "Open or create a catalog before adding packages.": "Open or create a catalog before adding packages.", + "{0} packages added to catalog {1}. Save the catalog to keep your changes.": "{0} packages added to catalog {1}. Save the catalog to keep your changes.", + "Could not add packages to the catalog": "Could not add packages to the catalog", + "Could not add packages to the catalog: {0}": "Could not add packages to the catalog: {0}", + "Catalog editor": "Catalog editor", + "Open file": "Open file", + "Open catalog file": "Open catalog file", + "Save catalog": "Save catalog", + "Save catalog as": "Save catalog as", + "Select catalog to edit": "Select catalog to edit", + "Catalog identifier": "Catalog identifier", + "Catalog name": "Catalog name", + "Catalog packages": "Catalog packages", + "Search package manager": "Search package manager", + "Catalog package search results": "Catalog package search results", + "Search catalog packages": "Search catalog packages", + "Search packages to add": "Search packages to add", + "New catalog": "New catalog", + "Add package": "Add package", + "Remove package": "Remove package", + "No package managers are ready. Enable a package manager before searching.": "No package managers are ready. Enable a package manager before searching.", + "Could not open catalog file {0}: {1}": "Could not open catalog file {0}: {1}", + "Catalog saved to {0}.": "Catalog saved to {0}.", + "Could not save catalog file {0}: {1}": "Could not save catalog file {0}: {1}", + "This package is already in the selected catalog.": "This package is already in the selected catalog.", + "The selected package manager is not ready.": "The selected package manager is not ready.", + "Catalog package search failed: {0}": "Catalog package search failed: {0}", + "Discard unsaved catalog changes?": "Discard unsaved catalog changes?", + "Select a local catalog file.": "Select a local catalog file.", + "Could not select a catalog file: {0}": "Could not select a catalog file: {0}", + "The catalog editor could not be opened: {0}": "The catalog editor could not be opened: {0}", + "Open the catalog editor to search, add or remove packages and save a catalog file.": "Open the catalog editor to search, add or remove packages and save a catalog file.", + "Catalog": "Catalog", + "Select software catalog": "Select software catalog", + "This catalog contains no packages.": "This catalog contains no packages.", + "Enable Software Catalog": "Enable Software Catalog", + "Requires a machine-wide SoftwareCatalog.json file and an application restart.": "Requires a machine-wide SoftwareCatalog.json file and an application restart.", + "Software Catalog is disabled": "Software Catalog is disabled", + "Enable Software Catalog in Experimental settings and developer options, then restart UniGetUI.": "Enable Software Catalog in Experimental settings and developer options, then restart UniGetUI.", + "The software catalog could not be loaded. Ask an administrator to check {0}, then try again.": "The software catalog could not be loaded. Ask an administrator to check {0}, then try again.", + "Edit software catalog": "Edit software catalog", + "Software Catalog": "Software Catalog", + "Software selected for you, ready to install.": "Software selected for you, ready to install.", + "Refresh software catalog": "Refresh software catalog", + "Installed": "Installed", + "The software catalog could not be loaded. Check SoftwareCatalog.json and try again.": "The software catalog could not be loaded. Check SoftwareCatalog.json and try again.", + "The package manager {0} is not available on this platform.": "The package manager {0} is not available on this platform.", + "The package manager {0} is disabled. Enable it in Package Managers.": "The package manager {0} is disabled. Enable it in Package Managers.", + "The package manager {0} is not available or is still initializing.": "The package manager {0} is not available or is still initializing.", + "The source {0} is not enabled for {1}. Add it in Package Managers.": "The source {0} is not enabled for {1}. Add it in Package Managers.", + "Waiting for installed package information. Refresh Installed Packages if this persists.": "Waiting for installed package information. Refresh Installed Packages if this persists.", + "Unable to install {0}": "Unable to install {0}", + "The catalog package could not be found or installed. Check that its identifier and source are correct, then try again.": "The catalog package could not be found or installed. Check that its identifier and source are correct, then try again.", "Operation": "Operation", "Status": "Status", "Date": "Date", diff --git a/src/SharedAssets/Assets/Symbols/catalog.svg b/src/SharedAssets/Assets/Symbols/catalog.svg new file mode 100644 index 0000000000..9be76856f1 --- /dev/null +++ b/src/SharedAssets/Assets/Symbols/catalog.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs index 83687461b6..7507bb09b1 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs @@ -255,7 +255,9 @@ private static IpcAppInfo GetAppInfo() CanNavigate = window is not null, CanQuit = true, CurrentPage = window is null ? "" : IpcAppPages.ToPageName(window.CurrentPage.ToString()), - SupportedPages = IpcAppPages.SupportedPages, + SupportedPages = window is { SoftwareCatalogEnabled: true } + ? IpcAppPages.SupportedPages + : IpcAppPages.SupportedPages.Where(page => page is not ("catalog" or "catalog-editor")).ToArray(), SystemRestartPending = PendingRebootStore.HasPending, SystemRestartPendingPackages = PendingRebootStore.PendingCount, }; @@ -290,6 +292,12 @@ private static IpcCommandResult NavigateApp(IpcAppNavigateRequest request) case "bundles": window.Navigate(PageType.Bundles); break; + case "catalog": + case "catalog-editor": + if (!window.SoftwareCatalogEnabled) + throw new InvalidOperationException("Software Catalog is disabled. Enable it in Experimental settings and developer options, then restart UniGetUI."); + window.Navigate(page == "catalog-editor" ? PageType.CatalogEditor : PageType.Catalog); + break; case "settings": window.Navigate(PageType.Settings); break; diff --git a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs index 5aa27b78f2..7802a52b99 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs @@ -141,6 +141,7 @@ private sealed record Entry( new("Administrator rights and other dangerous settings", ["administrator", "dangerous"], typeof(Administrator), null), // ── Experimental ───────────────────────────────────────────────────── + new("Enable Software Catalog", ["catalog", "software catalog"], typeof(Experimental), "SoftwareCatalogCard"), new("Show UniGetUI's version and build number on the titlebar.", ["version number", "titlebar", "build number"], typeof(Experimental), "ShowVersionNumberOnTitlebar"), new("Enable background api (UniGetUI Widgets and Sharing, port 7058)", ["api", "widgets", "sharing", "port"], typeof(Experimental), "BackgroundApiCard"), new("Disable the 1-minute timeout for package-related operations", ["timeout"], typeof(Experimental), "DisableTimeoutCard"), diff --git a/src/UniGetUI.Avalonia/Models/CatalogPackageIdentitySet.cs b/src/UniGetUI.Avalonia/Models/CatalogPackageIdentitySet.cs new file mode 100644 index 0000000000..2bee5f3227 --- /dev/null +++ b/src/UniGetUI.Avalonia/Models/CatalogPackageIdentitySet.cs @@ -0,0 +1,48 @@ +using UniGetUI.PackageEngine.Interfaces; + +namespace UniGetUI.Avalonia.Models; + +/// +/// A per-refresh snapshot of package identities, using the same aliases and case rules as CatalogEntry. +/// Construction takes expected O(packages) time and space; each tile membership check takes expected O(1). +/// Rebuild on each refresh so inventory removals and completed operations cannot leave stale identities. +/// +internal sealed class CatalogPackageIdentitySet +{ + private readonly HashSet _identities = new(new IdentityComparer()); + + public CatalogPackageIdentitySet(IEnumerable packages, bool installedInventory = false) + { + foreach (var package in packages) + { + var manager = package.Manager; + // Only installed Chocolatey inventory loses feed provenance. Pending operations remain source-specific. + string? source = installedInventory + && string.Equals(manager.Id, "chocolatey", StringComparison.OrdinalIgnoreCase) + ? null + : package.Source.Name; + _identities.Add(new Identity(manager.Id, package.Id, source)); + _identities.Add(new Identity(manager.Name, package.Id, source)); + _identities.Add(new Identity(manager.DisplayName, package.Id, source)); + } + } + + public bool Contains(CatalogEntry entry) => + _identities.Contains(new Identity(entry.ManagerName, entry.Id, entry.Source)) + || _identities.Contains(new Identity(entry.ManagerName, entry.Id, null)); + + private readonly record struct Identity(string Manager, string Id, string? Source); + + private sealed class IdentityComparer : IEqualityComparer + { + public bool Equals(Identity x, Identity y) => + StringComparer.OrdinalIgnoreCase.Equals(x.Manager, y.Manager) + && StringComparer.OrdinalIgnoreCase.Equals(x.Id, y.Id) + && StringComparer.OrdinalIgnoreCase.Equals(x.Source, y.Source); + + public int GetHashCode(Identity identity) => HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(identity.Manager), + StringComparer.OrdinalIgnoreCase.GetHashCode(identity.Id), + identity.Source is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(identity.Source)); + } +} diff --git a/src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs b/src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs new file mode 100644 index 0000000000..cb363d2568 --- /dev/null +++ b/src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs @@ -0,0 +1,147 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using UniGetUI.Core.Data; +using UniGetUI.PackageEngine.Classes.Serializable; +using UniGetUI.PackageEngine.Interfaces; + +namespace UniGetUI.Avalonia.Models; + +public sealed class CatalogDocument +{ + [JsonPropertyName("version")] + public required int Version { get; init; } + + [JsonPropertyName("catalogs")] + public required CatalogDefinition[] Catalogs { get; init; } +} + +public sealed class CatalogDefinition +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + [JsonPropertyName("name")] + public required string Name { get; init; } + [JsonPropertyName("packages")] + public required CatalogEntry[] Packages { get; init; } +} + +public sealed class CatalogEntry +{ + public required string Id { get; init; } + public required string Name { get; init; } + public string Version { get; set; } = ""; + public required string Source { get; init; } + public required string ManagerName { get; init; } + + public static CatalogEntry FromPackage(IPackage package) => new() + { + Id = package.Id, + Name = package.Name, + Version = "", + Source = package.Source.Name, + ManagerName = package.Manager.DisplayName, + }; + + public SerializablePackage AsSerializable() => new() + { + Id = Id, + Name = Name, + Version = Version, + Source = Source, + ManagerName = ManagerName, + }; + + public bool Matches(string id, string manager, string source) => + string.Equals(Id, id, StringComparison.OrdinalIgnoreCase) + && string.Equals(ManagerName, manager, StringComparison.OrdinalIgnoreCase) + && string.Equals(Source, source, StringComparison.OrdinalIgnoreCase); + + public bool MatchesManager(IPackageManager manager) => + string.Equals(ManagerName, manager.Id, StringComparison.OrdinalIgnoreCase) + || string.Equals(ManagerName, manager.Name, StringComparison.OrdinalIgnoreCase) + || string.Equals(ManagerName, manager.DisplayName, StringComparison.OrdinalIgnoreCase); + + public bool Matches(IPackage package) => + string.Equals(Id, package.Id, StringComparison.OrdinalIgnoreCase) + && string.Equals(Source, package.Source.Name, StringComparison.OrdinalIgnoreCase) + && MatchesManager(package.Manager); + + public bool MatchesInstalled(IPackage package) => + string.Equals(Id, package.Id, StringComparison.OrdinalIgnoreCase) + && MatchesManager(package.Manager) + // Chocolatey's installed inventory assigns DefaultSource and does not retain feed provenance. + && (string.Equals(package.Manager.Id, "chocolatey", StringComparison.OrdinalIgnoreCase) + || string.Equals(Source, package.Source.Name, StringComparison.OrdinalIgnoreCase)); +} + +internal static class SoftwareCatalog +{ + public static string FilePath => Path.Combine(CoreData.UniGetUIGlobalDirectory, "SoftwareCatalog.json"); + + internal static async Task SaveAsync(string filePath, CatalogDefinition[] catalogs) + { + var document = new CatalogDocument { Version = 1, Catalogs = catalogs }; + string json = JsonSerializer.Serialize(document, CatalogJsonContext.Default.CatalogDocument); + Parse(json); + string destination = Path.GetFullPath(filePath); + string temporary = Path.Combine(Path.GetDirectoryName(destination)!, $".catalog-{Guid.NewGuid():N}.tmp"); + try + { + await File.WriteAllTextAsync(temporary, json + Environment.NewLine); + if (File.Exists(destination)) + File.Replace(temporary, destination, null); + else + File.Move(temporary, destination); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + + public static Task LoadAsync() => LoadAsync(FilePath); + + internal static async Task LoadAsync(string filePath) + { + await using var stream = File.OpenRead(filePath); + using var reader = new StreamReader(stream); + return Parse(await reader.ReadToEndAsync()); + } + + internal static CatalogDefinition[] Parse(string json) + { + var document = JsonSerializer.Deserialize(json, CatalogJsonContext.Default.CatalogDocument) + ?? throw new JsonException("The software catalog must be a versioned JSON object."); + if (document.Version != 1) + throw new JsonException($"Unsupported software catalog version: {document.Version}."); + var catalogs = document.Catalogs + ?? throw new JsonException("The software catalog requires a catalogs array."); + var catalogIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var catalog in catalogs) + { + if (catalog is null || string.IsNullOrWhiteSpace(catalog.Id) + || string.IsNullOrWhiteSpace(catalog.Name) || catalog.Packages is null) + throw new JsonException("Every catalog requires an id, name and packages array."); + if (!catalogIds.Add(catalog.Id)) + throw new JsonException($"Duplicate catalog identifier: {catalog.Id}."); + + var identities = new HashSet<(string, string, string)>(); + foreach (var entry in catalog.Packages) + { + if (entry is null || string.IsNullOrWhiteSpace(entry.Name) + || string.IsNullOrWhiteSpace(entry.Id) || string.IsNullOrWhiteSpace(entry.ManagerName) + || string.IsNullOrWhiteSpace(entry.Source) || entry.Version is null) + throw new JsonException("Every catalog entry requires Name, Id, ManagerName and Source, with a non-null Version."); + + if (!identities.Add((entry.Id.ToUpperInvariant(), entry.ManagerName.ToUpperInvariant(), entry.Source.ToUpperInvariant()))) + throw new JsonException($"Duplicate package in catalog {catalog.Id}: {entry.ManagerName}/{entry.Source}/{entry.Id}."); + } + } + return catalogs; + } +} + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(CatalogDocument))] +internal partial class CatalogJsonContext : JsonSerializerContext; diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs new file mode 100644 index 0000000000..f461a04e95 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs @@ -0,0 +1,367 @@ +using System.Collections.ObjectModel; +using Avalonia.Automation; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UniGetUI.Avalonia.Infrastructure; +using UniGetUI.Avalonia.Models; +using UniGetUI.Core.Logging; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine; +using UniGetUI.PackageEngine.Interfaces; + +namespace UniGetUI.Avalonia.ViewModels; + +public partial class CatalogEditorCatalog : ViewModelBase +{ + [ObservableProperty] + private string _id; + + [ObservableProperty] + private string _name; + + public ObservableCollection Packages { get; } + + public CatalogEditorCatalog(CatalogDefinition definition) + { + _id = definition.Id; + _name = definition.Name; + Packages = new(definition.Packages); + } + + public CatalogDefinition ToDefinition() => new() { Id = Id, Name = Name, Packages = Packages.ToArray() }; +} + +public partial class CatalogEditorViewModel : ViewModelBase +{ + public ObservableCollection Catalogs { get; } = []; + public ObservableCollection Managers { get; } = []; + public ObservableCollection SearchResults { get; } = []; + + [ObservableProperty] + private CatalogEditorCatalog? _selectedCatalog; + + [ObservableProperty] + private IPackageManager? _selectedManager; + + [ObservableProperty] + private CatalogEntry? _selectedPackage; + + [ObservableProperty] + private CatalogEntry? _selectedResult; + + [ObservableProperty] + private string _query = ""; + + [ObservableProperty] + private string _filePath = SoftwareCatalog.FilePath; + + private string _message = ""; + + private readonly Action _announce; + + public string Message + { + get => _message; + set => SetMessage(value); + } + + public CatalogEditorViewModel() : this(AccessibilityAnnouncementService.Announce) { } + + internal CatalogEditorViewModel(Action announce) => _announce = announce; + + public void SetErrorMessage(string message) => SetMessage(message, AutomationLiveSetting.Assertive); + + private void SetMessage(string message, AutomationLiveSetting liveSetting = AutomationLiveSetting.Polite) + { + if (SetProperty(ref _message, message, nameof(Message)) && !string.IsNullOrWhiteSpace(message)) + _announce(message, liveSetting); + } + + [ObservableProperty] + private bool _isDirty; + + [ObservableProperty] + private bool _isLoaded; + + [ObservableProperty] + private bool _isBusy; + + [ObservableProperty] + private bool _isSearching; + + private bool _loading; + private Task? _initialLoad; + private CatalogDefinition[] _savedCatalogs = []; + + public Task EnsureLoadedAsync() => + IsLoaded ? Task.FromResult(true) : _initialLoad ??= LoadAsync(FilePath); + + public void NewDocument() + { + SelectedCatalog = null; + Catalogs.Clear(); + FilePath = ""; + _savedCatalogs = []; + IsLoaded = true; + IsDirty = true; + Message = ""; + NewCatalog(); + } + + public int AddPackages(IEnumerable packages) + { + if (!CanEdit || SelectedCatalog is not { } catalog) + throw new InvalidOperationException(CoreTools.Translate("Open or create a catalog before adding packages.")); + int added = 0; + foreach (var package in packages) + { + if (catalog.Packages.Any(p => p.Matches(package))) continue; + catalog.Packages.Add(CatalogEntry.FromPackage(package)); + added++; + } + Message = CoreTools.Translate("{0} packages added to catalog {1}. Save the catalog to keep your changes.", added, catalog.Name); + return added; + } + + public bool CanEdit => IsLoaded && !IsBusy; + public bool CanDiscardChanges => CanEdit && IsDirty; + public bool HasSelectedCatalog => SelectedCatalog is not null; + public bool CanAddPackage => CanEdit && HasSelectedCatalog && SelectedResult is not null; + public bool CanRemovePackage => CanEdit && HasSelectedCatalog && SelectedPackage is not null; + public bool CanSearch => CanEdit && !IsSearching && SelectedManager is not null && !string.IsNullOrWhiteSpace(Query); + + partial void OnIsLoadedChanged(bool value) => UpdateAvailability(); + partial void OnIsDirtyChanged(bool value) => OnPropertyChanged(nameof(CanDiscardChanges)); + partial void OnIsBusyChanged(bool value) => UpdateAvailability(); + partial void OnIsSearchingChanged(bool value) => UpdateAvailability(); + partial void OnQueryChanged(string value) => UpdateAvailability(); + partial void OnSelectedManagerChanged(IPackageManager? value) + { + SearchCommand.Cancel(); + SearchResults.Clear(); + SelectedResult = null; + UpdateAvailability(); + } + + partial void OnSelectedCatalogChanged(CatalogEditorCatalog? value) + { + SelectedPackage = null; + OnPropertyChanged(nameof(HasSelectedCatalog)); + UpdateAvailability(); + } + + partial void OnSelectedResultChanged(CatalogEntry? value) => UpdateAvailability(); + partial void OnSelectedPackageChanged(CatalogEntry? value) => UpdateAvailability(); + + private void UpdateAvailability() + { + OnPropertyChanged(nameof(CanEdit)); + OnPropertyChanged(nameof(CanDiscardChanges)); + OnPropertyChanged(nameof(CanSearch)); + OnPropertyChanged(nameof(CanAddPackage)); + OnPropertyChanged(nameof(CanRemovePackage)); + SearchCommand.NotifyCanExecuteChanged(); + } + + public void RefreshManagers() + { + var selectedId = SelectedManager?.Id; + Managers.Clear(); + foreach (var manager in PEInterface.Managers.Where(m => m.IsReady())) + Managers.Add(manager); + SelectedManager = Managers.FirstOrDefault(m => m.Id == selectedId) ?? Managers.FirstOrDefault(); + if (Managers.Count == 0 && Message.Length == 0) + Message = CoreTools.Translate("No package managers are ready. Enable a package manager before searching."); + } + + private void Attach(CatalogEditorCatalog catalog) + { + catalog.PropertyChanged += (_, _) => { if (!_loading) IsDirty = true; }; + catalog.Packages.CollectionChanged += (_, _) => { if (!_loading) IsDirty = true; }; + } + + public async Task LoadAsync(string path) + { + IsBusy = true; + Message = ""; + try + { + var definitions = await SoftwareCatalog.LoadAsync(path); + SearchCommand.Cancel(); + _loading = true; + SelectedCatalog = null; + Catalogs.Clear(); + SearchResults.Clear(); + SelectedResult = null; + foreach (var definition in definitions) + { + var catalog = new CatalogEditorCatalog(definition); + Attach(catalog); + Catalogs.Add(catalog); + } + SelectedCatalog = Catalogs.FirstOrDefault(); + FilePath = Path.GetFullPath(path); + _savedCatalogs = CopyCatalogs(definitions); + IsLoaded = true; + IsDirty = false; + return true; + } + catch (Exception ex) + { + Logger.Error($"Could not open catalog file {path}."); + Logger.Error(ex); + SetErrorMessage(CoreTools.Translate("Could not open catalog file {0}: {1}", path, ex.Message)); + return false; + } + finally + { + _loading = false; + IsBusy = false; + } + } + + public async Task SaveAsync(string path) + { + IsBusy = true; + Message = ""; + try + { + var definitions = CopyCatalogs(Catalogs.Select(c => c.ToDefinition())); + await SoftwareCatalog.SaveAsync(path, definitions); + FilePath = Path.GetFullPath(path); + _savedCatalogs = definitions; + IsDirty = false; + Message = CoreTools.Translate("Catalog saved to {0}.", FilePath); + return true; + } + catch (Exception ex) + { + Logger.Error($"Could not save catalog file {path}."); + Logger.Error(ex); + SetErrorMessage(CoreTools.Translate("Could not save catalog file {0}: {1}", path, ex.Message)); + return false; + } + finally + { + IsBusy = false; + } + } + + public void DiscardChanges() + { + if (!CanDiscardChanges) return; + string? selectedId = SelectedCatalog?.Id; + SearchCommand.Cancel(); + _loading = true; + try + { + SelectedCatalog = null; + Catalogs.Clear(); + SearchResults.Clear(); + SelectedResult = null; + foreach (var definition in CopyCatalogs(_savedCatalogs)) + { + var catalog = new CatalogEditorCatalog(definition); + Attach(catalog); + Catalogs.Add(catalog); + } + SelectedCatalog = Catalogs.FirstOrDefault(c => + string.Equals(c.Id, selectedId, StringComparison.OrdinalIgnoreCase)) ?? Catalogs.FirstOrDefault(); + IsDirty = false; + Message = ""; + } + finally { _loading = false; } + } + + private static CatalogDefinition[] CopyCatalogs(IEnumerable catalogs) => + catalogs.Select(c => new CatalogDefinition + { + Id = c.Id, + Name = c.Name, + Packages = c.Packages.Select(p => new CatalogEntry + { + Id = p.Id, + Name = p.Name, + Version = p.Version, + Source = p.Source, + ManagerName = p.ManagerName, + }).ToArray(), + }).ToArray(); + + [RelayCommand] + public void NewCatalog() + { + if (!CanEdit) return; + string id = "catalog"; + int suffix = 1; + while (Catalogs.Any(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase))) + id = $"catalog-{++suffix}"; + var catalog = new CatalogEditorCatalog(new CatalogDefinition + { + Id = id, + Name = CoreTools.Translate("New catalog"), + Packages = [], + }); + Attach(catalog); + Catalogs.Add(catalog); + SelectedCatalog = catalog; + IsDirty = true; + } + + [RelayCommand] + private void AddPackage() + { + if (!CanEdit || SelectedCatalog is not { } catalog || SelectedResult is not { } entry) return; + if (catalog.Packages.Any(p => p.Matches(entry.Id, entry.ManagerName, entry.Source))) + { + Message = CoreTools.Translate("This package is already in the selected catalog."); + return; + } + catalog.Packages.Add(entry); + Message = ""; + } + + [RelayCommand] + private void RemovePackage() + { + if (!CanEdit || SelectedCatalog is not { } catalog || SelectedPackage is not { } entry) return; + catalog.Packages.Remove(entry); + SelectedPackage = null; + Message = ""; + } + + [RelayCommand(CanExecute = nameof(CanSearch))] + private async Task SearchAsync(CancellationToken cancellationToken) + { + if (SelectedManager is not { } manager) return; + string query = Query.Trim(); + IsSearching = true; + SearchResults.Clear(); + SelectedResult = null; + Message = ""; + try + { + if (!manager.IsReady()) + throw new InvalidOperationException(CoreTools.Translate("The selected package manager is not ready.")); + var packages = await Task.Run(() => manager.FindPackages(query), cancellationToken); + if (cancellationToken.IsCancellationRequested) return; + foreach (var package in packages) + { + if (SearchResults.Any(p => p.Matches(package))) continue; + SearchResults.Add(CatalogEntry.FromPackage(package)); + } + Message = CoreTools.Translate("{0} packages found", SearchResults.Count); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (Exception ex) + { + Logger.Error("Catalog package search failed."); + Logger.Error(ex); + SetErrorMessage(CoreTools.Translate("Catalog package search failed: {0}", ex.Message)); + } + finally + { + IsSearching = false; + } + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 54df2d50ec..6a79a980fe 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -35,6 +35,9 @@ public partial class MainWindowViewModel : ViewModelBase private readonly SoftwareUpdatesPage UpdatesPage; private readonly InstalledPackagesPage InstalledPage; private readonly PackageBundlesPage BundlesPage; + private SoftwareCatalogPage? CatalogPage; + private CatalogEditorPage? _catalogEditorPage; + public CatalogEditorViewModel? CatalogEditor { get; } private SettingsBasePage? SettingsPage; private SettingsBasePage? ManagersPage; private UniGetUILogPage? UniGetUILogPage; @@ -195,6 +198,8 @@ partial void OnGlobalSearchTextChanged(string value) if (_syncingSearch) return; if (CurrentPageContent is AbstractPackagesPage page) page.ViewModel.GlobalQueryText = value; + else if (CurrentPageContent is SoftwareCatalogPage catalogPage) + catalogPage.ApplyQuery(value); else if (CurrentPageContent is SettingsBasePage) UpdateSettingsSuggestions(value); else if (CurrentPageContent is Views.Pages.LogPages.OperationHistoryPage historyPage) @@ -206,6 +211,7 @@ public void ClearAllSearchQueries() DiscoverPage.ViewModel.ClearSearchQuery(); UpdatesPage.ViewModel.ClearSearchQuery(); InstalledPage.ViewModel.ClearSearchQuery(); + CatalogPage?.ApplyQuery(""); GlobalSearchText = ""; } @@ -420,7 +426,8 @@ public MainWindowViewModel() RegisterBannerToast(TelemetryWarner); RegisterBannerToast(PortableImportBanner); - DiscoverPage = new DiscoverSoftwarePage(); + CatalogEditor = Sidebar.SoftwareCatalogEnabled ? new CatalogEditorViewModel() : null; + DiscoverPage = new DiscoverSoftwarePage(CatalogEditor); UpdatesPage = new SoftwareUpdatesPage(); InstalledPage = new InstalledPackagesPage(); BundlesPage = new PackageBundlesPage(); @@ -647,6 +654,8 @@ public void LoadDefaultPage() "updates" => PageType.Updates, "installed" => PageType.Installed, "bundles" => PageType.Bundles, + "catalog" when Sidebar.SoftwareCatalogEnabled => PageType.Catalog, + "catalog-editor" when Sidebar.SoftwareCatalogEnabled => PageType.CatalogEditor, "settings" => PageType.Settings, _ => UpgradablePackagesLoader.Instance is { } l && l.Count() > 0 ? PageType.Updates : PageType.Discover, }; @@ -667,6 +676,9 @@ private Control GetPageForType(PageType type) => PageType.Updates => UpdatesPage, PageType.Installed => InstalledPage, PageType.Bundles => BundlesPage, + PageType.Catalog => CatalogPage ??= new SoftwareCatalogPage(), + PageType.CatalogEditor => _catalogEditorPage ??= new CatalogEditorPage( + CatalogEditor ?? throw new InvalidOperationException("Catalog Editor is disabled.")), PageType.Settings => SettingsPage ??= new SettingsBasePage(false), PageType.Managers => ManagersPage ??= new SettingsBasePage(true), PageType.OwnLog => UniGetUILogPage ??= new UniGetUILogPage(), @@ -679,25 +691,35 @@ private Control GetPageForType(PageType type) => }; public static PageType GetNextPage(PageType type) => + GetNextPage(type, Settings.Get(Settings.K.EnableSoftwareCatalog)); + + internal static PageType GetNextPage(PageType type, bool catalogEnabled) => type switch { PageType.Discover => PageType.Updates, PageType.Updates => PageType.Installed, PageType.Installed => PageType.Bundles, - PageType.Bundles => PageType.Settings, + PageType.Bundles => catalogEnabled ? PageType.Catalog : PageType.Settings, + PageType.Catalog => catalogEnabled ? PageType.CatalogEditor : PageType.Settings, + PageType.CatalogEditor => PageType.Settings, PageType.Settings => PageType.Managers, PageType.Managers => PageType.Discover, _ => PageType.Discover, }; public static PageType GetPreviousPage(PageType type) => + GetPreviousPage(type, Settings.Get(Settings.K.EnableSoftwareCatalog)); + + internal static PageType GetPreviousPage(PageType type, bool catalogEnabled) => type switch { PageType.Discover => PageType.Managers, PageType.Updates => PageType.Discover, PageType.Installed => PageType.Updates, PageType.Bundles => PageType.Installed, - PageType.Settings => PageType.Bundles, + PageType.Catalog => PageType.Bundles, + PageType.CatalogEditor => catalogEnabled ? PageType.Catalog : PageType.Bundles, + PageType.Settings => catalogEnabled ? PageType.CatalogEditor : PageType.Bundles, PageType.Managers => PageType.Settings, _ => PageType.Discover, }; @@ -710,6 +732,15 @@ public async Task NavigateToAsync( bool toHistory = true, CancellationToken cancellationToken = default) { + if (newPage_t is PageType.Catalog or PageType.CatalogEditor && !Sidebar.SoftwareCatalogEnabled) + { + Logger.Warn("Software Catalog navigation was rejected because the experimental feature is disabled."); + MainWindow.Instance?.ShowBanner( + CoreTools.Translate("Software Catalog is disabled"), + CoreTools.Translate("Enable Software Catalog in Experimental settings and developer options, then restart UniGetUI."), + MainWindow.RuntimeNotificationLevel.Error); + return false; + } if (newPage_t is PageType.About) { _ = ShowAboutDialog(); return true; } if (newPage_t is PageType.Quit) { MainWindow.Instance?.QuitApplication(); return true; } @@ -795,6 +826,8 @@ public async Task NavigateToAsync( PageType.Updates => CoreTools.Translate("Software Updates"), PageType.Installed => CoreTools.Translate("Installed Packages"), PageType.Bundles => CoreTools.Translate("Package Bundles"), + PageType.Catalog => CoreTools.Translate("Software Catalog"), + PageType.CatalogEditor => CoreTools.Translate("Catalog Editor"), PageType.Settings => CoreTools.Translate("Settings"), PageType.Managers => CoreTools.Translate("Package Managers"), PageType.OwnLog => CoreTools.Translate("UniGetUI Log"), @@ -907,8 +940,14 @@ public async Task CanShutdownAsync(CancellationToken cancellationToken = d await _navigationSemaphore.WaitAsync(cancellationToken); try { - return CurrentPageContent is not IAsyncLeaveGuard guard - || await guard.CanLeaveAsync(PageLeaveReason.Shutdown, cancellationToken); + if (CurrentPageContent is IAsyncLeaveGuard guard + && !await guard.CanLeaveAsync(PageLeaveReason.Shutdown, cancellationToken)) + return false; + if (CatalogEditor is null) return true; + if (CatalogEditor.IsBusy) return false; + if (!CatalogEditor.IsDirty) return true; + _catalogEditorPage ??= new CatalogEditorPage(CatalogEditor); + return await _catalogEditorPage.ConfirmDiscardAsync(); } finally { diff --git a/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs index 777e654275..4f8bf290de 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs @@ -10,6 +10,8 @@ namespace UniGetUI.Avalonia.ViewModels; public partial class SidebarViewModel : ViewModelBase { + public bool SoftwareCatalogEnabled { get; } = Settings.Get(Settings.K.EnableSoftwareCatalog); + // ─── Badge properties ───────────────────────────────────────────────────── [ObservableProperty] [NotifyPropertyChangedFor(nameof(UpdatesBadgeText))] diff --git a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs new file mode 100644 index 0000000000..a8bdb75d03 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs @@ -0,0 +1,449 @@ +using System.Collections.ObjectModel; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UniGetUI.Avalonia.Models; +using UniGetUI.Avalonia.Views; +using UniGetUI.Core.Logging; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Operations; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.PackageLoader; +using UniGetUI.PackageOperations; + +namespace UniGetUI.Avalonia.ViewModels.Pages; + +public partial class SoftwareCatalogViewModel : ViewModelBase +{ + private readonly Dictionary> _sources = new(StringComparer.OrdinalIgnoreCase); + private readonly List _allPackages = []; + private readonly Dictionary<(string Manager, string Source), SourceTreeNode> _catalogSources = []; + private string? _sourceCatalogId; + private bool _updatingSourceSelection; + public ObservableCollection Packages { get; } = []; + public ObservableCollection Catalogs { get; } = []; + public ObservableCollection SourceNodes { get; } = []; + + [ObservableProperty] + private CatalogDefinition? _selectedCatalog; + + [ObservableProperty] + private bool _hideUnavailablePackages = true; + + [ObservableProperty] + private string _query = ""; + + [ObservableProperty] + private bool _isFilterPaneOpen; + + [ObservableProperty] + private SearchMode _searchMode = SearchMode.Both; + + public bool SearchMode_Name { get => SearchMode == SearchMode.Name; set { if (value) SearchMode = SearchMode.Name; } } + public bool SearchMode_Id { get => SearchMode == SearchMode.Id; set { if (value) SearchMode = SearchMode.Id; } } + public bool SearchMode_Both { get => SearchMode == SearchMode.Both; set { if (value) SearchMode = SearchMode.Both; } } + public bool SearchMode_Exact { get => SearchMode == SearchMode.Exact; set { if (value) SearchMode = SearchMode.Exact; } } + + partial void OnSearchModeChanged(SearchMode value) + { + OnPropertyChanged(nameof(SearchMode_Name)); + OnPropertyChanged(nameof(SearchMode_Id)); + OnPropertyChanged(nameof(SearchMode_Both)); + OnPropertyChanged(nameof(SearchMode_Exact)); + ApplyAvailabilityFilter(); + } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsListView))] + [NotifyPropertyChangedFor(nameof(IsGridView))] + [NotifyPropertyChangedFor(nameof(IsIconsView))] + [NotifyPropertyChangedFor(nameof(IsTilesView))] + private int _viewModeIndex = 3; + + public bool IsListView => ViewModeIndex == 0; + public bool IsGridView => ViewModeIndex == 1; + public bool IsIconsView => ViewModeIndex == 2; + public bool IsTilesView => ViewModeIndex == 3; + + partial void OnQueryChanged(string value) => ApplyAvailabilityFilter(); + partial void OnHideUnavailablePackagesChanged(bool value) => ApplyAvailabilityFilter(); + + public bool HasHiddenPackages + { + get + { + if (!HideUnavailablePackages) return false; + string query = Query.Trim(); + return _allPackages.Any(p => p.UnavailableReason is not null + && _catalogSources.TryGetValue(SourceKey(p.Entry), out var source) + && source.IsSelected + && MatchesQuery(p.Entry, query)); + } + } + public string PackageCountText => CoreTools.Translate("{0} packages found", Packages.Count); + public string EmptyMessage => CoreTools.Translate(_allPackages.Count > 0 + ? "No packages are available with the current filters." + : "This catalog contains no packages."); + + public bool IsEmpty => !IsLoading && !HasError && Packages.Count == 0; + + partial void OnIsLoadingChanged(bool value) => OnPropertyChanged(nameof(IsEmpty)); + + partial void OnSelectedCatalogChanged(CatalogDefinition? value) + { + BuildSourceNodes(value); + _allPackages.Clear(); + foreach (var entry in value?.Packages ?? []) + { + var tile = new CatalogTileViewModel(entry); + _allPackages.Add(tile); + var manager = FindManager(entry); + var source = FindSource(entry); + if (manager is null || source is null) continue; + var package = InstalledPackagesLoader.Instance?.Packages.FirstOrDefault(entry.MatchesInstalled) + ?? new Package(entry.Name, entry.Id, entry.Version, source, manager); + _ = tile.LoadIconAsync(package); + } + UpdateStates(); + OnPropertyChanged(nameof(IsEmpty)); + } + + [ObservableProperty] + private bool _isLoading; + + [ObservableProperty] + private string _errorMessage = ""; + + public bool HasError => ErrorMessage.Length > 0; + + partial void OnErrorMessageChanged(string value) + { + OnPropertyChanged(nameof(HasError)); + OnPropertyChanged(nameof(IsEmpty)); + if (value.Length > 0) + Avalonia.Infrastructure.AccessibilityAnnouncementService.Announce( + value, global::Avalonia.Automation.AutomationLiveSetting.Assertive); + } + + [RelayCommand] + private async Task EditAsync() + { + try + { + var owner = MainWindow.Instance + ?? throw new InvalidOperationException("The catalog editor requires an application window."); + owner.Navigate(PageType.CatalogEditor); + await Task.CompletedTask; + } + catch (Exception ex) + { + Logger.Error("Could not open the software catalog editor."); + Logger.Error(ex); + ErrorMessage = CoreTools.Translate("The catalog editor could not be opened: {0}", ex.Message); + } + } + + [RelayCommand] + public async Task RefreshAsync() + { + if (IsLoading) return; + IsLoading = true; + ErrorMessage = ""; + UpdateStates(); + try + { + var catalogs = await SoftwareCatalog.LoadAsync(); + var selectedId = SelectedCatalog?.Id; + _sources.Clear(); + SelectedCatalog = null; + Catalogs.Clear(); + + foreach (var manager in catalogs.SelectMany(c => c.Packages).Select(FindManager).OfType().Distinct()) + { + if (!manager.IsReady()) continue; + _sources[manager.Id] = manager.Capabilities.SupportsCustomSources + ? await Task.Run(manager.SourcesHelper.GetSources) + : [manager.DefaultSource]; + } + + foreach (var catalog in catalogs) + Catalogs.Add(catalog); + SelectedCatalog = Catalogs.FirstOrDefault(c => string.Equals(c.Id, selectedId, StringComparison.OrdinalIgnoreCase)) + ?? Catalogs.FirstOrDefault(); + } + catch (Exception ex) + { + Logger.Error("Could not load the software catalog."); + Logger.Error(ex); + SelectedCatalog = null; + Catalogs.Clear(); + Packages.Clear(); + ErrorMessage = CoreTools.Translate("The software catalog could not be loaded. Ask an administrator to check {0}, then try again.", SoftwareCatalog.FilePath); + } + finally + { + IsLoading = false; + UpdateStates(); + } + } + + private static IPackageManager? FindManager(CatalogEntry entry) => + PEInterface.Managers.FirstOrDefault(entry.MatchesManager); + + private static (string Manager, string Source) SourceKey(CatalogEntry entry) => + ((FindManager(entry)?.Id ?? entry.ManagerName).ToUpperInvariant(), entry.Source.ToUpperInvariant()); + + private void BuildSourceNodes(CatalogDefinition? catalog) + { + SourceNodes.Clear(); + if (catalog is null) return; + var previous = string.Equals(_sourceCatalogId, catalog.Id, StringComparison.OrdinalIgnoreCase) + ? _catalogSources.ToDictionary(p => p.Key, p => p.Value.IsSelected) + : []; + _sourceCatalogId = catalog.Id; + _catalogSources.Clear(); + _updatingSourceSelection = true; + try + { + foreach (var group in catalog.Packages.GroupBy(p => SourceKey(p).Manager)) + { + var root = new SourceTreeNode + { + PackageName = FindManager(group.First())?.DisplayName ?? group.First().ManagerName, + IsExpanded = true, + }; + foreach (var entry in group.DistinctBy(SourceKey)) + { + var key = SourceKey(entry); + var child = new SourceTreeNode + { + PackageName = entry.Source, + Source = entry.Source, + IsSelected = previous.GetValueOrDefault(key, true), + }; + root.Children.Add(child); + _catalogSources.Add(key, child); + child.PropertyChanged += (_, e) => + { + if (_updatingSourceSelection || e.PropertyName != nameof(SourceTreeNode.IsSelected)) return; + _updatingSourceSelection = true; + try { root.IsSelected = root.Children.All(c => c.IsSelected); } + finally { _updatingSourceSelection = false; } + ApplyAvailabilityFilter(); + }; + } + root.IsSelected = root.Children.All(c => c.IsSelected); + root.PropertyChanged += (_, e) => + { + if (_updatingSourceSelection || e.PropertyName != nameof(SourceTreeNode.IsSelected)) return; + _updatingSourceSelection = true; + try + { + foreach (var child in root.Children) child.IsSelected = root.IsSelected; + } + finally { _updatingSourceSelection = false; } + ApplyAvailabilityFilter(); + }; + SourceNodes.Add(root); + } + } + finally { _updatingSourceSelection = false; } + } + + [RelayCommand] + private void SelectAllSources() => SetSourceSelection(true); + + [RelayCommand] + private void ClearSourceSelection() => SetSourceSelection(false); + + private void SetSourceSelection(bool selected) + { + _updatingSourceSelection = true; + try + { + foreach (var root in SourceNodes) + { + root.IsSelected = selected; + foreach (var child in root.Children) child.IsSelected = selected; + } + } + finally { _updatingSourceSelection = false; } + ApplyAvailabilityFilter(); + } + + private IManagerSource? FindSource(CatalogEntry entry) => + FindManager(entry) is { } manager && _sources.TryGetValue(manager.Id, out var sources) + ? sources.FirstOrDefault(s => string.Equals(s.Name, entry.Source, StringComparison.OrdinalIgnoreCase)) + : null; + + public void UpdateStates() + { + if (_allPackages.Count == 0) + { + ApplyAvailabilityFilter(); + return; + } + var loader = InstalledPackagesLoader.Instance; + var installed = new CatalogPackageIdentitySet(loader?.Packages ?? [], installedInventory: true); + var pending = new CatalogPackageIdentitySet( + Avalonia.Infrastructure.AvaloniaOperationRegistry.OperationViewModels + .Select(o => o.Operation) + .OfType() + .Where(o => o.Status is OperationStatus.InQueue or OperationStatus.Running) + .Select(o => o.Package)); + foreach (var tile in _allPackages) + { + var manager = FindManager(tile.Entry); + var source = FindSource(tile.Entry); + string? unavailableReason = manager is null + ? CoreTools.Translate("The package manager {0} is not available on this platform.", tile.Entry.ManagerName) + : !manager.IsEnabled() + ? CoreTools.Translate("The package manager {0} is disabled. Enable it in Package Managers.", manager.DisplayName) + : !manager.IsReady() + ? CoreTools.Translate("The package manager {0} is not available or is still initializing.", manager.DisplayName) + : source is null + ? CoreTools.Translate("The source {0} is not enabled for {1}. Add it in Package Managers.", tile.Entry.Source, manager.DisplayName) + : null; + tile.UpdateState( + installed.Contains(tile.Entry), + unavailableReason, + !IsLoading && loader is { HasPendingInitialLoad: false, IsLoading: false } + && manager is { LastInstalledListingFailed: false }, + pending.Contains(tile.Entry)); + } + ApplyAvailabilityFilter(); + } + + internal void ApplyAvailabilityFilter() + { + string query = Query.Trim(); + var visible = _allPackages.Where(p => (!HideUnavailablePackages || p.UnavailableReason is null) + && _catalogSources.TryGetValue(SourceKey(p.Entry), out var source) && source.IsSelected + && MatchesQuery(p.Entry, query)).ToArray(); + if (!Packages.SequenceEqual(visible)) + { + Packages.Clear(); + foreach (var package in visible) + Packages.Add(package); + } + OnPropertyChanged(nameof(HasHiddenPackages)); + OnPropertyChanged(nameof(EmptyMessage)); + OnPropertyChanged(nameof(IsEmpty)); + OnPropertyChanged(nameof(PackageCountText)); + } + + private bool MatchesQuery(CatalogEntry entry, string query) => query.Length == 0 || SearchMode switch + { + SearchMode.Name => entry.Name.Contains(query, StringComparison.OrdinalIgnoreCase), + SearchMode.Id => entry.Id.Contains(query, StringComparison.OrdinalIgnoreCase), + SearchMode.Exact => string.Equals(entry.Name, query, StringComparison.OrdinalIgnoreCase) + || string.Equals(entry.Id, query, StringComparison.OrdinalIgnoreCase), + _ => entry.Name.Contains(query, StringComparison.OrdinalIgnoreCase) + || entry.Id.Contains(query, StringComparison.OrdinalIgnoreCase), + }; + + [RelayCommand] + private async Task InstallAsync(CatalogTileViewModel? tile) + { + UpdateStates(); + if (tile is null || !tile.CanInstall) return; + tile.IsBusy = true; + try + { + var manager = FindManager(tile.Entry) + ?? throw new InvalidOperationException("The catalog package manager is unavailable."); + var matches = await Task.Run(() => manager.FindPackages(tile.Entry.Id)); + var package = matches.FirstOrDefault(tile.Entry.Matches) + ?? throw new InvalidOperationException($"Package {tile.Entry.Id} was not found in {tile.Entry.ManagerName}/{tile.Entry.Source}."); + UpdateStates(); + if (tile.IsInstalled || tile.UnavailableReason is not null || !tile.InventoryKnown || tile.IsPending) return; + await PackagesPageViewModel.LaunchInstall([package]); + } + catch (Exception ex) + { + Logger.Error($"Could not install catalog package {tile.Entry.Id}."); + Logger.Error(ex); + MainWindow.Instance?.ShowBanner( + CoreTools.Translate("Unable to install {0}", tile.Entry.Name), + CoreTools.Translate("The catalog package could not be found or installed. Check that its identifier and source are correct, then try again."), + MainWindow.RuntimeNotificationLevel.Error); + } + finally + { + tile.IsBusy = false; + UpdateStates(); + } + } +} + +public partial class CatalogTileViewModel(CatalogEntry entry) : ViewModelBase +{ + public CatalogEntry Entry { get; } = entry; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasCustomIcon))] + private Bitmap? _iconBitmap; + + public bool HasCustomIcon => IconBitmap is not null; + + internal async Task LoadIconAsync(IPackage package) + { + try + { + var bitmap = await PackageWrapper.LoadSharedIconAsync(package); + await Dispatcher.UIThread.InvokeAsync(() => IconBitmap = bitmap); + } + catch (Exception ex) + { + Logger.Warn($"Could not load the catalog icon for {Entry.Id}."); + Logger.Warn(ex); + } + } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowInstall))] + [NotifyPropertyChangedFor(nameof(CanInstall))] + private bool _isInstalled; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(TitleOpacity))] + [NotifyPropertyChangedFor(nameof(CanInstall))] + [NotifyPropertyChangedFor(nameof(StatusText))] + private string? _unavailableReason; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanInstall))] + [NotifyPropertyChangedFor(nameof(StatusText))] + private bool _inventoryKnown; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanInstall))] + [NotifyPropertyChangedFor(nameof(StatusText))] + private bool _isPending; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanInstall))] + [NotifyPropertyChangedFor(nameof(StatusText))] + private bool _isBusy; + + public bool ShowInstall => !IsInstalled; + public bool CanInstall => !IsInstalled && UnavailableReason is null && InventoryKnown && !IsPending && !IsBusy; + public double TitleOpacity => UnavailableReason is null ? 1 : 0.45; + public string StatusText => UnavailableReason + ?? (IsBusy ? CoreTools.Translate("Loading packages") + : IsPending ? CoreTools.Translate("Installing") + : !InventoryKnown ? CoreTools.Translate("Waiting for installed package information. Refresh Installed Packages if this persists.") + : ""); + + public void UpdateState(bool installed, string? unavailableReason, bool inventoryKnown, bool pending) + { + IsInstalled = installed; + UnavailableReason = unavailableReason; + InventoryKnown = inventoryKnown; + IsPending = pending; + } +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/CatalogSelector.cs b/src/UniGetUI.Avalonia/Views/Controls/CatalogSelector.cs new file mode 100644 index 0000000000..bc64629380 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/CatalogSelector.cs @@ -0,0 +1,80 @@ +using System.ComponentModel; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Layout; +using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.Views.Controls; + +public sealed class CatalogSelector : ComboBox +{ + protected override Type StyleKeyOverride => typeof(ComboBox); + + private readonly CatalogEditorViewModel _document; + private bool _syncing; + + public CatalogSelector(CatalogEditorViewModel document) + { + _document = document; + MinWidth = 180; + MaxWidth = 300; + VerticalAlignment = VerticalAlignment.Center; + VerticalContentAlignment = VerticalAlignment.Center; + PlaceholderText = CoreTools.Translate("Select a catalog"); + AutomationProperties.SetName(this, CoreTools.Translate("Catalog")); + ItemsSource = document.Catalogs; + ItemTemplate = new FuncDataTemplate((catalog, _) => + { + var label = new TextBlock { Text = catalog?.Name }; + if (catalog is not null) + { + void UpdateName(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(CatalogEditorCatalog.Name)) + label.Text = catalog.Name; + } + label.AttachedToVisualTree += (_, _) => + { + label.Text = catalog.Name; + catalog.PropertyChanged += UpdateName; + }; + label.DetachedFromVisualTree += (_, _) => catalog.PropertyChanged -= UpdateName; + } + return label; + }, supportsRecycling: false); + SelectionChanged += (_, _) => + { + if (!_syncing && document.CanEdit) + document.SelectedCatalog = SelectedItem as CatalogEditorCatalog; + }; + document.PropertyChanged += (_, e) => + { + if (e.PropertyName is nameof(CatalogEditorViewModel.SelectedCatalog) + or nameof(CatalogEditorViewModel.CanEdit)) + SyncSelection(); + }; + SyncSelection(); + } + + private void SyncSelection() + { + _syncing = true; + SelectedItem = _document.SelectedCatalog; + IsEnabled = _document.CanEdit; + _syncing = false; + } + + public void ShowFlyoutAt(Control anchor) + { + var flyout = new MenuFlyout(); + foreach (var catalog in _document.Catalogs) + { + var item = new MenuItem { Header = catalog.Name, IsEnabled = _document.CanEdit }; + item.Click += (_, _) => _document.SelectedCatalog = catalog; + flyout.Items.Add(item); + } + flyout.ShowAt(anchor); + } +} diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs index 15d1a11b53..5c04322ffd 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs @@ -43,6 +43,8 @@ public enum PageType About, Quit, Null, // Used for initializers + Catalog, + CatalogEditor, } public partial class MainWindow : Window @@ -170,6 +172,7 @@ public enum RuntimeNotificationLevel private MainWindowViewModel ViewModel => (MainWindowViewModel)DataContext!; public PageType CurrentPage => ViewModel.CurrentPage_t; + public bool SoftwareCatalogEnabled => ViewModel.Sidebar.SoftwareCatalogEnabled; public MainWindow() { @@ -293,8 +296,8 @@ private void Window_KeyDownTunnel(object? sender, KeyEventArgs e) _focusSidebarSelectionOnNextPageChange = true; ViewModel.NavigateTo(e.KeyModifiers.HasFlag(KeyModifiers.Shift) - ? MainWindowViewModel.GetPreviousPage(ViewModel.CurrentPage_t) - : MainWindowViewModel.GetNextPage(ViewModel.CurrentPage_t)); + ? MainWindowViewModel.GetPreviousPage(ViewModel.CurrentPage_t, SoftwareCatalogEnabled) + : MainWindowViewModel.GetNextPage(ViewModel.CurrentPage_t, SoftwareCatalogEnabled)); e.Handled = true; } diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Experimental.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Experimental.axaml index f6bde943d5..08b4a9d0fa 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Experimental.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Experimental.axaml @@ -79,6 +79,14 @@ Text="{t:Translate Text='When batch installing packages from a bundle, install also packages that are already installed'}" CornerRadius="8"/> + + + + diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml.cs index d6b2e7af69..b97e0d20bf 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml.cs @@ -43,6 +43,11 @@ public Interface_P() StartupPageSelector.AddItem(CoreTools.Translate("Software Updates"), "updates"); StartupPageSelector.AddItem(CoreTools.Translate("Installed Packages"), "installed"); StartupPageSelector.AddItem(CoreTools.Translate("Package Bundles"), "bundles"); + if (CoreSettings.Get(CoreSettings.K.EnableSoftwareCatalog)) + { + StartupPageSelector.AddItem(CoreTools.Translate("Software Catalog"), "catalog"); + StartupPageSelector.AddItem(CoreTools.Translate("Catalog Editor"), "catalog-editor"); + } StartupPageSelector.AddItem(CoreTools.Translate("Settings"), "settings"); StartupPageSelector.SettingName = CoreSettings.K.StartupPage; StartupPageSelector.Text = CoreTools.Translate("UniGetUI startup page:"); diff --git a/src/UniGetUI.Avalonia/Views/SidebarView.axaml b/src/UniGetUI.Avalonia/Views/SidebarView.axaml index a384bd0071..74155e4cb7 100644 --- a/src/UniGetUI.Avalonia/Views/SidebarView.axaml +++ b/src/UniGetUI.Avalonia/Views/SidebarView.axaml @@ -145,6 +145,30 @@ + + + + + + + + + + + + + + diff --git a/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs b/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs index be809c8f47..0e4e918e10 100644 --- a/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs @@ -78,6 +78,12 @@ protected override void OnDataContextChanged(EventArgs e) base.OnDataContextChanged(e); if (DataContext is SidebarViewModel vm) { + if (!vm.SoftwareCatalogEnabled) + { + NavListBox.Items.Remove(CatalogNavBtn); + NavListBox.Items.Remove(CatalogEditorNavBtn); + } + vm.PropertyChanged += (_, args) => { if (args.PropertyName == nameof(SidebarViewModel.SelectedPageType)) @@ -101,6 +107,8 @@ private void SyncListBoxSelection(PageType page) PageType.Updates => UpdatesNavBtn, PageType.Installed => InstalledNavBtn, PageType.Bundles => BundlesNavBtn, + PageType.Catalog => CatalogNavBtn, + PageType.CatalogEditor => CatalogEditorNavBtn, _ => null, }; FooterNavListBox.SelectedItem = page switch diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml index 6f6caa6bd2..ff0525d7de 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml @@ -232,7 +232,7 @@ - + + + @@ -1168,7 +1170,7 @@ - diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs index 1d517783a5..ab67187b28 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs @@ -43,7 +43,7 @@ public abstract partial class AbstractPackagesPage : UserControl, private static readonly TimeSpan FilterAnimationDuration = TimeSpan.FromMilliseconds(300); private readonly MenuFlyout _toolbarOverflowFlyout = new(); - protected AbstractPackagesPage(PackagesPageData data) + protected AbstractPackagesPage(PackagesPageData data, Action? configureToolbar = null) { // InitializeComponent BEFORE setting DataContext so that the svg:Svg // Path binding has no context during XamlIlPopulate — Skia crashes if @@ -98,6 +98,7 @@ or nameof(PackagesPageViewModel.SortAscending)) // Build the toolbar now that both AXAML controls and the ViewModel are ready GenerateToolBar(ViewModel); + configureToolbar?.Invoke(ViewModel); InitializeToolbarOverflow(); // Double-click a package row → show details. DataGrid-level gestures also receive @@ -266,7 +267,12 @@ private void PopulateToolbarOverflowFlyout() continue; } - var item = new MenuItem { Header = entry.Label, Icon = LoadMenuIcon(entry.IconName) }; + var item = new MenuItem + { + Header = entry.Label, + Icon = LoadMenuIcon(entry.IconName), + IsEnabled = control.IsEnabled, + }; if (entry.InvokeAt is { } invokeAt) item.Click += (_, _) => Dispatcher.UIThread.Post(() => invokeAt(ToolbarOverflowButton)); else if (entry.Invoke is { } invoke) item.Click += (_, _) => invoke(); @@ -344,6 +350,14 @@ protected void SetMainButtonDropdown(MenuFlyout flyout) MainToolbarButtonDropdown.Flyout = flyout; } + protected void SetPageOptions(Control control) => PageOptions.Content = control; + + protected void SetMainButtonState(bool enabled, bool showDropdown = true) + { + MainToolbarButton.IsEnabled = enabled; + MainToolbarButtonDropdown.IsVisible = showDropdown; + } + // ─── Package selection ──────────────────────────────────────────────────── /// /// Returns the focused row's package, or the single checked package if diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/CatalogEditorPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/CatalogEditorPage.cs new file mode 100644 index 0000000000..24045bbc7a --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/CatalogEditorPage.cs @@ -0,0 +1,322 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using UniGetUI.Avalonia.Models; +using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Avalonia.ViewModels.Pages; +using UniGetUI.Avalonia.Views.Controls; +using UniGetUI.Avalonia.Views.DialogPages; +using UniGetUI.Core.Logging; +using UniGetUI.Core.Tools; +using UniGetUI.Interface.Telemetry; +using UniGetUI.PackageEngine; +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.PackageLoader; + +namespace UniGetUI.Avalonia.Views.Pages; + +public sealed class CatalogEditorPage : AbstractPackagesPage +{ + private readonly CatalogEditorViewModel _document; + private readonly CatalogPackagesLoader _loader; + private CatalogEditorCatalog? _observedCatalog; + private readonly TextBlock _status = new() { TextWrapping = TextWrapping.Wrap }; + private Button? _renameButton; + private Button? _discardButton; + private bool _pickingFile; + + public CatalogEditorPage(CatalogEditorViewModel document) + : this(document, new CatalogPackagesLoader()) { } + + private CatalogEditorPage(CatalogEditorViewModel document, CatalogPackagesLoader loader) + : base(new PackagesPageData + { + PageName = "SoftwarePages.CatalogEditorPage", + PageTitle = CoreTools.Translate("Catalog Editor"), + IconName = "package_managers", + PageRole = OperationType.Install, + Loader = loader, + DisableAutomaticPackageLoadOnStart = true, + DisableReload = true, + NoPackages_BackgroundText = CoreTools.Translate("This catalog contains no packages."), + NoPackages_SourcesText = CoreTools.Translate("Add packages from Discover using Add selection to catalog."), + NoPackages_SubtitleText_Base = CoreTools.Translate("No packages in this catalog"), + MainSubtitle_StillLoading = CoreTools.Translate("Loading catalog"), + NoMatches_BackgroundText = CoreTools.Translate("No packages match your filters"), + }, vm => + { + var selector = new CatalogSelector(document) { MinWidth = 260, MaxWidth = double.PositiveInfinity }; + vm.AddToolbarEntry(new ToolbarEntry(selector, "add_to", CoreTools.Translate("Select a catalog"), + null, selector.ShowFlyoutAt), index: vm.ToolbarEntries.Count - 1); + }) + { + _document = document; + _loader = loader; + var options = new StackPanel { Spacing = 8, Margin = new Thickness(8, 0, 8, 12) }; + options.Children.Add(_status); + SetPageOptions(options); + + document.PropertyChanged += DocumentChanged; + SyncCatalog(); + SyncStatus(); + } + + protected override void GenerateToolBar(PackagesPageViewModel vm) + { + SetMainButton("save_as", CoreTools.Translate("Save"), () => _ = SaveAsync(false)); + vm.AddToolbarButton("add_to", CoreTools.Translate("New"), () => _ = NewAsync()); + vm.AddToolbarButton("open_folder", CoreTools.Translate("Open file"), () => _ = OpenAsync()); + vm.AddToolbarButton("save_as", CoreTools.Translate("Save as"), () => _ = SaveAsync(true)); + _discardButton = vm.AddToolbarButton("undo", CoreTools.Translate("Discard changes"), + () => _ = DiscardChangesAsync()); + vm.AddToolbarButton("add_to", CoreTools.Translate("New catalog"), () => + { + if (_document.CanEdit && !_pickingFile) _document.NewCatalog(); + }); + vm.AddToolbarButton("delete", CoreTools.Translate("Remove selection from catalog"), RemoveSelection); + vm.AddToolbarButton("info_round", CoreTools.Translate("Package details"), + () => _ = ShowDetailsForPackage(SelectedItem)); + _renameButton = vm.AddToolbarButton("options", CoreTools.Translate("Rename catalog"), + () => _ = RenameCatalogAsync()); + } + + protected override ContextMenu GenerateContextMenu() + { + var menu = new ContextMenu(); + var remove = new MenuItem { Header = CoreTools.Translate("Remove selection from catalog") }; + remove.Click += (_, _) => RemoveSelection(); + menu.Items.Add(remove); + return menu; + } + + protected override void PerformMainPackageAction(IPackage? package) => _ = ShowDetailsForPackage(package); + + protected override Task ShowInstallationOptionsForPackage(IPackage? package) => ShowDetailsForPackage(package); + + protected override async Task ShowDetailsForPackage(IPackage? package) + { + if (package is null || GetMainWindow() is not { } window) return; + if (package is InvalidImportedPackage) + { + window.ShowBanner(CoreTools.Translate("Package unavailable"), + CoreTools.Translate("Enable this package's manager and source to view its details."), + MainWindow.RuntimeNotificationLevel.Error); + return; + } + var dialog = new PackageDetailsWindow(package, OperationType.None, TEL_InstallReferral.FROM_BUNDLE); + await dialog.ShowDialog(window); + } + + public override void OnEnter() => _ = LoadInitialAsync(); + + private async Task LoadInitialAsync() + { + await _document.EnsureLoadedAsync(); + SyncCatalog(); + SyncStatus(); + } + + private void DocumentChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(CatalogEditorViewModel.SelectedCatalog)) + SyncCatalog(); + SyncStatus(); + } + + private void SyncCatalog() + { + _observedCatalog?.Packages.CollectionChanged -= PackagesChanged; + _observedCatalog = _document.SelectedCatalog; + _observedCatalog?.Packages.CollectionChanged += PackagesChanged; + _loader.Replace(_observedCatalog?.Packages ?? []); + } + + private void PackagesChanged(object? sender, NotifyCollectionChangedEventArgs e) => + _loader.Replace(_observedCatalog?.Packages ?? []); + + private void SyncStatus() + { + _status.Text = (_document.IsDirty ? CoreTools.Translate("Unsaved changes") + "\n" : "") + + _document.Message; + _renameButton?.IsEnabled = _document.CanEdit && _document.HasSelectedCatalog && !_pickingFile; + _discardButton?.IsEnabled = _document.CanDiscardChanges && !_pickingFile; + SetMainButtonState(_document.CanEdit && !_pickingFile, showDropdown: false); + } + + private async Task RenameCatalogAsync() + { + if (!_document.CanEdit || _pickingFile || _document.SelectedCatalog is not { } catalog + || GetMainWindow() is not { } window) return; + var input = new TextBox { Text = catalog.Name, MinWidth = 280 }; + AutomationProperties.SetName(input, CoreTools.Translate("Catalog name")); + var error = new TextBlock + { + Text = CoreTools.Translate("Enter a catalog name."), + IsVisible = false, + TextWrapping = TextWrapping.Wrap, + }; + var body = new StackPanel { Spacing = 8 }; + body.Children.Add(new TextBlock { Text = CoreTools.Translate("Catalog name") }); + body.Children.Add(input); + body.Children.Add(error); + var dialog = new ImmersiveConfirmationDialog(CoreTools.Translate("Rename catalog"), body, + CoreTools.Translate("Rename"), CoreTools.Translate("Cancel")) + { FocusPrimaryButton = false }; + var confirm = dialog.GetControl + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareCatalogPage.axaml.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareCatalogPage.axaml.cs new file mode 100644 index 0000000000..3b26bef9f8 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareCatalogPage.axaml.cs @@ -0,0 +1,49 @@ +using Avalonia.Controls; +using Avalonia.Threading; +using UniGetUI.Avalonia.ViewModels.Pages; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.PackageLoader; + +namespace UniGetUI.Avalonia.Views.Pages; + +public partial class SoftwareCatalogPage : UserControl, IEnterLeaveListener, ISearchBoxPage +{ + private readonly SoftwareCatalogViewModel _viewModel = new(); + private readonly DispatcherTimer _stateTimer = new() { Interval = TimeSpan.FromSeconds(2) }; + private InstalledPackagesLoader? _loader; + + public string QueryBackup + { + get => _viewModel.Query; + set => _viewModel.Query = value; + } + + public string SearchBoxPlaceholder => CoreTools.Translate("Search for packages"); + public void ApplyQuery(string query) => _viewModel.Query = query; + public void SearchBox_QuerySubmitted(object? sender, EventArgs? e) { } + + public SoftwareCatalogPage() + { + InitializeComponent(); + DataContext = _viewModel; + _stateTimer.Tick += (_, _) => _viewModel.UpdateStates(); + } + + public void OnEnter() + { + _loader = InstalledPackagesLoader.Instance; + _loader?.FinishedLoading += OnInventoryLoaded; + _stateTimer.Start(); + _ = _viewModel.RefreshAsync(); + } + + public void OnLeave() + { + _stateTimer.Stop(); + _loader?.FinishedLoading -= OnInventoryLoaded; + _loader = null; + } + + private void OnInventoryLoaded(object? sender, EventArgs e) => + Dispatcher.UIThread.Post(() => _ = _viewModel.RefreshAsync()); +} diff --git a/src/UniGetUI.Core.Data.Tests/CoreTests.cs b/src/UniGetUI.Core.Data.Tests/CoreTests.cs index 2eafee8b68..364a14f1da 100644 --- a/src/UniGetUI.Core.Data.Tests/CoreTests.cs +++ b/src/UniGetUI.Core.Data.Tests/CoreTests.cs @@ -22,6 +22,17 @@ public void CheckDirectoryAttributes(string directory) ); } + [Fact] + public void GlobalDirectoryResolvesToTheMachineWidePlatformPath() + { + string expected = OperatingSystem.IsWindows() + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Devolutions", "UniGetUI") + : OperatingSystem.IsLinux() ? "/etc/UniGetUI" + : "/Library/Application Support/Devolutions/UniGetUI"; + Assert.Equal(expected, CoreData.UniGetUIGlobalDirectory); + Assert.True(Path.IsPathFullyQualified(CoreData.UniGetUIGlobalDirectory)); + } + [Fact] public void CheckOtherAttributes() { diff --git a/src/UniGetUI.Core.Data/CoreData.cs b/src/UniGetUI.Core.Data/CoreData.cs index 15fb2e5123..7dd75e7068 100644 --- a/src/UniGetUI.Core.Data/CoreData.cs +++ b/src/UniGetUI.Core.Data/CoreData.cs @@ -108,6 +108,19 @@ private static bool UsesPrefixedCalendarReleaseTags(string versionName) public static bool IsPortable => AppPaths.IsPortable; + /// + /// Machine-wide configuration directory, independent of portable mode. Resolving this + /// path does not create it; administrators provision its contents and permissions. + /// + public static string UniGetUIGlobalDirectory => + OperatingSystem.IsWindows() + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Devolutions", "UniGetUI") + : OperatingSystem.IsLinux() + ? "/etc/UniGetUI" + : OperatingSystem.IsMacOS() + ? "/Library/Application Support/Devolutions/UniGetUI" + : throw new PlatformNotSupportedException("No global UniGetUI directory is defined for this platform."); + /// /// Where the per-user data directory lives, regardless of whether portable mode is /// active. Unlike this creates and migrates nothing. diff --git a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs index 439439b339..749ab97c92 100644 --- a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs +++ b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs @@ -98,6 +98,7 @@ public enum K GitHubUserLogin, DisableNewProcessLineHandler, InstallInstalledPackagesBundlesPage, + EnableSoftwareCatalog, ProhibitElevation, DisableIntegrityChecks, UseLegacyElevator, @@ -238,6 +239,7 @@ public static string ResolveKey(K key) K.GitHubUserLogin => "GitHubUserLogin", K.DisableNewProcessLineHandler => "DisableNewProcessLineHandler", K.InstallInstalledPackagesBundlesPage => "InstallInstalledPackagesBundlesPage", + K.EnableSoftwareCatalog => "EnableSoftwareCatalog", K.ProhibitElevation => "ProhibitElevation", K.DisableIntegrityChecks => "DisableIntegrityChecks", K.UseLegacyElevator => "UseLegacyElevator", diff --git a/src/UniGetUI.Interface.IpcApi/IpcAppApi.cs b/src/UniGetUI.Interface.IpcApi/IpcAppApi.cs index ee19952921..69e327bc67 100644 --- a/src/UniGetUI.Interface.IpcApi/IpcAppApi.cs +++ b/src/UniGetUI.Interface.IpcApi/IpcAppApi.cs @@ -29,6 +29,8 @@ public static class IpcAppPages "updates", "installed", "bundles", + "catalog", + "catalog-editor", "settings", "managers", "own-log", @@ -50,6 +52,8 @@ public static string NormalizePageName(string page) "updates" => normalized, "installed" => normalized, "bundles" => normalized, + "catalog" => normalized, + "catalog-editor" => normalized, "settings" => normalized, "managers" => normalized, "own-log" => normalized, @@ -77,6 +81,8 @@ public static string ToPageName(string? pageTypeName) "Updates" => "updates", "Installed" => "installed", "Bundles" => "bundles", + "Catalog" => "catalog", + "CatalogEditor" => "catalog-editor", "Settings" => "settings", "Managers" => "managers", "OwnLog" => "own-log", diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/InvalidImportedPackage.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/InvalidImportedPackage.cs index 40c31e2eb1..3935fefd44 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/InvalidImportedPackage.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/InvalidImportedPackage.cs @@ -85,7 +85,7 @@ public string Scope public event PropertyChangedEventHandler? PropertyChanged; - public InvalidImportedPackage(SerializableIncompatiblePackage data, IManagerSource source) + public InvalidImportedPackage(SerializableIncompatiblePackage data, IManagerSource source, string? identity = null) { Name = data.Name; Id = data.Id.Split('\\')[^1]; @@ -97,8 +97,8 @@ public InvalidImportedPackage(SerializableIncompatiblePackage data, IManagerSour Source = source; Details = new PackageDetails(this); - __hash = CoreTools.HashStringAsLong(data.Name + data.Id); - __extended_hash = CoreTools.HashStringAsLong(data.Name + data.Id + data.Version); + __hash = CoreTools.HashStringAsLong(identity ?? data.Name + data.Id); + __extended_hash = CoreTools.HashStringAsLong((identity ?? data.Name + data.Id) + data.Version); } public Task AddToIgnoredUpdatesAsync(string version = "*") diff --git a/src/UniGetUI.Tests/CatalogEditorTests.cs b/src/UniGetUI.Tests/CatalogEditorTests.cs new file mode 100644 index 0000000000..cb8d95d7cb --- /dev/null +++ b/src/UniGetUI.Tests/CatalogEditorTests.cs @@ -0,0 +1,624 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Avalonia.Automation; +using UniGetUI.Avalonia.Infrastructure; +using UniGetUI.Avalonia.Models; +using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Avalonia.Views.Pages; +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Classes.Manager.Classes; +using UniGetUI.PackageEngine.Classes.Manager.ManagerHelpers; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Interfaces.ManagerProviders; +using UniGetUI.PackageEngine.ManagerClasses.Classes; +using UniGetUI.PackageEngine.ManagerClasses.Manager; +using UniGetUI.PackageEngine.PackageClasses; + +namespace UniGetUI.Tests; + +public class CatalogEditorTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), $"catalog-editor-tests-{Guid.NewGuid():N}"); + private string FilePath => Path.Combine(_directory, "catalog.json"); + + public CatalogEditorTests() => Directory.CreateDirectory(_directory); + + private static CatalogEntry Entry(string source = "winget") => new() + { + Name = "Claude", + Id = "Anthropic.Claude", + ManagerName = "WinGet", + Source = source, + }; + + private static CatalogDefinition[] Definitions => + [ + new() { Id = "devolutions", Name = "Devolutions", Packages = [Entry()] }, + new() { Id = "tools", Name = "Tools", Packages = [] }, + ]; + + private async Task LoadEditorAsync() + { + await SoftwareCatalog.SaveAsync(FilePath, Definitions); + var vm = new CatalogEditorViewModel(); + Assert.True(await vm.LoadAsync(FilePath)); + return vm; + } + + [Fact] + public void OnlyChangedNonEmptyMessagesAreAnnounced() + { + var announcements = new List(); + var vm = new CatalogEditorViewModel((message, setting) => + announcements.Add(new AccessibilityAnnouncement(message!, setting))); + vm.NewDocument(); + vm.Message = "Status"; + vm.Message = "Status"; + vm.Query = "tool"; + vm.IsBusy = true; + vm.IsBusy = false; + vm.SelectedCatalog!.Name = "Renamed"; + vm.IsDirty = false; + vm.IsDirty = true; + vm.SelectedCatalog = null; + Assert.Equal(new AccessibilityAnnouncement("Status", AutomationLiveSetting.Polite), + Assert.Single(announcements)); + vm.Message = ""; + vm.Message = " "; + Assert.Single(announcements); + vm.SetErrorMessage("File picker failed"); + vm.SetErrorMessage("File picker failed"); + vm.Message = "Recovered"; + Assert.Equal( + [ + new AccessibilityAnnouncement("Status", AutomationLiveSetting.Polite), + new AccessibilityAnnouncement("File picker failed", AutomationLiveSetting.Assertive), + new AccessibilityAnnouncement("Recovered", AutomationLiveSetting.Polite), + ], announcements); + } + + [Fact] + public async Task LoadSaveAndSearchOutcomesAnnounceWithTheCorrectPriorityOnce() + { + var announcements = new List(); + var vm = new CatalogEditorViewModel((message, setting) => + announcements.Add(new AccessibilityAnnouncement(message!, setting))); + await SoftwareCatalog.SaveAsync(FilePath, Definitions); + Assert.True(await vm.LoadAsync(FilePath)); + Assert.Empty(announcements); + + string invalid = Path.Combine(_directory, "invalid.json"); + await File.WriteAllTextAsync(invalid, "[{}]"); + Assert.False(await vm.LoadAsync(invalid)); + Assert.Equal(new AccessibilityAnnouncement(vm.Message, AutomationLiveSetting.Assertive), + Assert.Single(announcements)); + announcements.Clear(); + + Assert.False(await vm.SaveAsync(Path.Combine(_directory, "missing", "catalog.json"))); + Assert.Equal(new AccessibilityAnnouncement(vm.Message, AutomationLiveSetting.Assertive), + Assert.Single(announcements)); + announcements.Clear(); + + Assert.True(await vm.SaveAsync(FilePath)); + Assert.Equal(new AccessibilityAnnouncement(vm.Message, AutomationLiveSetting.Polite), + Assert.Single(announcements)); + announcements.Clear(); + + vm.Query = "tool"; + vm.SelectedManager = new SearchManager { Failure = new IOException("Search unavailable") }; + await vm.SearchCommand.ExecuteAsync(null); + Assert.Equal(new AccessibilityAnnouncement(vm.Message, AutomationLiveSetting.Assertive), + Assert.Single(announcements)); + announcements.Clear(); + + vm.SelectedManager = new SearchManager(); + await vm.SearchCommand.ExecuteAsync(null); + Assert.Equal(new AccessibilityAnnouncement(vm.Message, AutomationLiveSetting.Polite), + Assert.Single(announcements)); + } + + [Fact] + public async Task DiscoverAddsSelectionToTheFirstCatalogAndPreservesTheOtherCatalogs() + { + var vm = await LoadEditorAsync(); + var manager = new SearchManager(); + var source = new ManagerSource(manager, "private", new Uri("https://example.test/feed")); + var package = new Package("Tool", "Company.Tool", "2.0", source, manager); + Assert.Same(vm.Catalogs[0], vm.SelectedCatalog); + Assert.Equal(1, vm.AddPackages([package, package])); + var entry = vm.SelectedCatalog!.Packages[1]; + Assert.Equal(package.Id, entry.Id); + Assert.Equal(package.Name, entry.Name); + Assert.Equal("private", entry.Source); + Assert.Equal(manager.DisplayName, entry.ManagerName); + Assert.Equal("", entry.Version); + Assert.True(vm.IsDirty); + Assert.Empty(vm.Catalogs[1].Packages); + Assert.Single((await SoftwareCatalog.LoadAsync(FilePath))[0].Packages); + Assert.True(await vm.SaveAsync(FilePath)); + Assert.Equal(2, (await SoftwareCatalog.LoadAsync(FilePath))[0].Packages.Length); + Assert.Equal(2, vm.Catalogs.Count); + } + + [Theory] + [InlineData("catalog-test")] + [InlineData("CatalogTest")] + [InlineData("Catalog test manager")] + public async Task DiscoverDeduplicatesManagerAliasesAndKeepsExistingVersions(string managerName) + { + var vm = await LoadEditorAsync(); + var manager = new SearchManager(); + var package = new Package("Tool", "Company.Tool", "2", manager.DefaultSource, manager); + vm.SelectedCatalog = vm.Catalogs[1]; + var existing = new CatalogEntry + { + Id = package.Id, + Name = "Pinned Tool", + Version = "1", + Source = package.Source.Name, + ManagerName = managerName, + }; + vm.SelectedCatalog.Packages.Add(existing); + Assert.Equal(0, vm.AddPackages([package])); + Assert.Same(existing, Assert.Single(vm.SelectedCatalog.Packages)); + Assert.Equal("1", existing.Version); + var other = new Package("Tool", package.Id, "2", + new ManagerSource(manager, "other", new Uri("https://example.test/other")), manager); + Assert.Equal(1, vm.AddPackages([other])); + Assert.Equal(2, vm.SelectedCatalog.Packages.Count); + } + + [Fact] + public void AddingPackagesRequiresALoadedDocumentAndACatalog() + { + var vm = new CatalogEditorViewModel(); + Assert.Throws(() => vm.AddPackages([])); + vm.NewDocument(); + Assert.True(vm.IsLoaded); + Assert.True(vm.IsDirty); + Assert.Single(vm.Catalogs); + Assert.Same(vm.Catalogs[0], vm.SelectedCatalog); + Assert.Equal("", vm.FilePath); + vm.SelectedCatalog = null; + Assert.Throws(() => vm.AddPackages([])); + } + + [Fact] + public async Task SharedDocumentDoesNotReloadAndDiscardUnsavedChanges() + { + var vm = await LoadEditorAsync(); + vm.SelectedCatalog = vm.Catalogs[1]; + vm.SelectedCatalog.Name = "Unsaved name"; + Assert.True(await vm.EnsureLoadedAsync()); + Assert.Same(vm.Catalogs[1], vm.SelectedCatalog); + Assert.Equal("Unsaved name", vm.SelectedCatalog.Name); + Assert.True(vm.IsDirty); + } + + [Fact] + public void CatalogPackageListPreservesUnavailableIdentitiesAndDoesNotTouchBundles() + { + var loader = new CatalogPackagesLoader(); + var first = new CatalogEntry { Id = "tool", Name = "Tool", ManagerName = "missing-one", Source = "one" }; + var second = new CatalogEntry { Id = "tool", Name = "Tool", ManagerName = "missing-two", Source = "two" }; + var bundles = UniGetUI.PackageEngine.PackageLoader.PackageBundlesLoader.Instance; + loader.Replace([first, second]); + Assert.Equal(2, loader.Packages.Count); + Assert.Equal(2, loader.Packages.Select(p => p.GetVersionedHash()).Distinct().Count()); + Assert.All(loader.Packages, p => Assert.Contains(loader.FindEntry(p), new[] { first, second })); + loader.Replace([second]); + Assert.Same(second, loader.FindEntry(Assert.Single(loader.Packages))); + Assert.Same(bundles, UniGetUI.PackageEngine.PackageLoader.PackageBundlesLoader.Instance); + } + + [Fact] + public void CatalogEntryLookupUsesTheLoaderIdentityRatherThanPackageObjectEquality() + { + var loader = new CatalogPackagesLoader(); + var entry = new CatalogEntry { Id = "tool", Name = "Tool", ManagerName = "missing", Source = "private" }; + loader.Replace([entry]); + var original = Assert.Single(loader.Packages); + var equivalent = new InvalidImportedPackage(entry.AsSerializable().GetInvalidEquivalent(), original.Source, + $"{entry.ManagerName}\0{entry.Source}\0{entry.Id}"); + Assert.NotSame(original, equivalent); + Assert.Equal(original.GetVersionedHash(), equivalent.GetVersionedHash()); + Assert.Same(entry, loader.FindEntry(equivalent)); + } + + [Fact] + public async Task DiscardRestoresEveryCatalogAndItsPackagesWithoutWritingToDisk() + { + var vm = await LoadEditorAsync(); + string original = await File.ReadAllTextAsync(FilePath); + Assert.False(vm.CanDiscardChanges); + vm.SelectedCatalog!.Name = "Changed"; + vm.SelectedCatalog.Packages[0].Version = "Changed"; + vm.SelectedCatalog.Packages.Clear(); + vm.NewCatalog(); + Assert.True(vm.CanDiscardChanges); + vm.DiscardChanges(); + Assert.False(vm.CanDiscardChanges); + Assert.False(vm.IsDirty); + Assert.Equal(["devolutions", "tools"], vm.Catalogs.Select(c => c.Id)); + Assert.Equal("Devolutions", vm.SelectedCatalog!.Name); + Assert.Equal("", Assert.Single(vm.SelectedCatalog.Packages).Version); + Assert.Equal(original, await File.ReadAllTextAsync(FilePath)); + vm.SelectedCatalog.Name = "Changed again"; + vm.DiscardChanges(); + Assert.Equal("Devolutions", vm.SelectedCatalog!.Name); + } + + [Fact] + public async Task DiscardUsesTheLastSuccessfulSaveAndRetainsTheSelectedCatalog() + { + var vm = await LoadEditorAsync(); + vm.SelectedCatalog = vm.Catalogs[1]; + vm.SelectedCatalog.Name = "Saved"; + string export = Path.Combine(_directory, "export.json"); + Assert.True(await vm.SaveAsync(export)); + vm.SelectedCatalog.Name = "Changed"; + vm.SelectedCatalog.Packages.Add(Entry()); + vm.DiscardChanges(); + Assert.Equal(export, vm.FilePath); + Assert.Equal("tools", vm.SelectedCatalog!.Id); + Assert.Equal("Saved", vm.SelectedCatalog.Name); + Assert.Empty(vm.SelectedCatalog.Packages); + Assert.False(vm.IsDirty); + Assert.Equal("Tools", (await SoftwareCatalog.LoadAsync(FilePath))[1].Name); + } + + [Fact] + public async Task FailedSaveDoesNotReplaceTheDiscardBaseline() + { + var vm = await LoadEditorAsync(); + vm.SelectedCatalog!.Name = "Unsaved"; + Assert.False(await vm.SaveAsync(Path.Combine(_directory, "missing", "catalog.json"))); + Assert.True(vm.CanDiscardChanges); + vm.DiscardChanges(); + Assert.Equal("Devolutions", vm.SelectedCatalog!.Name); + Assert.Equal(FilePath, vm.FilePath); + Assert.False(vm.IsDirty); + Assert.Empty(vm.Message); + } + + [Fact] + public async Task DiscardingANewDocumentDoesNotRestoreThePreviousFile() + { + var vm = await LoadEditorAsync(); + vm.NewDocument(); + vm.DiscardChanges(); + Assert.Empty(vm.Catalogs); + Assert.Null(vm.SelectedCatalog); + Assert.Equal("", vm.FilePath); + Assert.True(vm.IsLoaded); + Assert.False(vm.IsDirty); + vm.NewCatalog(); + Assert.True(vm.CanDiscardChanges); + } + + [Fact] + public async Task EditorLoadsAllCatalogsWithoutMarkingThemDirty() + { + var vm = await LoadEditorAsync(); + Assert.Equal(2, vm.Catalogs.Count); + Assert.Equal("devolutions", vm.SelectedCatalog!.Id); + Assert.Equal(FilePath, vm.FilePath); + Assert.True(vm.CanEdit); + Assert.False(vm.IsDirty); + Assert.Single(vm.SelectedCatalog.Packages); + } + + [Fact] + public async Task AddingAndRemovingPreservesThePackageManagerAndSource() + { + var vm = await LoadEditorAsync(); + vm.SelectedCatalog = vm.Catalogs[1]; + vm.SelectedResult = Entry("private"); + Assert.True(vm.CanAddPackage); + vm.AddPackageCommand.Execute(null); + Assert.True(vm.IsDirty); + Assert.Equal("private", Assert.Single(vm.SelectedCatalog.Packages).Source); + Assert.Single(vm.Catalogs[0].Packages); + vm.SelectedPackage = vm.SelectedCatalog.Packages[0]; + Assert.True(vm.CanRemovePackage); + vm.RemovePackageCommand.Execute(null); + Assert.Empty(vm.SelectedCatalog.Packages); + Assert.False(vm.CanRemovePackage); + } + + [Fact] + public async Task DuplicateIdentityIsRejectedButOtherSourcesAndCatalogsAreAllowed() + { + var vm = await LoadEditorAsync(); + vm.SelectedResult = new CatalogEntry { Name = "Renamed", Id = "anthropic.claude", ManagerName = "WINGET", Source = "WINGET" }; + vm.AddPackageCommand.Execute(null); + Assert.Single(vm.SelectedCatalog!.Packages); + Assert.False(vm.IsDirty); + Assert.NotEmpty(vm.Message); + vm.SelectedResult = Entry("private"); + vm.AddPackageCommand.Execute(null); + Assert.Equal(2, vm.SelectedCatalog.Packages.Count); + vm.SelectedCatalog = vm.Catalogs[1]; + vm.SelectedResult = Entry(); + vm.AddPackageCommand.Execute(null); + Assert.Single(vm.SelectedCatalog.Packages); + } + + [Fact] + public async Task SaveAsRoundTripsAllCatalogsAndDoesNotChangeTheOriginalFile() + { + var vm = await LoadEditorAsync(); + vm.SelectedCatalog!.Name = "Updated"; + vm.SelectedPackage = vm.SelectedCatalog.Packages[0]; + vm.RemovePackageCommand.Execute(null); + string output = Path.Combine(_directory, "export.json"); + Assert.True(await vm.SaveAsync(output)); + Assert.False(vm.IsDirty); + Assert.Equal(output, vm.FilePath); + var saved = await SoftwareCatalog.LoadAsync(output); + Assert.Equal(2, saved.Length); + Assert.Equal("Updated", saved[0].Name); + Assert.Empty(saved[0].Packages); + Assert.Single((await SoftwareCatalog.LoadAsync(FilePath))[0].Packages); + Assert.Empty(Directory.GetFiles(_directory, "*.tmp")); + } + + [Fact] + public async Task SaveOverwritesAnExistingFileWithValidatedMetadata() + { + var vm = await LoadEditorAsync(); + vm.SelectedCatalog!.Name = "Updated"; + Assert.True(vm.IsDirty); + Assert.True(await vm.SaveAsync(FilePath)); + Assert.Equal("Updated", (await SoftwareCatalog.LoadAsync(FilePath))[0].Name); + Assert.Empty(Directory.GetFiles(_directory, "*.tmp")); + } + + [Fact] + public async Task SavedSchemaIsVersionedAndPackagesMatchSoftwareBundles() + { + var definitions = Definitions; + definitions[0] = new CatalogDefinition + { + Id = "devolutions", + Name = "Devolutions", + Packages = + [ + new CatalogEntry + { + Id = "Microsoft.VisualStudioCode", Name = "Visual Studio Code", + Version = "1.90.0", Source = "winget", ManagerName = "WinGet", + }, + ], + }; + await SoftwareCatalog.SaveAsync(FilePath, definitions); + using var json = JsonDocument.Parse(await File.ReadAllTextAsync(FilePath)); + Assert.Equal(["version", "catalogs"], json.RootElement.EnumerateObject().Select(p => p.Name)); + Assert.Equal(1, json.RootElement.GetProperty("version").GetInt32()); + var catalog = json.RootElement.GetProperty("catalogs")[0]; + Assert.Equal(["id", "name", "packages"], catalog.EnumerateObject().Select(p => p.Name)); + var package = catalog.GetProperty("packages")[0]; + Assert.Equal(["Id", "Name", "Version", "Source", "ManagerName"], package.EnumerateObject().Select(p => p.Name)); + var bundled = new UniGetUI.PackageEngine.Classes.Serializable.SerializablePackage(JsonNode.Parse(package.GetRawText())!); + Assert.Equal("Microsoft.VisualStudioCode", bundled.Id); + Assert.Equal("Visual Studio Code", bundled.Name); + Assert.Equal("1.90.0", bundled.Version); + Assert.Equal("winget", bundled.Source); + Assert.Equal("WinGet", bundled.ManagerName); + var vm = new CatalogEditorViewModel(); + Assert.True(await vm.LoadAsync(FilePath)); + vm.SelectedCatalog!.Name = "Renamed"; + Assert.True(await vm.SaveAsync(FilePath)); + Assert.Equal("1.90.0", (await SoftwareCatalog.LoadAsync(FilePath))[0].Packages[0].Version); + } + + [Fact] + public async Task InvalidEditsDoNotDamageTheExistingFileOrClearDirtyState() + { + var vm = await LoadEditorAsync(); + string original = await File.ReadAllTextAsync(FilePath); + vm.SelectedCatalog!.Id = "TOOLS"; + Assert.False(await vm.SaveAsync(FilePath)); + Assert.True(vm.IsDirty); + Assert.NotEmpty(vm.Message); + Assert.Equal(original, await File.ReadAllTextAsync(FilePath)); + Assert.Empty(Directory.GetFiles(_directory, "*.tmp")); + } + + [Fact] + public async Task UnwritableSaveRetainsEditsAndReportsFailure() + { + var vm = await LoadEditorAsync(); + vm.SelectedCatalog!.Name = "Unsaved"; + Assert.False(await vm.SaveAsync(Path.Combine(_directory, "missing-folder", "catalog.json"))); + Assert.True(vm.IsDirty); + Assert.Equal(FilePath, vm.FilePath); + Assert.NotEmpty(vm.Message); + Assert.False(vm.IsBusy); + } + + [Fact] + public async Task FailedOpenPreservesTheCurrentDocumentAndEdits() + { + var vm = await LoadEditorAsync(); + vm.SelectedCatalog!.Name = "Unsaved"; + string invalid = Path.Combine(_directory, "invalid.json"); + await File.WriteAllTextAsync(invalid, "[{}]"); + Assert.False(await vm.LoadAsync(invalid)); + Assert.Equal(FilePath, vm.FilePath); + Assert.Equal("Unsaved", vm.SelectedCatalog.Name); + Assert.True(vm.IsDirty); + Assert.Equal(2, vm.Catalogs.Count); + } + + [Fact] + public async Task EmptyFileCanReceiveNewCatalogsWithUniqueIdentifiers() + { + await SoftwareCatalog.SaveAsync(FilePath, []); + var vm = new CatalogEditorViewModel(); + Assert.True(await vm.LoadAsync(FilePath)); + Assert.Null(vm.SelectedCatalog); + Assert.False(vm.HasSelectedCatalog); + vm.NewCatalogCommand.Execute(null); + vm.NewCatalogCommand.Execute(null); + Assert.Equal(2, vm.Catalogs.Count); + Assert.Equal(2, vm.Catalogs.Select(c => c.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.True(await vm.SaveAsync(FilePath)); + Assert.Equal(2, (await SoftwareCatalog.LoadAsync(FilePath)).Length); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task EmptyCatalogNamesCannotBeSaved(string name) + { + var vm = await LoadEditorAsync(); + vm.SelectedCatalog!.Name = name; + Assert.False(await vm.SaveAsync(FilePath)); + } + + [Fact] + public void SearchIsDisabledWithoutAReadyManagerOrQuery() + { + var vm = new CatalogEditorViewModel(); + Assert.False(vm.SearchCommand.CanExecute(null)); + vm.Query = "Claude"; + Assert.False(vm.SearchCommand.CanExecute(null)); + } + + [Fact] + public async Task SerializationRejectsInvalidDefinitionsBeforeCreatingAFile() + { + await Assert.ThrowsAsync(() => SoftwareCatalog.SaveAsync(FilePath, + [new CatalogDefinition { Id = "", Name = "Bad", Packages = [] }])); + Assert.False(File.Exists(FilePath)); + Assert.Empty(Directory.GetFiles(_directory)); + } + + [Fact] + public async Task SearchUsesTheQueryAndPreservesFullIdentityWithoutDuplicates() + { + var vm = await LoadEditorAsync(); + var manager = new SearchManager(); + var source = new ManagerSource(manager, "private", new Uri("https://example.test/feed")); + var package = new Package("Test package", "Company.Tool", "1.0", source, manager); + manager.Results = [package, package]; + vm.SelectedManager = manager; + vm.Query = " tool "; + Assert.True(vm.CanSearch); + await vm.SearchCommand.ExecuteAsync(null); + Assert.Equal("tool", manager.LastQuery); + var result = Assert.Single(vm.SearchResults); + Assert.Equal("Company.Tool", result.Id); + Assert.Equal("Catalog test manager", result.ManagerName); + Assert.Equal("private", result.Source); + Assert.Equal("", result.Version); + Assert.True(result.Matches(package)); + Assert.True(new CatalogEntry + { + Id = result.Id, + Name = result.Name, + Source = result.Source, + ManagerName = manager.Id, + }.Matches(package)); + Assert.True(new CatalogEntry + { + Id = result.Id, + Name = result.Name, + Source = result.Source, + ManagerName = manager.Name, + }.Matches(package)); + vm.SelectedResult = result; + vm.AddPackageCommand.Execute(null); + Assert.Contains(result, vm.SelectedCatalog!.Packages); + Assert.False(vm.IsSearching); + } + + [Fact] + public async Task SearchFailuresAreVisibleAndDoNotChangeCatalogContents() + { + var vm = await LoadEditorAsync(); + vm.SelectedManager = new SearchManager { Failure = new IOException("Search unavailable") }; + vm.Query = "tool"; + await vm.SearchCommand.ExecuteAsync(null); + Assert.Contains("Search unavailable", vm.Message); + Assert.Empty(vm.SearchResults); + Assert.Single(vm.SelectedCatalog!.Packages); + Assert.False(vm.IsDirty); + Assert.False(vm.IsSearching); + } + + [Fact] + public async Task ManagerSwitchCancelsStaleSearchResults() + { + var vm = await LoadEditorAsync(); + using var started = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var manager = new SearchManager { Started = started, Release = release }; + manager.Results = [new Package("Test", "Company.Tool", "1", manager.DefaultSource, manager)]; + vm.SelectedManager = manager; + vm.Query = "tool"; + var search = vm.SearchCommand.ExecuteAsync(null); + try + { + Assert.True(started.Wait(TimeSpan.FromSeconds(10))); + vm.SelectedManager = new SearchManager(); + } + finally + { + release.Set(); + } + await search; + Assert.Empty(vm.SearchResults); + Assert.False(vm.IsSearching); + } + + private sealed class SearchManager : IPackageManager + { + public IReadOnlyList Results { get; set; } = []; + public string? LastQuery { get; private set; } + public Exception? Failure { get; init; } + public ManualResetEventSlim? Started { get; init; } + public ManualResetEventSlim? Release { get; init; } + public ManagerProperties Properties { get; } = new(IsDummy: true); + public ManagerCapabilities Capabilities { get; } = new(IsDummy: true); + public ManagerStatus Status { get; } = new(); + public string Id => "catalog-test"; + public string Name => "CatalogTest"; + public string DisplayName => "Catalog test manager"; + public IManagerSource DefaultSource => new ManagerSource(this, "test", new Uri("https://example.test/feed")); + public IManagerLogger TaskLogger => throw new NotSupportedException(); + public IMultiSourceHelper SourcesHelper => throw new NotSupportedException(); + public IPackageDetailsHelper DetailsHelper => throw new NotSupportedException(); + public IPackageOperationHelper OperationHelper => throw new NotSupportedException(); + public IReadOnlyList Dependencies => []; + public Encoding OutputEncoding => Encoding.UTF8; + public bool InstallerUrlFollowsPackageVersion => false; + public bool CommandLineIsShellInterpreted => false; + public bool IdentifiersAreQuotedOnCommandLine => true; + public bool LastUpdatesListingFailed => false; + public bool LastInstalledListingFailed => false; + public int? CompareVersions(string versionA, string versionB) => throw new NotSupportedException(); + public void Initialize() => throw new NotSupportedException(); + public bool IsEnabled() => true; + public bool IsReady() => true; + public IReadOnlyList FindPackages(string query) + { + LastQuery = query; + Started?.Set(); + if (Release is not null && !Release.Wait(TimeSpan.FromSeconds(10))) + throw new TimeoutException("The test did not release the search."); + if (Failure is not null) throw Failure; + return Results; + } + public IReadOnlyList GetAvailableUpdates() => throw new NotSupportedException(); + public IReadOnlyList GetInstalledPackages() => throw new NotSupportedException(); + public void RefreshPackageIndexes() => throw new NotSupportedException(); + public void AttemptFastRepair() => throw new NotSupportedException(); + public IReadOnlyList FindCandidateExecutableFiles() => []; + public Tuple GetExecutableFile() => Tuple.Create(false, ""); + } + + public void Dispose() => Directory.Delete(_directory, true); +} diff --git a/src/UniGetUI.Tests/CatalogPackageIdentitySetTests.cs b/src/UniGetUI.Tests/CatalogPackageIdentitySetTests.cs new file mode 100644 index 0000000000..fa8f7aa179 --- /dev/null +++ b/src/UniGetUI.Tests/CatalogPackageIdentitySetTests.cs @@ -0,0 +1,198 @@ +using System.Diagnostics; +using UniGetUI.Avalonia.Infrastructure; +using UniGetUI.Avalonia.Models; +using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Avalonia.ViewModels.Pages; +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Managers.ChocolateyManager; +using UniGetUI.PackageEngine.Managers.NpmManager; +using UniGetUI.PackageEngine.Operations; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Serializable; +using Xunit.Abstractions; + +namespace UniGetUI.Tests; + +public class CatalogPackageIdentitySetTests(ITestOutputHelper output) +{ + private static Npm CreateManager() + { + var manager = new Npm(); + var properties = manager.Properties; + properties.Id = "catalog-test"; + properties.Name = "CatalogTest"; + properties.DisplayName = "Catalog test manager"; + manager.Properties = properties; + return manager; + } + + private static CatalogEntry Entry(string manager, string id = "example.tool", string source = "private") => new() + { + Name = "Example tool", + Id = id, + ManagerName = manager, + Source = source, + Version = "unrelated-version", + }; + + private static Package Package(IPackageManager manager, string id = "EXAMPLE.TOOL", string source = "PRIVATE") => + new("Example tool", id, "1.0", new ManagerSource(manager, source, new Uri("https://example.test/feed")), manager); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void IndexedIdentitiesMatchExistingPredicates(bool installedInventory) + { + var manager = CreateManager(); + var chocolatey = new Chocolatey(); + IPackage[] packages = + [ + Package(manager), + Package(manager, source: "public"), + Package(chocolatey, source: "community"), + Package(manager), // Duplicate inventory versions or operations do not change membership. + ]; + var identities = new CatalogPackageIdentitySet(packages, installedInventory); + foreach (string alias in new[] + { + manager.Id, manager.Name, manager.DisplayName, chocolatey.Id, chocolatey.Name, + chocolatey.DisplayName, "missing-manager", + }) + foreach (string id in new[] { "EXAMPLE.TOOL", "example.other", "example.tool.extra" }) + foreach (string source in new[] { "private", "PUBLIC", "community", "different" }) + { + var entry = Entry(alias.ToUpperInvariant(), id, source); + Assert.Equal(packages.Any(installedInventory ? entry.MatchesInstalled : entry.Matches), + identities.Contains(entry)); + } + Assert.False(new CatalogPackageIdentitySet([]).Contains(Entry(manager.Id))); + } + + [Fact] + public void ChocolateySourceExceptionIsBasedOnManagerIdNotAnAlias() + { + var manager = CreateManager(); + var properties = manager.Properties; + properties.DisplayName = "Chocolatey"; + manager.Properties = properties; + var identities = new CatalogPackageIdentitySet([Package(manager)], installedInventory: true); + Assert.True(identities.Contains(Entry("CHOCOLATEY"))); + Assert.False(identities.Contains(Entry("CHOCOLATEY", source: "different"))); + } + + [Fact] + public void RebuildingSnapshotsReflectsInventoryRemovalAndSourceChanges() + { + var manager = CreateManager(); + var packages = new List { Package(manager) }; + var first = new CatalogPackageIdentitySet(packages, installedInventory: true); + packages.Clear(); + packages.Add(Package(manager, source: "public")); + var second = new CatalogPackageIdentitySet(packages, installedInventory: true); + Assert.True(first.Contains(Entry(manager.Id))); + Assert.False(second.Contains(Entry(manager.Id))); + Assert.True(second.Contains(Entry(manager.Name, source: "public"))); + packages.Clear(); + Assert.False(new CatalogPackageIdentitySet(packages, installedInventory: true).Contains(Entry(manager.Id))); + } + + [Theory] + [InlineData(OperationStatus.InQueue, true)] + [InlineData(OperationStatus.Running, true)] + [InlineData(OperationStatus.Succeeded, false)] + [InlineData(OperationStatus.Failed, false)] + [InlineData(OperationStatus.Canceled, false)] + public void StateRefreshIndexesOnlyActiveOperationsWithExactSourceIdentity(OperationStatus status, bool pending) + { + // This manager is deliberately absent from PEInterface, avoiding icon loading and manager initialization. + var manager = CreateManager(); + using var operation = new InstallPackageOperation(Package(manager), new InstallOptions()) { Status = status }; + var operationVm = new OperationViewModel(operation); + AvaloniaOperationRegistry.OperationViewModels.Add(operationVm); + try + { + var vm = new SoftwareCatalogViewModel + { + HideUnavailablePackages = false, + SelectedCatalog = new CatalogDefinition + { + Id = "test", + Name = "Test", + Packages = + [ + Entry(manager.Id), Entry(manager.Name), Entry(manager.DisplayName), + Entry(manager.Id, source: "different"), + Entry(manager.Id, id: "different"), + Entry("different-manager"), + ], + }, + }; + Assert.Equal([pending, pending, pending, false, false, false], vm.Packages.Select(p => p.IsPending)); + operation.Status = OperationStatus.Canceled; + vm.UpdateStates(); + Assert.All(vm.Packages, tile => Assert.False(tile.IsPending)); + } + finally + { + AvaloniaOperationRegistry.OperationViewModels.Remove(operationVm); + } + } + + [Fact] + public void PendingChocolateyOperationsStillRequireTheCatalogSource() + { + var manager = new Chocolatey(); + var package = Package(manager, source: "community"); + var entry = Entry(manager.DisplayName, source: "private"); + Assert.True(new CatalogPackageIdentitySet([package], installedInventory: true).Contains(entry)); + Assert.False(new CatalogPackageIdentitySet([package]).Contains(entry)); + } + + [Theory] + [InlineData(100)] + [InlineData(1000)] + [InlineData(5000)] + public void IdentityRefreshEnumeratesInventoryAndOperationsOnceRegardlessOfCatalogSize(int count) + { + var manager = CreateManager(); + var packages = Enumerable.Range(0, count).Select(i => Package(manager, id: $"installed-{i}")).ToArray(); + int visits = 0; + IEnumerable CountedPackages() + { + for (int i = 0; i < count; i++) + { + visits++; + yield return packages[i]; + } + } + var entries = Enumerable.Range(0, count).Select(i => Entry(manager.Id, id: $"missing-{i}")).ToArray(); + var timer = Stopwatch.StartNew(); + var installed = new CatalogPackageIdentitySet(CountedPackages(), installedInventory: true); + var pending = new CatalogPackageIdentitySet(CountedPackages()); + foreach (var entry in entries) + { + Assert.False(installed.Contains(entry)); + Assert.False(pending.Contains(entry)); + } + timer.Stop(); + Assert.Equal(2 * count, visits); + double indexedMs = timer.Elapsed.TotalMilliseconds; + visits = 0; + int matches = 0; + timer.Restart(); + foreach (var entry in entries) + { + if (CountedPackages().Any(entry.MatchesInstalled)) matches++; + if (CountedPackages().Any(entry.Matches)) matches++; + } + timer.Stop(); + Assert.Equal(0, matches); + Assert.Equal(2 * count * count, visits); + output.WriteLine( + $"Catalog/inventory/operations={count}: indexed visits={2 * count}, scan visits={visits}; " + + $"indexed={indexedMs:F3} ms, scans={timer.Elapsed.TotalMilliseconds:F3} ms. " + + "Timings are diagnostic; deterministic enumeration counts are the scaling assertion."); + } +} diff --git a/src/UniGetUI.Tests/SoftwareCatalogTests.cs b/src/UniGetUI.Tests/SoftwareCatalogTests.cs new file mode 100644 index 0000000000..c555d14bbe --- /dev/null +++ b/src/UniGetUI.Tests/SoftwareCatalogTests.cs @@ -0,0 +1,612 @@ +using System.Text.Json; +using UniGetUI.Avalonia.Models; +using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Avalonia.ViewModels.Pages; +using UniGetUI.Avalonia.Views; +using UniGetUI.Core.Data; +using UniGetUI.Interface; + +namespace UniGetUI.Tests; + +public class SoftwareCatalogTests +{ + [Theory] + [InlineData("clAuDe")] + [InlineData("Anthropic.")] + public void CatalogSearchMatchesNameAndIdCaseInsensitively(string query) + { + var vm = new SoftwareCatalogViewModel + { + HideUnavailablePackages = false, + SelectedCatalog = new CatalogDefinition + { + Id = "test", + Name = "Test", + Packages = + [ + new CatalogEntry + { + Id = "Anthropic.Claude", Name = "Claude", ManagerName = "missing-test-manager", Source = "private", + }, + new CatalogEntry { Id = "other", Name = "Other", ManagerName = "different-manager", Source = "public" }, + ], + }, + }; + var original = vm.Packages[0]; + vm.Query = $" {query} "; + Assert.Same(original, Assert.Single(vm.Packages)); + Assert.False(vm.HasHiddenPackages); + vm.HideUnavailablePackages = true; + Assert.Empty(vm.Packages); + Assert.True(vm.HasHiddenPackages); + vm.HideUnavailablePackages = false; + Assert.Same(original, Assert.Single(vm.Packages)); + vm.Query = "no match"; + Assert.Empty(vm.Packages); + Assert.True(vm.IsEmpty); + Assert.False(vm.HasHiddenPackages); + vm.Query = ""; + Assert.Equal(2, vm.Packages.Count); + Assert.Same(original, vm.Packages[0]); + } + + [Fact] + public void CatalogDefaultsToTilesAndViewSwitchingPreservesPackagesAndFilters() + { + var vm = new SoftwareCatalogViewModel + { + HideUnavailablePackages = false, + Query = "Claude", + SelectedCatalog = new CatalogDefinition { Id = "test", Name = "Test", Packages = [Entry] }, + }; + Assert.Equal(3, vm.ViewModeIndex); + Assert.False(vm.IsFilterPaneOpen); + Assert.True(vm.IsTilesView); + var tile = Assert.Single(vm.Packages); + for (int index = 0; index < 4; index++) + { + vm.ViewModeIndex = index; + Assert.Equal(index == 0, vm.IsListView); + Assert.Equal(index == 1, vm.IsGridView); + Assert.Equal(index == 2, vm.IsIconsView); + Assert.Equal(index == 3, vm.IsTilesView); + Assert.Same(tile, Assert.Single(vm.Packages)); + Assert.Equal("Claude", vm.Query); + Assert.False(vm.HideUnavailablePackages); + } + } + + private const string EntryJson = """ + {"Id":"Anthropic.Claude","Name":"Claude","Version":"","Source":"winget","ManagerName":"WinGet"} + """; + + private static CatalogEntry Entry => new() + { + Name = "Claude", + Id = "Anthropic.Claude", + ManagerName = "WinGet", + Source = "winget", + }; + + [Theory] + [InlineData(SearchMode.Name, "claude", 1)] + [InlineData(SearchMode.Id, "claude", 1)] + [InlineData(SearchMode.Name, "anthropic", 0)] + [InlineData(SearchMode.Id, "anthropic", 1)] + [InlineData(SearchMode.Both, "anthropic", 1)] + [InlineData(SearchMode.Exact, "cla", 0)] + [InlineData(SearchMode.Exact, "CLAUDE", 1)] + [InlineData(SearchMode.Exact, "ANTHROPIC.CLAUDE", 1)] + [InlineData(SearchMode.Exact, " ", 1)] + public void CatalogSearchModesApplyImmediately(SearchMode mode, string query, int count) + { + var vm = new SoftwareCatalogViewModel + { + HideUnavailablePackages = false, + SelectedCatalog = new CatalogDefinition { Id = "test", Name = "Test", Packages = [Entry] }, + Query = query, + SearchMode = mode, + }; + Assert.Equal(count, vm.Packages.Count); + Assert.Equal(mode == SearchMode.Name, vm.SearchMode_Name); + Assert.Equal(mode == SearchMode.Id, vm.SearchMode_Id); + Assert.Equal(mode == SearchMode.Both, vm.SearchMode_Both); + Assert.Equal(mode == SearchMode.Exact, vm.SearchMode_Exact); + vm.Query = "anthropic"; + vm.SearchMode_Name = true; + Assert.Empty(vm.Packages); + vm.SearchMode_Id = true; + Assert.Single(vm.Packages); + vm.SearchMode_Exact = true; + Assert.Empty(vm.Packages); + vm.SearchMode_Both = true; + Assert.Single(vm.Packages); + } + + [Fact] + public void CatalogSourcesFilterByManagerAndSourceAndPersistAcrossSameCatalogReload() + { + var catalog = new CatalogDefinition + { + Id = "test", + Name = "Test", + Packages = + [ + new CatalogEntry { Id = "one", Name = "One", ManagerName = "missing-one", Source = "private" }, + new CatalogEntry { Id = "two", Name = "Two", ManagerName = "MISSING-ONE", Source = "public" }, + new CatalogEntry { Id = "three", Name = "Three", ManagerName = "missing-two", Source = "private" }, + ], + }; + var vm = new SoftwareCatalogViewModel { HideUnavailablePackages = false, SelectedCatalog = catalog }; + Assert.False(vm.IsFilterPaneOpen); + Assert.Equal(2, vm.SourceNodes.Count); + Assert.Equal(2, vm.SourceNodes[0].Children.Count); + var first = vm.Packages[0]; + vm.SourceNodes[0].Children[0].IsSelected = false; + Assert.Equal(["two", "three"], vm.Packages.Select(p => p.Entry.Id)); + Assert.False(vm.SourceNodes[0].IsSelected); + vm.SourceNodes[0].IsSelected = true; + Assert.Equal(3, vm.Packages.Count); + Assert.Same(first, vm.Packages[0]); + vm.SourceNodes[0].IsSelected = false; + Assert.Equal("three", Assert.Single(vm.Packages).Entry.Id); + vm.SelectedCatalog = null; + vm.SelectedCatalog = catalog; + Assert.Equal("three", Assert.Single(vm.Packages).Entry.Id); + vm.SelectAllSourcesCommand.Execute(null); + Assert.Equal(3, vm.Packages.Count); + vm.ClearSourceSelectionCommand.Execute(null); + Assert.Empty(vm.Packages); + vm.HideUnavailablePackages = true; + vm.SelectAllSourcesCommand.Execute(null); + Assert.Empty(vm.Packages); + vm.HideUnavailablePackages = false; + Assert.Equal(3, vm.Packages.Count); + vm.ClearSourceSelectionCommand.Execute(null); + vm.SelectedCatalog = new CatalogDefinition { Id = "other", Name = "Other", Packages = catalog.Packages }; + Assert.Equal(3, vm.Packages.Count); + } + + [Fact] + public void UnavailableSourcesAreHiddenByDefaultAndTheFilterRestoresTheSameTiles() + { + var vm = new SoftwareCatalogViewModel + { + SelectedCatalog = new CatalogDefinition + { + Id = "missing-manager", + Name = "Missing manager", + Packages = [new CatalogEntry { Id = "tool", Name = "Tool", Source = "private", ManagerName = "missing-test-manager" }], + }, + }; + Assert.True(vm.HideUnavailablePackages); + Assert.Empty(vm.Packages); + Assert.True(vm.HasHiddenPackages); + vm.HideUnavailablePackages = false; + var tile = Assert.Single(vm.Packages); + Assert.NotNull(tile.UnavailableReason); + vm.HideUnavailablePackages = true; + Assert.Empty(vm.Packages); + vm.HideUnavailablePackages = false; + Assert.Same(tile, Assert.Single(vm.Packages)); + } + + [Fact] + public void FilterRespondsToSourceAvailabilityWithoutLosingStateOrChangingCatalogs() + { + var vm = new SoftwareCatalogViewModel { HideUnavailablePackages = false }; + var catalog = new CatalogDefinition + { + Id = "test", + Name = "Test", + Packages = [new CatalogEntry { Id = "tool", Name = "Tool", Source = "private", ManagerName = "missing-test-manager" }], + }; + vm.SelectedCatalog = catalog; + var tile = Assert.Single(vm.Packages); + vm.HideUnavailablePackages = true; + tile.UpdateState(true, null, true, false); + vm.ApplyAvailabilityFilter(); + Assert.Same(tile, Assert.Single(vm.Packages)); + Assert.True(tile.IsInstalled); + Assert.False(vm.HasHiddenPackages); + tile.UpdateState(true, "Disabled source", true, false); + vm.ApplyAvailabilityFilter(); + Assert.Empty(vm.Packages); + Assert.Same(catalog, vm.SelectedCatalog); + vm.SelectedCatalog = null; + vm.HideUnavailablePackages = false; + Assert.Empty(vm.Packages); + Assert.False(vm.HasHiddenPackages); + } + + [Fact] + public void EditUsesAnAsynchronousInAppEditorCommand() + { + Assert.IsAssignableFrom(new SoftwareCatalogViewModel().EditCommand); + } + + [Fact] + public void CatalogFileLivesInTheGlobalDirectory() + { + Assert.Equal(Path.Combine(CoreData.UniGetUIGlobalDirectory, "SoftwareCatalog.json"), SoftwareCatalog.FilePath); + } + + private static string CatalogJson(string packages, string id = "tools", string name = "Tools") => + $$"""{"id":"{{id}}","name":"{{name}}","packages":[{{packages}}]}"""; + + private static string DocumentJson(string catalogs) => + $$"""{"version":1,"catalogs":[{{catalogs}}]}"""; + + [Fact] + public void MultipleCatalogsLoadWithSourceGeneratedMetadata() + { + var catalogs = SoftwareCatalog.Parse(DocumentJson($"{CatalogJson(EntryJson)},{CatalogJson(EntryJson, "development", "Development")}")); + Assert.Equal(["tools", "development"], catalogs.Select(c => c.Id).ToArray()); + Assert.Equal(["Tools", "Development"], catalogs.Select(c => c.Name).ToArray()); + Assert.All(catalogs, c => Assert.Equal("Anthropic.Claude", Assert.Single(c.Packages).Id)); + } + + [Fact] + public void TheRequestedBundleStyleDocumentLoadsExactly() + { + const string json = """ + { + "version": 1, + "catalogs": [ + { + "id": "devolutions", + "name": "Devolutions", + "packages": [ + { + "Id": "Microsoft.VisualStudioCode", + "Name": "Visual Studio Code", + "Version": "", + "Source": "winget", + "ManagerName": "WinGet" + } + ] + } + ] + } + """; + var catalog = Assert.Single(SoftwareCatalog.Parse(json)); + Assert.Equal("devolutions", catalog.Id); + Assert.Equal("Devolutions", catalog.Name); + var entry = Assert.Single(catalog.Packages); + Assert.Equal("Microsoft.VisualStudioCode", entry.Id); + Assert.Equal("Visual Studio Code", entry.Name); + Assert.Equal("", entry.Version); + Assert.Equal("winget", entry.Source); + Assert.Equal("WinGet", entry.ManagerName); + } + + [Fact] + public void MissingPackageVersionDefaultsToEmptyLikeBundles() + { + string entry = EntryJson.Replace("\"Version\":\"\",", ""); + Assert.Equal("", Assert.Single(Assert.Single(SoftwareCatalog.Parse(DocumentJson(CatalogJson(entry)))).Packages).Version); + } + + [Fact] + public void LegacyPackageFieldsInsideANewDocumentAreRejected() + { + const string entry = """{"name":"Claude","id":"Anthropic.Claude","manager":"winget","source":"winget"}"""; + Assert.Throws(() => SoftwareCatalog.Parse(DocumentJson(CatalogJson(entry)))); + } + + [Fact] + public void EmptyCatalogsAreValidAndNoDefaultIsInjected() + { + Assert.Empty(SoftwareCatalog.Parse(DocumentJson(""))); + Assert.Empty(Assert.Single(SoftwareCatalog.Parse(DocumentJson(CatalogJson("")))).Packages); + } + + [Fact] + public async Task MissingCatalogIsReportedInsteadOfUsingADefault() + { + string path = Path.Combine(Path.GetTempPath(), $"missing-catalog-{Guid.NewGuid():N}.json"); + await Assert.ThrowsAsync(() => SoftwareCatalog.LoadAsync(path)); + Assert.False(File.Exists(path)); + } + + [Fact] + public async Task CatalogFileReloadReadsUpdatedCatalogDefinitions() + { + string path = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(path, DocumentJson(CatalogJson(EntryJson))); + Assert.Equal("tools", Assert.Single(await SoftwareCatalog.LoadAsync(path)).Id); + await File.WriteAllTextAsync(path, DocumentJson(CatalogJson("", "development", "Development"))); + var catalog = Assert.Single(await SoftwareCatalog.LoadAsync(path)); + Assert.Equal("development", catalog.Id); + Assert.Equal("Development", catalog.Name); + Assert.Empty(catalog.Packages); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void CatalogSelectorTracksEmptyCatalogs() + { + var vm = new SoftwareCatalogViewModel(); + var first = new CatalogDefinition { Id = "first", Name = "First", Packages = [] }; + var second = new CatalogDefinition { Id = "second", Name = "Second", Packages = [] }; + vm.Catalogs.Add(first); + vm.Catalogs.Add(second); + vm.SelectedCatalog = first; + Assert.Same(first, vm.SelectedCatalog); + Assert.True(vm.IsEmpty); + vm.SelectedCatalog = second; + Assert.Same(second, vm.SelectedCatalog); + Assert.Empty(vm.Packages); + vm.ErrorMessage = "Catalog file unavailable"; + Assert.False(vm.IsEmpty); + Assert.True(vm.HasError); + } + + [Fact] + public void ExperimentalCatalogOptionIsSearchable() + { + Assert.Contains( + global::UniGetUI.Avalonia.Infrastructure.SettingsSearchIndex.Search("software catalog"), + result => result.Anchor == "SoftwareCatalogCard"); + } + + [Theory] + [InlineData("""{"id":"","name":"Tools","packages":[]}""")] + [InlineData("""{"id":"tools","name":" ","packages":[]}""")] + [InlineData("""{"id":"tools","name":"Tools"}""")] + [InlineData("""{"id":"tools","name":"Tools","packages":null}""")] + [InlineData("""{"id":null,"name":"Tools","packages":[]}""")] + [InlineData("null")] + public void InvalidCatalogDefinitionIsRejected(string catalog) => + Assert.Throws(() => SoftwareCatalog.Parse(DocumentJson(catalog))); + + [Fact] + public void CatalogIdsMustBeUniqueRegardlessOfCase() + { + Assert.Throws(() => SoftwareCatalog.Parse(DocumentJson($"{CatalogJson("")},{CatalogJson("", "TOOLS")}"))); + } + + [Fact] + public void CatalogIdentityIncludesManagerAndSource() + { + Assert.True(Entry.Matches("anthropic.claude", "WinGet", "WINGET")); + Assert.False(Entry.Matches("Anthropic.Claude", "scoop", "winget")); + Assert.False(Entry.Matches("Anthropic.Claude", "winget", "msstore")); + Assert.False(Entry.Matches("Anthropic.Claude.Other", "winget", "winget")); + } + + [Theory] + [InlineData("Chocolatey")] + [InlineData("chocolatey")] + [InlineData("CHOCOLATEY")] + public void ChocolateyInstalledMatchingIgnoresFeedButRepositoryMatchingRemainsExact(string managerName) + { + var manager = new UniGetUI.PackageEngine.Managers.ChocolateyManager.Chocolatey(); + var entry = new CatalogEntry + { + Id = "example.tool", + Name = "Example tool", + ManagerName = managerName, + Source = "private", + }; + var installed = new UniGetUI.PackageEngine.PackageClasses.Package( + "Example tool", "EXAMPLE.TOOL", "1.0", manager.DefaultSource, manager); + Assert.True(entry.MatchesInstalled(installed)); + Assert.False(entry.Matches(installed)); + var privateSource = new UniGetUI.PackageEngine.Classes.Manager.ManagerSource( + manager, "private", new Uri("https://example.test/feed")); + var repositoryPackage = new UniGetUI.PackageEngine.PackageClasses.Package( + "Example tool", entry.Id, "2.0", privateSource, manager); + Assert.True(entry.Matches(repositoryPackage)); + Assert.True(entry.MatchesInstalled(repositoryPackage)); + var differentPackage = new UniGetUI.PackageEngine.PackageClasses.Package( + "Different tool", "example.other", "1.0", manager.DefaultSource, manager); + Assert.False(entry.MatchesInstalled(differentPackage)); + var tile = new CatalogTileViewModel(entry); + tile.UpdateState(entry.MatchesInstalled(installed), null, true, false); + Assert.True(tile.IsInstalled); + Assert.False(tile.ShowInstall); + Assert.False(tile.CanInstall); + } + + [Fact] + public void OtherManagersStillRequireTheInstalledSourceAndManagerToMatch() + { + var manager = new UniGetUI.PackageEngine.Managers.NpmManager.Npm(); + var entry = new CatalogEntry + { + Id = "example.tool", + Name = "Example tool", + ManagerName = manager.Id, + Source = "private", + }; + var package = new UniGetUI.PackageEngine.PackageClasses.Package( + entry.Name, entry.Id, "1.0", manager.DefaultSource, manager); + Assert.False(entry.MatchesInstalled(package)); + var privateSource = new UniGetUI.PackageEngine.Classes.Manager.ManagerSource( + manager, "PRIVATE", new Uri("https://example.test/feed")); + package = new UniGetUI.PackageEngine.PackageClasses.Package( + entry.Name, entry.Id, "1.0", privateSource, manager); + Assert.True(entry.MatchesInstalled(package)); + var chocolateyEntry = new CatalogEntry + { + Id = entry.Id, + Name = entry.Name, + ManagerName = "chocolatey", + Source = "private", + }; + Assert.False(chocolateyEntry.MatchesInstalled(package)); + } + + [Theory] + [InlineData("null")] + [InlineData("{}")] + [InlineData("""{"catalogs":[]}""")] + [InlineData("""{"version":1}""")] + [InlineData("""{"version":0,"catalogs":[]}""")] + [InlineData("""{"version":2,"catalogs":[]}""")] + [InlineData("""{"version":1,"catalogs":null}""")] + [InlineData("""{"version":"1","catalogs":[]}""")] + [InlineData("[]")] + [InlineData("[null]")] + [InlineData("[{}]")] + [InlineData("""[{"name":"Claude","id":"Anthropic.Claude","manager":"winget"}]""")] + [InlineData("""[{"name":"Claude","id":"","manager":"winget","source":"winget"}]""")] + [InlineData("""[{"name":" ","id":"Anthropic.Claude","manager":"winget","source":"winget"}]""")] + [InlineData("""[{"name":"Claude","id":"Anthropic.Claude","manager":null,"source":"winget"}]""")] + public void InvalidCatalogIsRejected(string json) => + Assert.Throws(() => SoftwareCatalog.Parse(json)); + + [Theory] + [InlineData("""{"Name":"Claude","Id":"Anthropic.Claude","ManagerName":"WinGet"}""")] + [InlineData("""{"Name":"Claude","Id":"","ManagerName":"WinGet","Source":"winget"}""")] + [InlineData("""{"Name":" ","Id":"Anthropic.Claude","ManagerName":"WinGet","Source":"winget"}""")] + [InlineData("""{"Name":"Claude","Id":"Anthropic.Claude","ManagerName":null,"Source":"winget"}""")] + [InlineData("""{"Name":"Claude","Id":"Anthropic.Claude","ManagerName":"WinGet","Source":"winget","Version":null}""")] + [InlineData("null")] + public void InvalidPackageInCatalogIsRejected(string package) => + Assert.Throws(() => SoftwareCatalog.Parse(DocumentJson(CatalogJson(package)))); + + [Fact] + public void DuplicateIdentitiesAreRejectedRegardlessOfCase() + { + string duplicate = EntryJson.Replace("WinGet", "WINGET").Replace("winget", "WINGET").Replace("Anthropic.Claude", "anthropic.claude"); + Assert.Throws(() => SoftwareCatalog.Parse(DocumentJson(CatalogJson($"{EntryJson},{duplicate}")))); + } + + [Fact] + public void SamePackageIdCanAppearInDifferentSources() + { + string otherSource = EntryJson.Replace("\"Source\":\"winget\"", "\"Source\":\"private\""); + Assert.Equal(2, Assert.Single(SoftwareCatalog.Parse(DocumentJson(CatalogJson($"{EntryJson},{otherSource}")))).Packages.Length); + } + + [Fact] + public void AvailableUninstalledTileCanInstall() + { + var tile = new CatalogTileViewModel(Entry); + tile.UpdateState(false, null, true, false); + + Assert.True(tile.ShowInstall); + Assert.True(tile.CanInstall); + Assert.Equal(1, tile.TitleOpacity); + Assert.Equal("", tile.StatusText); + Assert.Null(tile.IconBitmap); + Assert.False(tile.HasCustomIcon); + } + + [Fact] + public void CatalogIconChangesNotifyTheTile() + { + global::Avalonia.Skia.SkiaPlatform.Initialize(); + var tile = new CatalogTileViewModel(Entry); + var changed = new List(); + tile.PropertyChanged += (_, args) => changed.Add(args.PropertyName); + using var bitmap = new global::Avalonia.Media.Imaging.WriteableBitmap( + new global::Avalonia.PixelSize(1, 1), new global::Avalonia.Vector(96, 96), + global::Avalonia.Platform.PixelFormat.Bgra8888, global::Avalonia.Platform.AlphaFormat.Premul); + + tile.IconBitmap = bitmap; + Assert.True(tile.HasCustomIcon); + Assert.Contains(nameof(tile.IconBitmap), changed); + Assert.Contains(nameof(tile.HasCustomIcon), changed); + tile.IconBitmap = null; + Assert.False(tile.HasCustomIcon); + } + + [Fact] + public void InstalledTileHidesInstallButton() + { + var tile = new CatalogTileViewModel(Entry); + tile.UpdateState(true, null, true, false); + + Assert.True(tile.IsInstalled); + Assert.False(tile.ShowInstall); + Assert.False(tile.CanInstall); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void UnavailableSourceDimsTitleAndExplainsWhy(bool installed) + { + var tile = new CatalogTileViewModel(Entry); + tile.UpdateState(installed, "Source winget is disabled.", true, false); + + Assert.Equal(0.45, tile.TitleOpacity); + Assert.Equal("Source winget is disabled.", tile.UnavailableReason); + Assert.Equal(tile.UnavailableReason, tile.StatusText); + Assert.False(tile.CanInstall); + Assert.Equal(!installed, tile.ShowInstall); + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(true, true, false)] + [InlineData(true, false, true)] + public void UnknownInventoryAndPendingInstallsCannotInstall(bool inventoryKnown, bool pending, bool busy) + { + var tile = new CatalogTileViewModel(Entry); + tile.UpdateState(false, null, inventoryKnown, pending); + tile.IsBusy = busy; + + Assert.False(tile.CanInstall); + Assert.NotEmpty(tile.StatusText); + } + + [Fact] + public void TileRecoversAfterSourceIsEnabledOrInstallFails() + { + var tile = new CatalogTileViewModel(Entry); + tile.UpdateState(false, "Source disabled.", true, false); + tile.UpdateState(false, null, true, true); + tile.UpdateState(false, null, true, false); + + Assert.True(tile.CanInstall); + Assert.Equal(1, tile.TitleOpacity); + Assert.Null(tile.UnavailableReason); + } + + [Fact] + public void TileTracksInstallAndUninstall() + { + var tile = new CatalogTileViewModel(Entry); + tile.UpdateState(false, null, true, true); + Assert.False(tile.CanInstall); + tile.UpdateState(true, null, true, false); + Assert.False(tile.ShowInstall); + tile.UpdateState(false, null, true, false); + Assert.True(tile.ShowInstall); + Assert.True(tile.CanInstall); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CatalogParticipatesInKeyboardPageCycleOnlyWhenEnabled(bool enabled) + { + Assert.Equal(enabled ? PageType.Catalog : PageType.Settings, MainWindowViewModel.GetNextPage(PageType.Bundles, enabled)); + Assert.Equal(enabled ? PageType.CatalogEditor : PageType.Settings, MainWindowViewModel.GetNextPage(PageType.Catalog, enabled)); + Assert.Equal(PageType.Settings, MainWindowViewModel.GetNextPage(PageType.CatalogEditor, enabled)); + Assert.Equal(enabled ? PageType.CatalogEditor : PageType.Bundles, MainWindowViewModel.GetPreviousPage(PageType.Settings, enabled)); + Assert.Equal(enabled ? PageType.Catalog : PageType.Bundles, MainWindowViewModel.GetPreviousPage(PageType.CatalogEditor, enabled)); + Assert.Equal(PageType.Bundles, MainWindowViewModel.GetPreviousPage(PageType.Catalog)); + } + + [Fact] + public void CatalogIsAnIpcNavigationTarget() + { + Assert.Contains("catalog", IpcAppPages.SupportedPages); + Assert.Contains("catalog-editor", IpcAppPages.SupportedPages); + Assert.Equal("catalog-editor", IpcAppPages.NormalizePageName("Catalog-Editor")); + Assert.Equal("catalog-editor", IpcAppPages.ToPageName(nameof(PageType.CatalogEditor))); + Assert.Equal("catalog", IpcAppPages.NormalizePageName(" Catalog ")); + Assert.Equal("catalog", IpcAppPages.ToPageName(nameof(PageType.Catalog))); + } +}