diff --git a/src/UniGetUI.Avalonia/App.axaml.cs b/src/UniGetUI.Avalonia/App.axaml.cs index 123c81ab92..4f6059fa10 100644 --- a/src/UniGetUI.Avalonia/App.axaml.cs +++ b/src/UniGetUI.Avalonia/App.axaml.cs @@ -5,6 +5,7 @@ using Avalonia.Markup.Xaml.Styling; using Avalonia.Platform; using Avalonia.Styling; +using Avalonia.Threading; using UniGetUI.Avalonia.Infrastructure; using UniGetUI.Avalonia.Views; using UniGetUI.Avalonia.Views.DialogPages; @@ -35,6 +36,21 @@ public override void Initialize() public override void OnFrameworkInitializationCompleted() { + if (OperatingSystem.IsWindows()) + { + // Safety net for NativeWebView (WebView2) initialization failures thrown + // asynchronously on the dispatcher. Without this the app crashes; with it + // the Help page shows a fallback "Open in browser" button. + Dispatcher.UIThread.UnhandledException += (_, e) => + { + if (e.Exception is InvalidOperationException { Message: var msg } + && msg.Contains("child window for native control host")) + { + e.Handled = true; + } + }; + } + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { if (OperatingSystem.IsMacOS()) diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs index 36a3440ef6..a1fe74b164 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs @@ -93,6 +93,43 @@ public static void Add(AbstractOperation op) }; } + public static void RetryFailed() + { + var failed = OperationViewModels + .Where(vm => vm.Operation.Status is OperationStatus.Failed) + .ToList(); + foreach (var vm in failed) + vm.Operation.Retry(AbstractOperation.RetryMode.Retry); + } + + public static void ClearSuccessful() + { + var succeeded = OperationViewModels + .Where(vm => vm.Operation.Status is OperationStatus.Succeeded) + .ToList(); + foreach (var vm in succeeded) + Remove(vm); + } + + public static void ClearFinished() + { + var finished = OperationViewModels + .Where(vm => vm.Operation.Status + is OperationStatus.Succeeded or OperationStatus.Failed or OperationStatus.Canceled) + .ToList(); + foreach (var vm in finished) + Remove(vm); + } + + public static void CancelAll() + { + var active = OperationViewModels + .Where(vm => vm.Operation.Status is OperationStatus.Running or OperationStatus.InQueue) + .ToList(); + foreach (var vm in active) + vm.Operation.Cancel(); + } + /// Remove a view-model (and its backing operation) from the panel. Called by the Close button. public static void Remove(OperationViewModel vm) { diff --git a/src/UniGetUI.Avalonia/Models/PackageCollections.cs b/src/UniGetUI.Avalonia/Models/PackageCollections.cs index c8442fd350..676e7b3dc3 100644 --- a/src/UniGetUI.Avalonia/Models/PackageCollections.cs +++ b/src/UniGetUI.Avalonia/Models/PackageCollections.cs @@ -1,7 +1,13 @@ +using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.ComponentModel; +using System.Net.Http; using Avalonia.Collections; +using Avalonia.Media.Imaging; +using Avalonia.Threading; using UniGetUI.Avalonia.ViewModels.Pages; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.Tools; using UniGetUI.Interface.Enums; using UniGetUI.PackageEngine.Interfaces; @@ -13,6 +19,12 @@ namespace UniGetUI.PackageEngine.PackageClasses; /// public sealed class PackageWrapper : INotifyPropertyChanged, IDisposable { + private static readonly HttpClient _iconHttpClient = new(CoreTools.GenericHttpClientParameters) + { + Timeout = TimeSpan.FromSeconds(8), + }; + private static readonly ConcurrentDictionary _iconCache = new(); + public IPackage Package { get; } public PackageWrapper Self => this; public int Index { get; set; } @@ -21,6 +33,19 @@ public sealed class PackageWrapper : INotifyPropertyChanged, IDisposable private readonly PackagesPageViewModel _page; + private Bitmap? _iconBitmap; + public Bitmap? IconBitmap + { + get => _iconBitmap; + private set + { + _iconBitmap = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IconBitmap))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasCustomIcon))); + } + } + public bool HasCustomIcon => _iconBitmap is not null; + public bool IsChecked { get => Package.IsChecked; @@ -35,6 +60,8 @@ public bool IsChecked public string VersionComboString { get; } public string ListedNameTooltip { get; private set; } = ""; public float ListedOpacity { get; private set; } = 1.0f; + public string TagIconPath { get; private set; } = ""; + public bool TagIconVisible { get; private set; } public string SourceIconPath => IconTypeToSvgPath(Package.Source.IconId); @@ -63,6 +90,43 @@ public PackageWrapper(IPackage package, PackagesPageViewModel page) Package.PropertyChanged += Package_PropertyChanged; UpdateDisplayState(); + + if (!Settings.Get(Settings.K.DisableIconsOnPackageLists)) + _ = LoadIconAsync(); + } + + private async Task LoadIconAsync() + { + long hash = Package.GetHash(); + if (_iconCache.TryGetValue(hash, out Bitmap? cached)) + { + if (cached is not null) + IconBitmap = cached; + return; + } + + try + { + var uri = Package.GetIconUrlIfAny(); + if (uri is null) { _iconCache[hash] = null; return; } + + Bitmap bitmap; + if (uri.IsFile) + { + bitmap = new Bitmap(uri.LocalPath); + } + else if (uri.Scheme is "http" or "https") + { + var bytes = await _iconHttpClient.GetByteArrayAsync(uri); + using var ms = new MemoryStream(bytes); + bitmap = new Bitmap(ms); + } + else { _iconCache[hash] = null; return; } + + _iconCache[hash] = bitmap; + await Dispatcher.UIThread.InvokeAsync(() => IconBitmap = bitmap); + } + catch { _iconCache[hash] = null; } } private void Package_PropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -72,6 +136,8 @@ private void Package_PropertyChanged(object? sender, PropertyChangedEventArgs e) UpdateDisplayState(); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ListedOpacity))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ListedNameTooltip))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(TagIconPath))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(TagIconVisible))); } else if (e.PropertyName == nameof(Package.IsChecked)) { @@ -91,6 +157,21 @@ private void UpdateDisplayState() _ => 1.0f, }; ListedNameTooltip = Package.Name; + + string tagName = Package.Tag switch + { + PackageTag.AlreadyInstalled => "installed_filled", + PackageTag.IsUpgradable => "upgradable_filled", + PackageTag.Pinned => "pin_filled", + PackageTag.OnQueue => "sandclock", + PackageTag.BeingProcessed => "loading_filled", + PackageTag.Failed => "warning_filled", + _ => "", + }; + TagIconVisible = tagName.Length > 0; + TagIconPath = TagIconVisible + ? $"avares://UniGetUI.Avalonia/Assets/Symbols/{tagName}.svg" + : ""; } public void Dispose() diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index b9e586a99a..062c71e555 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -20,6 +20,7 @@ UniGetUI.Avalonia ..\UniGetUI\icon.ico true + app.manifest diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs index 9b90bca88f..6abbca80a0 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs @@ -305,6 +305,10 @@ public InstallOptionsViewModel(IPackage package, OperationType operation, Instal } // ── Commands ────────────────────────────────────────────────────────────── + + /// Captures the current UI state into the options object without closing. + public void ApplyChanges() => ApplyToOptions(); + [RelayCommand] private void Save() { diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationOutputViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationOutputViewModel.cs index 11e07aa2ac..84b78798a0 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationOutputViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationOutputViewModel.cs @@ -1,4 +1,8 @@ +using System.Collections.ObjectModel; +using Avalonia.Media; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; +using UniGetUI.Avalonia.ViewModels.Pages.LogPages; using UniGetUI.PackageOperations; namespace UniGetUI.Avalonia.ViewModels.DialogPages; @@ -6,11 +10,31 @@ namespace UniGetUI.Avalonia.ViewModels.DialogPages; public partial class OperationOutputViewModel : ObservableObject { [ObservableProperty] private string _title = ""; - [ObservableProperty] private string _outputText = ""; + public ObservableCollection OutputLines { get; } = new(); + + private static readonly IBrush _errorBrush = new SolidColorBrush(Color.Parse("#FF6B6B")); + private static readonly IBrush _debugBrush = new SolidColorBrush(Color.Parse("#888888")); + private static readonly IBrush _normalBrush = Brushes.White; public OperationOutputViewModel(AbstractOperation operation) { Title = operation.Metadata.Title; - OutputText = string.Join("\n", operation.GetOutput().Select(x => x.Item1)); + + foreach (var (text, type) in operation.GetOutput()) + OutputLines.Add(MakeLine(text, type)); + + operation.LogLineAdded += (_, ev) => + Dispatcher.UIThread.Post(() => OutputLines.Add(MakeLine(ev.Item1, ev.Item2))); + } + + private LogLineItem MakeLine(string text, AbstractOperation.LineType type) + { + IBrush brush = type switch + { + AbstractOperation.LineType.Error => _errorBrush, + AbstractOperation.LineType.VerboseDetails => _debugBrush, + _ => _normalBrush, + }; + return new LogLineItem(text, brush); } } diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 6b06f35896..45a6fce0bc 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -64,6 +64,24 @@ public partial class MainWindowViewModel : ViewModelBase [ObservableProperty] private bool _operationsPanelVisible; + [ObservableProperty] + private bool _operationsPanelExpanded = true; + + [RelayCommand] + private void ToggleOperationsPanel() => OperationsPanelExpanded = !OperationsPanelExpanded; + + [RelayCommand] + private void RetryFailedOperations() => AvaloniaOperationRegistry.RetryFailed(); + + [RelayCommand] + private void ClearSuccessfulOperations() => AvaloniaOperationRegistry.ClearSuccessful(); + + [RelayCommand] + private void ClearFinishedOperations() => AvaloniaOperationRegistry.ClearFinished(); + + [RelayCommand] + private void CancelAllOperations() => AvaloniaOperationRegistry.CancelAll(); + // ─── Sidebar ───────────────────────────────────────────────────────────── public SidebarViewModel Sidebar { get; } = new(); diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/BackupViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/BackupViewModel.cs index 2ec3ffac46..c4933e7d6f 100644 --- a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/BackupViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/BackupViewModel.cs @@ -224,6 +224,16 @@ private async Task DoCloudBackup() { _isLoading = true; UpdateCloudControlsEnabled(); + try { await DoCloudBackupStatic(); } + finally + { + _isLoading = false; + UpdateCloudControlsEnabled(); + } + } + + public static async Task DoCloudBackupStatic() + { try { var packages = InstalledPackagesLoader.Instance?.Packages.ToList() ?? []; @@ -236,11 +246,6 @@ private async Task DoCloudBackup() Logger.Error("An error occurred while performing a CLOUD backup:"); Logger.Error(ex); } - finally - { - _isLoading = false; - UpdateCloudControlsEnabled(); - } } [RelayCommand] diff --git a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs index 280bca9c1c..217182eb83 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs @@ -70,7 +70,7 @@ public class SourceTreeNode : INotifyPropertyChanged public string? PackageID { get; init; } public string? Version { get; init; } public string? Source { get; init; } - public List Children { get; } = []; + public AvaloniaList Children { get; } = new(); public event PropertyChangedEventHandler? PropertyChanged; @@ -92,7 +92,11 @@ public bool IsExpanded public partial class PackagesPageViewModel : ViewModelBase { public double FilterPaneColumnWidth => IsFilterPaneOpen ? 220.0 : 0.0; - partial void OnIsFilterPaneOpenChanged(bool _) => OnPropertyChanged(nameof(FilterPaneColumnWidth)); + partial void OnIsFilterPaneOpenChanged(bool value) + { + OnPropertyChanged(nameof(FilterPaneColumnWidth)); + Settings.SetDictionaryItem(Settings.K.HideToggleFilters, PageName, !value); + } // ─── Static config (set once in constructor) ────────────────────────────── public readonly string PageName; @@ -205,6 +209,10 @@ public PackagesPageViewModel(PackagesPageData data) ? (PackageViewMode)savedMode : PackageViewMode.List; + // Restore per-page filter pane open/closed state (default: open). + // Use backing field to avoid writing to settings during construction. + _isFilterPaneOpen = !Settings.GetDictionaryItem(Settings.K.HideToggleFilters, PageName); + _localPackagesNode.PackageName = CoreTools.Translate("Local"); if (Loader.IsLoading) @@ -584,11 +592,12 @@ public void AddPackageToSourcesList(IPackage package) UsedSourcesForManager[source.Manager].Add(source); var item = new SourceTreeNode { - PackageName = source.Manager.DisplayName, + PackageName = source.Name, PackageID = package.Id, Version = package.VersionString, Source = package.Source.Name }; + item.PropertyChanged += OnRootSourceNodePropertyChanged; NodesForSources.TryAdd(source, item); if (source.IsVirtualManager) @@ -610,7 +619,11 @@ public void AddPackageToSourcesList(IPackage package) public void ClearSourcesList() { foreach (var node in SourceNodes) + { node.PropertyChanged -= OnRootSourceNodePropertyChanged; + foreach (var child in node.Children) + child.PropertyChanged -= OnRootSourceNodePropertyChanged; + } UsedManagers.Clear(); SourceNodes.Clear(); UsedSourcesForManager.Clear(); @@ -631,11 +644,37 @@ private void OnRootSourceNodePropertyChanged(object? sender, PropertyChangedEven FilterPackages(); } private List GetAllSourceNodes() => SourceNodes.ToList(); - private List GetSelectedSourceNodes() => SourceNodes.Where(n => n.IsSelected).ToList(); + + private List GetSelectedSourceNodes() + { + var result = new List(); + foreach (var root in SourceNodes) + { + if (root.IsSelected) result.Add(root); + result.AddRange(root.Children.Where(c => c.IsSelected)); + } + return result; + } public void SetSourceNodeSelected(SourceTreeNode node, bool selected) => node.IsSelected = selected; - public void ClearSourceSelection() { foreach (var n in SourceNodes) n.IsSelected = false; } - public void SelectAllSources() { foreach (var n in SourceNodes) n.IsSelected = true; } + + public void ClearSourceSelection() + { + foreach (var n in SourceNodes) + { + n.IsSelected = false; + foreach (var c in n.Children) c.IsSelected = false; + } + } + + public void SelectAllSources() + { + foreach (var n in SourceNodes) + { + n.IsSelected = true; + foreach (var c in n.Children) c.IsSelected = true; + } + } // ─── Header texts ───────────────────────────────────────────────────────── public void UpdateHeaderTexts() diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsControl.axaml b/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsControl.axaml new file mode 100644 index 0000000000..f6406c1d65 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsControl.axaml @@ -0,0 +1,362 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -223,6 +281,7 @@ + @@ -284,7 +343,8 @@ automation:AutomationProperties.AccessibilityView="Control" Orientation="Horizontal" Spacing="0"> - ViewModel.NavigateTo(type); + /// + /// Focuses the global search box and optionally pre-fills a character typed + /// while the package list had focus (type-to-search). + /// + public void FocusGlobalSearch(string prefill = "") + { + if (!string.IsNullOrEmpty(prefill)) + { + ViewModel.GlobalSearchText = prefill; + // Place cursor at end so the user can keep typing + GlobalSearchBox.CaretIndex = prefill.Length; + } + GlobalSearchBox.Focus(); + } + // ─── Public API (legacy compat) ─────────────────────────────────────────── public void ShowBanner(string title, string message, RuntimeNotificationLevel level) { diff --git a/src/UniGetUI.Avalonia/Views/Pages/HelpPage.axaml b/src/UniGetUI.Avalonia/Views/Pages/HelpPage.axaml index 0578f663e4..43d37cd008 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/HelpPage.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/HelpPage.axaml @@ -74,9 +74,21 @@ automation:AutomationProperties.AccessibilityView="Raw"/> - + + + + + + + diff --git a/src/UniGetUI.Avalonia/Views/Pages/HelpPage.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/HelpPage.axaml.cs index d18803b7e0..e68467488e 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/HelpPage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/Pages/HelpPage.axaml.cs @@ -1,7 +1,6 @@ using Avalonia.Controls; using Avalonia.Interactivity; using UniGetUI.Avalonia.ViewModels.Pages; -using UniGetUI.Avalonia.Views.Pages; namespace UniGetUI.Avalonia.Views.Pages; @@ -9,6 +8,7 @@ public partial class HelpPage : UserControl, IEnterLeaveListener { private readonly HelpPageViewModel _viewModel; private string _pendingNavigation = HelpPageViewModel.HelpBaseUrl; + private bool _adapterReady; public HelpPage() { @@ -16,36 +16,46 @@ public HelpPage() DataContext = _viewModel; InitializeComponent(); - WebViewControl.NavigationStarted += OnNavigationStarted; - WebViewControl.NavigationCompleted += OnNavigationCompleted; - } + if (OperatingSystem.IsLinux()) + { + WebViewBorder.IsVisible = false; + LinuxFallbackPanel.IsVisible = true; + return; + } - private void OnNavigationStarted(object? sender, WebViewNavigationStartingEventArgs e) - { - NavProgressBar.IsVisible = true; - } + WebViewControl.NavigationStarted += (_, _) => + NavProgressBar.IsVisible = true; - private void OnNavigationCompleted(object? sender, WebViewNavigationCompletedEventArgs e) - { - NavProgressBar.IsVisible = false; - _viewModel.CurrentUrl = WebViewControl.Source?.ToString() ?? HelpPageViewModel.HelpBaseUrl; + WebViewControl.NavigationCompleted += (_, e) => + { + NavProgressBar.IsVisible = false; + _viewModel.CurrentUrl = WebViewControl.Source?.ToString() ?? HelpPageViewModel.HelpBaseUrl; + BackButton.IsEnabled = WebViewControl.CanGoBack; + ForwardButton.IsEnabled = WebViewControl.CanGoForward; + }; - BackButton.IsEnabled = WebViewControl.CanGoBack; - ForwardButton.IsEnabled = WebViewControl.CanGoForward; + // WebView2 on Windows initializes asynchronously after the control is attached + // to the visual tree. Navigate() called before AdapterCreated is silently dropped. + // This mirrors WinUI's EnsureCoreWebView2Async() pattern. + WebViewControl.AdapterCreated += (_, _) => + { + _adapterReady = true; + WebViewControl.Navigate(new Uri(_pendingNavigation)); + }; } public void NavigateTo(string uriAttachment) { string url = _viewModel.GetInitialUrl(uriAttachment); - if (WebViewControl.IsLoaded) + _pendingNavigation = url; + if (_adapterReady) WebViewControl.Navigate(new Uri(url)); - else - _pendingNavigation = url; } public void OnEnter() { - WebViewControl.Navigate(new Uri(_pendingNavigation)); + if (_adapterReady) + WebViewControl.Navigate(new Uri(_pendingNavigation)); } public void OnLeave() { } diff --git a/src/UniGetUI.Avalonia/Views/Pages/ReleaseNotesPage.axaml b/src/UniGetUI.Avalonia/Views/Pages/ReleaseNotesPage.axaml index 33db9f7280..75437361bf 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/ReleaseNotesPage.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/ReleaseNotesPage.axaml @@ -35,9 +35,21 @@ automation:AutomationProperties.AccessibilityView="Raw"/> - + + + + + + + diff --git a/src/UniGetUI.Avalonia/Views/Pages/ReleaseNotesPage.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/ReleaseNotesPage.axaml.cs index 842cc529b9..a0b5e5e04c 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/ReleaseNotesPage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/Pages/ReleaseNotesPage.axaml.cs @@ -7,6 +7,7 @@ public partial class ReleaseNotesPage : UserControl, IEnterLeaveListener { private readonly ReleaseNotesPageViewModel _viewModel; private bool _loaded; + private bool _adapterReady; public ReleaseNotesPage() { @@ -14,17 +15,36 @@ public ReleaseNotesPage() DataContext = _viewModel; InitializeComponent(); - WebViewControl.NavigationStarted += (_, _) => NavProgressBar.IsVisible = true; + if (OperatingSystem.IsLinux()) + { + WebViewBorder.IsVisible = false; + LinuxFallbackPanel.IsVisible = true; + return; + } + + WebViewControl.NavigationStarted += (_, _) => + NavProgressBar.IsVisible = true; + WebViewControl.NavigationCompleted += (_, e) => { NavProgressBar.IsVisible = false; _viewModel.CurrentUrl = WebViewControl.Source?.ToString() ?? _viewModel.ReleaseNotesUrl; }; + + WebViewControl.AdapterCreated += (_, _) => + { + _adapterReady = true; + if (!_loaded) + { + WebViewControl.Navigate(new Uri(_viewModel.ReleaseNotesUrl)); + _loaded = true; + } + }; } public void OnEnter() { - if (!_loaded) + if (!_loaded && _adapterReady) { WebViewControl.Navigate(new Uri(_viewModel.ReleaseNotesUrl)); _loaded = true; diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml index 5fdf3b256a..d43b9d2404 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml @@ -248,19 +248,15 @@ - - + + - - + + + + + + + + + + + @@ -420,10 +429,25 @@ - + + + + + - + - + + + + + @@ -578,12 +611,19 @@ automation:AutomationProperties.Name="{Binding Package.AutomationName}" automation:AutomationProperties.ItemType="{t:Translate Package}" Background="{DynamicResource AppBorderBrush}" Padding="4"> - - + + + + + + - + + + + + @@ -629,11 +679,27 @@ HorizontalAlignment="Left" VerticalAlignment="Top" Padding="0" Margin="-4,0,0,0"/> - + + + + + + - + diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs index daa61430fe..99bc90d2e7 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs @@ -4,9 +4,13 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Input; using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; using Avalonia.Threading; using UniGetUI.Avalonia.ViewModels.Pages; using UniGetUI.Avalonia.Views.Controls; +using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.PackageClasses; @@ -17,6 +21,9 @@ public abstract partial class AbstractPackagesPage : UserControl, IKeyboardShortcutListener, IEnterLeaveListener, ISearchBoxPage { public PackagesPageViewModel ViewModel => (PackagesPageViewModel)DataContext!; + private readonly ContextMenu? _contextMenu; + private double _savedFilterPaneWidth = 220; + private bool _isOverlayMode; protected AbstractPackagesPage(PackagesPageData data) { @@ -49,7 +56,10 @@ or nameof(PackagesPageViewModel.SortAscending)) SyncOrderByButtonName(); } if (args.PropertyName is nameof(PackagesPageViewModel.IsFilterPaneOpen)) + { SyncFiltersButtonName(); + UpdateFilterPaneColumn(ViewModel.IsFilterPaneOpen); + } }; SyncFiltersButtonName(); SyncOrderByButtonName(); @@ -63,17 +73,54 @@ or nameof(PackagesPageViewModel.SortAscending)) // Keyboard shortcuts on the package list PackageList.KeyDown += PackageList_KeyDown; + // Type-to-search: printable characters typed while the list is focused + // redirect focus + the typed character to the global search box. + PackageList.TextInput += PackageList_TextInput; + + // Snap-close when splitter is dragged below the minimum (inline mode only). + // Using ColumnDefinition.WidthProperty fires every drag step, not just on release. + FilteringPanel.ColumnDefinitions[0] + .GetObservable(ColumnDefinition.WidthProperty) + .Subscribe(width => + { + if (_isOverlayMode || !ViewModel.IsFilterPaneOpen) return; + if (width.IsAbsolute && width.Value >= 100) + { + _savedFilterPaneWidth = width.Value; + Settings.SetDictionaryItem(Settings.K.SidepanelWidths, ViewModel.PageName, (int)width.Value); + } + else if (width.IsAbsolute && width.Value < 100) + { + _savedFilterPaneWidth = 220; + ViewModel.IsFilterPaneOpen = false; + } + }); + + // Responsive: switch between inline and overlay modes based on content width. + FilteringPanel.GetObservable(BoundsProperty) + .Subscribe(bounds => OnFilteringPanelWidthChanged(bounds.Width)); + + // Overlay backdrop dismisses the filter pane when tapped. + FilterOverlayBackdrop.PointerPressed += (_, _) => ViewModel.IsFilterPaneOpen = false; + // Wire context menu (built by subclass) - var contextMenu = GenerateContextMenu(); - if (contextMenu is not null) + _contextMenu = GenerateContextMenu(); + if (_contextMenu is not null) { - PackageList.ContextMenu = contextMenu; - contextMenu.Opening += (_, _) => + PackageList.ContextMenu = _contextMenu; + _contextMenu.Opening += (_, _) => { var pkg = SelectedItem; if (pkg is not null) WhenShowingContextMenu(pkg); }; } + + // Restore per-page filter pane width from settings. + var savedWidth = Settings.GetDictionaryItem(Settings.K.SidepanelWidths, ViewModel.PageName); + if (savedWidth >= 100) _savedFilterPaneWidth = savedWidth; + + // Apply the initial filter-pane state (AXAML defaults to 220px open). + UpdateFilterPaneColumn(ViewModel.IsFilterPaneOpen); } // ─── UI-only: focus the package list ───────────────────────────────────── @@ -193,10 +240,7 @@ private void UpdateSortMenuChecks() } // ─── IKeyboardShortcutListener ──────────────────────────────────────────── - public void SearchTriggered() - { - // TODO: focus global search box - } + public void SearchTriggered() => GetMainWindow()?.FocusGlobalSearch(); public void ReloadTriggered() => ViewModel.TriggerReload(); public void SelectAllTriggered() => ViewModel.ToggleSelectAll(); @@ -243,6 +287,79 @@ private void PackageList_KeyDown(object? sender, KeyEventArgs e) } } + private void PackageList_TextInput(object? sender, TextInputEventArgs e) + { + if (string.IsNullOrEmpty(e.Text)) return; + + // Append the typed character to the current query and move focus to the search box + GetMainWindow()?.FocusGlobalSearch(ViewModel.GlobalQueryText + e.Text); + e.Handled = true; + } + + // ─── Filter pane column width management ───────────────────────────────── + + private void OnFilteringPanelWidthChanged(double width) + { + if (width <= 0) return; // layout not complete yet + bool shouldBeOverlay = width < 1000; + if (shouldBeOverlay == _isOverlayMode) return; + + _isOverlayMode = shouldBeOverlay; + + if (_isOverlayMode && ViewModel.IsFilterPaneOpen) + ViewModel.IsFilterPaneOpen = false; // collapse pane when entering overlay + else + UpdateFilterPaneColumn(ViewModel.IsFilterPaneOpen); + } + + private void UpdateFilterPaneColumn(bool open) + { + if (FilteringPanel.ColumnDefinitions.Count < 2) return; + + if (_isOverlayMode) + { + // Package list fills full width; filter pane and splitter take no space. + FilteringPanel.ColumnDefinitions[0].Width = new GridLength(0); + FilteringPanel.ColumnDefinitions[1].Width = new GridLength(0); + + // Float the filter pane on top of the content when open. + Grid.SetColumnSpan(SidePanel, 3); + SidePanel.ZIndex = 10; + SidePanel.Width = _savedFilterPaneWidth; + SidePanel.HorizontalAlignment = HorizontalAlignment.Left; + + // Semi-transparent backdrop covers the package list behind the pane. + FilterOverlayBackdrop.IsVisible = open; + } + else + { + // Inline mode: pane sits beside the package list. + Grid.SetColumnSpan(SidePanel, 1); + SidePanel.ZIndex = 0; + SidePanel.Width = double.NaN; + SidePanel.HorizontalAlignment = HorizontalAlignment.Stretch; + FilterOverlayBackdrop.IsVisible = false; + + FilteringPanel.ColumnDefinitions[0].Width = open + ? new GridLength(_savedFilterPaneWidth) + : new GridLength(0); + FilteringPanel.ColumnDefinitions[1].Width = open + ? new GridLength(4) + : new GridLength(0); + } + } + + // ─── Card overflow button (Grid / Icons view) ───────────────────────────── + private void CardOverflowButton_Click(object? sender, RoutedEventArgs e) + { + if (sender is not Button { DataContext: PackageWrapper wrapper }) return; + PackageList.SelectedItem = wrapper; + if (_contextMenu is null) return; + WhenShowingContextMenu(wrapper.Package); + _contextMenu.Open(sender as Control); + e.Handled = true; + } + // ─── Shared cross-page helpers ──────────────────────────────────────────── protected static MainWindow? GetMainWindow() => Application.Current?.ApplicationLifetime diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs index 934d4efca7..e602a8c069 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs @@ -63,6 +63,8 @@ public InstalledPackagesPage() : base(new PackagesPageData _hasBackedUp = true; if (Settings.Get(Settings.K.EnablePackageBackup_LOCAL)) _ = BackupViewModel.DoLocalBackupStatic(); + if (Settings.Get(Settings.K.EnablePackageBackup_CLOUD)) + _ = BackupViewModel.DoCloudBackupStatic(); } if (OperatingSystem.IsWindows() diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs index e1645f3b57..14ec931858 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs @@ -396,6 +396,11 @@ private static async Task WhenPackagesLoaded() Logger.Warn("Updates will not be installed automatically because battery saver is enabled."); ShowAvailableUpdatesNotification(upgradable); } + else if (Settings.Get(Settings.K.DisableAUPOnMeteredConnections) && IsOnMeteredConnection()) + { + Logger.Warn("Updates will not be installed automatically because the current internet connection is metered."); + ShowAvailableUpdatesNotification(upgradable); + } else if (Settings.Get(Settings.K.AutomaticallyUpdatePackages)) { _ = AvaloniaPackageOperationHelper.UpdateAllAsync(); @@ -473,4 +478,18 @@ private static bool IsBatterySaverOn() return GetSystemPowerStatus(out var s) && (s.SystemStatusFlag & 0x01) != 0; #pragma warning restore CA1416 } + + private static bool IsOnMeteredConnection() + { +#if WINDOWS + var costType = Windows.Networking.Connectivity.NetworkInformation + .GetInternetConnectionProfile() + ?.GetConnectionCost() + .NetworkCostType; + return costType is Windows.Networking.Connectivity.NetworkCostType.Fixed + or Windows.Networking.Connectivity.NetworkCostType.Variable; +#else + return false; +#endif + } } diff --git a/src/UniGetUI.Avalonia/app.manifest b/src/UniGetUI.Avalonia/app.manifest index a4c7b5a162..16f839599f 100644 --- a/src/UniGetUI.Avalonia/app.manifest +++ b/src/UniGetUI.Avalonia/app.manifest @@ -7,12 +7,18 @@ - - - + + + + + + PerMonitorV2, PerMonitor + true + + +