diff --git a/.idea/.idea.Flawless-Version-Control/.idea/avalonia.xml b/.idea/.idea.Flawless-Version-Control/.idea/avalonia.xml index 0c9d2dd..f4aa09b 100644 --- a/.idea/.idea.Flawless-Version-Control/.idea/avalonia.xml +++ b/.idea/.idea.Flawless-Version-Control/.idea/avalonia.xml @@ -16,6 +16,8 @@ + + diff --git a/Flawless-Version-Control.sln.DotSettings.user b/Flawless-Version-Control.sln.DotSettings.user index 958b690..4ad8656 100644 --- a/Flawless-Version-Control.sln.DotSettings.user +++ b/Flawless-Version-Control.sln.DotSettings.user @@ -33,13 +33,13 @@ <Assembly Path="C:\Users\Cardi\.nuget\packages\irihi.ursa\1.10.0\lib\net8.0\Ursa.dll" /> <Assembly Path="C:\Users\Cardi\.nuget\packages\irihi.ursa.themes.semi\1.10.0\lib\netstandard2.0\Ursa.Themes.Semi.dll" /> </AssemblyExplorer> - <SessionState ContinuousTestingMode="0" IsActive="True" IsLocked="True" Name="PathValidationTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>MSTest::5B1CB26D-99F5-491A-B368-7E3552FE67E9::net9.0::Flawless.Abstract.Test.WorkPathTestUnit</TestId> - </TestAncestor> + <SessionState ContinuousTestingMode="0" IsActive="True" IsLocked="True" Name="PathValidationTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>MSTest::5B1CB26D-99F5-491A-B368-7E3552FE67E9::net9.0::Flawless.Abstract.Test.WorkPathTestUnit</TestId> + </TestAncestor> </SessionState> - <SessionState ContinuousTestingMode="0" Name="PathValidationTest #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>MSTest::5B1CB26D-99F5-491A-B368-7E3552FE67E9::net9.0::Flawless.Abstract.Test.WorkPathTestUnit.PathValidationTest</TestId> - </TestAncestor> + <SessionState ContinuousTestingMode="0" Name="PathValidationTest #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>MSTest::5B1CB26D-99F5-491A-B368-7E3552FE67E9::net9.0::Flawless.Abstract.Test.WorkPathTestUnit.PathValidationTest</TestId> + </TestAncestor> </SessionState> \ No newline at end of file diff --git a/Flawless.Client/AppDefaultValues.cs b/Flawless.Client/AppDefaultValues.cs index 1e6362b..273c5bc 100644 --- a/Flawless.Client/AppDefaultValues.cs +++ b/Flawless.Client/AppDefaultValues.cs @@ -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"); diff --git a/Flawless.Client/ErrorGUIHandler.cs b/Flawless.Client/ErrorGUIHandler.cs new file mode 100644 index 0000000..63125e6 --- /dev/null +++ b/Flawless.Client/ErrorGUIHandler.cs @@ -0,0 +1,11 @@ +using System; +using Avalonia.Controls.Notifications; + +namespace Flawless.Client; + +public static class ErrorGUIHandler +{ + public static void OnError(Exception ex) + { + } +} \ No newline at end of file diff --git a/Flawless.Client/Flawless.Client.csproj b/Flawless.Client/Flawless.Client.csproj index 744b21e..8b03c2c 100644 --- a/Flawless.Client/Flawless.Client.csproj +++ b/Flawless.Client/Flawless.Client.csproj @@ -36,6 +36,7 @@ + @@ -55,5 +56,14 @@ ServerSetupPageView.axaml Code + + SimpleMessageDialogView.axaml + Code + + + + + + diff --git a/Flawless.Client/Models/RepositoryLocalDatabaseModel.cs b/Flawless.Client/Models/RepositoryLocalDatabaseModel.cs new file mode 100644 index 0000000..3349125 --- /dev/null +++ b/Flawless.Client/Models/RepositoryLocalDatabaseModel.cs @@ -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 CurrentLockedFiles { get; } = new(); + + [NonSerialized] + [Reactive] private Guid? _currentCommit; +} \ No newline at end of file diff --git a/Flawless.Client/ObserverHelper.cs b/Flawless.Client/ObserverHelper.cs index 0a36445..2c70c77 100644 --- a/Flawless.Client/ObserverHelper.cs +++ b/Flawless.Client/ObserverHelper.cs @@ -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(this ObservableCollection collection, Comparison comparison) + { + var sortableList = new List(collection); + sortableList.Sort(comparison); + + for (int i = 0; i < sortableList.Count; i++) collection.Move(collection.IndexOf(sortableList[i]), i); + } + } \ No newline at end of file diff --git a/Flawless.Client/PathUtility.cs b/Flawless.Client/PathUtility.cs new file mode 100644 index 0000000..a5aefab --- /dev/null +++ b/Flawless.Client/PathUtility.cs @@ -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); + +} \ No newline at end of file diff --git a/Flawless.Client/Service/LocalFileTreeAccessor.cs b/Flawless.Client/Service/LocalFileTreeAccessor.cs new file mode 100644 index 0000000..b283141 --- /dev/null +++ b/Flawless.Client/Service/LocalFileTreeAccessor.cs @@ -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 _baseline; + + private readonly Dictionary _newFiles = new(); + + private readonly Dictionary _deleteFiles = new(); + + private readonly Dictionary _modifyFiles = new(); + + private object _optLock = new(); + + public string WorkingDirectory => _rootDirectory; + + public IReadOnlyDictionary BaselineFiles => _baseline; + + public IReadOnlyDictionary NewFiles => _newFiles; + + public IReadOnlyDictionary DeletedFiles => _deleteFiles; + + public IReadOnlyDictionary ModifiedFiles => _modifyFiles; + + + public LocalFileTreeAccessor(RepositoryModel repo, IEnumerable baselines) + { + _repo = repo; + _baseline = baselines.ToImmutableDictionary(b => b.WorkPath); + _rootDirectory = PathUtility.GetWorkspacePath(repo.OwnerName, repo.Name); + } + + public void SetBaseline(IEnumerable 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 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); + } +} \ No newline at end of file diff --git a/Flawless.Client/Service/Remote_Generated.cs b/Flawless.Client/Service/Remote_Generated.cs index 3c714c3..5db99a8 100644 --- a/Flawless.Client/Service/Remote_Generated.cs +++ b/Flawless.Client/Service/Remote_Generated.cs @@ -167,7 +167,7 @@ namespace Flawless.Client.Remote /// Thrown when the request returns a non-success status code. [Headers("Accept: text/plain, application/json, text/json")] [Post("/api/repo_create")] - Task RepoCreate([Query] string repositoryName); + Task RepoCreate([Query] string repositoryName, [Query] string description); /// A that completes when the request is finished. /// Thrown when the request returns a non-success status code. diff --git a/Flawless.Client/Service/RepositoryFileTreeAccessor.cs b/Flawless.Client/Service/RepositoryFileTreeAccessor.cs new file mode 100644 index 0000000..7e76b9b --- /dev/null +++ b/Flawless.Client/Service/RepositoryFileTreeAccessor.cs @@ -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 _mappings; + + private readonly CommitManifest _manifest; + + private readonly Dictionary _headers; + + private readonly Dictionary _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 currentReadFs, CommitManifest manifest) + { + IsCached = false; + _mappings = currentReadFs; + _manifest = manifest; + _headers = new Dictionary(); + _cached = new Dictionary(); + _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"); + } + +} \ No newline at end of file diff --git a/Flawless.Client/Service/RepositoryService.cs b/Flawless.Client/Service/RepositoryService.cs index f8f57ed..0af8911 100644 --- a/Flawless.Client/Service/RepositoryService.cs +++ b/Flawless.Client/Service/RepositoryService.cs @@ -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 { public ObservableCollection Repositories => _repositories; - private readonly ObservableCollection _repositories = new(); - public async ValueTask CreateRepositoryOnServerAsync(string repositoryName) + private readonly Dictionary _localRepoDbModel = new(); + + private readonly HashSet _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 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 UpdateRepositoriesFromServerAsync() @@ -98,18 +168,27 @@ public class RepositoryService : BaseService return true; } - public async ValueTask UpdateRepositoriesDownloadedFromDiskAsync() + public async ValueTask 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 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 UpdateRepositoryMembersFromServerAsync(RepositoryModel repo) + public async ValueTask UpdateMembersFromServerAsync(RepositoryModel repo) { var api = Api.C; try @@ -158,4 +237,344 @@ public class RepositoryService : BaseService return true; } + + public bool IsRepositoryOpened(RepositoryModel repo) + { + return _openedRepos.Any(r => r == repo); + } + + public async ValueTask 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 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 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 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 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 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 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 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 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(manifestResponse.Stream); + } + catch (Exception e) + { + Console.WriteLine(e); + return null; + } + } + + public async ValueTask 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; + } } \ No newline at end of file diff --git a/Flawless.Client/ViewModels/HomeViewModel.cs b/Flawless.Client/ViewModels/HomeViewModel.cs index 1d7ecb9..b036636 100644 --- a/Flawless.Client/ViewModels/HomeViewModel.cs +++ b/Flawless.Client/ViewModels/HomeViewModel.cs @@ -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 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(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)); } } \ No newline at end of file diff --git a/Flawless.Client/ViewModels/ModalBox/CreateRepositoryDialogViewModel.cs b/Flawless.Client/ViewModels/ModalBox/CreateRepositoryDialogViewModel.cs index 411fdcb..00b26da 100644 --- a/Flawless.Client/ViewModels/ModalBox/CreateRepositoryDialogViewModel.cs +++ b/Flawless.Client/ViewModels/ModalBox/CreateRepositoryDialogViewModel.cs @@ -5,4 +5,6 @@ namespace Flawless.Client.ViewModels.ModalBox; public partial class CreateRepositoryDialogViewModel : ViewModelBase { [Reactive] private string _repositoryName; + + [Reactive] private string _description; } \ No newline at end of file diff --git a/Flawless.Client/ViewModels/ModalBox/SimpleMessageDialogViewModel.cs b/Flawless.Client/ViewModels/ModalBox/SimpleMessageDialogViewModel.cs new file mode 100644 index 0000000..20b03b9 --- /dev/null +++ b/Flawless.Client/ViewModels/ModalBox/SimpleMessageDialogViewModel.cs @@ -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; + } +} \ No newline at end of file diff --git a/Flawless.Client/ViewModels/RepositoryViewModel.cs b/Flawless.Client/ViewModels/RepositoryViewModel.cs index 0078e17..c2a244f 100644 --- a/Flawless.Client/ViewModels/RepositoryViewModel.cs +++ b/Flawless.Client/ViewModels/RepositoryViewModel.cs @@ -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; } } \ No newline at end of file diff --git a/Flawless.Client/Views/HomeView.axaml b/Flawless.Client/Views/HomeView.axaml index 31b7692..9403ff7 100644 --- a/Flawless.Client/Views/HomeView.axaml +++ b/Flawless.Client/Views/HomeView.axaml @@ -18,7 +18,7 @@ + Command="{Binding OpenGlobalSettingCommand}"/> @@ -35,7 +35,7 @@ Command="{Binding OpenRepositoryCommand}"/> + Command="{Binding DownloadRepositoryCommand}"/> @@ -44,14 +44,14 @@ - - + - diff --git a/Flawless.Client/Views/ModalBox/CreateRepositoryDialogView.axaml b/Flawless.Client/Views/ModalBox/CreateRepositoryDialogView.axaml index 5d0863b..132d5bb 100644 --- a/Flawless.Client/Views/ModalBox/CreateRepositoryDialogView.axaml +++ b/Flawless.Client/Views/ModalBox/CreateRepositoryDialogView.axaml @@ -12,11 +12,14 @@ - + - + + + + diff --git a/Flawless.Client/Views/ModalBox/SimpleMessageDialogView.axaml b/Flawless.Client/Views/ModalBox/SimpleMessageDialogView.axaml new file mode 100644 index 0000000..e49afab --- /dev/null +++ b/Flawless.Client/Views/ModalBox/SimpleMessageDialogView.axaml @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/Flawless.Client/Views/ModalBox/SimpleMessageDialogView.axaml.cs b/Flawless.Client/Views/ModalBox/SimpleMessageDialogView.axaml.cs new file mode 100644 index 0000000..724683e --- /dev/null +++ b/Flawless.Client/Views/ModalBox/SimpleMessageDialogView.axaml.cs @@ -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 +{ + public SimpleMessageDialogView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/Flawless.Client/Views/RepositoryView.axaml b/Flawless.Client/Views/RepositoryView.axaml index f8cc8e3..d22d9a9 100644 --- a/Flawless.Client/Views/RepositoryView.axaml +++ b/Flawless.Client/Views/RepositoryView.axaml @@ -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"> - + diff --git a/Flawless.Communication/Request/CommitRequest.cs b/Flawless.Communication/Request/CommitRequest.cs index 4d21065..03d238f 100644 --- a/Flawless.Communication/Request/CommitRequest.cs +++ b/Flawless.Communication/Request/CommitRequest.cs @@ -1,3 +1,4 @@ +using Flawless.Communication.Shared; using Flawless.Core.Modal; namespace Flawless.Communication.Request; diff --git a/Flawless.Core/Modal/CommitManifest.cs b/Flawless.Core/Modal/CommitManifest.cs index ea0db22..277375d 100644 --- a/Flawless.Core/Modal/CommitManifest.cs +++ b/Flawless.Core/Modal/CommitManifest.cs @@ -1,4 +1,4 @@ namespace Flawless.Core.Modal; [Serializable] -public record struct CommitManifest(Guid ManifestId, DepotLabel Depot, WorkspaceFile[] FilePaths); \ No newline at end of file +public record struct CommitManifest(Guid ManifestId, DepotLabel[] Depot, WorkspaceFile[] FilePaths); \ No newline at end of file diff --git a/Flawless.Core/Modal/DepotLabel.cs b/Flawless.Core/Modal/DepotLabel.cs index d4a94f4..891f807 100644 --- a/Flawless.Core/Modal/DepotLabel.cs +++ b/Flawless.Core/Modal/DepotLabel.cs @@ -3,4 +3,4 @@ namespace Flawless.Core.Modal; [Serializable] -public record struct DepotLabel(Guid Id, long Length, Guid[] Dependency); \ No newline at end of file +public record struct DepotLabel(Guid Id, long Length); \ No newline at end of file diff --git a/Flawless.Server/Controllers/RepositoryInnieController.cs b/Flawless.Server/Controllers/RepositoryInnieController.cs index 7864d85..7fb9ac1 100644 --- a/Flawless.Server/Controllers/RepositoryInnieController.cs +++ b/Flawless.Server/Controllers/RepositoryInnieController.cs @@ -405,11 +405,12 @@ public class RepositoryInnieController( var test = new HashSet(req.WorkspaceSnapshot); var createNewDepot = false; RepositoryDepot mainDepot; - DepotLabel depotLabel; + List 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(); // 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 }; diff --git a/Flawless.Server/Controllers/RepositoryOutieController.cs b/Flawless.Server/Controllers/RepositoryOutieController.cs index 9355631..ed2e777 100644 --- a/Flawless.Server/Controllers/RepositoryOutieController.cs +++ b/Flawless.Server/Controllers/RepositoryOutieController.cs @@ -39,7 +39,7 @@ public class RepositoryOutieController(AppDbContext dbContext, UserManager> CreateRepositoryAsync([FromQuery] string repositoryName) + public async Task> 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