mirror of
https://github.com/4sval/FModel.git
synced 2026-03-22 09:44:37 -05:00
* 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.
263 lines
8.0 KiB
C#
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();
|
|
}
|