Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions SourceGit.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@
<Project Path="src/SourceGit.csproj" />
</Folder>

<Folder Name="/tests/">
<Project Path="tests/SourceGit.Tests/SourceGit.Tests.csproj" />
</Folder>

<Folder Name="/files/">
<File Path=".editorconfig"/>
<File Path=".gitattributes"/>
Expand Down
37 changes: 37 additions & 0 deletions src/Converters/OFPAConverters.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using Avalonia.Data.Converters;

namespace SourceGit.Converters
{
public class PathToDisplayNameConverter : IMultiValueConverter
{
public object Convert(IList<object> values, Type targetType, object parameter, CultureInfo culture)
{
if (values.Count < 2)
return "";

string path = values[0] as string ?? string.Empty;
var decodedPaths = values[1] as IReadOnlyDictionary<string, string>;

if (decodedPaths != null &&
decodedPaths.TryGetValue(path, out var decoded) &&
!string.IsNullOrEmpty(decoded))
{
return decoded;
}

if (parameter as string == "PureFileName")
return Path.GetFileName(path);

return path;
}
}

public static class OFPAConverters
{
public static readonly PathToDisplayNameConverter PathToDisplayName = new();
}
}
6 changes: 6 additions & 0 deletions src/Models/RepositorySettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ public string PreferredOpenAIService
set;
} = "---";

public bool EnableOFPADecoding
{
get;
set;
} = false;

public AvaloniaList<CommitTemplate> CommitTemplates
{
get;
Expand Down
1 change: 1 addition & 0 deletions src/Resources/Icons.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,5 @@
<StreamGeometry x:Key="Icons.Worktree">M853 267H514c-4 0-6-2-9-4l-38-66c-13-21-38-36-64-36H171c-41 0-75 34-75 75v555c0 41 34 75 75 75h683c41 0 75-34 75-75V341c0-41-34-75-75-75zm-683-43h233c4 0 6 2 9 4l38 66c13 21 38 36 64 36H853c6 0 11 4 11 11v75h-704V235c0-6 4-11 11-11zm683 576H171c-6 0-11-4-11-11V480h704V789c0 6-4 11-11 11z</StreamGeometry>
<StreamGeometry x:Key="Icons.Worktree.Add">M896 96 614 96c-58 0-128-19-179-51C422 38 390 19 358 19L262 19 128 19c-70 0-128 58-128 128l0 736c0 70 58 128 128 128l768 0c70 0 128-58 128-128L1024 224C1024 154 966 96 896 96zM704 685 544 685l0 160c0 19-13 32-32 32s-32-13-32-32l0-160L320 685c-19 0-32-13-32-32 0-19 13-32 32-32l160 0L480 461c0-19 13-32 32-32s32 13 32 32l0 160L704 621c19 0 32 13 32 32C736 666 723 685 704 685zM890 326 102 326 102 250c0-32 32-64 64-64l659 0c38 0 64 32 64 64L890 326z</StreamGeometry>
<StreamGeometry x:Key="Icons.Worktrees">M1182 527a91 91 0 00-88-117H92a91 91 0 00-88 117l137 441A80 80 0 00217 1024h752a80 80 0 0076-56zM133 295a31 31 0 0031 31h858a31 31 0 0031-31A93 93 0 00959 203H226a93 93 0 00-94 92zM359 123h467a31 31 0 0031-31A92 92 0 00765 0H421a92 92 0 00-92 92 31 31 0 0031 31z</StreamGeometry>
<StreamGeometry x:Key="Icons.Unreal">M803.7,995.81c156.5-73.92,205.56-210.43,216.6-263.61c-57.22,58.6-120.53,118-163.11,76.88c0,0-2.33-219.45-2.33-309.43c0-121,114.75-211.18,114.75-211.18c-63.11,11.24-138.89,33.71-219.33,112.65c-7.26,7.2-14.14,14.76-20.62,22.67c-34.47-26.39-79.14-18.48-79.14-18.48c24.14,13.26,48.23,51.88,48.23,83.85v314.26c0,0-52.63,46.3-93.19,46.3c-9.14,0.07-18.17-2.05-26.33-6.18c-8.16-4.13-15.21-10.15-20.56-17.56c-3.21-4.19-5.87-8.78-7.91-13.65V424.07c-11.99,9.89-52.51,18.04-52.51-49.22c0-41.79,30.11-91.6,83.73-122.15c-73.63,11.23-142.59,43.04-198.92,91.76c-42.8,36.98-77.03,82.85-100.31,134.4c-23.28,51.55-35.06,107.55-34.51,164.12c0,0,39.21-122.51,88.32-133.83c7.15-1.88,14.65-2.07,21.89-0.54c7.24,1.53,14.02,4.72,19.81,9.34c5.79,4.61,10.41,10.51,13.51,17.23c3.1,6.72,4.59,14.07,4.34,21.46V844.3c0,29.16-18.8,35.53-36.17,35.22c-11.77-0.83-23.4-3.02-34.66-6.53c35.86,48.53,82.46,88.12,136.15,115.66c53.69,27.54,113.03,42.29,173.37,43.1l106.05-106.6L803.7,995.81z</StreamGeometry>
</ResourceDictionary>
2 changes: 2 additions & 0 deletions src/Resources/Locales/en_US.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -978,4 +978,6 @@
<x:String x:Key="Text.Worktree.Remove" xml:space="preserve">Remove</x:String>
<x:String x:Key="Text.Worktree.Unlock" xml:space="preserve">Unlock</x:String>
<x:String x:Key="Text.Yes" xml:space="preserve">YES</x:String>
<x:String x:Key="Text.Configure.OFPA.Enable" xml:space="preserve">Decode Unreal Engine OFPA file names</x:String>
<x:String x:Key="Text.Configure.OFPA.Enable.Tip" xml:space="preserve">Show human-readable actor names instead of hash-based file names in __ExternalActors__ and __ExternalObjects__ folders. If decoding fails, raw paths remain unchanged.</x:String>
</ResourceDictionary>
4 changes: 4 additions & 0 deletions src/SourceGit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,8 @@
<TrimmerRootAssembly Include="SourceGit" />
<TrimmerRootAssembly Include="Avalonia.Themes.Fluent" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="SourceGit.Tests" />
</ItemGroup>
</Project>
92 changes: 92 additions & 0 deletions src/Utilities/OFPADecodingContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;

using Avalonia.Threading;

using CommunityToolkit.Mvvm.ComponentModel;

namespace SourceGit.Utilities
{
/// <summary>
/// Shared async decoding context for OFPA filenames.
/// Handles scheduling, stale-guard, PropertyChanged notification,
/// and reactive enable/disable in response to Repository.EnableOFPADecoding changes.
/// </summary>
internal sealed class OFPADecodingContext : ObservableObject, IDisposable
{
public IReadOnlyDictionary<string, string> DecodedPaths => _decodedPaths;

public OFPADecodingContext(ViewModels.Repository repo, Action onReEnabled)
{
_repo = repo;
_onReEnabled = onReEnabled;
_repo.PropertyChanged += OnRepositoryPropertyChanged;
}

public void Dispose()
{
if (_repo != null)
_repo.PropertyChanged -= OnRepositoryPropertyChanged;
_repo = null;
_onReEnabled = null;
}

/// <summary>
/// Schedule an async OFPA decode. The caller provides a factory
/// that returns the decoded path map. Stale requests are discarded.
/// </summary>
public void ScheduleRefresh(Func<Task<Dictionary<string, string>>> lookupFactory)
{
var requestId = Interlocked.Increment(ref _requestId);
_ = RunAsync(lookupFactory, requestId);
}

public void Clear()
{
Interlocked.Increment(ref _requestId);
_decodedPaths = null;
OnPropertyChanged(nameof(DecodedPaths));
}

private async Task RunAsync(Func<Task<Dictionary<string, string>>> lookupFactory, long requestId)
{
Dictionary<string, string> results = null;
try
{
results = await lookupFactory().ConfigureAwait(false);
}
catch (Exception)
{
// Decode failures are non-fatal; raw paths remain visible.
}

await Dispatcher.UIThread.InvokeAsync(() =>
{
if (_repo == null || !_repo.EnableOFPADecoding || requestId != Interlocked.Read(ref _requestId))
return;

_decodedPaths = results;
OnPropertyChanged(nameof(DecodedPaths));
});
}

private void OnRepositoryPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(ViewModels.Repository.EnableOFPADecoding))
return;

if (_repo?.EnableOFPADecoding == true)
_onReEnabled?.Invoke();
else
Clear();
}

private ViewModels.Repository _repo;
private Action _onReEnabled;
private Dictionary<string, string> _decodedPaths;
private long _requestId;
}
}
47 changes: 47 additions & 0 deletions src/Utilities/OFPAFilePrefixReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System;
using System.IO;

namespace SourceGit.Utilities
{
internal static class OFPAFilePrefixReader
{
public static byte[] Read(string filePath, int maxBytes)
{
try
{
if (string.IsNullOrEmpty(filePath) || maxBytes <= 0 || !File.Exists(filePath))
return null;

using var stream = File.OpenRead(filePath);
var length = (int)Math.Min(stream.Length, maxBytes);
if (length <= 0)
return [];

var buffer = new byte[length];
var offset = 0;
while (offset < length)
{
var read = stream.Read(buffer, offset, length - offset);
if (read <= 0)
break;

offset += read;
}

if (offset == length)
return buffer;

if (offset == 0)
return [];

var resized = new byte[offset];
Array.Copy(buffer, resized, offset);
return resized;
}
catch (Exception)
{
return null;
}
}
}
}
149 changes: 149 additions & 0 deletions src/Utilities/OFPAGitBatchReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading.Tasks;

namespace SourceGit.Utilities
{
internal static class OFPAGitBatchReader
{
public static async Task<Dictionary<string, byte[]>> ReadAsync(string repo, IReadOnlyList<string> objectSpecs, int maxBytesPerObject)
{
var results = new Dictionary<string, byte[]>(StringComparer.Ordinal);
if (objectSpecs.Count == 0)
return results;

var gitExecutable = string.IsNullOrEmpty(Native.OS.GitExecutable) ? "git" : Native.OS.GitExecutable;
var starter = new ProcessStartInfo
{
WorkingDirectory = repo,
FileName = gitExecutable,
Arguments = "cat-file --batch",
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
RedirectStandardInput = true,
RedirectStandardOutput = true,
};

try
{
using var proc = Process.Start(starter)!;
var writeTask = Task.Run(async () =>
{
await using var input = proc.StandardInput;
foreach (var spec in objectSpecs)
await input.WriteLineAsync(spec).ConfigureAwait(false);
});

await using var output = proc.StandardOutput.BaseStream;
for (var i = 0; i < objectSpecs.Count; i++)
{
var header = await ReadHeaderLineAsync(output).ConfigureAwait(false);
if (header == null)
break;

if (header.EndsWith(" missing", StringComparison.Ordinal))
continue;

var size = ParseObjectSize(header);
if (size > 0)
{
var bytesToRead = maxBytesPerObject > 0 && size > maxBytesPerObject
? maxBytesPerObject
: size;
var bytesToSkip = size - bytesToRead;
var data = await ReadExactBytesAsync(output, bytesToRead).ConfigureAwait(false);
if (data != null)
results[objectSpecs[i]] = data;

if (bytesToSkip > 0)
await SkipBytesAsync(output, bytesToSkip).ConfigureAwait(false);
}

_ = await ReadSingleByteAsync(output).ConfigureAwait(false);
}

await writeTask.ConfigureAwait(false);
await proc.WaitForExitAsync().ConfigureAwait(false);
}
catch (Exception e)
{
App.RaiseException(repo, $"Failed to query OFPA batch file content: {e}");
}

return results;
}

private static int ParseObjectSize(string header)
{
var lastSpace = header.LastIndexOf(' ');
if (lastSpace <= 0 || lastSpace == header.Length - 1)
return 0;

return int.TryParse(header.AsSpan(lastSpace + 1), out var size) ? size : 0;
}

private static async Task<string> ReadHeaderLineAsync(Stream stream)
{
var buffer = new MemoryStream();
while (true)
{
var value = await ReadSingleByteAsync(stream).ConfigureAwait(false);
if (value == -1)
break;

if (value == '\n')
break;

buffer.WriteByte((byte)value);
}

if (buffer.Length == 0)
return null;

var line = Encoding.ASCII.GetString(buffer.ToArray());
return line.EndsWith('\r') ? line[..^1] : line;
}

private static async Task<byte[]> ReadExactBytesAsync(Stream stream, int length)
{
var buffer = new byte[length];
var totalRead = 0;
while (totalRead < length)
{
var read = await stream.ReadAsync(buffer.AsMemory(totalRead, length - totalRead)).ConfigureAwait(false);
if (read <= 0)
return null;

totalRead += read;
}

return buffer;
}

private static async Task SkipBytesAsync(Stream stream, int length)
{
var buffer = new byte[Math.Min(length, 8192)];
var remaining = length;
while (remaining > 0)
{
var toRead = Math.Min(remaining, buffer.Length);
var read = await stream.ReadAsync(buffer.AsMemory(0, toRead)).ConfigureAwait(false);
if (read <= 0)
break;

remaining -= read;
}
}

private static async Task<int> ReadSingleByteAsync(Stream stream)
{
var buffer = new byte[1];
var read = await stream.ReadAsync(buffer.AsMemory(0, 1)).ConfigureAwait(false);
return read == 0 ? -1 : buffer[0];
}
}
}
Loading