FModel/FModel/ViewModels/UpdateViewModel.cs
Rob Trame 40d8b73ec5
fix: address remaining WPF→Avalonia gaps for phase 2 completion (#85)
* fix: address remaining WPF→Avalonia gaps for phase 2 completion

Resolves r6e/FModel-Linux#84

- Convert Colors.xaml & Icons.xaml to Avalonia xmlns
- Migrate CommitControl to Avalonia (replace DataTriggers with IsVisible)
- Migrate SearchTextBox to Avalonia (StyledProperty, native Watermark)
- Migrate TreeViewItemBehavior to Avalonia attached properties
- Replace ListCollectionView with DataGridCollectionView in 8 ViewModels
- Add Framework/CompositeCollection as WPF CompositeCollection replacement
- Remove AdonisUI.Controls refs from LoadCommand & AddEditDirectoryCommand
- Replace SystemParameters with Avalonia screen API in CUE4ParseViewModel
- Rewrite Resources.xaml for Avalonia Fluent theme (~2150→290 lines)
- Extend CommitMessageConverter with HasDescription parameter support

* fix: address review findings for PR #85

- C1: Fix ToolTip syntax (TextBlock.ToolTip → ToolTip.Tip) in tree view
- C2: Add Fill binding to folder icons via ConverterParameter=Brush
- C3: Convert AddEditDirectoryCommand.ShowDialog() to async with owner
- M1: Convert keyed Style → ControlTheme + Theme= in all consumers
- M2: Fix CommitControl badge overlap (Latest/Current mutual exclusivity)
  Add FallbackValue=False for null-safe Asset.IsLatest binding
- M3: Add TODO(P4-004) markers for OpenFileDialog/SaveFileDialog
- M4: Implement IDisposable on CompositeCollection; dispose on replace
- PR: Add UrlToBitmapConverter for HTTP avatar image loading
- PR: Fix ContextMenu DataContext in GameFilesTabControl ContentTemplate
  Restore 'Disable Alpha Channel' toggle (NoAlpha binding)
- PR: Replace ElementName=TabControlName with direct DataContext bindings
- PR: TreeViewItemBehavior uses GetObservable(IsSelectedProperty) with
  proper IDisposable subscription cleanup
- S1: Clarify UpdateViewModel grouping TODO (non-DataGrid limitation)
- S2: Cache compiled Regex in SearchViewModel.ItemFilter
- m3: Implement TabItemFillSpace with HorizontalAlignment=Stretch

Also updates FolderToGeometryConverter to support ConverterParameter
for explicit brush/geometry selection.

* fix: address review #2 findings on PR #85

- M1/m1/m2: UrlToBitmapConverter — sync download with ConcurrentDictionary
  URL cache, 4-space indent, doc comment noting async limitation
- M2: Remove duplicate IsVisible attribute on Latest badge Border
  (MultiBinding property element takes precedence; attribute was dead code)
- m3: Add TimeSpan.FromSeconds(1) timeout to user-input Regex construction
- PR: ZIndex → Panel.ZIndex in SearchTextBox.xaml (Avalonia attached prop)
- PR: ShadowEffect comment updated to reflect key removal (no consumers)
- PR: CustomSeparator Tag TextBlock hidden when empty via
  StringConverters.IsNotNullOrEmpty (eliminates unwanted 10px margin)
- PR: CustomVerticalSeparator BasedOn changed from default Separator to
  CustomSeparator (preserves tagged template inheritance from WPF)
- PR: AddEditDirectoryCommand — early return when owner is null instead
  of silently skipping dialog
- PR: CompositeCollection.Count — skip non-ICollection sources instead
  of O(n) enumeration fallback

* Fix Avalonia parity gaps in updates, styles, and dialogs

* fix: address parity review follow-ups and unresolved PR comments

* Fix remaining PR review thread regressions

* Address review findings: thread safety, Linux guards, indentation, and cleanup

- [C1] Fix LoadCoAuthors thread race by snapshotting Commits before Task.Run
- [M1] Remove IsAsync=True (WPF-only) from Avalonia bindings
- [M2] Add OperatingSystem.IsWindows() guard for Win32 OpenFileDialog
- [M3] Add null-safety for Application.Current in PlayPause converter
- [N1] Add BrushTransition to HighlightedCheckBox for animation parity
- [N2] Dispose TreeViewItem subscriptions on DetachedFromVisualTree
- [N3] Fix SelectionMode converter: only Multiple uses multi-select
- [S1] Simplify AddEditDirectoryCommand owner-null to early return
- [S3] Remove dead WPF trigger comments from UpdateView.xaml
- [S4] Add 10s timeout to UrlToBitmapConverter HttpClient
- PR: Reformat UrlToBitmapConverter to 4-space indentation
- PR: Catch RegexMatchTimeoutException in SearchViewModel filter

* Fix review round 2: ActualWidth→Bounds, IsAsync removal, FindAncestor xmlns, misc

Critical:
- C1: Replace ActualHeight/ActualWidth with Bounds.Height/Bounds.Width
  in UpdateView, MainWindow, SettingsView, DirectorySelector (Avalonia
  has no ActualWidth/ActualHeight properties)
- C2: Remove remaining IsAsync=True from SearchView.xaml and
  AudioPlayer.xaml (WPF-only, no-op in Avalonia)

Major:
- M1: Add xmlns:views for FModel.Views and replace dotted sub-namespace
  local:Views.SettingsView with views:SettingsView in all 23
  FindAncestor bindings (avoids ambiguous XAML type resolution)
- M2: CompositeCollection.Count now skips non-ICollection sources
  instead of enumerating them (O(1) for current callers)

Minor:
- N1: Add Application.Current null guard in FolderToGeometryConverter
  (matches PlaybackStateToPlayPauseConverter pattern)

Suggestions:
- S1: UrlToBitmapConverter.LoadAsync now cleans up _inFlight on all
  exit paths (defense-in-depth for exceptional failures)
- S2: Add comment in CommitMessageConverter documenting interaction
  with LoadCoAuthors co-author cleanup

* fix: merge resource dicts, regex cache, handler cleanup, null-init

- Merge Colors.xaml, Icons.xaml, Resources.xaml into App.xaml so
  StaticResource/DynamicResource lookups resolve at runtime
- Cache invalid regex state in SearchViewModel to avoid repeated
  recompilation on every filter invocation
- Detach DetachedFromVisualTree handler in TreeViewItemBehavior
  when IsBroughtIntoViewWhenSelected is set to false
- Fix LoadingModeToSelectionModeConverter doc to match behavior
- Initialize Commit/Author backing fields with null! to satisfy
  non-nullable contract before JSON deserialization
- Add Debug.Fail in CompositeCollection.Count for non-ICollection
  sources to surface unexpected usage in development

* fix: load missing resource dictionaries, remove dead style refs, fix using

- App.xaml: add ResourceInclude for FileContextMenu, FolderContextMenu,
  and TiledExplorer/Resources.xaml so StaticResource lookups resolve at
  runtime (C1 fix)
- TiledExplorer/Resources.xaml: remove ContextMenu Setters from both
  TiledExplorer and AssetsListBox styles; Avalonia does not support
  x:Shared="False", so the shared instance would break when two
  ListBoxes reference the same ContextMenu. ListBoxItemBehavior already
  handles assignment programmatically via TryFindResource on right-click.
- SettingsView.xaml: remove 11 references to undefined TextBoxDefaultStyle;
  HotkeyTextBox inherits FluentTheme TextBox styling by default (C2 fix)
- LoadingModeToSelectionModeConverter.cs: add missing 'using FModel' for
  ELoadingMode resolution (M1 fix)

* fix: convert Resources.xaml to Styles root, restore toggle button states

- Resources.xaml: change root from <ResourceDictionary> to <Styles> so
  StyleInclude in App.xaml correctly loads an IStyle-implementing element.
  All keyed resources (ControlThemes, converters) moved into
  <Styles.Resources>; selector-based styles (audio controls, FoldingMargin)
  are direct <Styles> children.
- AssetsExplorerToggleButtonStyle: restore IsChecked-dependent behavior
  using Avalonia selectors. :checked shows FolderIconAlt + accent background,
  :unchecked shows AssetIcon + default background. Tooltip shows hotkey.
- MainWindow.xaml: add Classes="AssetsExplorerToggle" to the toggle button
  so the selector-based checked/unchecked styles match.

* Fix Round 7 review findings: ControlTheme, ToolTip.Tip, dead code

- C1: Convert keyed Styles to ControlTheme in TiledExplorer/Resources.xaml,
  update Style= to Theme= in MainWindow.xaml, convert CustomRichTextBox
  to auto-applying Style Selector in Resources.xaml
- M1: Fix ToolTip= to ToolTip.Tip= across SearchView, SettingsView,
  AudioPlayer, and DirectorySelector (22 occurrences)
- m1: Remove duplicate _inFlight.TryRemove in UrlToBitmapConverter.cs
- m2: Remove dead Result property from CustomDir.xaml.cs
- S2: Remove unused InvertBooleanConverter from Resources.xaml

* Fix PR review findings: image column collapse, thread safety

- Add HasImageToColumnSpanConverter so AvalonEditor spans all columns
  when HasImage is false, preventing unused blank space from the image
  column width (Resources.xaml GameFilesTabControl ContentTemplate)
- Marshal LoadAssets and LoadCoAuthors collection/property mutations to
  Dispatcher.UIThread.InvokeAsync for explicit thread safety

* fix: reformat HasImageToColumnSpanConverter to 4-space indentation

* fix: map All/AllButNew/AllButModified/AllButPatched to multi-select

LoadingModeToSelectionModeConverter only mapped Multiple to
SelectionMode.Multiple. The original WPF DataTriggers also enabled
Extended selection for All, AllButNew, AllButModified, and
AllButPatched modes. Add those mappings to restore parity.
2026-03-16 21:35:16 -06:00

263 lines
8.0 KiB
C#

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Avalonia.Collections;
using Avalonia.Threading;
using CUE4Parse.Utils;
using FModel.Framework;
using FModel.Services;
using FModel.Settings;
using FModel.ViewModels.ApiEndpoints.Models;
using FModel.ViewModels.Commands;
using FModel.Views.Resources.Converters;
using Serilog;
namespace FModel.ViewModels;
public class CommitGroup
{
public DateTime Date { get; }
public IReadOnlyList<GitHubCommit> Items { get; }
public CommitGroup(DateTime date, IReadOnlyList<GitHubCommit> items)
{
Date = date;
Items = items;
}
}
public partial class UpdateViewModel : ViewModel
{
private ApiEndpointViewModel _apiEndpointView => ApplicationService.ApiEndpointView;
private RemindMeCommand _remindMeCommand;
public RemindMeCommand RemindMeCommand => _remindMeCommand ??= new RemindMeCommand(this);
public RangeObservableCollection<GitHubCommit> Commits { get; }
public RangeObservableCollection<CommitGroup> CommitGroups { get; }
public bool HasNoCommits => CommitGroups.Count == 0;
private bool _suppressRegroup;
public UpdateViewModel()
{
Commits = [];
CommitGroups = [];
Commits.CollectionChanged += (_, _) =>
{
if (!_suppressRegroup)
RebuildCommitGroups();
};
if (UserSettings.Default.NextUpdateCheck < DateTime.Now)
RemindMeCommand.Execute(this, null);
}
public async Task LoadAsync()
{
var commits = await _apiEndpointView.GitHubApi.GetCommitHistoryAsync();
if (commits == null || commits.Length == 0)
return;
Commits.AddRange(commits);
_ = LoadAvatars();
try
{
_ = LoadCoAuthors().ContinueWith(t =>
{
if (t.IsFaulted)
Log.Error(t.Exception, "Failed to load co-authors");
}, TaskScheduler.Default);
_ = LoadAssets().ContinueWith(t =>
{
if (t.IsFaulted)
Log.Error(t.Exception, "Failed to load assets");
}, TaskScheduler.Default);
}
catch
{
//
}
}
private async Task LoadCoAuthors()
{
var snapshot = Commits.ToList();
var coAuthorMap = await Task.Run(() =>
{
var map = new Dictionary<GitHubCommit, (string CleanMessage, HashSet<string> Usernames)>();
var regex = GetCoAuthorRegex();
foreach (var commit in snapshot)
{
if (!commit.Commit.Message.Contains("Co-authored-by"))
continue;
var matches = regex.Matches(commit.Commit.Message);
if (matches.Count == 0)
continue;
var usernames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in matches)
{
if (match.Groups.Count < 3)
continue;
var username = match.Groups[1].Value;
if (username.Equals("Asval", StringComparison.OrdinalIgnoreCase))
username = "4sval"; // found out the hard way co-authored usernames can't be trusted
usernames.Add(username);
}
if (usernames.Count == 0)
continue;
var cleanMessage = regex.Replace(commit.Commit.Message, string.Empty).Trim();
map[commit] = (cleanMessage, usernames);
}
return map;
});
if (coAuthorMap.Count == 0)
return;
await Dispatcher.UIThread.InvokeAsync(() =>
{
foreach (var (commit, data) in coAuthorMap)
commit.Commit.Message = data.CleanMessage;
});
var uniqueUsernames = coAuthorMap.Values.SelectMany(x => x.Usernames).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var authorCache = new Dictionary<string, Author>();
foreach (var username in uniqueUsernames)
{
try
{
var author = await _apiEndpointView.GitHubApi.GetUserAsync(username);
if (author != null)
authorCache[username] = author;
}
catch
{
//
}
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
foreach (var (commit, data) in coAuthorMap)
{
var coAuthors = data.Usernames
.Where(username => authorCache.ContainsKey(username))
.Select(username => authorCache[username])
.ToArray();
if (coAuthors.Length > 0)
commit.CoAuthors = coAuthors;
}
});
await LoadAvatars();
}
private async Task LoadAssets()
{
var qa = await _apiEndpointView.GitHubApi.GetReleaseAsync("qa");
var assets = qa.Assets.OrderByDescending(x => x.CreatedAt).ToList();
await Dispatcher.UIThread.InvokeAsync(() =>
{
_suppressRegroup = true;
try
{
for (var i = 0; i < assets.Count; i++)
{
var asset = assets[i];
asset.IsLatest = i == 0;
var commitSha = asset.Name.SubstringBeforeLast(".zip");
var commit = Commits.FirstOrDefault(x => x.Sha == commitSha);
if (commit != null)
{
commit.Asset = asset;
}
else
{
Commits.Add(new GitHubCommit
{
Sha = commitSha,
Commit = new Commit
{
Message = $"FModel ({commitSha[..7]})",
Author = new Author { Name = asset.Uploader.Login, Date = asset.CreatedAt }
},
Author = asset.Uploader,
Asset = asset
});
}
}
}
finally
{
_suppressRegroup = false;
RebuildCommitGroups();
}
});
await LoadAvatars();
}
private void RebuildCommitGroups()
{
var groups = Commits
.OrderByDescending(x => x.Commit?.Author?.Date ?? DateTime.MinValue)
.GroupBy(x => (x.Commit?.Author?.Date ?? DateTime.MinValue).Date)
.Select(g => new CommitGroup(g.Key, g.ToList()))
.ToList();
CommitGroups.Clear();
CommitGroups.AddRange(groups);
RaisePropertyChanged(nameof(HasNoCommits));
}
private async Task LoadAvatars()
{
var authorsByUrl = Commits
.SelectMany(x => x.Authors)
.Where(x => x != null && !string.IsNullOrWhiteSpace(x.AvatarUrl))
.ToArray()
.GroupBy(x => x.AvatarUrl, StringComparer.OrdinalIgnoreCase)
.ToArray();
foreach (var authors in authorsByUrl)
{
if (authors.All(x => x.AvatarImage != null))
continue;
var bitmap = await UrlToBitmapConverter.LoadAsync(authors.Key);
if (bitmap == null)
continue;
await Dispatcher.UIThread.InvokeAsync(() =>
{
foreach (var author in authors)
author.AvatarImage = bitmap;
});
}
}
public void DownloadLatest()
{
Commits.FirstOrDefault(x => x.IsDownloadable && x.Asset.IsLatest)?.Download();
}
[GeneratedRegex(@"Co-authored-by:\s*(.+?)\s*<(.+?)>", RegexOptions.IgnoreCase | RegexOptions.Multiline, "en-US")]
private static partial Regex GetCoAuthorRegex();
}