diff --git a/.github/agents/wpf-to-avalonia-migrator.agent.md b/.github/agents/wpf-to-avalonia-migrator.agent.md
index 969954bd..44a54b6b 100644
--- a/.github/agents/wpf-to-avalonia-migrator.agent.md
+++ b/.github/agents/wpf-to-avalonia-migrator.agent.md
@@ -4,11 +4,34 @@ name: WPF → Avalonia Migrator
argument-hint: Name a specific file, view, or area to migrate (e.g. "migrate FModel/Views/SettingsView.xaml" or "migrate all Views")
tools:
[
- execute,
- read,
- edit,
- search,
- web,
+ vscode/memory,
+ execute/runNotebookCell,
+ execute/testFailure,
+ execute/getTerminalOutput,
+ execute/awaitTerminal,
+ execute/killTerminal,
+ execute/createAndRunTask,
+ execute/runInTerminal,
+ execute/runTests,
+ read/getNotebookSummary,
+ read/problems,
+ read/readFile,
+ read/terminalSelection,
+ read/terminalLastCommand,
+ edit/createDirectory,
+ edit/createFile,
+ edit/createJupyterNotebook,
+ edit/editFiles,
+ edit/editNotebook,
+ edit/rename,
+ search/changes,
+ search/codebase,
+ search/fileSearch,
+ search/listDirectory,
+ search/searchResults,
+ search/textSearch,
+ search/usages,
+ web/fetch,
github/add_issue_comment,
github/add_reply_to_pull_request_comment,
github/create_pull_request,
diff --git a/FModel/App.xaml b/FModel/App.xaml
index 81f03f52..abcf2ed5 100644
--- a/FModel/App.xaml
+++ b/FModel/App.xaml
@@ -4,13 +4,17 @@
RequestedThemeVariant="Dark">
-
+
+
+
+
+
+
+
+
#206BD4
+/// Minimal replacement for WPF's CompositeCollection.
+/// Merges multiple sources into a single flat enumerable
+/// and relays events from each source.
+///
+public class CompositeCollection : IEnumerable, INotifyCollectionChanged, IDisposable
+{
+ private readonly IEnumerable[] _sources;
+ private bool _disposed;
+
+ public event NotifyCollectionChangedEventHandler? CollectionChanged;
+
+ public CompositeCollection(params IEnumerable[] sources)
+ {
+ _sources = sources;
+ foreach (var source in _sources)
+ {
+ if (source is INotifyCollectionChanged ncc)
+ ncc.CollectionChanged += OnSourceCollectionChanged;
+ }
+ }
+
+ private void OnSourceCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ // Relay as a reset since index mapping across multiple sources is complex
+ CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+ }
+
+ public IEnumerator GetEnumerator()
+ {
+ foreach (var source in _sources)
+ {
+ foreach (var item in source)
+ yield return item;
+ }
+ }
+
+ public int Count
+ {
+ get
+ {
+ var count = 0;
+ foreach (var source in _sources)
+ {
+ if (source is ICollection c)
+ count += c.Count;
+ else
+ Debug.Fail($"CompositeCollection.Count: source {source.GetType().Name} does not implement ICollection; its items are not counted.");
+ }
+ return count;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+
+ foreach (var source in _sources)
+ {
+ if (source is INotifyCollectionChanged ncc)
+ ncc.CollectionChanged -= OnSourceCollectionChanged;
+ }
+ }
+}
diff --git a/FModel/MainWindow.xaml b/FModel/MainWindow.xaml
index ad76d152..b85dd4ce 100644
--- a/FModel/MainWindow.xaml
+++ b/FModel/MainWindow.xaml
@@ -294,7 +294,7 @@
-
@@ -341,14 +341,14 @@
-
@@ -500,17 +500,17 @@
-
@@ -603,17 +603,17 @@
@@ -783,6 +783,7 @@
Height="32"
Cursor="Hand"
Margin="0,0,2,0"
+ Classes="AssetsExplorerToggle"
IsChecked="{Binding IsAssetsExplorerVisible, Mode=TwoWay}"
Theme="{StaticResource AssetsExplorerToggleButtonStyle}" />
+ x:Name="LogRtbName" />
ApplicationService.ThreadWorkerView;
public FullyObservableCollection AesKeys { get; private set; } // holds all aes keys even the main one
- public ICollectionView AesKeysView { get; private set; } // holds all aes key ordered by name for the ui
+ public DataGridCollectionView AesKeysView { get; private set; } // holds all aes key ordered by name for the ui
public bool HasChange { get; set; }
private AesResponse _keysFromSettings;
@@ -38,7 +38,7 @@ public class AesManagerViewModel : ViewModel
_mainKey.Key = Helper.FixKey(_keysFromSettings.MainKey);
AesKeys = new FullyObservableCollection(EnumerateAesKeys());
AesKeys.ItemPropertyChanged += AesKeysOnItemPropertyChanged;
- AesKeysView = new ListCollectionView(AesKeys) { SortDescriptions = { new SortDescription("Name", ListSortDirection.Ascending) } };
+ AesKeysView = new DataGridCollectionView(AesKeys) { SortDescriptions = { DataGridSortDescription.FromPath("Name", ListSortDirection.Ascending) } };
});
}
diff --git a/FModel/ViewModels/ApiEndpoints/Models/GitHubResponse.cs b/FModel/ViewModels/ApiEndpoints/Models/GitHubResponse.cs
index 2bd8e1cd..e9e8b4fd 100644
--- a/FModel/ViewModels/ApiEndpoints/Models/GitHubResponse.cs
+++ b/FModel/ViewModels/ApiEndpoints/Models/GitHubResponse.cs
@@ -1,10 +1,12 @@
using System;
using System.Diagnostics;
using System.Linq;
+using Avalonia.Media.Imaging;
using FModel.Framework;
using FModel.Settings;
using Serilog;
using J = Newtonsoft.Json.JsonPropertyAttribute;
+using JI = Newtonsoft.Json.JsonIgnoreAttribute;
namespace FModel.ViewModels.ApiEndpoints.Models;
@@ -116,17 +118,37 @@ public class GitHubCommit : ViewModel
}
}
-public class Commit
+public class Commit : ViewModel
{
- [J("author")] public Author Author { get; set; }
- [J("message")] public string Message { get; set; }
+ private Author _author = null!;
+ [J("author")] public Author Author
+ {
+ get => _author;
+ set => SetProperty(ref _author, value);
+ }
+
+ private string _message = null!;
+ [J("message")] public string Message
+ {
+ get => _message;
+ set => SetProperty(ref _message, value);
+ }
}
-public class Author
+public class Author : ViewModel
{
- [J("name")] public string Name { get; set; }
- [J("login")] public string Login { get; set; }
+ [J("name")] public string Name { get; set; } = null!;
+ [J("login")] public string Login { get; set; } = null!;
[J("date")] public DateTime Date { get; set; }
- [J("avatar_url")] public string AvatarUrl { get; set; }
- [J("html_url")] public string HtmlUrl { get; set; }
+ [J("avatar_url")] public string AvatarUrl { get; set; } = null!;
+ [J("html_url")] public string HtmlUrl { get; set; } = null!;
+
+ private Bitmap? _avatarImage;
+
+ [JI]
+ public Bitmap? AvatarImage
+ {
+ get => _avatarImage;
+ set => SetProperty(ref _avatarImage, value);
+ }
}
diff --git a/FModel/ViewModels/AssetsFolderViewModel.cs b/FModel/ViewModels/AssetsFolderViewModel.cs
index 135f08cf..2712e4da 100644
--- a/FModel/ViewModels/AssetsFolderViewModel.cs
+++ b/FModel/ViewModels/AssetsFolderViewModel.cs
@@ -4,7 +4,7 @@ using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
-using System.Windows.Data;
+using Avalonia.Collections;
using Avalonia.Threading;
using CUE4Parse.FileProvider.Objects;
using CUE4Parse.UE4.Versions;
@@ -87,35 +87,35 @@ public class TreeItem : ViewModel
public AssetsListViewModel AssetsList { get; } = new();
public RangeObservableCollection Folders { get; } = [];
- private ICollectionView _foldersView;
- public ICollectionView FoldersView
+ private DataGridCollectionView _foldersView;
+ public DataGridCollectionView FoldersView
{
get
{
- _foldersView ??= new ListCollectionView(Folders)
+ _foldersView ??= new DataGridCollectionView(Folders)
{
- SortDescriptions = { new SortDescription(nameof(Header), ListSortDirection.Ascending) }
+ SortDescriptions = { DataGridSortDescription.FromPath(nameof(Header), ListSortDirection.Ascending) }
};
return _foldersView;
}
}
- private ICollectionView? _filteredFoldersView;
- public ICollectionView? FilteredFoldersView
+ private DataGridCollectionView? _filteredFoldersView;
+ public DataGridCollectionView? FilteredFoldersView
{
get
{
- _filteredFoldersView ??= new ListCollectionView(Folders)
+ _filteredFoldersView ??= new DataGridCollectionView(Folders)
{
- SortDescriptions = { new SortDescription(nameof(Header), ListSortDirection.Ascending) },
+ SortDescriptions = { DataGridSortDescription.FromPath(nameof(Header), ListSortDirection.Ascending) },
Filter = e => ItemFilter(e, SearchText.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries))
};
return _filteredFoldersView;
}
}
- private CompositeCollection _combinedEntries;
- public CompositeCollection CombinedEntries
+ private Framework.CompositeCollection _combinedEntries;
+ public Framework.CompositeCollection CombinedEntries
{
get
{
@@ -123,11 +123,8 @@ public class TreeItem : ViewModel
{
void CreateCombinedEntries()
{
- _combinedEntries = new CompositeCollection
- {
- new CollectionContainer { Collection = FilteredFoldersView },
- new CollectionContainer { Collection = AssetsList.AssetsView }
- };
+ _combinedEntries?.Dispose();
+ _combinedEntries = new Framework.CompositeCollection(FilteredFoldersView, AssetsList.AssetsView);
}
if (!Dispatcher.UIThread.CheckAccess())
@@ -200,12 +197,12 @@ public class TreeItem : ViewModel
public class AssetsFolderViewModel
{
public RangeObservableCollection Folders { get; }
- public ICollectionView FoldersView { get; }
+ public DataGridCollectionView FoldersView { get; }
public AssetsFolderViewModel()
{
Folders = [];
- FoldersView = new ListCollectionView(Folders) { SortDescriptions = { new SortDescription("Header", ListSortDirection.Ascending) } };
+ FoldersView = new DataGridCollectionView(Folders) { SortDescriptions = { DataGridSortDescription.FromPath("Header", ListSortDirection.Ascending) } };
}
public void BulkPopulate(IReadOnlyCollection entries)
diff --git a/FModel/ViewModels/AssetsListViewModel.cs b/FModel/ViewModels/AssetsListViewModel.cs
index 1666644c..2122c41e 100644
--- a/FModel/ViewModels/AssetsListViewModel.cs
+++ b/FModel/ViewModels/AssetsListViewModel.cs
@@ -1,5 +1,5 @@
using System.ComponentModel;
-using System.Windows.Data;
+using Avalonia.Collections;
using CUE4Parse.FileProvider.Objects;
using FModel.Framework;
@@ -9,14 +9,14 @@ public class AssetsListViewModel
{
public RangeObservableCollection Assets { get; } = [];
- private ICollectionView _assetsView;
- public ICollectionView AssetsView
+ private DataGridCollectionView _assetsView;
+ public DataGridCollectionView AssetsView
{
get
{
- _assetsView ??= new ListCollectionView(Assets)
+ _assetsView ??= new DataGridCollectionView(Assets)
{
- SortDescriptions = { new SortDescription("Asset.Path", ListSortDirection.Ascending) }
+ SortDescriptions = { DataGridSortDescription.FromPath("Asset.Path", ListSortDirection.Ascending) }
};
return _assetsView;
}
diff --git a/FModel/ViewModels/AudioPlayerViewModel.cs b/FModel/ViewModels/AudioPlayerViewModel.cs
index 0824db71..6f4f7ce8 100644
--- a/FModel/ViewModels/AudioPlayerViewModel.cs
+++ b/FModel/ViewModels/AudioPlayerViewModel.cs
@@ -6,7 +6,7 @@ using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
-using System.Windows.Data;
+using Avalonia.Collections;
using Avalonia.Threading;
using CSCore;
using CSCore.CoreAudioAPI;
@@ -201,17 +201,17 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable
public bool IsPaused => PlayedFile.PlaybackState == PlaybackState.Paused;
private readonly ObservableCollection _audioFiles;
- public ICollectionView AudioFilesView { get; }
- public ICollectionView AudioDevicesView { get; }
+ public DataGridCollectionView AudioFilesView { get; }
+ public DataGridCollectionView AudioDevicesView { get; }
public AudioPlayerViewModel()
{
_sourceTimer = new Timer(TimerTick, null, 0, 10);
_audioFiles = new ObservableCollection();
- AudioFilesView = new ListCollectionView(_audioFiles);
+ AudioFilesView = new DataGridCollectionView(_audioFiles);
var audioDevices = new ObservableCollection(EnumerateDevices());
- AudioDevicesView = new ListCollectionView(audioDevices) { SortDescriptions = { new SortDescription("FriendlyName", ListSortDirection.Ascending) } };
+ AudioDevicesView = new DataGridCollectionView(audioDevices) { SortDescriptions = { DataGridSortDescription.FromPath("FriendlyName", ListSortDirection.Ascending) } };
SelectedAudioDevice ??= audioDevices.FirstOrDefault();
}
@@ -322,6 +322,7 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable
if (!auto)
{
+ // TODO(P4-004): Replace Microsoft.Win32.SaveFileDialog with Avalonia StorageProvider API.
var saveFileDialog = new SaveFileDialog
{
Title = "Save Audio",
diff --git a/FModel/ViewModels/BackupManagerViewModel.cs b/FModel/ViewModels/BackupManagerViewModel.cs
index aff999b4..ffce03c1 100644
--- a/FModel/ViewModels/BackupManagerViewModel.cs
+++ b/FModel/ViewModels/BackupManagerViewModel.cs
@@ -4,7 +4,7 @@ using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
-using System.Windows.Data;
+using Avalonia.Collections;
using Avalonia.Threading;
using CUE4Parse.FileProvider.Objects;
using FModel.Framework;
@@ -35,13 +35,13 @@ public class BackupManagerViewModel : ViewModel
}
public ObservableCollection Backups { get; }
- public ICollectionView BackupsView { get; }
+ public DataGridCollectionView BackupsView { get; }
public BackupManagerViewModel(string gameName)
{
_gameName = gameName;
Backups = new ObservableCollection();
- BackupsView = new ListCollectionView(Backups) { SortDescriptions = { new SortDescription("FileName", ListSortDirection.Ascending) } };
+ BackupsView = new DataGridCollectionView(Backups) { SortDescriptions = { DataGridSortDescription.FromPath("FileName", ListSortDirection.Ascending) } };
}
public async Task Initialize()
diff --git a/FModel/ViewModels/CUE4ParseViewModel.cs b/FModel/ViewModels/CUE4ParseViewModel.cs
index 260a641b..166270f8 100644
--- a/FModel/ViewModels/CUE4ParseViewModel.cs
+++ b/FModel/ViewModels/CUE4ParseViewModel.cs
@@ -9,7 +9,9 @@ using System.Net.Http.Headers;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
+using Avalonia;
using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
using CUE4Parse;
using CUE4Parse.Compression;
@@ -136,13 +138,16 @@ public class CUE4ParseViewModel : ViewModel
{
var scale = ImGuiController.GetDpiScale();
var htz = Snooper.GetMaxRefreshFrequency();
+ var primaryScreen = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow?.Screens.Primary;
+ var screenWidth = primaryScreen?.Bounds.Width ?? 1920;
+ var screenHeight = primaryScreen?.Bounds.Height ?? 1080;
return _snooper = new Snooper(
new GameWindowSettings { UpdateFrequency = htz },
new NativeWindowSettings
{
ClientSize = new OpenTK.Mathematics.Vector2i(
- Convert.ToInt32(SystemParameters.MaximizedPrimaryScreenWidth * .75 * scale),
- Convert.ToInt32(SystemParameters.MaximizedPrimaryScreenHeight * .85 * scale)),
+ Convert.ToInt32(screenWidth * .75 * scale),
+ Convert.ToInt32(screenHeight * .85 * scale)),
NumberOfSamples = Constants.SAMPLES_COUNT,
WindowBorder = WindowBorder.Resizable,
Flags = ContextFlags.ForwardCompatible,
diff --git a/FModel/ViewModels/Commands/AddEditDirectoryCommand.cs b/FModel/ViewModels/Commands/AddEditDirectoryCommand.cs
index ea17267a..0926b4a8 100644
--- a/FModel/ViewModels/Commands/AddEditDirectoryCommand.cs
+++ b/FModel/ViewModels/Commands/AddEditDirectoryCommand.cs
@@ -1,7 +1,11 @@
-using AdonisUI.Controls;
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
using FModel.Framework;
using FModel.Settings;
using FModel.Views;
+using Serilog;
namespace FModel.ViewModels.Commands;
@@ -11,25 +15,44 @@ public class AddEditDirectoryCommand : ViewModelCommand("Custom Directory", () =>
+ try
{
- var index = contextViewModel.GetIndex(customDir);
- var input = new CustomDir(customDir);
- var result = input.ShowDialog();
- if (!result.HasValue || !result.Value || string.IsNullOrEmpty(customDir.Header) && string.IsNullOrEmpty(customDir.DirectoryPath))
- return;
+ var sourceDir = parameter as CustomDirectory ?? new CustomDirectory();
+ var editableDir = new CustomDirectory(sourceDir.Header, sourceDir.DirectoryPath);
- if (index > 1)
+ var index = contextViewModel.GetIndex(sourceDir);
+ var input = new CustomDir(editableDir);
+ var owner = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
+
+ if (owner == null)
{
- contextViewModel.Edit(index, customDir);
+ Log.Warning("AddEditDirectoryCommand: no owner window available, cannot show dialog");
+ return;
}
- else
- contextViewModel.Add(customDir);
- });
+
+ var result = await input.ShowDialog(owner);
+ Apply(result);
+
+ void Apply(bool? dialogResult)
+ {
+ if (dialogResult is not true || string.IsNullOrEmpty(editableDir.Header) && string.IsNullOrEmpty(editableDir.DirectoryPath))
+ return;
+
+ // Sync edits back to sourceDir so menu CommandParameters stay in sync
+ sourceDir.Header = editableDir.Header;
+ sourceDir.DirectoryPath = editableDir.DirectoryPath;
+
+ if (index > 1)
+ contextViewModel.Edit(index, sourceDir);
+ else
+ contextViewModel.Add(sourceDir);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "AddEditDirectoryCommand failed");
+ }
}
}
diff --git a/FModel/ViewModels/Commands/LoadCommand.cs b/FModel/ViewModels/Commands/LoadCommand.cs
index fc4ad6cb..e191dd93 100644
--- a/FModel/ViewModels/Commands/LoadCommand.cs
+++ b/FModel/ViewModels/Commands/LoadCommand.cs
@@ -7,7 +7,7 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
-using AdonisUI.Controls;
+using Avalonia.Controls;
using CUE4Parse.FileProvider.Objects;
using CUE4Parse.UE4.Readers;
using CUE4Parse.UE4.VirtualFileSystem;
@@ -19,6 +19,7 @@ using FModel.Settings;
using FModel.Views.Resources.Controls;
using K4os.Compression.LZ4.Streams;
using Microsoft.Win32;
+using Serilog;
namespace FModel.ViewModels.Commands;
@@ -57,7 +58,7 @@ public class LoadCommand : ViewModelCommand
_applicationView.CUE4Parse.SearchVm.SearchResults.Clear();
_applicationView.SelectedLeftTabIndex = 1; // folders tab
_applicationView.IsAssetsExplorerVisible = true;
- Helper.CloseWindow("Search For Packages"); // close search window if opened
+ Helper.CloseWindow("Search For Packages"); // close search window if opened
await Task.WhenAll(
_applicationView.CUE4Parse.LoadLocalizedResources(), // load locres if not already loaded,
@@ -69,35 +70,36 @@ public class LoadCommand : ViewModelCommand
switch (UserSettings.Default.LoadingMode)
{
case ELoadingMode.Multiple:
- {
- var l = (IList) parameter;
- if (l.Count == 0)
{
- UserSettings.Default.LoadingMode = ELoadingMode.All;
- goto case ELoadingMode.All;
- }
+ var l = (IList) parameter;
+ if (l.Count == 0)
+ {
+ UserSettings.Default.LoadingMode = ELoadingMode.All;
+ goto case ELoadingMode.All;
+ }
- var directoryFilesToShow = l.Cast();
- FilterDirectoryFilesToDisplay(cancellationToken, directoryFilesToShow);
- break;
- }
+ var directoryFilesToShow = l.Cast();
+ FilterDirectoryFilesToDisplay(cancellationToken, directoryFilesToShow);
+ break;
+ }
case ELoadingMode.All:
- {
- FilterDirectoryFilesToDisplay(cancellationToken, null);
- break;
- }
+ {
+ FilterDirectoryFilesToDisplay(cancellationToken, null);
+ break;
+ }
case ELoadingMode.AllButNew:
case ELoadingMode.AllButModified:
- {
- FilterNewOrModifiedFilesToDisplay(cancellationToken);
- break;
- }
+ {
+ FilterNewOrModifiedFilesToDisplay(cancellationToken);
+ break;
+ }
case ELoadingMode.AllButPatched:
- {
- FilterPacthedFilesToDisplay(cancellationToken);
- break;
- }
- default: throw new ArgumentOutOfRangeException();
+ {
+ FilterPacthedFilesToDisplay(cancellationToken);
+ break;
+ }
+ default:
+ throw new ArgumentOutOfRangeException();
}
_discordHandler.UpdatePresence(_applicationView.CUE4Parse);
@@ -113,13 +115,15 @@ public class LoadCommand : ViewModelCommand
private void FilterDirectoryFilesToDisplay(CancellationToken cancellationToken, IEnumerable directoryFiles)
{
HashSet filter;
- if (directoryFiles == null) filter = null;
+ if (directoryFiles == null)
+ filter = null;
else
{
filter = [];
foreach (var directoryFile in directoryFiles)
{
- if (!directoryFile.IsEnabled) continue;
+ if (!directoryFile.IsEnabled)
+ continue;
filter.Add(directoryFile.Name);
}
}
@@ -130,7 +134,8 @@ public class LoadCommand : ViewModelCommand
foreach (var asset in _applicationView.CUE4Parse.Provider.Files.Values)
{
cancellationToken.ThrowIfCancellationRequested(); // cancel if needed
- if (asset.IsUePackagePayload) continue;
+ if (asset.IsUePackagePayload)
+ continue;
if (hasFilter)
{
@@ -151,6 +156,13 @@ public class LoadCommand : ViewModelCommand
private void FilterNewOrModifiedFilesToDisplay(CancellationToken cancellationToken)
{
+ if (!OperatingSystem.IsWindows())
+ {
+ Log.Warning("Backup file comparison is not yet available on this platform (requires P4-004 StorageProvider migration)");
+ return;
+ }
+
+ // TODO(P4-004): Replace Microsoft.Win32.OpenFileDialog with Avalonia StorageProvider API.
var openFileDialog = new OpenFileDialog
{
Title = "Select a backup file older than your current game version",
@@ -159,7 +171,8 @@ public class LoadCommand : ViewModelCommand
Multiselect = false
};
- if (!openFileDialog.ShowDialog().GetValueOrDefault()) return;
+ if (!openFileDialog.ShowDialog().GetValueOrDefault())
+ return;
FLogger.Append(ELog.Information, () =>
FLogger.Text($"Backup file older than current game is '{openFileDialog.FileName.SubstringAfterLast("\\")}'", Constants.WHITE, true));
@@ -182,7 +195,8 @@ public class LoadCommand : ViewModelCommand
using var compressionStream = LZ4Stream.Decode(fileStream);
compressionStream.CopyTo(memoryStream);
}
- else fileStream.CopyTo(memoryStream);
+ else
+ fileStream.CopyTo(memoryStream);
memoryStream.Position = 0;
using var archive = new FStreamArchive(fileStream.Name, memoryStream);
@@ -191,85 +205,88 @@ public class LoadCommand : ViewModelCommand
switch (mode)
{
case ELoadingMode.AllButNew:
- {
- var paths = new HashSet(StringComparer.OrdinalIgnoreCase);
- var magic = archive.Read();
- if (magic != BackupManagerViewModel.FBKP_MAGIC)
{
- archive.Position -= sizeof(uint);
- while (archive.Position < archive.Length)
+ var paths = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var magic = archive.Read();
+ if (magic != BackupManagerViewModel.FBKP_MAGIC)
+ {
+ archive.Position -= sizeof(uint);
+ while (archive.Position < archive.Length)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ archive.Position += 29;
+ paths.Add(archive.ReadString()[1..]);
+ archive.Position += 4;
+ }
+ }
+ else
+ {
+ var version = archive.Read();
+ var count = archive.Read();
+ for (var i = 0; i < count; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ archive.Position += sizeof(long) + sizeof(byte);
+ var fullPath = archive.ReadString();
+ if (version < EBackupVersion.PerfectPath)
+ fullPath = fullPath[1..];
+
+ paths.Add(fullPath);
+ }
+ }
+
+ foreach (var (key, asset) in _applicationView.CUE4Parse.Provider.Files)
{
cancellationToken.ThrowIfCancellationRequested();
+ if (asset.IsUePackagePayload || paths.Contains(key))
+ continue;
- archive.Position += 29;
- paths.Add(archive.ReadString()[1..]);
- archive.Position += 4;
+ entries.Add(asset);
}
+
+ break;
}
- else
- {
- var version = archive.Read();
- var count = archive.Read();
- for (var i = 0; i < count; i++)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- archive.Position += sizeof(long) + sizeof(byte);
- var fullPath = archive.ReadString();
- if (version < EBackupVersion.PerfectPath) fullPath = fullPath[1..];
-
- paths.Add(fullPath);
- }
- }
-
- foreach (var (key, asset) in _applicationView.CUE4Parse.Provider.Files)
- {
- cancellationToken.ThrowIfCancellationRequested();
- if (asset.IsUePackagePayload || paths.Contains(key)) continue;
-
- entries.Add(asset);
- }
-
- break;
- }
case ELoadingMode.AllButModified:
- {
- var magic = archive.Read();
- if (magic != BackupManagerViewModel.FBKP_MAGIC)
{
- archive.Position -= sizeof(uint);
- while (archive.Position < archive.Length)
+ var magic = archive.Read();
+ if (magic != BackupManagerViewModel.FBKP_MAGIC)
{
- cancellationToken.ThrowIfCancellationRequested();
+ archive.Position -= sizeof(uint);
+ while (archive.Position < archive.Length)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
- archive.Position += 16;
- var uncompressedSize = archive.Read();
- var isEncrypted = archive.ReadFlag();
- archive.Position += 4;
- var fullPath = archive.ReadString()[1..];
- archive.Position += 4;
+ archive.Position += 16;
+ var uncompressedSize = archive.Read();
+ var isEncrypted = archive.ReadFlag();
+ archive.Position += 4;
+ var fullPath = archive.ReadString()[1..];
+ archive.Position += 4;
- AddEntry(fullPath, uncompressedSize, isEncrypted, entries);
+ AddEntry(fullPath, uncompressedSize, isEncrypted, entries);
+ }
}
- }
- else
- {
- var version = archive.Read();
- var count = archive.Read();
- for (var i = 0; i < count; i++)
+ else
{
- cancellationToken.ThrowIfCancellationRequested();
+ var version = archive.Read();
+ var count = archive.Read();
+ for (var i = 0; i < count; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
- var uncompressedSize = archive.Read();
- var isEncrypted = archive.ReadFlag();
- var fullPath = archive.ReadString();
- if (version < EBackupVersion.PerfectPath) fullPath = fullPath[1..];
+ var uncompressedSize = archive.Read();
+ var isEncrypted = archive.ReadFlag();
+ var fullPath = archive.ReadString();
+ if (version < EBackupVersion.PerfectPath)
+ fullPath = fullPath[1..];
- AddEntry(fullPath, uncompressedSize, isEncrypted, entries);
+ AddEntry(fullPath, uncompressedSize, isEncrypted, entries);
+ }
}
+ break;
}
- break;
- }
}
return entries;
@@ -291,7 +308,8 @@ public class LoadCommand : ViewModelCommand
foreach (var (key, asset) in _applicationView.CUE4Parse.Provider.Files)
{
cancellationToken.ThrowIfCancellationRequested(); // cancel if needed
- if (asset.IsUePackagePayload) continue;
+ if (asset.IsUePackagePayload)
+ continue;
if (asset is VfsEntry entry && loaded.TryGetValue(key, out var file) &&
file is VfsEntry existingEntry && entry.Vfs.ReadOrder < existingEntry.Vfs.ReadOrder)
diff --git a/FModel/ViewModels/CustomDirectoriesViewModel.cs b/FModel/ViewModels/CustomDirectoriesViewModel.cs
index 379dd63c..8a1c4b98 100644
--- a/FModel/ViewModels/CustomDirectoriesViewModel.cs
+++ b/FModel/ViewModels/CustomDirectoriesViewModel.cs
@@ -49,6 +49,7 @@ public class CustomDirectoriesViewModel : ViewModel
dir.Header = newDir.Header;
dir.Tag = newDir.DirectoryPath;
+ dir.ItemsSource = EnumerateCommands(newDir);
Save();
}
diff --git a/FModel/ViewModels/GameDirectoryViewModel.cs b/FModel/ViewModels/GameDirectoryViewModel.cs
index 707c24bb..fb2f9ef7 100644
--- a/FModel/ViewModels/GameDirectoryViewModel.cs
+++ b/FModel/ViewModels/GameDirectoryViewModel.cs
@@ -3,7 +3,7 @@ using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Text.RegularExpressions;
-using System.Windows.Data;
+using Avalonia.Collections;
using Avalonia.Threading;
using CUE4Parse.Compression;
using CUE4Parse.UE4.IO;
@@ -105,7 +105,7 @@ public class GameDirectoryViewModel : ViewModel
{
public bool HasNoFile => DirectoryFiles.Count < 1;
public readonly ObservableCollection DirectoryFiles;
- public ICollectionView DirectoryFilesView { get; }
+ public DataGridCollectionView DirectoryFilesView { get; }
private readonly Regex _hiddenArchives = new(@"^(?!global|pakchunk.+(optional|ondemand)\-).+(pak|utoc)$", // should be universal
RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
@@ -113,7 +113,7 @@ public class GameDirectoryViewModel : ViewModel
public GameDirectoryViewModel()
{
DirectoryFiles = new ObservableCollection();
- DirectoryFilesView = new ListCollectionView(DirectoryFiles) { SortDescriptions = { new SortDescription("Name", ListSortDirection.Ascending) } };
+ DirectoryFilesView = new DataGridCollectionView(DirectoryFiles) { SortDescriptions = { DataGridSortDescription.FromPath("Name", ListSortDirection.Ascending) } };
}
public void Add(IAesVfsReader reader)
diff --git a/FModel/ViewModels/SearchViewModel.cs b/FModel/ViewModels/SearchViewModel.cs
index 17b904f2..a600826f 100644
--- a/FModel/ViewModels/SearchViewModel.cs
+++ b/FModel/ViewModels/SearchViewModel.cs
@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
-using System.Windows.Data;
+using Avalonia.Collections;
using CUE4Parse.FileProvider.Objects;
using CUE4Parse.UE4.VirtualFileSystem;
using FModel.Framework;
@@ -62,12 +62,12 @@ public class SearchViewModel : ViewModel
}
public RangeObservableCollection SearchResults { get; }
- public ListCollectionView SearchResultsView { get; }
+ public DataGridCollectionView SearchResultsView { get; }
public SearchViewModel()
{
SearchResults = [];
- SearchResultsView = new ListCollectionView(SearchResults)
+ SearchResultsView = new DataGridCollectionView(SearchResults)
{
Filter = e => ItemFilter(e, FilterText.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)),
};
@@ -130,6 +130,11 @@ public class SearchViewModel : ViewModel
SearchResults.AddRange(sorted);
}
+ private Regex? _cachedFilterRegex;
+ private string? _cachedFilterText;
+ private bool _cachedMatchCase;
+ private bool _cachedRegexInvalid;
+
private bool ItemFilter(object item, IEnumerable filters)
{
if (item is not GameFile entry)
@@ -138,8 +143,37 @@ public class SearchViewModel : ViewModel
if (!HasRegexEnabled)
return filters.All(x => entry.Path.Contains(x, HasMatchCaseEnabled ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase));
- var o = RegexOptions.None;
- if (!HasMatchCaseEnabled) o |= RegexOptions.IgnoreCase;
- return new Regex(FilterText, o).Match(entry.Path).Success;
+ if (_cachedFilterText != FilterText || _cachedMatchCase != HasMatchCaseEnabled)
+ {
+ var o = RegexOptions.None;
+ if (!HasMatchCaseEnabled)
+ o |= RegexOptions.IgnoreCase;
+
+ try
+ {
+ _cachedFilterRegex = new Regex(FilterText, o, TimeSpan.FromSeconds(1));
+ _cachedRegexInvalid = false;
+ }
+ catch (ArgumentException)
+ {
+ _cachedFilterRegex = null;
+ _cachedRegexInvalid = true;
+ }
+
+ _cachedFilterText = FilterText;
+ _cachedMatchCase = HasMatchCaseEnabled;
+ }
+
+ if (_cachedRegexInvalid)
+ return false;
+
+ try
+ {
+ return _cachedFilterRegex?.Match(entry.Path).Success == true;
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ return false;
+ }
}
}
diff --git a/FModel/ViewModels/UpdateViewModel.cs b/FModel/ViewModels/UpdateViewModel.cs
index adbc79bd..a3c40d43 100644
--- a/FModel/ViewModels/UpdateViewModel.cs
+++ b/FModel/ViewModels/UpdateViewModel.cs
@@ -4,7 +4,8 @@ using System.ComponentModel;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
-using System.Windows.Data;
+using Avalonia.Collections;
+using Avalonia.Threading;
using CUE4Parse.Utils;
using FModel.Framework;
using FModel.Services;
@@ -12,9 +13,22 @@ 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 Items { get; }
+
+ public CommitGroup(DateTime date, IReadOnlyList items)
+ {
+ Date = date;
+ Items = items;
+ }
+}
+
public partial class UpdateViewModel : ViewModel
{
private ApiEndpointViewModel _apiEndpointView => ApplicationService.ApiEndpointView;
@@ -23,14 +37,19 @@ public partial class UpdateViewModel : ViewModel
public RemindMeCommand RemindMeCommand => _remindMeCommand ??= new RemindMeCommand(this);
public RangeObservableCollection Commits { get; }
- public ICollectionView CommitsView { get; }
+ public RangeObservableCollection CommitGroups { get; }
+ public bool HasNoCommits => CommitGroups.Count == 0;
+
+ private bool _suppressRegroup;
public UpdateViewModel()
{
Commits = [];
- CommitsView = new ListCollectionView(Commits)
+ CommitGroups = [];
+ Commits.CollectionChanged += (_, _) =>
{
- GroupDescriptions = { new PropertyGroupDescription("Commit.Author.Date", new DateTimeToDateConverter()) }
+ if (!_suppressRegroup)
+ RebuildCommitGroups();
};
if (UserSettings.Default.NextUpdateCheck < DateTime.Now)
@@ -44,11 +63,20 @@ public partial class UpdateViewModel : ViewModel
return;
Commits.AddRange(commits);
+ _ = LoadAvatars();
try
{
- _ = LoadCoAuthors();
- _ = LoadAssets();
+ _ = 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
{
@@ -56,58 +84,76 @@ public partial class UpdateViewModel : ViewModel
}
}
- private Task LoadCoAuthors()
+ private async Task LoadCoAuthors()
{
- return Task.Run(async () =>
+ var snapshot = Commits.ToList();
+ var coAuthorMap = await Task.Run(() =>
{
- var coAuthorMap = new Dictionary>();
- foreach (var commit in Commits)
+ var map = new Dictionary Usernames)>();
+ var regex = GetCoAuthorRegex();
+
+ foreach (var commit in snapshot)
{
if (!commit.Commit.Message.Contains("Co-authored-by"))
continue;
- var regex = GetCoAuthorRegex();
var matches = regex.Matches(commit.Commit.Message);
- if (matches.Count == 0) continue;
+ if (matches.Count == 0)
+ continue;
- commit.Commit.Message = regex.Replace(commit.Commit.Message, string.Empty).Trim();
-
- coAuthorMap[commit] = [];
+ var usernames = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (Match match in matches)
{
- if (match.Groups.Count < 3) continue;
+ 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
- }
- coAuthorMap[commit].Add(username);
+ usernames.Add(username);
}
+
+ if (usernames.Count == 0)
+ continue;
+
+ var cleanMessage = regex.Replace(commit.Commit.Message, string.Empty).Trim();
+ map[commit] = (cleanMessage, usernames);
}
- if (coAuthorMap.Count == 0) return;
+ return map;
+ });
- var uniqueUsernames = coAuthorMap.Values.SelectMany(x => x).Distinct().ToArray();
- var authorCache = new Dictionary();
- foreach (var username in uniqueUsernames)
+ 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();
+ foreach (var username in uniqueUsernames)
+ {
+ try
{
- try
- {
- var author = await _apiEndpointView.GitHubApi.GetUserAsync(username);
- if (author != null)
- authorCache[username] = author;
- }
- catch
- {
- //
- }
+ var author = await _apiEndpointView.GitHubApi.GetUserAsync(username);
+ if (author != null)
+ authorCache[username] = author;
}
-
- foreach (var (commit, usernames) in coAuthorMap)
+ catch
{
- var coAuthors = usernames
+ //
+ }
+ }
+
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ foreach (var (commit, data) in coAuthorMap)
+ {
+ var coAuthors = data.Usernames
.Where(username => authorCache.ContainsKey(username))
.Select(username => authorCache[username])
.ToArray();
@@ -116,42 +162,94 @@ public partial class UpdateViewModel : ViewModel
commit.CoAuthors = coAuthors;
}
});
+
+ await LoadAvatars();
}
- private Task LoadAssets()
+ private async Task LoadAssets()
{
- return Task.Run(async () =>
+ var qa = await _apiEndpointView.GitHubApi.GetReleaseAsync("qa");
+ var assets = qa.Assets.OrderByDescending(x => x.CreatedAt).ToList();
+
+ await Dispatcher.UIThread.InvokeAsync(() =>
{
- var qa = await _apiEndpointView.GitHubApi.GetReleaseAsync("qa");
- var assets = qa.Assets.OrderByDescending(x => x.CreatedAt).ToList();
-
- for (var i = 0; i < assets.Count; i++)
+ _suppressRegroup = true;
+ try
{
- var asset = assets[i];
- asset.IsLatest = i == 0;
+ 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
+ var commitSha = asset.Name.SubstringBeforeLast(".zip");
+ var commit = Commits.FirstOrDefault(x => x.Sha == commitSha);
+ if (commit != null)
{
- Sha = commitSha,
- Commit = new Commit
+ commit.Asset = asset;
+ }
+ else
+ {
+ Commits.Add(new GitHubCommit
{
- Message = $"FModel ({commitSha[..7]})",
- Author = new Author { Name = asset.Uploader.Login, Date = asset.CreatedAt }
- },
- Author = asset.Uploader,
- Asset = asset
- });
+ 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()
diff --git a/FModel/Views/AudioPlayer.xaml b/FModel/Views/AudioPlayer.xaml
index 0734123c..07fcefa6 100644
--- a/FModel/Views/AudioPlayer.xaml
+++ b/FModel/Views/AudioPlayer.xaml
@@ -43,7 +43,7 @@
Margin="0 0 0 10" />
@@ -103,7 +103,7 @@
+ Theme="{StaticResource CustomSeparator}" />
@@ -135,7 +135,7 @@
Watermark="Search by name..." />
@@ -214,7 +214,7 @@
HorizontalAlignment="Right"
VerticalAlignment="Top"
Orientation="Horizontal">
-
-
-