1
0

feat: Finish repository fetch functions.

This commit is contained in:
Ca2didi 2025-04-01 04:45:01 +08:00
parent 4d24eb80f9
commit 14a4ccd6f6
26 changed files with 895 additions and 62 deletions

View File

@ -16,6 +16,8 @@
<entry key="Flawless.Client/Views/MainWindow.axaml" value="Flawless.Client/Flawless.Client.csproj" />
<entry key="Flawless.Client/Views/MainWindowView.axaml" value="Flawless.Client/Flawless.Client.csproj" />
<entry key="Flawless.Client/Views/ModalBox/CreateRepositoryDialog.axaml" value="Flawless.Client/Flawless.Client.csproj" />
<entry key="Flawless.Client/Views/ModalBox/CreateRepositoryDialogView.axaml" value="Flawless.Client/Flawless.Client.csproj" />
<entry key="Flawless.Client/Views/ModalBox/SimpleMessageDialogView.axaml" value="Flawless.Client/Flawless.Client.csproj" />
<entry key="Flawless.Client/Views/RegisterPageView.axaml" value="Flawless.Client/Flawless.Client.csproj" />
<entry key="Flawless.Client/Views/RegisterView.axaml" value="Flawless.Client/Flawless.Client.csproj" />
<entry key="Flawless.Client/Views/RepositoryPage/RepoCommitPageView.axaml" value="Flawless.Client/Flawless.Client.csproj" />

View File

@ -33,13 +33,13 @@
&lt;Assembly Path="C:\Users\Cardi\.nuget\packages\irihi.ursa\1.10.0\lib\net8.0\Ursa.dll" /&gt;&#xD;
&lt;Assembly Path="C:\Users\Cardi\.nuget\packages\irihi.ursa.themes.semi\1.10.0\lib\netstandard2.0\Ursa.Themes.Semi.dll" /&gt;&#xD;
&lt;/AssemblyExplorer&gt;</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=b5573ef9_002Db554_002D4a56_002D82c4_002D2531c8feef65/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" IsLocked="True" Name="PathValidationTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;TestAncestor&gt;
&lt;TestId&gt;MSTest::5B1CB26D-99F5-491A-B368-7E3552FE67E9::net9.0::Flawless.Abstract.Test.WorkPathTestUnit&lt;/TestId&gt;
&lt;/TestAncestor&gt;
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=b5573ef9_002Db554_002D4a56_002D82c4_002D2531c8feef65/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" IsLocked="True" Name="PathValidationTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;&#xD;
&lt;TestAncestor&gt;&#xD;
&lt;TestId&gt;MSTest::5B1CB26D-99F5-491A-B368-7E3552FE67E9::net9.0::Flawless.Abstract.Test.WorkPathTestUnit&lt;/TestId&gt;&#xD;
&lt;/TestAncestor&gt;&#xD;
&lt;/SessionState&gt;</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=f3f8a684_002Dc08e_002D489f_002D949c_002D6c38a1ed63b0/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="PathValidationTest #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;TestAncestor&gt;
&lt;TestId&gt;MSTest::5B1CB26D-99F5-491A-B368-7E3552FE67E9::net9.0::Flawless.Abstract.Test.WorkPathTestUnit.PathValidationTest&lt;/TestId&gt;
&lt;/TestAncestor&gt;
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=f3f8a684_002Dc08e_002D489f_002D949c_002D6c38a1ed63b0/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="PathValidationTest #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;&#xD;
&lt;TestAncestor&gt;&#xD;
&lt;TestId&gt;MSTest::5B1CB26D-99F5-491A-B368-7E3552FE67E9::net9.0::Flawless.Abstract.Test.WorkPathTestUnit.PathValidationTest&lt;/TestId&gt;&#xD;
&lt;/TestAncestor&gt;&#xD;
&lt;/SessionState&gt;</s:String></wpf:ResourceDictionary>

View File

@ -6,6 +6,12 @@ namespace Flawless.Client;
public static class AppDefaultValues
{
public const string HostId = "Overlay";
public const string RepoLocalStorageManagerFolder = ".flawless";
public const string RepoLocalStorageDatabaseFile = "db.json";
public const string RepoLocalStorageDepotFolder = "depots";
public static string ProgramDataDirectory { get; } =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Ca2dWorks", "FlawlessClient");

View File

@ -0,0 +1,11 @@
using System;
using Avalonia.Controls.Notifications;
namespace Flawless.Client;
public static class ErrorGUIHandler
{
public static void OnError(Exception ex)
{
}
}

View File

@ -36,6 +36,7 @@
</PackageReference>
<PackageReference Include="Refit" Version="8.0.0" />
<PackageReference Include="Semi.Avalonia" Version="11.2.1.6" />
<PackageReference Include="ValueTaskSupplement" Version="1.1.0" />
</ItemGroup>
<ItemGroup>
@ -55,5 +56,14 @@
<DependentUpon>ServerSetupPageView.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Views\ModalBox\SimpleMessageDialogView.axaml.cs">
<DependentUpon>SimpleMessageDialogView.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Flawless.Abstract\Flawless.Abstract.csproj" />
<ProjectReference Include="..\Flawless.Core\Flawless.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,21 @@
using System;
using System.Collections.ObjectModel;
using Flawless.Client.Service;
using ReactiveUI.SourceGenerators;
namespace Flawless.Client.Models;
[Serializable]
public partial class RepositoryLocalDatabaseModel : ReactiveModel
{
public required RepositoryModel RootModal { get; init; }
public required LocalFileTreeAccessor LocalAccessor { get; init; }
public RepositoryFileTreeAccessor? RepoAccessor { get; set; }
public ObservableCollection<string> CurrentLockedFiles { get; } = new();
[NonSerialized]
[Reactive] private Guid? _currentCommit;
}

View File

@ -1,4 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using Avalonia.Threading;
namespace Flawless.Client;
@ -10,4 +13,13 @@ public static class ObserverHelper
var dispatcher = Dispatcher.UIThread;
observer.Subscribe(val => dispatcher.Invoke(() => onNext(val)));
}
public static void Sort<T>(this ObservableCollection<T> collection, Comparison<T> comparison)
{
var sortableList = new List<T>(collection);
sortableList.Sort(comparison);
for (int i = 0; i < sortableList.Count; i++) collection.Move(collection.IndexOf(sortableList[i]), i);
}
}

View File

@ -0,0 +1,24 @@
using System.IO;
using Flawless.Client.Service;
namespace Flawless.Client;
public static class PathUtility
{
public static string GetWorkspacePath(string owner, string repo)
=> Path.Combine(SettingService.C.AppSetting.RepositoryPath, owner, repo);
public static string GetWorkspaceManagerPath(string owner, string repo)
=> Path.Combine(SettingService.C.AppSetting.RepositoryPath, owner, repo, AppDefaultValues.RepoLocalStorageManagerFolder);
public static string GetWorkspaceDbPath(string owner, string repo)
=> Path.Combine(SettingService.C.AppSetting.RepositoryPath, owner, repo,
AppDefaultValues.RepoLocalStorageManagerFolder, AppDefaultValues.RepoLocalStorageDatabaseFile);
public static string GetWorkspaceDepotCachePath(string owner, string repo)
=> Path.Combine(SettingService.C.AppSetting.RepositoryPath, owner, repo,
AppDefaultValues.RepoLocalStorageManagerFolder, AppDefaultValues.RepoLocalStorageDepotFolder);
}

View File

@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using Flawless.Abstraction;
using Flawless.Client.Models;
using Flawless.Core.Modal;
namespace Flawless.Client.Service;
public class LocalFileTreeAccessor
{
private static readonly string[] IgnoredDirectories =
{
AppDefaultValues.RepoLocalStorageManagerFolder
};
private readonly RepositoryModel _repo;
private readonly string _rootDirectory;
private IReadOnlyDictionary<string, WorkspaceFile> _baseline;
private readonly Dictionary<string, WorkspaceFile> _newFiles = new();
private readonly Dictionary<string, WorkspaceFile> _deleteFiles = new();
private readonly Dictionary<string, WorkspaceFile> _modifyFiles = new();
private object _optLock = new();
public string WorkingDirectory => _rootDirectory;
public IReadOnlyDictionary<string, WorkspaceFile> BaselineFiles => _baseline;
public IReadOnlyDictionary<string, WorkspaceFile> NewFiles => _newFiles;
public IReadOnlyDictionary<string, WorkspaceFile> DeletedFiles => _deleteFiles;
public IReadOnlyDictionary<string, WorkspaceFile> ModifiedFiles => _modifyFiles;
public LocalFileTreeAccessor(RepositoryModel repo, IEnumerable<WorkspaceFile> baselines)
{
_repo = repo;
_baseline = baselines.ToImmutableDictionary(b => b.WorkPath);
_rootDirectory = PathUtility.GetWorkspacePath(repo.OwnerName, repo.Name);
}
public void SetBaseline(IEnumerable<WorkspaceFile> baselines)
{
lock (_optLock)
{
_baseline = baselines.ToImmutableDictionary(b => b.WorkPath);
_newFiles.Clear();
_deleteFiles.Clear();
_modifyFiles.Clear();
RefreshInternal();
}
}
public void Refresh()
{
lock (_optLock)
{
_newFiles.Clear();
_deleteFiles.Clear();
_modifyFiles.Clear();
RefreshInternal();
}
}
private void RefreshInternal()
{
Dictionary<string, WorkspaceFile> currentFiles = new();
foreach (var f in Directory.GetFiles(_rootDirectory, "*", SearchOption.AllDirectories))
{
var workPath = WorkPath.FromPlatformPath(f, _rootDirectory);
if (!WorkPath.IsPathValid(workPath) || IgnoredDirectories.Any(d => workPath.StartsWith(d)))
continue;
var modifyTime = File.GetLastWriteTimeUtc(f);
currentFiles.Add(workPath, new WorkspaceFile { WorkPath = workPath, ModifyTime = modifyTime });
}
// Find those are changed
var changes = currentFiles.Values.Where(v => _baseline.TryGetValue(v.WorkPath, out var fi) && fi.ModifyTime != v.ModifyTime);
var news = currentFiles.Values.Where(v => !_baseline.ContainsKey(v.WorkPath));
var removed = _baseline.Values.Where(v => !currentFiles.ContainsKey(v.WorkPath));
foreach (var f in changes) _modifyFiles.Add(f.WorkPath, f);
foreach (var f in news) _newFiles.Add(f.WorkPath, f);
foreach (var f in removed) _deleteFiles.Add(f.WorkPath, f);
}
}

View File

@ -167,7 +167,7 @@ namespace Flawless.Client.Remote
/// <exception cref="ApiException">Thrown when the request returns a non-success status code.</exception>
[Headers("Accept: text/plain, application/json, text/json")]
[Post("/api/repo_create")]
Task<RepositoryInfoResponse> RepoCreate([Query] string repositoryName);
Task<RepositoryInfoResponse> RepoCreate([Query] string repositoryName, [Query] string description);
/// <returns>A <see cref="Task"/> that completes when the request is finished.</returns>
/// <exception cref="ApiException">Thrown when the request returns a non-success status code.</exception>

View File

@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Threading.Tasks;
using Flawless.Core.BinaryDataFormat;
using Flawless.Core.Modal;
namespace Flawless.Client.Service;
public class RepositoryFileTreeAccessor : IDisposable, IAsyncDisposable
{
private struct FileReadInfoCache
{
public readonly Guid DepotId { get; init; }
public readonly DepotFileInfo FileInfo { get; init; }
}
private readonly Dictionary<Guid, Stream> _mappings;
private readonly CommitManifest _manifest;
private readonly Dictionary<Guid, StandardDepotHeaderV1> _headers;
private readonly Dictionary<string, FileReadInfoCache> _cached;
private object _readonlyLock = new(); // todo support async method.
private bool _disposed;
public CommitManifest Manifest => _manifest;
public bool IsCached { get; private set; }
public RepositoryFileTreeAccessor(Dictionary<Guid, Stream> currentReadFs, CommitManifest manifest)
{
IsCached = false;
_mappings = currentReadFs;
_manifest = manifest;
_headers = new Dictionary<Guid, StandardDepotHeaderV1>();
_cached = new Dictionary<string, FileReadInfoCache>();
_disposed = false;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var mv in _mappings.Values)
{
try { mv.Dispose(); }
catch (Exception e) { Console.WriteLine(e); }
}
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
foreach (var mv in _mappings.Values)
{
try { await mv.DisposeAsync(); }
catch (Exception e) { Console.WriteLine(e); }
}
}
public async Task CreateCacheAsync()
{
DisposeCheck();
lock (_readonlyLock) if (IsCached) return;
var ms = _manifest.FilePaths.ToImmutableSortedDictionary(x => x.WorkPath, x => x.ModifyTime);
foreach (var (id, stream) in _mappings)
{
var header = DataTransformer.ExtractStandardDepotHeaderV1(stream);
_headers.Add(id, header);
await foreach (var inf in DataTransformer.ExtractDepotFileInfoMapAsync(stream, header.FileMapSize))
{
if (ms.TryGetValue(inf.Path, out var targetTime) && targetTime == inf.ModifyTime)
{
_cached.Add(inf.Path, new FileReadInfoCache { DepotId = id, FileInfo = inf });
}
}
}
if (ms.Count != _cached.Count)
throw new FileNotFoundException("Some of those files are not able to be fount in any depots!");
lock (_readonlyLock) IsCached = true;
}
public bool TryGetFileInfo(string workPath, out DepotFileInfo info)
{
DisposeCheck();
lock (_readonlyLock) if (!IsCached) throw new InvalidOperationException("Not cached!");
if (_cached.TryGetValue(workPath, out var r))
{
info = r.FileInfo;
return true;
}
info = default;
return false;
}
public bool TryWriteDataIntoStream(string workPath, Stream stream)
{
DisposeCheck();
if (stream == null || !stream.CanWrite) throw new ArgumentException("Stream is not writable!");
if (_cached.TryGetValue(workPath, out var r))
{
var baseStream = DataTransformer.ExtractStandardDepotFile(_mappings[r.DepotId], r.FileInfo);
baseStream.CopyTo(stream);
return true;
}
return false;
}
private void DisposeCheck()
{
if (_disposed) throw new ObjectDisposedException("Accessor has already been disposed");
}
}

View File

@ -4,8 +4,11 @@ using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Flawless.Abstraction;
using Flawless.Client.Models;
using Flawless.Client.Remote;
using Flawless.Core.Modal;
using Newtonsoft.Json;
using ValueTaskSupplement;
namespace Flawless.Client.Service;
@ -13,40 +16,107 @@ public class RepositoryService : BaseService<RepositoryService>
{
public ObservableCollection<RepositoryModel> Repositories => _repositories;
private readonly ObservableCollection<RepositoryModel> _repositories = new();
public async ValueTask<bool> CreateRepositoryOnServerAsync(string repositoryName)
private readonly Dictionary<RepositoryModel, RepositoryLocalDatabaseModel> _localRepoDbModel = new();
private readonly HashSet<RepositoryModel> _openedRepos = new();
private bool TryCreateRepositoryBaseStorageStructure(RepositoryModel repo)
{
var dbPath = PathUtility.GetWorkspaceDbPath(repo.OwnerName, repo.Name);
if (File.Exists(dbPath)) return false;
// Get directories
var localRepoDb = GetRepositoryLocalDatabase(repo);
var folderPath = PathUtility.GetWorkspaceManagerPath(repo.OwnerName, repo.Name);
// Create initial data.
Directory.CreateDirectory(folderPath);
using var writeFs = new StreamWriter(new FileStream(dbPath, FileMode.Create));
JsonSerializer.CreateDefault().Serialize(writeFs, localRepoDb);
return true;
}
public bool SaveRepositoryLocalDatabaseChanges(RepositoryModel repo)
{
var dbPath = PathUtility.GetWorkspaceDbPath(repo.OwnerName, repo.Name);
if (File.Exists(dbPath)) return false;
if (!_localRepoDbModel.TryGetValue(repo, out var localRepo))
{
using var writeFs = new StreamWriter(new FileStream(dbPath, FileMode.Truncate));
JsonSerializer.CreateDefault().Serialize(writeFs, localRepo);
return true;
}
return false;
}
public RepositoryLocalDatabaseModel GetRepositoryLocalDatabase(RepositoryModel repo)
{
if (!_localRepoDbModel.TryGetValue(repo, out var localRepo))
{
var dbPath = PathUtility.GetWorkspaceDbPath(repo.OwnerName, repo.Name);
if (File.Exists(dbPath))
{
// Use existed target
using var readFs = new StreamReader(new FileStream(dbPath, FileMode.Open));
localRepo = JsonSerializer.CreateDefault().Deserialize(readFs, typeof(RepositoryLocalDatabaseModel))
as RepositoryLocalDatabaseModel; // todo add broken test.
}
else
{
// Create new one.
localRepo = new RepositoryLocalDatabaseModel
{
RootModal = repo,
LocalAccessor = new LocalFileTreeAccessor(repo, [])
};
}
_localRepoDbModel.Add(repo, localRepo);
}
return localRepo;
}
public async ValueTask<RepositoryModel?> CreateRepositoryOnServerAsync(string repositoryName, string description)
{
RepositoryModel repo;
var api = Api.C;
try
{
if (api.RequireRefreshToken() && !(await api.TryRefreshTokenAsync()))
{
api.ClearGateway();
return false;
return null;
}
var r = await api.Gateway.RepoCreate(repositoryName);
var r = await api.Gateway.RepoCreate(repositoryName, description);
var repo = new RepositoryModel();
repo.Name = r.RepositoryName;
repo.OwnerName = r.OwnerUsername;
repo.StandaloneName = RepositoryModel.GetStandaloneName(r.RepositoryName, r.OwnerUsername);
repo.Description = r.Description;
repo.Archived = r.IsArchived;
repo.OwnByCurrentUser = (int) r.Role == (int) RepositoryModel.RepositoryRole.Owner;
repo = new RepositoryModel
{
Name = r.RepositoryName,
OwnerName = r.OwnerUsername,
StandaloneName = RepositoryModel.GetStandaloneName(r.RepositoryName, r.OwnerUsername),
Description = r.Description,
Archived = r.IsArchived,
OwnByCurrentUser = (int) r.Role == (int) RepositoryModel.RepositoryRole.Owner
};
Repositories.Insert(0, repo);
}
catch (Exception e)
{
Console.WriteLine(e);
return false;
return null;
}
return true;
return repo;
}
public async ValueTask<bool> UpdateRepositoriesFromServerAsync()
@ -98,18 +168,27 @@ public class RepositoryService : BaseService<RepositoryService>
return true;
}
public async ValueTask<bool> UpdateRepositoriesDownloadedFromDiskAsync()
public async ValueTask<bool> UpdateRepositoriesDownloadedStatusFromDiskAsync()
{
foreach (var repo in _repositories)
{
var fsPath = Path.Combine(SettingService.C.AppSetting.RepositoryPath, repo.OwnerName, repo.Name);
repo.IsDownloaded = Directory.Exists(fsPath);
var isFolderExists = Directory.Exists(PathUtility.GetWorkspacePath(repo.OwnerName, repo.Name));
var isDbFileExists = File.Exists(PathUtility.GetWorkspaceDbPath(repo.OwnerName, repo.Name));
repo.IsDownloaded = isFolderExists && isDbFileExists;
}
return true;
}
public async ValueTask<bool> UpdateDownloadedStatusFromDiskAsync(RepositoryModel repo)
{
var isFolderExists = Directory.Exists(PathUtility.GetWorkspacePath(repo.OwnerName, repo.Name));
var isDbFileExists = File.Exists(PathUtility.GetWorkspaceDbPath(repo.OwnerName, repo.Name));
return isFolderExists && isDbFileExists;
}
public async ValueTask<bool> UpdateRepositoryMembersFromServerAsync(RepositoryModel repo)
public async ValueTask<bool> UpdateMembersFromServerAsync(RepositoryModel repo)
{
var api = Api.C;
try
@ -158,4 +237,344 @@ public class RepositoryService : BaseService<RepositoryService>
return true;
}
public bool IsRepositoryOpened(RepositoryModel repo)
{
return _openedRepos.Any(r => r == repo);
}
public async ValueTask<bool> CloseRepositoryAsync(RepositoryModel repo)
{
if (_openedRepos.Contains(repo))
{
var ls = GetRepositoryLocalDatabase(repo);
if (ls.RepoAccessor != null) await ls.RepoAccessor!.DisposeAsync();
ls.RepoAccessor = null;
if (!SaveRepositoryLocalDatabaseChanges(repo)) return false;
_openedRepos.Remove(repo);
return true;
}
return false;
}
public async ValueTask<bool> OpenRepositoryOnStorageAsync(RepositoryModel repo)
{
if (!await UpdateDownloadedStatusFromDiskAsync(repo) || repo.IsDownloaded == false) return false;
if (!await UpdateCommitsFromServerAsync(repo)) return false;
var ls = GetRepositoryLocalDatabase(repo);
if (ls.CurrentCommit != null)
{
var accessor = await DownloadDepotsToGetCommitFileTreeFromServerAsync(repo, ls.CurrentCommit.Value);
if (accessor == null) return false;
ls.RepoAccessor = accessor;
}
_openedRepos.Add(repo);
return true;
}
public async ValueTask<bool> CreateNewRepositoryOnStorageAsync(RepositoryModel repo)
{
// Create basic structures.
if (!TryCreateRepositoryBaseStorageStructure(repo)) return false;
if (!await UpdateCommitsFromServerAsync(repo)) return false;
var peekCommit = repo.Commits.MaxBy(sl => sl.CommittedOn);
if (peekCommit == null) return false; // Should not use this function!
repo.IsDownloaded = true;
_openedRepos.Add(repo);
return true;
}
public async ValueTask<bool> CloneRepositoryFromRemoteAsync(RepositoryModel repo)
{
// Create basic structures.
if (!TryCreateRepositoryBaseStorageStructure(repo)) return false;
if (!await UpdateCommitsFromServerAsync(repo)) return false;
var peekCommit = repo.Commits.MaxBy(sl => sl.CommittedOn);
if (peekCommit == null) return false; // Should not use this function!
// Create basic structures.
if (!TryCreateRepositoryBaseStorageStructure(repo)) return false;
// Download base repo info
var accessor = await DownloadDepotsToGetCommitFileTreeFromServerAsync(repo, peekCommit.CommitId);
if (accessor == null)
{
await DeleteFromDiskAsync(repo);
return false;
};
var ls = GetRepositoryLocalDatabase(repo);
ls.CurrentCommit = peekCommit.CommitId;
ls.RepoAccessor = accessor;
try
{
foreach (var f in accessor.Manifest.FilePaths)
{
var pfs = WorkPath.ToPlatformPath(f.WorkPath, ls.LocalAccessor.WorkingDirectory);
var directory = Path.GetDirectoryName(pfs);
// Write into fs
if (directory != null) Directory.CreateDirectory(directory);
await using var write = File.Create(pfs);
accessor.TryWriteDataIntoStream(f.WorkPath, write);
}
}
catch (Exception e)
{
Console.WriteLine(e);
await DeleteFromDiskAsync(repo);
return false;
}
repo.IsDownloaded = true;
_openedRepos.Add(repo);
return true;
}
public async ValueTask<bool> ShouldUpdateLocalCommitsCacheFromServerAsync(RepositoryModel repo)
{
var api = Api.C;
try
{
if (api.RequireRefreshToken() && !(await api.TryRefreshTokenAsync()))
{
api.ClearGateway();
return false;
}
var rsp = await api.Gateway.PeekCommit(repo.Name, repo.OwnerName);
var emptyRepo = repo.Commits.Count == 0;
// If they both empty
if ((rsp.Result == Guid.Empty) == emptyRepo) return false;
if (emptyRepo) return true;
return rsp.Result == repo.Commits.MaxBy(cm => cm.CommittedOn)!.CommitId;
}
catch (Exception e)
{
Console.WriteLine(e);
return false;
}
}
public async ValueTask<bool> UpdateCommitsFromServerAsync(RepositoryModel repo)
{
var api = Api.C;
try
{
if (api.RequireRefreshToken() && !(await api.TryRefreshTokenAsync()))
{
api.ClearGateway();
return false;
}
var rsp = await api.Gateway.ListCommit(repo.Name, repo.OwnerName);
// Update existed
var dict = rsp.Result.ToDictionary(m => m.Id);
for (var i = 0; i < repo.Commits.Count; i++)
{
var ele = repo.Commits[i];
if (!dict.Remove(ele.CommitId, out var cm))
{
repo.Members.RemoveAt(i);
i -= 1;
continue;
}
ele.Message = cm.Message;
ele.DepotId = cm.MainDepotId;
ele.CommittedOn = cm.CommitedOn.UtcDateTime;
ele.Author = cm.Author;
}
// Add missing
foreach (var cm in dict.Values)
{
var r = new RepositoryModel.Commit
{
CommitId = cm.Id,
Message = cm.Message,
DepotId = cm.MainDepotId,
CommittedOn = cm.CommitedOn.UtcDateTime,
Author = cm.Author,
};
repo.Commits.Add(r);
}
// Resort them again
repo.Commits.Sort((l, r) => r.CommittedOn.CompareTo(l.CommittedOn));
}
catch (Exception e)
{
Console.WriteLine(e);
return false;
}
return true;
}
public async ValueTask<RepositoryFileTreeAccessor?> DownloadDepotsToGetCommitFileTreeFromServerAsync
(RepositoryModel repo, Guid commit, bool storeDownloadedDepots = true)
{
if (commit == Guid.Empty) return null;
// Get depots list and manifest
var manifest = await DownloadManifestFromServerAsync(repo, commit);
if (manifest == null) return null;
// Prepare folders
var path = PathUtility.GetWorkspacePath(repo.OwnerName, repo.Name);
var depotsRoot = PathUtility.GetWorkspaceDepotCachePath(repo.OwnerName, repo.Name);
Directory.CreateDirectory(path);
// Generate download depots list
var mainDepotLabel = manifest.Value.Depot;
var willDownload = mainDepotLabel.Where(label =>
{
var dpPath = Path.Combine(depotsRoot, label.Id.ToString());
if (!File.Exists(dpPath)) return true;
// todo Needs a way to check if that valid.
return false;
});
// Download them
var downloadedDepots = await DownloadDepotsAndCopyNetworkStreamIntoNewMemoryStreamFromServerAsync(repo, willDownload);
try
{
// Check if anyone download failed
if (downloadedDepots == null || downloadedDepots.Any(dl => dl.Item2 == null))
throw new Exception("Some depots are not able to be downloaded.");
if (storeDownloadedDepots)
{
// Write new downloaded depots into disk
var transform = downloadedDepots.Select(dl => (dl.Item1, dl.Item2!));
await WriteDownloadedDepotsFromServerToStorageAsync(repo, transform);
}
// Create mapping dictionary
var mappingDict = downloadedDepots.ToDictionary(i => i.Item1, i => i.Item2!);
foreach (var dl in mainDepotLabel)
{
if (mappingDict.ContainsKey(dl.Id)) continue;
var dst = Path.Combine(depotsRoot, dl.Id.ToString());
mappingDict.Add(dl.Id, new FileStream(dst, FileMode.Create));
}
return new RepositoryFileTreeAccessor(mappingDict, manifest.Value);
}
catch (Exception e)
{
if (downloadedDepots != null)
foreach (var t in downloadedDepots)
{
if (t.Item2 == null) continue;
try { await t.Item2.DisposeAsync(); }
catch (Exception ex) { Console.WriteLine(ex); }
}
Console.WriteLine(e);
return null;
}
}
public async ValueTask WriteDownloadedDepotsFromServerToStorageAsync
(RepositoryModel repo, IEnumerable<(Guid, Stream)> depots)
{
var depotsRoot = PathUtility.GetWorkspaceDepotCachePath(repo.OwnerName, repo.Name);
var tasks = depots.Select(async d =>
{
var dst = Path.Combine(depotsRoot, d.Item1.ToString());
await using var ws = new FileStream(dst, FileMode.Create);
await d.Item2.CopyToAsync(ws);
d.Item2.Seek(0, SeekOrigin.Begin);
});
await Task.WhenAll(tasks);
}
public async ValueTask<(Guid, Stream?)[]?> DownloadDepotsAndCopyNetworkStreamIntoNewMemoryStreamFromServerAsync
(RepositoryModel repo, IEnumerable<DepotLabel> depotsId)
{
try
{
var api = Api.C;
if (api.RequireRefreshToken() && !(await api.TryRefreshTokenAsync()))
{
api.ClearGateway();
return null;
}
return await ValueTaskEx.WhenAll(depotsId.Select(p => DownloadDepotInternalAsync(p.Id, p.Length)));
}
catch (Exception e)
{
Console.WriteLine(e);
return null;
}
async ValueTask<(Guid, Stream?)> DownloadDepotInternalAsync(Guid depotId, long length)
{
using var rsp = await Api.C.Gateway.FetchDepot(repo.OwnerName, repo.Name, depotId.ToString());
if (rsp.StatusCode != 200) return (depotId, null);
if (rsp.Stream.Length != length) return (depotId, null);
var memoryStream = new MemoryStream(new byte[rsp.Stream.Length]);
await rsp.Stream.CopyToAsync(memoryStream);
return (depotId, memoryStream);
}
}
public async ValueTask<CommitManifest?> DownloadManifestFromServerAsync(RepositoryModel repo, Guid manifestId)
{
try
{
var api = Api.C;
if (api.RequireRefreshToken() && !(await api.TryRefreshTokenAsync()))
{
api.ClearGateway();
return null;
}
using var manifestResponse = await api.Gateway.FetchManifest(repo.OwnerName, repo.Name, manifestId.ToString());
if (manifestResponse.StatusCode != 200) return null;
return await System.Text.Json.JsonSerializer.DeserializeAsync<CommitManifest>(manifestResponse.Stream);
}
catch (Exception e)
{
Console.WriteLine(e);
return null;
}
}
public async ValueTask<bool> DeleteFromDiskAsync(RepositoryModel repo)
{
try
{
var path = PathUtility.GetWorkspacePath(repo.OwnerName, repo.Name);
if (Directory.Exists(path)) Directory.Delete(path, true);
}
catch (Exception e)
{
Console.WriteLine(e);
return false;
}
return true;
}
}

View File

@ -1,6 +1,5 @@
using System;
using System.Collections.ObjectModel;
using System.Reactive;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Avalonia.ReactiveUI;
@ -22,9 +21,9 @@ public partial class HomeViewModel : ViewModelBase, IRoutableViewModel
[Reactive] private RepositoryModel? _selectedRepository;
[Reactive] private string _serverFriendlyName;
[Reactive] private string? _serverFriendlyName;
public RepositoryService RepoSrc => RepositoryService.C;
public ObservableCollection<RepositoryModel> Repos => RepositoryService.C.Repositories;
public HomeViewModel(IScreen hostScreen)
{
@ -36,10 +35,8 @@ public partial class HomeViewModel : ViewModelBase, IRoutableViewModel
[ReactiveCommand]
private async Task RefreshRepositoriesAsync()
{
if (await RepoSrc.UpdateRepositoriesFromServerAsync())
{
}
await RepositoryService.C.UpdateRepositoriesFromServerAsync();
await RepositoryService.C.UpdateRepositoriesDownloadedStatusFromDiskAsync();
}
[ReactiveCommand]
@ -62,34 +59,64 @@ public partial class HomeViewModel : ViewModelBase, IRoutableViewModel
if (mr == DialogResult.OK)
{
await RepoSrc.CreateRepositoryOnServerAsync(form.RepositoryName);
var repo = await RepositoryService.C.CreateRepositoryOnServerAsync(form.RepositoryName, form.Description);
if (repo == null) return;
if (!await RepositoryService.C.CreateNewRepositoryOnStorageAsync(repo)) return;
await HostScreen.Router.Navigate.Execute(new RepositoryViewModel(repo, HostScreen));
}
}
[ReactiveCommand]
private async Task OpenRepositoryAsync()
{
if (_selectedRepository == null) return;
if (!await RepositoryService.C.OpenRepositoryOnStorageAsync(_selectedRepository)) return;
await HostScreen.Router.Navigate.Execute(new RepositoryViewModel(_selectedRepository, HostScreen));
}
[ReactiveCommand]
private async Task DownloadRepositoryAsync()
{
if (_selectedRepository == null) return;
if (!await RepositoryService.C.CloneRepositoryFromRemoteAsync(_selectedRepository)) return;
await HostScreen.Router.Navigate.Execute(new RepositoryViewModel(_selectedRepository, HostScreen));
}
[ReactiveCommand]
private async Task DeleteRepositoryAsync()
{
if (_selectedRepository == null) return;
if (await RepositoryService.C.DeleteFromDiskAsync(_selectedRepository))
{
await RepositoryService.C.UpdateDownloadedStatusFromDiskAsync(_selectedRepository);
}
}
[ReactiveCommand]
private async Task QuitLoginAsync()
{
Api.C.ClearGateway();
var opt = new OverlayDialogOptions
{
FullScreen = false,
Buttons = DialogButton.YesNo,
CanResize = false,
CanDragMove = false,
IsCloseButtonVisible = true,
CanLightDismiss = true,
Mode = DialogMode.Question
};
var vm = new SimpleMessageDialogViewModel("Do you really want to logout?");
var mr = await OverlayDialog
.ShowModal<SimpleMessageDialogView, SimpleMessageDialogViewModel>(vm, AppDefaultValues.HostId, opt);
if (mr == DialogResult.Yes) Api.C.ClearGateway();
}
[ReactiveCommand]
private void OpenGlobalSettingAsync()
private async Task OpenGlobalSettingAsync()
{
HostScreen.Router.Navigate.Execute(new SettingViewModel(HostScreen));
await HostScreen.Router.Navigate.Execute(new SettingViewModel(HostScreen));
}
}

View File

@ -5,4 +5,6 @@ namespace Flawless.Client.ViewModels.ModalBox;
public partial class CreateRepositoryDialogViewModel : ViewModelBase
{
[Reactive] private string _repositoryName;
[Reactive] private string _description;
}

View File

@ -0,0 +1,13 @@
using ReactiveUI.SourceGenerators;
namespace Flawless.Client.ViewModels.ModalBox;
public partial class SimpleMessageDialogViewModel : ViewModelBase
{
[Reactive] private string _message;
public SimpleMessageDialogViewModel(string message)
{
_message = message;
}
}

View File

@ -1,18 +1,25 @@
using Flawless.Client.Models;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Flawless.Client.Models;
using Flawless.Client.Service;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
namespace Flawless.Client.ViewModels;
public class RepositoryViewModel : RoutableViewModelBase
public partial class RepositoryViewModel : RoutableViewModelBase
{
public RepositoryModel Repository { get; }
public RepositoryService RepoSrv { get; }
[ReactiveCommand]
private async Task GoBackAsync()
{
await RepositoryService.C.CloseRepositoryAsync(Repository);
await HostScreen.Router.NavigateBack.Execute();
}
public RepositoryViewModel(RepositoryModel repo, IScreen hostScreen) : base(hostScreen)
{
Repository = repo;
RepoSrv = RepositoryService.C;
}
}

View File

@ -18,7 +18,7 @@
<StackPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="8">
<u:IconButton Icon="{StaticResource SemiIconSetting}"
Command="{Binding OpenGlobalSettingAsyncCommand}"/>
Command="{Binding OpenGlobalSettingCommand}"/>
<u:IconButton Classes="Danger" Icon="{StaticResource SemiIconQuit}"
Command="{Binding QuitLoginCommand}"/>
</StackPanel>
@ -35,7 +35,7 @@
Command="{Binding OpenRepositoryCommand}"/>
<u:IconButton Icon="{StaticResource SemiIconDownload}" Content="Download"
IsEnabled="{Binding !SelectedRepository.IsDownloaded}"
Command="{Binding OpenRepositoryCommand}"/>
Command="{Binding DownloadRepositoryCommand}"/>
<u:IconButton Classes="Danger" Icon="{StaticResource SemiIconDelete}" Content="Delete"
IsEnabled="{Binding SelectedRepository.IsDownloaded}"
Command="{Binding DeleteRepositoryCommand}"/>
@ -44,14 +44,14 @@
</StackPanel>
</Grid>
<Rectangle DockPanel.Dock="Top" Height="18"/>
<StackPanel IsVisible="{Binding !RepoSrc.Repositories.Count}"
<StackPanel IsVisible="{Binding !Repos.Count}"
VerticalAlignment="Center" Spacing="18">
<PathIcon Data="{StaticResource SemiIconAlertCircle}" HorizontalAlignment="Left" Width="48" Height="48"/>
<Label FontSize="18" Content="Repository is empty, try refresh or create repository."/>
</StackPanel>
<Grid IsVisible="{Binding RepoSrc.Repositories.Count}" ColumnDefinitions="*, 10, *" VerticalAlignment="Stretch">
<Grid IsVisible="{Binding Repos.Count}" ColumnDefinitions="*, 10, *" VerticalAlignment="Stretch">
<ScrollViewer Grid.Column="0" VerticalScrollBarVisibility="Auto" AllowAutoHide="True">
<u:SelectionList ItemsSource="{Binding RepoSrc.Repositories, Mode=TwoWay}"
<u:SelectionList ItemsSource="{Binding Repos, Mode=TwoWay}"
SelectedItem="{Binding SelectedRepository, Mode=TwoWay}"
Margin="0, 0, 8, 0">
<u:SelectionList.ItemTemplate>

View File

@ -12,11 +12,14 @@
<u:Form HorizontalAlignment="Stretch" LabelPosition="Top">
<u:Form.ItemsPanel>
<ItemsPanelTemplate>
<Grid ColumnDefinitions="*" RowDefinitions="*" />
<Grid ColumnDefinitions="*" RowDefinitions="*, *" />
</ItemsPanelTemplate>
</u:Form.ItemsPanel>
<u:FormItem Label="Name">
<u:FormItem Grid.Row="0" Label="Name">
<TextBox Text="{Binding RepositoryName}"/>
</u:FormItem>
<u:FormItem Grid.Row="1" Label="Description">
<TextBox MinHeight="40" MaxHeight="80" Text="{Binding Description}"/>
</u:FormItem>
</u:Form>
</UserControl>

View File

@ -0,0 +1,22 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:u="https://irihi.tech/ursa"
xmlns:vm="using:Flawless.Client.ViewModels.ModalBox"
x:DataType="vm:SimpleMessageDialogViewModel"
d:DesignHeight="450" d:DesignWidth="800" mc:Ignorable="d"
MinWidth="400"
x:Class="Flawless.Client.Views.ModalBox.SimpleMessageDialogView">
<u:Form HorizontalAlignment="Stretch" LabelPosition="Top">
<u:Form.ItemsPanel>
<ItemsPanelTemplate>
<Grid ColumnDefinitions="*" RowDefinitions="*" />
</ItemsPanelTemplate>
</u:Form.ItemsPanel>
<u:FormItem>
<Label Content="{Binding Message}"/>
</u:FormItem>
</u:Form>
</UserControl>

View File

@ -0,0 +1,15 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Flawless.Client.ViewModels.ModalBox;
using Ursa.ReactiveUIExtension;
namespace Flawless.Client.Views.ModalBox;
public partial class SimpleMessageDialogView : ReactiveUrsaView<SimpleMessageDialogViewModel>
{
public SimpleMessageDialogView()
{
InitializeComponent();
}
}

View File

@ -6,12 +6,14 @@
xmlns:semi="https://irihi.tech/semi"
xmlns:vm="using:Flawless.Client.ViewModels"
xmlns:page="using:Flawless.Client.Views.RepositoryPage"
x:DataType="vm:RepositoryViewModel"
mc:Ignorable="d" d:DesignWidth="1280" d:DesignHeight="768"
x:Class="Flawless.Client.Views.RepositoryView">
<DockPanel Margin="50">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" VerticalAlignment="Center" Spacing="20">
<u:IconButton Height="4" Icon="{StaticResource SemiIconArrowLeft}" Content="All Repositories"/>
<u:IconButton Height="4" Icon="{StaticResource SemiIconArrowLeft}"
Command="{Binding GoBackCommand}" Content="All Repositories"/>
<Label FontWeight="400" FontSize="28" Content="Name of Repository"/>
</StackPanel>
<TabControl TabStripPlacement="Top" Margin="0 20">

View File

@ -1,3 +1,4 @@
using Flawless.Communication.Shared;
using Flawless.Core.Modal;
namespace Flawless.Communication.Request;

View File

@ -1,4 +1,4 @@
namespace Flawless.Core.Modal;
[Serializable]
public record struct CommitManifest(Guid ManifestId, DepotLabel Depot, WorkspaceFile[] FilePaths);
public record struct CommitManifest(Guid ManifestId, DepotLabel[] Depot, WorkspaceFile[] FilePaths);

View File

@ -3,4 +3,4 @@
namespace Flawless.Core.Modal;
[Serializable]
public record struct DepotLabel(Guid Id, long Length, Guid[] Dependency);
public record struct DepotLabel(Guid Id, long Length);

View File

@ -405,11 +405,12 @@ public class RepositoryInnieController(
var test = new HashSet<WorkspaceFile>(req.WorkspaceSnapshot);
var createNewDepot = false;
RepositoryDepot mainDepot;
DepotLabel depotLabel;
List<DepotLabel> depotLabels = new();
// Judge if receive new depot or use old one...
if (req.Depot == null)
{
// Use existed depots
var mainDepotId = Guid.Parse(req.MainDepotId!);
var preDepot = await StencilWorkspaceSnapshotViaDatabaseAsync(rp.Id, mainDepotId, test);
@ -422,11 +423,13 @@ public class RepositoryInnieController(
// Set this depot label...
mainDepot = preDepot;
var deps = mainDepot.Dependencies.Select(d => d.DepotId).ToArray();
depotLabel = new DepotLabel(mainDepot.DepotId, mainDepot.Length, deps);
depotLabels.Add(new DepotLabel(mainDepot.DepotId, mainDepot.Length));
depotLabels.AddRange(mainDepot.Dependencies.Select(d => new DepotLabel(d.DepotId, d.Length)));
}
else
{
// Get a new depot from request - this will flat to direct depot reference to avoid deep search that
// raise memory issue.
var actualRequiredDepots = new HashSet<Guid>();
// Read and create a new depot
@ -477,9 +480,6 @@ public class RepositoryInnieController(
.SelectMany(r => r.Depots)
.Where(cm => actualRequiredDepots.Contains(cm.DepotId))
.ToArrayAsync());
depotLabel = new DepotLabel(mainDepot.DepotId, mainDepot.Length, actualRequiredDepots.ToArray());
rp.Depots.Add(mainDepot);
// Then write depot into disk
var depotPath = transformer.GetDepotPath(rp.Id, mainDepot.DepotId);
@ -489,6 +489,11 @@ public class RepositoryInnieController(
await cacheStream.CopyToAsync(depotStream, HttpContext.RequestAborted);
}
// Everything alright, so make response
depotLabels.Add(new DepotLabel(mainDepot.DepotId, mainDepot.Length));
depotLabels.AddRange(mainDepot.Dependencies.Select(d => new DepotLabel(d.DepotId, d.Length)));
rp.Depots.Add(mainDepot);
createNewDepot = true;
}
@ -498,7 +503,7 @@ public class RepositoryInnieController(
var manifest = new CommitManifest
{
ManifestId = commitId,
Depot = depotLabel,
Depot = depotLabels.ToArray(),
FilePaths = req.WorkspaceSnapshot
};

View File

@ -39,7 +39,7 @@ public class RepositoryOutieController(AppDbContext dbContext, UserManager<AppUs
}
[HttpPost("repo_create")]
public async Task<ActionResult<RepositoryInfoResponse>> CreateRepositoryAsync([FromQuery] string repositoryName)
public async Task<ActionResult<RepositoryInfoResponse>> CreateRepositoryAsync([FromQuery] string repositoryName,[FromQuery] string description)
{
if (string.IsNullOrWhiteSpace(repositoryName) || repositoryName.Length <= 3)
return BadRequest(new FailedResponse("Repository name is empty or too short!"));
@ -51,6 +51,7 @@ public class RepositoryOutieController(AppDbContext dbContext, UserManager<AppUs
var repo = new Repository()
{
Name = repositoryName,
Description = description,
Owner = u,
};