From 287435d0b3cfc785bac03d491e7a9e8f1c79958a Mon Sep 17 00:00:00 2001 From: Marlon Date: Thu, 16 Jul 2026 13:59:51 +0200 Subject: [PATCH] Optimize asset tree construction --- CUE4Parse | 2 +- FModel/Framework/RangeObservableCollection.cs | 24 +- FModel/MainWindow.xaml | 6 +- FModel/MainWindow.xaml.cs | 6 +- FModel/ViewModels/AssetsFolderViewModel.cs | 234 +++++++++--------- FModel/ViewModels/AssetsListViewModel.cs | 46 +++- FModel/ViewModels/CUE4ParseViewModel.cs | 2 +- FModel/ViewModels/Commands/LoadCommand.cs | 2 +- FModel/ViewModels/SearchViewModel.cs | 44 +++- .../Controls/TiledExplorer/FolderButton2.xaml | 2 +- .../Controls/TiledExplorer/FolderButton3.xaml | 2 +- .../Controls/TiledExplorer/Resources.xaml.cs | 2 +- FModel/Views/Resources/Resources.xaml | 6 +- FModel/Views/SearchView.xaml.cs | 2 +- 14 files changed, 220 insertions(+), 160 deletions(-) diff --git a/CUE4Parse b/CUE4Parse index 396ede2a..cd9fa6d9 160000 --- a/CUE4Parse +++ b/CUE4Parse @@ -1 +1 @@ -Subproject commit 396ede2a1d89752142c3b73dba7c908467ccf3a3 +Subproject commit cd9fa6d9c7f521370e40045d9515e982fc486bc0 diff --git a/FModel/Framework/RangeObservableCollection.cs b/FModel/Framework/RangeObservableCollection.cs index cf0b5261..cc34eacc 100644 --- a/FModel/Framework/RangeObservableCollection.cs +++ b/FModel/Framework/RangeObservableCollection.cs @@ -2,11 +2,14 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.ComponentModel; namespace FModel.Framework; public sealed class RangeObservableCollection : ObservableCollection { + private static readonly PropertyChangedEventArgs CountChanged = new(nameof(Count)); + private static readonly PropertyChangedEventArgs IndexerChanged = new("Item[]"); private bool _suppressNotification; protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e) @@ -20,15 +23,26 @@ public sealed class RangeObservableCollection : ObservableCollection if (list == null) throw new ArgumentNullException(nameof(list)); - _suppressNotification = true; - + var changed = false; foreach (var item in list) - Add(item); + { + Items.Add(item); + changed = true; + } - _suppressNotification = false; + if (!changed || _suppressNotification) + return; + + OnPropertyChanged(CountChanged); + OnPropertyChanged(IndexerChanged); OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } + /// + /// Adds an item while constructing a collection that has not been published to a binding yet. + /// + public void AddWithoutNotification(T item) => Items.Add(item); + public void SetSuppressionState(bool state) { _suppressNotification = state; @@ -38,4 +52,4 @@ public sealed class RangeObservableCollection : ObservableCollection { OnCollectionChanged(new NotifyCollectionChangedEventArgs(changedAction)); } -} \ No newline at end of file +} diff --git a/FModel/MainWindow.xaml b/FModel/MainWindow.xaml index 46d2ddb5..41ea26e3 100644 --- a/FModel/MainWindow.xaml +++ b/FModel/MainWindow.xaml @@ -316,7 +316,7 @@ - + @@ -371,7 +371,7 @@ - + @@ -386,7 +386,7 @@ diff --git a/FModel/MainWindow.xaml.cs b/FModel/MainWindow.xaml.cs index fbed15d4..90f2ae63 100644 --- a/FModel/MainWindow.xaml.cs +++ b/FModel/MainWindow.xaml.cs @@ -338,7 +338,7 @@ public partial class MainWindow } var childFolder = folder; - while (childFolder.Folders.Count == 1 && childFolder.AssetsList.Assets.Count == 0) + while (childFolder.Folders.Count == 1 && childFolder.AssetsList.Count == 0) { childFolder.IsExpanded = true; childFolder = childFolder.Folders[0]; @@ -360,14 +360,14 @@ public partial class MainWindow if (e.Key != Key.Enter || sender is not TreeView treeView || treeView.SelectedItem is not TreeItem folder) return; - if ((folder.IsExpanded || folder.Folders.Count == 0) && folder.AssetsList.Assets.Count > 0) + if ((folder.IsExpanded || folder.Folders.Count == 0) && folder.AssetsList.Count > 0) { _applicationView.SelectedLeftTabIndex++; return; } var childFolder = folder; - while (childFolder.Folders.Count == 1 && childFolder.AssetsList.Assets.Count == 0) + while (childFolder.Folders.Count == 1 && childFolder.AssetsList.Count == 0) { childFolder.IsExpanded = true; childFolder = childFolder.Folders[0]; diff --git a/FModel/ViewModels/AssetsFolderViewModel.cs b/FModel/ViewModels/AssetsFolderViewModel.cs index 70f6eeaf..b25bc5f0 100644 --- a/FModel/ViewModels/AssetsFolderViewModel.cs +++ b/FModel/ViewModels/AssetsFolderViewModel.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Linq; -using System.Text; using System.Threading.Tasks; using System.Windows; using System.Windows.Data; @@ -16,14 +15,9 @@ using FModel.Services; namespace FModel.ViewModels; -public class TreeItem : ViewModel +public sealed class TreeItem : ViewModel { - private readonly string _header; - public string Header - { - get => _header; - private init => SetProperty(ref _header, value); - } + public string Header { get; } private bool _isExpanded; public bool IsExpanded @@ -39,26 +33,9 @@ public class TreeItem : ViewModel set => SetProperty(ref _isSelected, value); } - private string _archive; - public string Archive - { - get => _archive; - private set => SetProperty(ref _archive, value); - } - - private string _mountPoint; - public string MountPoint - { - get => _mountPoint; - private set => SetProperty(ref _mountPoint, value); - } - - private FPackageFileVersion _version; - public FPackageFileVersion Version - { - get => _version; - private set => SetProperty(ref _version, value); - } + public string Archive { get; } + public string MountPoint { get; } + public FPackageFileVersion Version { get; } private string _searchText = string.Empty; public string SearchText @@ -157,12 +134,12 @@ public class TreeItem : ViewModel } PathAtThisPoint = pathHere; - AssetsList.AssetsView.Filter = o => ItemFilter(o, SearchText.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)); + AssetsList.SetFilter(o => ItemFilter(o, SearchText.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries))); } private void RefreshFilters() { - AssetsList.AssetsView.Refresh(); + AssetsList.RefreshView(); FilteredFoldersView?.Refresh(); } @@ -195,7 +172,7 @@ public class TreeItem : ViewModel RefreshFilters(); } - public override string ToString() => $"{Header} | {Folders.Count} Folders | {AssetsList.Assets.Count} Files"; + public override string ToString() => $"{Header} | {Folders.Count} Folders | {AssetsList.Count} Files"; } public class AssetsFolderViewModel @@ -214,80 +191,98 @@ public class AssetsFolderViewModel if (entries == null || entries.Count == 0) return; + var treeItems = new List(); + var foldersByPath = new Dictionary(StringComparer.Ordinal); + var folderLookup = foldersByPath.GetAlternateLookup>(); + TreeItem previousFolder = null; + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + .Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + foreach (var entry in entries) + { + var path = entry.Path.AsSpan(); + if (path.StartsWith(localAppData.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + path = path[localAppData.Length..]; + while (!path.IsEmpty && + (path[0] == Path.DirectorySeparatorChar || path[0] == Path.AltDirectorySeparatorChar)) + path = path[1..]; + } + + var pathEnd = path.Length; + while (pathEnd > 0 && path[pathEnd - 1] == Path.AltDirectorySeparatorChar) + pathEnd--; + + var lastSeparator = path[..pathEnd].LastIndexOf(Path.AltDirectorySeparatorChar); + if (lastSeparator < 0) + { + previousFolder = GetOrAddContentFolder(foldersByPath, entry, treeItems); + previousFolder.AssetsList.Add(entry); + continue; + } + + var directories = path[..lastSeparator]; + if (previousFolder != null && directories.SequenceEqual(previousFolder.PathAtThisPoint.AsSpan())) + { + previousFolder.AssetsList.Add(entry); + continue; + } + + if (folderLookup.TryGetValue(directories, out var leafFolder)) + { + leafFolder.AssetsList.Add(entry); + previousFolder = leafFolder; + continue; + } + + TreeItem parentNode = null; + var segmentStart = 0; + + while (segmentStart < directories.Length) + { + while (segmentStart < directories.Length && directories[segmentStart] == Path.AltDirectorySeparatorChar) + segmentStart++; + if (segmentStart == directories.Length) + break; + + var segmentEnd = directories[segmentStart..].IndexOf(Path.AltDirectorySeparatorChar); + if (segmentEnd < 0) + segmentEnd = directories.Length; + else + segmentEnd += segmentStart; + + var folderPath = directories[..segmentEnd]; + if (!folderLookup.TryGetValue(folderPath, out var node)) + { + var header = directories[segmentStart..segmentEnd].ToString(); + var normalizedPath = parentNode == null + ? header + : string.Concat(parentNode.PathAtThisPoint, "/", header); + if (!foldersByPath.TryGetValue(normalizedPath, out node)) + { + node = new TreeItem(header, entry, normalizedPath) { Parent = parentNode }; + foldersByPath.Add(normalizedPath, node); + + if (parentNode == null) + treeItems.Add(node); + else + parentNode.Folders.AddWithoutNotification(node); + } + } + + parentNode = node; + segmentStart = segmentEnd + 1; + } + + if (parentNode == null) + parentNode = GetOrAddContentFolder(foldersByPath, entry, treeItems); + + parentNode.AssetsList.Add(entry); + previousFolder = parentNode; + } + Application.Current.Dispatcher.Invoke(() => { - var treeItems = new RangeObservableCollection(); - treeItems.SetSuppressionState(true); - - static TreeItem FindByHeaderOrNull(IReadOnlyList list, string header) - { - for (var i = 0; i < list.Count; i++) - { - if (list[i].Header == header) - return list[i]; - } - - return null; - } - - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData).Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - foreach (var entry in entries) - { - TreeItem lastNode = null; - TreeItem parentItem = null; - - var path = entry.Path; - if (path.StartsWith(localAppData, StringComparison.OrdinalIgnoreCase)) - path = path[localAppData.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - - var folders = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - var builder = new StringBuilder(64); - var parentNode = treeItems; - - if (folders.Length <= 1) - { - var rootNode = FindByHeaderOrNull(treeItems, "Content"); - if (rootNode == null) - { - rootNode = new TreeItem("Content", entry, "Content") - { - Parent = null - }; - - rootNode.Folders.SetSuppressionState(true); - rootNode.AssetsList.Assets.SetSuppressionState(true); - treeItems.Add(rootNode); - } - - rootNode.AssetsList.Add(entry); - continue; - } - - for (var i = 0; i < folders.Length - 1; i++) - { - var folder = folders[i]; - builder.Append(folder).Append('/'); - lastNode = FindByHeaderOrNull(parentNode, folder); - - if (lastNode == null) - { - var nodePath = builder.ToString(); - lastNode = new TreeItem(folder, entry, nodePath[..^1]) - { - Parent = parentItem - }; - lastNode.Folders.SetSuppressionState(true); - lastNode.AssetsList.Assets.SetSuppressionState(true); - parentNode.Add(lastNode); - } - - parentItem = lastNode; - parentNode = lastNode.Folders; - } - - lastNode?.AssetsList.Add(entry); - } - Folders.AddRange(treeItems); if (treeItems.Count > 0) @@ -299,26 +294,19 @@ public class AssetsFolderViewModel } ApplicationService.ApplicationView.CUE4Parse.SearchVm.ChangeCollection(entries); - - foreach (var folder in Folders) - InvokeOnCollectionChanged(folder); - - static void InvokeOnCollectionChanged(TreeItem item) - { - item.Folders.SetSuppressionState(false); - item.AssetsList.Assets.SetSuppressionState(false); - - if (item.Folders.Count != 0) - { - item.Folders.InvokeOnCollectionChanged(); - - foreach (var folderItem in item.Folders) - InvokeOnCollectionChanged(folderItem); - } - - if (item.AssetsList.Assets.Count != 0) - item.AssetsList.Assets.InvokeOnCollectionChanged(); - } }); } + + private static TreeItem GetOrAddContentFolder(Dictionary foldersByPath, GameFile entry, + List roots) + { + const string content = "Content"; + if (foldersByPath.TryGetValue(content, out var node)) + return node; + + node = new TreeItem(content, entry, content); + foldersByPath.Add(content, node); + roots.Add(node); + return node; + } } diff --git a/FModel/ViewModels/AssetsListViewModel.cs b/FModel/ViewModels/AssetsListViewModel.cs index 1666644c..def8b1cb 100644 --- a/FModel/ViewModels/AssetsListViewModel.cs +++ b/FModel/ViewModels/AssetsListViewModel.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.ComponentModel; using System.Windows.Data; using CUE4Parse.FileProvider.Objects; @@ -7,20 +9,58 @@ namespace FModel.ViewModels; public class AssetsListViewModel { - public RangeObservableCollection Assets { get; } = []; + private List _pendingAssets; + private RangeObservableCollection _assets; + public RangeObservableCollection Assets + { + get + { + if (_assets != null) + return _assets; + + _assets = []; + if (_pendingAssets == null) + return _assets; + + foreach (var asset in _pendingAssets) + _assets.AddWithoutNotification(new GameFileViewModel(asset)); + + _pendingAssets = null; + return _assets; + } + } + + public int Count => _assets?.Count ?? _pendingAssets?.Count ?? 0; private ICollectionView _assetsView; + private Predicate _filter; public ICollectionView AssetsView { get { _assetsView ??= new ListCollectionView(Assets) { - SortDescriptions = { new SortDescription("Asset.Path", ListSortDirection.Ascending) } + SortDescriptions = { new SortDescription("Asset.Path", ListSortDirection.Ascending) }, + Filter = _filter }; return _assetsView; } } - public void Add(GameFile gameFile) => Assets.Add(new GameFileViewModel(gameFile)); + public void Add(GameFile gameFile) + { + if (_assets == null) + (_pendingAssets ??= []).Add(gameFile); + else + _assets.Add(new GameFileViewModel(gameFile)); + } + + public void SetFilter(Predicate filter) + { + _filter = filter; + if (_assetsView != null) + _assetsView.Filter = filter; + } + + public void RefreshView() => _assetsView?.Refresh(); } diff --git a/FModel/ViewModels/CUE4ParseViewModel.cs b/FModel/ViewModels/CUE4ParseViewModel.cs index 364033c0..eedf7b1f 100644 --- a/FModel/ViewModels/CUE4ParseViewModel.cs +++ b/FModel/ViewModels/CUE4ParseViewModel.cs @@ -390,7 +390,7 @@ public class CUE4ParseViewModel : ViewModel if (Provider == null) return; AssetsFolder.Folders.Clear(); - SearchVm.SearchResults.Clear(); + SearchVm.Clear(); Helper.CloseWindow("Search For Packages"); Provider.UnloadNonStreamedVfs(); GC.Collect(); diff --git a/FModel/ViewModels/Commands/LoadCommand.cs b/FModel/ViewModels/Commands/LoadCommand.cs index c29a695e..7ee3780c 100644 --- a/FModel/ViewModels/Commands/LoadCommand.cs +++ b/FModel/ViewModels/Commands/LoadCommand.cs @@ -54,7 +54,7 @@ public class LoadCommand : ViewModelCommand var loadingTime = Stopwatch.StartNew(); #endif _applicationView.CUE4Parse.AssetsFolder.Folders.Clear(); - _applicationView.CUE4Parse.SearchVm.SearchResults.Clear(); + _applicationView.CUE4Parse.SearchVm.Clear(); _applicationView.SelectedLeftTabIndex = 1; // folders tab _applicationView.IsAssetsExplorerVisible = true; Helper.CloseWindow("Search For Packages"); // close search window if opened diff --git a/FModel/ViewModels/SearchViewModel.cs b/FModel/ViewModels/SearchViewModel.cs index 17b904f2..248548b9 100644 --- a/FModel/ViewModels/SearchViewModel.cs +++ b/FModel/ViewModels/SearchViewModel.cs @@ -61,17 +61,32 @@ public class SearchViewModel : ViewModel private set => SetProperty(ref _refFile, value); } - public RangeObservableCollection SearchResults { get; } - public ListCollectionView SearchResultsView { get; } + private List _searchResults = []; + public List SearchResults + { + get => _searchResults; + private set => SetProperty(ref _searchResults, value); + } + private ListCollectionView _searchResultsView; + public ListCollectionView SearchResultsView + { + get + { + if (_searchResultsView != null) + return _searchResultsView; + + _searchResultsView = new ListCollectionView(SearchResults) + { + Filter = e => ItemFilter(e, FilterText.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)), + }; + ResultsCount = _searchResultsView.Count; + return _searchResultsView; + } + } public SearchViewModel() { - SearchResults = []; - SearchResultsView = new ListCollectionView(SearchResults) - { - Filter = e => ItemFilter(e, FilterText.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)), - }; - ResultsCount = SearchResultsView.Count; + ResultsCount = 0; } public void RefreshFilter() @@ -82,12 +97,16 @@ public class SearchViewModel : ViewModel public void ChangeCollection(IEnumerable files, GameFile refFile = null) { - SearchResults.Clear(); - SearchResults.AddRange(files); + var results = files as List ?? files.ToList(); + _searchResultsView = null; + SearchResults = results; + RaisePropertyChanged(nameof(SearchResultsView)); RefFile = refFile; - ResultsCount = SearchResultsView.Count; + ResultsCount = results.Count; } + public void Clear() => ChangeCollection([]); + public async Task CycleSortSizeMode() { CurrentSortSizeMode = CurrentSortSizeMode switch @@ -126,8 +145,7 @@ public class SearchViewModel : ViewModel }; }); - SearchResults.Clear(); - SearchResults.AddRange(sorted); + ChangeCollection(sorted, RefFile); } private bool ItemFilter(object item, IEnumerable filters) diff --git a/FModel/Views/Resources/Controls/TiledExplorer/FolderButton2.xaml b/FModel/Views/Resources/Controls/TiledExplorer/FolderButton2.xaml index 0eda947a..a21a3107 100644 --- a/FModel/Views/Resources/Controls/TiledExplorer/FolderButton2.xaml +++ b/FModel/Views/Resources/Controls/TiledExplorer/FolderButton2.xaml @@ -100,7 +100,7 @@ Foreground="{DynamicResource {x:Static adonisUi:Brushes.ForegroundBrush}}" Opacity="0.8" /> - + diff --git a/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml.cs b/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml.cs index 907a9a71..172c1dfb 100644 --- a/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml.cs +++ b/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml.cs @@ -39,7 +39,7 @@ public partial class ResourcesDictionary // Auto expand single child folders var childFolder = folder; - while (childFolder.Folders.Count == 1 && childFolder.AssetsList.Assets.Count == 0) + while (childFolder.Folders.Count == 1 && childFolder.AssetsList.Count == 0) { childFolder.IsExpanded = true; childFolder = childFolder.Folders[0]; diff --git a/FModel/Views/Resources/Resources.xaml b/FModel/Views/Resources/Resources.xaml index 201841ef..670c7f83 100644 --- a/FModel/Views/Resources/Resources.xaml +++ b/FModel/Views/Resources/Resources.xaml @@ -308,7 +308,7 @@ Background="#705542" Margin="-10.5 0 4.5 0" VerticalAlignment="Bottom"> - - + @@ -340,7 +340,7 @@ Property="Source" Value="/FModel;component/Resources/empty_folder.png" /> -