Skip to content

Commit 828cd3e

Browse files
NainetenNaineten
authored andcommitted
feat: complete Unreal Engine OFPA support with contextual UI and caching
This update finalizes the OFPA feature logic. Changes: - Added contextual visibility: The decoding toggle is now hidden unless a .uproject or .uplugin file is detected. - Implemented metadata caching: Files are re-decoded only when modified (size/time check). - Added fallback to Git Index/HEAD: Deleted or missing files are now decoded by reading blobs from Git. - Fixed Race Condition: Guarded against applying results if the feature was disabled during async processing. - Safety: Added checks for object disposal to prevent crashes.
1 parent 6aad495 commit 828cd3e

4 files changed

Lines changed: 123 additions & 21 deletions

File tree

src/Utilities/OFPAParser.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,12 @@ public override int GetHashCode() =>
7070
/// </summary>
7171
public static bool IsOFPAFile(string path)
7272
{
73-
return path.Contains("__ExternalActors__", StringComparison.Ordinal) ||
74-
path.Contains("__ExternalObjects__", StringComparison.Ordinal);
73+
// OFPA files are only .uasset entries.
74+
if (!path.EndsWith(".uasset", StringComparison.OrdinalIgnoreCase))
75+
return false;
76+
77+
return path.Contains("__ExternalActors__", StringComparison.OrdinalIgnoreCase) ||
78+
path.Contains("__ExternalObjects__", StringComparison.OrdinalIgnoreCase);
7579
}
7680

7781
/// <summary>

src/ViewModels/WorkingCopy.cs

Lines changed: 111 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.IO;
4+
using System.Linq;
45
using System.Threading;
56
using System.Threading.Tasks;
67

@@ -31,6 +32,12 @@ public bool EnableOFPADecoding
3132

3233
public IReadOnlyDictionary<string, string> DecodedPaths => _decodedPaths;
3334

35+
public bool IsOFPASupported
36+
{
37+
get => _isOFPASupported;
38+
private set => SetProperty(ref _isOFPASupported, value);
39+
}
40+
3441
public Repository Repository
3542
{
3643
get => _repo;
@@ -248,6 +255,7 @@ public WorkingCopy(Repository repo)
248255

249256
public void Dispose()
250257
{
258+
_isDisposed = true;
251259
_repo = null;
252260
_inProgressContext = null;
253261

@@ -269,6 +277,7 @@ public void Dispose()
269277
_staged.Clear();
270278
OnPropertyChanged(nameof(Staged));
271279

280+
ClearDecodedPaths();
272281
_detailContext = null;
273282
_commitMessage = string.Empty;
274283
}
@@ -340,6 +349,8 @@ public void SetData(List<Models.Change> changes, CancellationToken cancellationT
340349

341350
_isLoadingData = true;
342351
_cached = changes;
352+
IsOFPASupported = Directory.EnumerateFiles(_repo.FullPath, "*.uproject").Any() ||
353+
Directory.EnumerateFiles(_repo.FullPath, "*.uplugin").Any();
343354
HasUnsolvedConflicts = hasConflict;
344355
VisibleUnstaged = visibleUnstaged;
345356
VisibleStaged = visibleStaged;
@@ -858,41 +869,107 @@ private bool IsChanged(List<Models.Change> old, List<Models.Change> cur)
858869

859870
private bool _hasUnsolvedConflicts = false;
860871
private InProgressContext _inProgressContext = null;
872+
private bool _isDisposed = false;
873+
private bool _isOFPASupported = false;
861874
private Dictionary<string, string> _decodedPaths = new Dictionary<string, string>();
875+
// Tracks file size/mtime for decoded paths to refresh when content changes.
876+
private Dictionary<string, (long Length, DateTime LastWriteUtc)> _decodedPathStats = new Dictionary<string, (long Length, DateTime LastWriteUtc)>();
862877

863878
private async Task DecodeOFPAPathsAsync(List<Models.Change> changes)
864879
{
865-
if (!_repo.Settings.EnableOFPADecoding || changes == null || changes.Count == 0)
880+
if (_isDisposed || _repo == null || !_repo.Settings.EnableOFPADecoding || changes == null || changes.Count == 0)
866881
return;
867882

868883
var repositoryPath = _repo.FullPath;
869884
var filesToProcess = new List<(string RelativePath, string FullPath)>();
885+
var missingWorkingTreePaths = new List<string>();
870886

871887
foreach (var change in changes)
872888
{
873-
if (Utilities.OFPAParser.IsOFPAFile(change.Path) &&
874-
!_decodedPaths.ContainsKey(change.Path))
889+
if (!Utilities.OFPAParser.IsOFPAFile(change.Path))
890+
continue;
891+
892+
var fullPath = Path.Combine(repositoryPath, change.Path);
893+
if (File.Exists(fullPath))
894+
{
895+
try
896+
{
897+
var info = new FileInfo(fullPath);
898+
// Skip re-decoding if content stats are unchanged.
899+
if (_decodedPaths.ContainsKey(change.Path) &&
900+
_decodedPathStats.TryGetValue(change.Path, out var stats) &&
901+
stats.Length == info.Length &&
902+
stats.LastWriteUtc == info.LastWriteTimeUtc)
903+
continue;
904+
}
905+
catch
906+
{
907+
// File might disappear between Exists and stat access.
908+
missingWorkingTreePaths.Add(change.Path);
909+
}
910+
}
911+
else
875912
{
876-
var fullPath = Path.Combine(repositoryPath, change.Path);
877-
if (File.Exists(fullPath))
878-
filesToProcess.Add((change.Path, fullPath));
913+
// Remove stats when the working-tree file disappears.
914+
if (_decodedPathStats.ContainsKey(change.Path))
915+
missingWorkingTreePaths.Add(change.Path);
916+
917+
if (_decodedPaths.ContainsKey(change.Path))
918+
continue;
879919
}
920+
921+
filesToProcess.Add((change.Path, fullPath));
880922
}
881923

882-
if (filesToProcess.Count == 0)
924+
if (filesToProcess.Count == 0 && missingWorkingTreePaths.Count == 0)
883925
return;
884926

885927
var results = new Dictionary<string, string>();
928+
var updatedStats = new Dictionary<string, (long Length, DateTime LastWriteUtc)>();
929+
930+
// Read bytes from either MemoryStream or a generic stream.
931+
static async Task<byte[]> ReadAllBytesAsync(Stream stream)
932+
{
933+
if (stream is MemoryStream memoryStream)
934+
return memoryStream.ToArray();
935+
936+
await using var buffer = new MemoryStream();
937+
await stream.CopyToAsync(buffer).ConfigureAwait(false);
938+
return buffer.ToArray();
939+
}
886940

887-
await Task.Run(() =>
941+
await Task.Run(async () =>
888942
{
889943
foreach (var (relativePath, fullPath) in filesToProcess)
890944
{
891945
try
892946
{
893-
var result = Utilities.OFPAParser.Decode(fullPath);
894-
if (result.HasValue)
895-
results[relativePath] = result.Value.LabelValue;
947+
if (File.Exists(fullPath))
948+
{
949+
// Prefer working-tree content when available.
950+
var result = Utilities.OFPAParser.Decode(fullPath);
951+
if (result.HasValue)
952+
{
953+
results[relativePath] = result.Value.LabelValue;
954+
var info = new FileInfo(fullPath);
955+
updatedStats[relativePath] = (info.Length, info.LastWriteTimeUtc);
956+
}
957+
958+
continue;
959+
}
960+
961+
// Fallback to Git index/HEAD for missing working-tree files.
962+
await using var indexStream = await Commands.QueryFileContent.RunIndexAsync(repositoryPath, relativePath).ConfigureAwait(false);
963+
var data = await ReadAllBytesAsync(indexStream).ConfigureAwait(false);
964+
if (data.Length == 0)
965+
{
966+
await using var headStream = await Commands.QueryFileContent.RunAsync(repositoryPath, "HEAD", relativePath).ConfigureAwait(false);
967+
data = await ReadAllBytesAsync(headStream).ConfigureAwait(false);
968+
}
969+
970+
var decoded = data.Length > 0 ? Utilities.OFPAParser.DecodeFromData(data) : null;
971+
if (decoded.HasValue)
972+
results[relativePath] = decoded.Value.LabelValue;
896973
}
897974
catch
898975
{
@@ -901,18 +978,34 @@ await Task.Run(() =>
901978
}
902979
});
903980

904-
if (results.Count > 0)
981+
if (results.Count > 0 || missingWorkingTreePaths.Count > 0 || updatedStats.Count > 0)
905982
{
906983
await Dispatcher.UIThread.InvokeAsync(() =>
907984
{
908-
if (!_repo.Settings.EnableOFPADecoding)
985+
if (_isDisposed || _repo == null || !_repo.Settings.EnableOFPADecoding)
909986
return;
910987

911-
var updated = new Dictionary<string, string>(_decodedPaths);
912-
foreach (var kvp in results)
913-
updated[kvp.Key] = kvp.Value;
988+
if (results.Count > 0)
989+
{
990+
var updated = new Dictionary<string, string>(_decodedPaths);
991+
foreach (var kvp in results)
992+
updated[kvp.Key] = kvp.Value;
993+
994+
_decodedPaths = updated;
995+
}
996+
997+
if (missingWorkingTreePaths.Count > 0)
998+
{
999+
foreach (var path in missingWorkingTreePaths)
1000+
_decodedPathStats.Remove(path);
1001+
}
1002+
1003+
if (updatedStats.Count > 0)
1004+
{
1005+
foreach (var kvp in updatedStats)
1006+
_decodedPathStats[kvp.Key] = kvp.Value;
1007+
}
9141008

915-
_decodedPaths = updated;
9161009
OnPropertyChanged(nameof(DecodedPaths));
9171010
});
9181011
}
@@ -921,6 +1014,7 @@ await Dispatcher.UIThread.InvokeAsync(() =>
9211014
private void ClearDecodedPaths()
9221015
{
9231016
_decodedPaths = new Dictionary<string, string>();
1017+
_decodedPathStats = new Dictionary<string, (long Length, DateTime LastWriteUtc)>();
9241018
OnPropertyChanged(nameof(DecodedPaths));
9251019
}
9261020
}

src/Views/ChangeCollectionView.axaml.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,10 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
234234
else if (change.Property == SelectedChangesProperty)
235235
UpdateSelection();
236236
else if (change.Property == DecodedPathsProperty)
237-
UpdateDataSource(true);
237+
{
238+
if (ViewMode == Models.ChangeViewMode.Tree)
239+
UpdateDataSource(true);
240+
}
238241

239242
if (change.Property == EnableCompactFoldersProperty && ViewMode == Models.ChangeViewMode.Tree)
240243
UpdateDataSource(true);

src/Views/WorkingCopy.axaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@
8585
Classes="icon_button"
8686
Width="26" Height="14"
8787
ToolTip.Tip="{DynamicResource Text.WorkingCopy.EnableOFPADecoding}"
88-
IsChecked="{Binding EnableOFPADecoding, Mode=TwoWay}">
88+
IsChecked="{Binding EnableOFPADecoding, Mode=TwoWay}"
89+
IsVisible="{Binding IsOFPASupported}">
8990
<Path Width="14" Height="14" Data="{StaticResource Icons.Rename}"/>
9091
</ToggleButton>
9192
<Button Grid.Column="7"

0 commit comments

Comments
 (0)