diff --git a/.idea/.idea.Flawless-Version-Control/.idea/avalonia.xml b/.idea/.idea.Flawless-Version-Control/.idea/avalonia.xml index f4aa09b..1ab1d7f 100644 --- a/.idea/.idea.Flawless-Version-Control/.idea/avalonia.xml +++ b/.idea/.idea.Flawless-Version-Control/.idea/avalonia.xml @@ -21,6 +21,7 @@ + diff --git a/Flawless.Client/App.axaml b/Flawless.Client/App.axaml index 7268800..4ff221c 100644 --- a/Flawless.Client/App.axaml +++ b/Flawless.Client/App.axaml @@ -8,5 +8,6 @@ + \ No newline at end of file diff --git a/Flawless.Client/Flawless.Client.csproj b/Flawless.Client/Flawless.Client.csproj index 8b03c2c..eb15b74 100644 --- a/Flawless.Client/Flawless.Client.csproj +++ b/Flawless.Client/Flawless.Client.csproj @@ -36,6 +36,7 @@ + @@ -66,4 +67,8 @@ + + + + diff --git a/Flawless.Client/Models/RepositoryLocalDatabaseModel.cs b/Flawless.Client/Models/RepositoryLocalDatabaseModel.cs index 3349125..eb249cf 100644 --- a/Flawless.Client/Models/RepositoryLocalDatabaseModel.cs +++ b/Flawless.Client/Models/RepositoryLocalDatabaseModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.ObjectModel; +using System.Text.Json.Serialization; using Flawless.Client.Service; using ReactiveUI.SourceGenerators; @@ -8,14 +9,17 @@ namespace Flawless.Client.Models; [Serializable] public partial class RepositoryLocalDatabaseModel : ReactiveModel { - public required RepositoryModel RootModal { get; init; } + [JsonIgnore] + public required RepositoryModel RootModal { get; set; } - public required LocalFileTreeAccessor LocalAccessor { get; init; } + [JsonIgnore] + public required LocalFileTreeAccessor LocalAccessor { get; set; } + [JsonIgnore] public RepositoryFileTreeAccessor? RepoAccessor { get; set; } - public ObservableCollection CurrentLockedFiles { get; } = new(); - - [NonSerialized] [Reactive] private Guid? _currentCommit; + + [Reactive] private string? _commitMessage; + } \ No newline at end of file diff --git a/Flawless.Client/Models/RepositoryModel.cs b/Flawless.Client/Models/RepositoryModel.cs index c4e2671..4aa14cd 100644 --- a/Flawless.Client/Models/RepositoryModel.cs +++ b/Flawless.Client/Models/RepositoryModel.cs @@ -34,7 +34,7 @@ public partial class RepositoryModel : ReactiveModel public ObservableCollection Locks { get; } = new(); - public enum RepositoryRole + public enum RepositoryRole : byte { Guest = 0, Reporter = 1, diff --git a/Flawless.Client/Models/UserModel.cs b/Flawless.Client/Models/UserModel.cs index 731443f..b3c9644 100644 --- a/Flawless.Client/Models/UserModel.cs +++ b/Flawless.Client/Models/UserModel.cs @@ -1,6 +1,33 @@ -namespace Flawless.Client.Models; +using System; +using ReactiveUI.SourceGenerators; -public record UserModel : ReactiveRecordModel +namespace Flawless.Client.Models; + +public partial class UserModel : ReactiveModel { + public enum SexType : byte + { + Unset = 0, + Male = 1, + Female = 2, + WalmartPlasticBag = 3 + } + [Reactive] private string _username; + + [Reactive] private string _nickname; + + [Reactive] private string _bio; + + [Reactive] private SexType _sex; + + [Reactive] private bool _emailIsVisible; + + [Reactive] private bool _canEdit; + + [Reactive] private string _email; + + [Reactive] private string _phoneNumber; + + [Reactive] private DateTime _joinDate; } \ No newline at end of file diff --git a/Flawless.Client/Service/Api.cs b/Flawless.Client/Service/Api.cs index f716a17..65a0ecc 100644 --- a/Flawless.Client/Service/Api.cs +++ b/Flawless.Client/Service/Api.cs @@ -36,6 +36,8 @@ public class Api : BaseService public IObservable Token => _token; + public IReactiveProperty Username => _username; + private readonly ReactiveProperty _isLoggedIn = new(false); private readonly ReactiveProperty _serverUrl = new(string.Empty); @@ -43,6 +45,8 @@ public class Api : BaseService private readonly ReactiveProperty _status = new(null); private readonly ReactiveProperty _token = new(null); + + private readonly ReactiveProperty _username = new(string.Empty); #region GatewayConfig @@ -55,6 +59,7 @@ public class Api : BaseService public void ClearGateway() { _gateway = null; + _username.Value = null; _isLoggedIn.Value = false; _status.Value = null; _serverUrl.Value = null; @@ -82,7 +87,9 @@ public class Api : BaseService Password = password }); + _username.Value = username; _isLoggedIn.Value = true; + await UserService.C.DownloadUserInfoAsync(username); } #endregion diff --git a/Flawless.Client/Service/BaseService.cs b/Flawless.Client/Service/BaseService.cs index 14a1aba..382fd2e 100644 --- a/Flawless.Client/Service/BaseService.cs +++ b/Flawless.Client/Service/BaseService.cs @@ -1,4 +1,6 @@ -namespace Flawless.Client.Service; +using Flawless.Client.ViewModels; + +namespace Flawless.Client.Service; public class BaseService where TService : class, new() { diff --git a/Flawless.Client/Service/LocalFileTreeAccessor.cs b/Flawless.Client/Service/LocalFileTreeAccessor.cs index b283141..81e1748 100644 --- a/Flawless.Client/Service/LocalFileTreeAccessor.cs +++ b/Flawless.Client/Service/LocalFileTreeAccessor.cs @@ -11,6 +11,28 @@ namespace Flawless.Client.Service; public class LocalFileTreeAccessor { + + public enum ChangeType + { + Folder = 0, + Add, + Remove, + Modify + } + + public struct ChangeRecord + { + public ChangeType Type { get; } + + public WorkspaceFile File { get; } + + public ChangeRecord(ChangeType type, WorkspaceFile file) + { + Type = type; + File = file; + } + } + private static readonly string[] IgnoredDirectories = { AppDefaultValues.RepoLocalStorageManagerFolder @@ -22,11 +44,7 @@ public class LocalFileTreeAccessor private IReadOnlyDictionary _baseline; - private readonly Dictionary _newFiles = new(); - - private readonly Dictionary _deleteFiles = new(); - - private readonly Dictionary _modifyFiles = new(); + private Dictionary _difference; private object _optLock = new(); @@ -34,16 +52,12 @@ public class LocalFileTreeAccessor public IReadOnlyDictionary BaselineFiles => _baseline; - public IReadOnlyDictionary NewFiles => _newFiles; - - public IReadOnlyDictionary DeletedFiles => _deleteFiles; - - public IReadOnlyDictionary ModifiedFiles => _modifyFiles; - + public IReadOnlyDictionary Changes => _difference; public LocalFileTreeAccessor(RepositoryModel repo, IEnumerable baselines) { _repo = repo; + _difference = new Dictionary(); _baseline = baselines.ToImmutableDictionary(b => b.WorkPath); _rootDirectory = PathUtility.GetWorkspacePath(repo.OwnerName, repo.Name); } @@ -53,9 +67,7 @@ public class LocalFileTreeAccessor lock (_optLock) { _baseline = baselines.ToImmutableDictionary(b => b.WorkPath); - _newFiles.Clear(); - _deleteFiles.Clear(); - _modifyFiles.Clear(); + _difference.Clear(); RefreshInternal(); } @@ -65,9 +77,7 @@ public class LocalFileTreeAccessor { lock (_optLock) { - _newFiles.Clear(); - _deleteFiles.Clear(); - _modifyFiles.Clear(); + _difference.Clear(); RefreshInternal(); } @@ -91,8 +101,8 @@ public class LocalFileTreeAccessor 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); + foreach (var f in changes) _difference.Add(f.WorkPath, new ChangeRecord(ChangeType.Modify, f)); + foreach (var f in news) _difference.Add(f.WorkPath, new ChangeRecord(ChangeType.Add, f)); + foreach (var f in removed) _difference.Add(f.WorkPath, new ChangeRecord(ChangeType.Remove, f)); } } \ No newline at end of file diff --git a/Flawless.Client/Service/RepositoryService.cs b/Flawless.Client/Service/RepositoryService.cs index 762c259..9aff3a5 100644 --- a/Flawless.Client/Service/RepositoryService.cs +++ b/Flawless.Client/Service/RepositoryService.cs @@ -65,8 +65,11 @@ public class RepositoryService : BaseService { // 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. + localRepo = (JsonSerializer.CreateDefault().Deserialize(readFs, typeof(RepositoryLocalDatabaseModel)) + as RepositoryLocalDatabaseModel)!; // todo add broken test. + + localRepo.RootModal = repo; + localRepo.LocalAccessor = new LocalFileTreeAccessor(repo, []); } else { @@ -572,7 +575,8 @@ public class RepositoryService : BaseService Console.WriteLine(e); return false; } - + + repo.IsDownloaded = false; return true; } } \ No newline at end of file diff --git a/Flawless.Client/Service/UserService.cs b/Flawless.Client/Service/UserService.cs new file mode 100644 index 0000000..7ddaeb2 --- /dev/null +++ b/Flawless.Client/Service/UserService.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Flawless.Client.Models; +using ReactiveUI.SourceGenerators; + +namespace Flawless.Client.Service; + +public partial class UserService : BaseService +{ + private Dictionary _cachedUsers = new(); + + public bool IsUserCached(string username) + => _cachedUsers.ContainsKey(username); + + public UserModel? GetUserInfoAsync(string username) + => _cachedUsers.ContainsKey(username) ? _cachedUsers[username] : null; + + public async ValueTask GetOrDownloadUserInfoAsync(string username) + { + if (_cachedUsers.TryGetValue(username, out var userModel)) return userModel; + return await DownloadUserInfoAsync(username); + } + + public async ValueTask DownloadUserInfoAsync(string username) + { + var api = Api.C; + UserModel user; + try + { + if (api.RequireRefreshToken() && !(await api.TryRefreshTokenAsync())) + { + api.ClearGateway(); + return null; + } + + var info = await api.Gateway.GetInfo(username); + user = new UserModel(); + + user.Username = info.Username; + user.Nickname = info.NickName; + user.Bio = info.Bio; + user.Sex = (UserModel.SexType) info.Gender; + user.EmailIsVisible = info.PublicEmail ?? false; + user.CanEdit = info.Authorized; + user.PhoneNumber = info.Phone; + user.Email = info.Email; + + _cachedUsers.Add(username, user); + } + catch (Exception e) + { + Console.WriteLine(e); + return null; + } + + return user; + } + + public async ValueTask RefreshUserInfoAsync(UserModel user) + { + var api = Api.C; + try + { + if (api.RequireRefreshToken() && !(await api.TryRefreshTokenAsync())) + { + api.ClearGateway(); + return false; + } + + var info = await api.Gateway.GetInfo(user.Username); + + user.Nickname = info.NickName; + user.Bio = info.Bio; + user.Sex = (UserModel.SexType) info.Gender; + user.EmailIsVisible = info.PublicEmail ?? false; + user.CanEdit = info.Authorized; + user.PhoneNumber = info.Phone; + user.Email = info.Email; + } + catch (Exception e) + { + Console.WriteLine(e); + return false; + } + + return true; + } + +} \ No newline at end of file diff --git a/Flawless.Client/ViewModels/RepositoryViewModel.cs b/Flawless.Client/ViewModels/RepositoryViewModel.cs index c2a244f..84bd57e 100644 --- a/Flawless.Client/ViewModels/RepositoryViewModel.cs +++ b/Flawless.Client/ViewModels/RepositoryViewModel.cs @@ -1,25 +1,191 @@ -using System.Reactive.Linq; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Reactive.Linq; using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Controls.Models.TreeDataGrid; +using DynamicData; +using DynamicData.Binding; using Flawless.Client.Models; using Flawless.Client.Service; using ReactiveUI; using ReactiveUI.SourceGenerators; +using ChangeType = Flawless.Client.Service.LocalFileTreeAccessor.ChangeType; namespace Flawless.Client.ViewModels; +public class LocalChangesNode +{ + public required string FullPath { get; set; } + + public required string Type { get; set; } + + public DateTime? ModifiedTime { get; set; } + + public bool Included + { + get + { + if (Contents != null) return Contents.All(c => c.Included); + return _actualIncluded; + } + set + { + if (Contents != null) foreach (var n in Contents) n.Included = value; + _actualIncluded = value; + } + } + + private bool _actualIncluded; + + public ObservableCollection? Contents { get; set; } + + public static LocalChangesNode FromFolder(string folderPath) + { + return new LocalChangesNode + { + Type = "Folder", + FullPath = folderPath, + Contents = new() + }; + } + + public static LocalChangesNode FromWorkspaceFile(LocalFileTreeAccessor.ChangeRecord file) + { + return new LocalChangesNode + { + Type = file.Type.ToString(), + FullPath = file.File.WorkPath, + ModifiedTime = file.File.ModifyTime + }; + } +} + public partial class RepositoryViewModel : RoutableViewModelBase { public RepositoryModel Repository { get; } + + public RepositoryLocalDatabaseModel LocalDatabase { get; } + + public HierarchicalTreeDataGridSource LocalChange { get; } + + public ObservableCollection LocalChangeSetRaw { get; } = new(); + + public UserModel User { get; } + + [Reactive] private bool _autoDetectChanges = true; - [ReactiveCommand] - private async Task GoBackAsync() - { - await RepositoryService.C.CloseRepositoryAsync(Repository); - await HostScreen.Router.NavigateBack.Execute(); - } + [Reactive] private bool _isOwnerRole, _isDeveloperRole, _isReporterRole, _isGuestRole; public RepositoryViewModel(RepositoryModel repo, IScreen hostScreen) : base(hostScreen) { Repository = repo; + LocalDatabase = RepositoryService.C.GetRepositoryLocalDatabase(repo); + User = UserService.C.GetUserInfoAsync(Api.C.Username.Value!)!; + + // Setup repository permission change watcher + RefreshRepositoryRoleInfo(); + Repository.Members.ObserveCollectionChanges().Subscribe(_ => RefreshRepositoryRoleInfo()); + + // Setup local change set + LocalChangeSetRaw.Add(new LocalChangesNode + { + Type = "Add", + FullPath = "test.md", + ModifiedTime = DateTime.Now, + }); + LocalChange = new HierarchicalTreeDataGridSource(LocalChangeSetRaw) + { + Columns = + { + new CheckBoxColumn( + string.Empty, n => n.Included, (n, v) => n.Included = v), + + new TextColumn( + "Change", + n => n.Contents != null ? String.Empty : n.Type), + + new HierarchicalExpanderColumn( + new TextColumn( + "Name", + n => Path.GetFileName(n.FullPath)), + n => n.Contents), + + new TextColumn( + "File Type", + n => n.Contents != null ? "Folder" : Path.GetExtension(n.FullPath)), + + new TextColumn( + "Size", + n => 0), + + new TextColumn( + "ModifiedTime", n => n.ModifiedTime.HasValue ? n.ModifiedTime.Value.ToLocalTime() : null), + } + }; + + // Do refresh when entered + // DetectLocalChangesAsyncCommand.Execute(); + } + + [ReactiveCommand] + private async Task CloseRepositoryAsync() + { + await RepositoryService.C.CloseRepositoryAsync(Repository); + await HostScreen.Router.NavigateBack.Execute(); + } + + [ReactiveCommand] + private void RefreshRepositoryRoleInfo() + { + var isOwner = Repository.OwnerName == User.Username; + var role = isOwner ? + RepositoryModel.RepositoryRole.Owner : + Repository.Members.First(p => p.Username == User.Username).Role; + + if (role >= RepositoryModel.RepositoryRole.Owner) IsOwnerRole = true; + if (role >= RepositoryModel.RepositoryRole.Developer) IsDeveloperRole = true; + if (role >= RepositoryModel.RepositoryRole.Reporter) IsReporterRole = true; + if (role >= RepositoryModel.RepositoryRole.Guest) IsGuestRole = true; + } + + [ReactiveCommand] + private async ValueTask DetectLocalChangesAsync() + { + var ns = await Task.Run(() => + { + LocalDatabase.LocalAccessor.Refresh(); + + // Generate a map of all folders + var folderMap = new Dictionary(); + foreach (var k in LocalDatabase.LocalAccessor.Changes.Keys) + AddParentToMap(k); + + var nodes = new List(); + foreach (var file in LocalDatabase.LocalAccessor.Changes.Values) + { + var directory = Path.GetDirectoryName(file.File.WorkPath); + var n = LocalChangesNode.FromWorkspaceFile(file); + if (string.IsNullOrEmpty(directory)) nodes.Add(n); + else folderMap[directory].Contents!.Add(n); + } + + nodes.AddRange(folderMap.Values); + return nodes; + + void AddParentToMap(string path) + { + var parent = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(parent) || folderMap.ContainsKey(parent)) return; + + folderMap.Add(parent, LocalChangesNode.FromFolder(parent)); + } + }); + + LocalChangeSetRaw.Clear(); + LocalChangeSetRaw.AddRange(ns); } } \ No newline at end of file diff --git a/Flawless.Client/Views/ModalBox/CreateRepositoryDialogView.axaml b/Flawless.Client/Views/ModalBox/CreateRepositoryDialogView.axaml index 132d5bb..58bc3e6 100644 --- a/Flawless.Client/Views/ModalBox/CreateRepositoryDialogView.axaml +++ b/Flawless.Client/Views/ModalBox/CreateRepositoryDialogView.axaml @@ -19,7 +19,7 @@ - + diff --git a/Flawless.Client/Views/RepositoryPage/RepoDashboardPageView.axaml b/Flawless.Client/Views/RepositoryPage/RepoDashboardPageView.axaml index a5bb643..da30e13 100644 --- a/Flawless.Client/Views/RepositoryPage/RepoDashboardPageView.axaml +++ b/Flawless.Client/Views/RepositoryPage/RepoDashboardPageView.axaml @@ -10,20 +10,22 @@ - - + + + + + + + + - - - - + diff --git a/Flawless.Client/Views/RepositoryPage/RepoWorkspacePageView.axaml b/Flawless.Client/Views/RepositoryPage/RepoWorkspacePageView.axaml index 8236857..0211cb7 100644 --- a/Flawless.Client/Views/RepositoryPage/RepoWorkspacePageView.axaml +++ b/Flawless.Client/Views/RepositoryPage/RepoWorkspacePageView.axaml @@ -2,51 +2,33 @@ 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" x:DataType="vm:RepositoryViewModel" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Flawless.Client.Views.RepositoryPage.RepoWorkspacePageView"> - - - - - - - - - - - - - - + + + + + + + + + + + + - - + + - - - - - - - - - - - - - + - + diff --git a/Flawless.Client/Views/RepositoryView.axaml b/Flawless.Client/Views/RepositoryView.axaml index 050e673..6e256a6 100644 --- a/Flawless.Client/Views/RepositoryView.axaml +++ b/Flawless.Client/Views/RepositoryView.axaml @@ -16,7 +16,7 @@ Command="{Binding GoBackCommand}" Content="All Repositories"/>