using System; using System.Collections.Generic; using System.Collections.Immutable; 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.Abstraction; using Flawless.Client.Models; using Flawless.Client.Remote; using Flawless.Client.Service; using Flawless.Client.ViewModels.ModalBox; using Flawless.Client.Views.ModalBox; using ReactiveUI; using ReactiveUI.SourceGenerators; using Ursa.Controls; using WorkspaceFile = Flawless.Core.Modal.WorkspaceFile; namespace Flawless.Client.ViewModels; public partial class LocalChangesNode : ReactiveModel { [Reactive] private string _fullPath; [Reactive] private string _type; [Reactive] private DateTime? _modifiedTime; [Reactive] private LocalChangesNode? _parent; [Reactive] private long _size; 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; this.RaiseAndSetIfChanged(ref _actualIncluded, value, nameof(Included)); _parent?.RaisePropertyChanged(nameof(Included)); } } private bool _actualIncluded; public ObservableCollection? Contents { get; set; } public static LocalChangesNode FromFolder(LocalChangesNode? parent, string folderPath) { return new LocalChangesNode { Type = "Folder", FullPath = folderPath, Contents = new(), Parent = parent, Size = -1 }; } public static LocalChangesNode FromWorkspaceFile(LocalChangesNode? parent, ChangeInfo file) { return new LocalChangesNode { Type = file.Type.ToString(), FullPath = file.File.WorkPath, ModifiedTime = file.File.ModifyTime, Parent = parent, Size = (long) file.File.Size }; } } public class CommitTransitNode { public required string CommitId { get; set; } public required string Author { get; set; } public required string Message { get; set; } public required DateTime? CommitAt { get; set; } public required string FullCommitId { get; set; } public required string FullMessage { get; set; } public required string MainDepotId { get; set; } public static CommitTransitNode FromCommit(RepositoryModel.Commit cm) { string msg; if (cm.Message.Length > 28) { msg = cm.Message.Substring(0, 28) + "..."; } else { msg = cm.Message; } return new CommitTransitNode { CommitId = cm.CommitId.ToString(), Author = cm.Author, CommitAt = cm.CommittedOn.ToLocalTime(), Message = msg, FullCommitId = cm.CommitId.ToString(), FullMessage = cm.Message, MainDepotId = cm.DepotId.ToString(), }; } } public partial class RepositoryViewModel : RoutableViewModelBase { public RepositoryModel Repository { get; } public RepositoryLocalDatabaseModel LocalDatabase { get; } public HierarchicalTreeDataGridSource LocalChange { get; } public HierarchicalTreeDataGridSource FileTree { get; } public FlatTreeDataGridSource Commits { get; } public ObservableCollectionExtended LocalChangeSetRaw { get; } = new(); public ObservableCollectionExtended CurrentCommitFileTreeRaw { get; } = new(); public ObservableCollectionExtended CommitsRaw { get; } = new(); public UserModel User { get; } [Reactive] private bool _autoDetectChanges = true; [Reactive] private bool _isOwnerRole, _isDeveloperRole, _isReporterRole, _isGuestRole, _showWebHook; private string _wsRoot; public RepositoryViewModel(RepositoryModel repo, IScreen hostScreen) : base(hostScreen) { Repository = repo; LocalDatabase = RepositoryService.C.GetRepositoryLocalDatabase(repo); User = UserService.C.GetUserInfoAsync(Api.C.Username.Value!)!; _wsRoot = PathUtility.GetWorkspacePath(Api.C.Username.Value!, Repository.OwnerName, Repository.Name); // Setup local change set LocalChange = new HierarchicalTreeDataGridSource(LocalChangeSetRaw) { Columns = { new CheckBoxColumn( null, n => n.Included, (n, v) => n.Included = v, null, new CheckBoxColumnOptions { CanUserResizeColumn = false, CanUserSortColumn = false, }), new HierarchicalExpanderColumn( new TextColumn( "Name", n => Path.GetFileName(n.FullPath)), n => n.Contents), new TextColumn( "Change", n => n.Contents != null ? String.Empty : n.Type.ToString()), new TextColumn( "File Type", n => n.Contents != null ? "Folder" : Path.GetExtension(n.FullPath)), new TextColumn( "Size", n => PathUtility.ConvertBytesToBestDisplay(n.Size)), new TextColumn( "ModifiedTime", n => n.ModifiedTime.HasValue ? n.ModifiedTime.Value.ToLocalTime() : null), } }; Commits = new FlatTreeDataGridSource(CommitsRaw) { Columns = { new TextColumn( string.Empty, n => n.CommitId == LocalDatabase.CurrentCommit.ToString() ? "HEAD" : String.Empty), new TextColumn( "Message", x => x.Message), new TextColumn( "Author", x => x.Author), new TextColumn( "Time", x => x.CommitAt!.Value), new TextColumn( "Id", x => x.CommitId.Substring(0, 13)), } }; FileTree = new HierarchicalTreeDataGridSource(CurrentCommitFileTreeRaw) { Columns = { 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 => PathUtility.ConvertBytesToBestDisplay(n.Size)), new TextColumn( "ModifiedTime", n => n.ModifiedTime.HasValue ? n.ModifiedTime.Value.ToLocalTime() : null), } }; _ = StartupTasksAsync(); } private async Task StartupTasksAsync() { await RefreshRepositoryRoleInfoAsyncCommand.Execute(); await RefreshRepositoryIssuesAsyncCommand.Execute(); await RefreshWebhooksCommand.Execute(); await DetectLocalChangesAsyncCommand.Execute(); await RendererFileTreeAsync(); SyncCommitsFromRepository(); } private async ValueTask RendererFileTreeAsync() { if (LocalDatabase.RepoAccessor == null) return; var accessor = LocalDatabase.RepoAccessor; var nodes = await CalculateFileTreeOfChangesNodeAsync(accessor.Select( f => new ChangeInfo(ChangeInfoType.Add, f))); CurrentCommitFileTreeRaw.Clear(); CurrentCommitFileTreeRaw.AddRange(nodes); } private void SyncCommitsFromRepository() { CommitsRaw.Clear(); CommitsRaw.AddRange(Repository.Commits.Select(CommitTransitNode.FromCommit)); } private void CollectChanges(List store, IEnumerable changesNode) { foreach (var n in changesNode) { if (n.Contents != null) CollectChanges(store, n.Contents); else if (n.Included) { store.Add(new ChangeInfo(Enum.Parse(n.Type), new WorkspaceFile { WorkPath = n.FullPath, ModifyTime = n.ModifiedTime!.Value })); } } } private Task> CalculateFileTreeOfChangesNodeAsync(IEnumerable changesNode) { return Task.Run(() => { // Generate a map of all folders var folderMap = new Dictionary(); var nodes = new List(); foreach (var file in changesNode) { var parent = AddParentToMap(file.File.WorkPath); var n = LocalChangesNode.FromWorkspaceFile(parent, file); if (parent == null) nodes.Add(n); else parent.Contents!.Add(n); } return nodes; LocalChangesNode? AddParentToMap(string path) { path = WorkPath.FormatPathDirectorySeparator(Path.GetDirectoryName(path) ?? string.Empty); // 如果为空,则其直接文件夹就是根目录 if (string.IsNullOrEmpty(path)) return null; // 如果直接文件夹已经存在,则不再生成,直接返回即可 if (folderMap.TryGetValue(path, out var node)) return node; // 生成当前文件夹,并先找到这个文件夹的直接文件夹 var parent = AddParentToMap(path); node = LocalChangesNode.FromFolder(parent, path); folderMap.Add(path, node); if (parent != null) parent.Contents!.Add(node); else nodes.Add(node); // 将新建的文件夹告知调用方 return node; } }); } private void UpdatePermissionOfRepository() { 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 Task AddWebhookAsync() { if (!IsOwnerRole) return; var vm = new WebhookEditDialogViewModel(); var result = await OverlayDialog.ShowModal( vm, AppDefaultValues.HostId, UIHelper.DefaultOverlayDialogOptionsYesNo()); if (result == DialogResult.Yes) { using var l = UIHelper.MakeLoading("Create Webhook..."); await RepositoryService.C.AddWebhookAsync(Repository, vm.Url, vm.Secret, vm.EventType); } } [ReactiveCommand] private async Task DeleteWebhookAsync(Webhook webhook) { if (!IsOwnerRole) return; var confirm = await UIHelper.SimpleAskAsync($"Do you sure to delete webhook with url at {webhook.TargetUrl} ?"); if (confirm == DialogResult.Yes) { using var l = UIHelper.MakeLoading("Delete webhook..."); await RepositoryService.C.DeleteWebhookAsync(Repository, webhook.Id); } } [ReactiveCommand] public async Task ToggleWebhookAsync(Webhook webhook) { if (!IsOwnerRole) return; using var l = UIHelper.MakeLoading("Update Webhook..."); var success = await RepositoryService.C.ToggleWebhookAsync( Repository, webhook.Id, !webhook.IsActive); } [ReactiveCommand] private async Task RevertFileTreeToSelectedCommitKeepAsync() { var sel = Commits.RowSelection?.SelectedItem; if (sel != null) { using var l = UIHelper.MakeLoading($"Reset (Keep) to Commit {sel.CommitId}"); var changes = new List(); CollectChanges(changes, LocalChangeSetRaw); var kid = Repository.Commits.First(x => x.CommitId.ToString() == sel.CommitId).CommitId; var changeDict = changes.ToImmutableDictionary(x => x.File.WorkPath, x => x.File); await RepositoryService.C .SetPointerAndMergeDepotsWithLocalFromRemoteAsync( Repository, kid, changeDict, RepositoryResetMethod.Keep); await DetectLocalChangesAsyncCommand.Execute(); await RendererFileTreeAsync(); SyncCommitsFromRepository(); } } [ReactiveCommand] private async Task RevertFileTreeToSelectedCommitSoftAsync() { var sel = Commits.RowSelection?.SelectedItem; if (sel != null) { using var l = UIHelper.MakeLoading($"Reset (Soft) to Commit {sel.CommitId}"); var changes = new List(); CollectChanges(changes, LocalChangeSetRaw); if (changes.Count != 0) { var result = await UIHelper.SimpleAskAsync( "There are commits that has not been committed. Do you wish discard them at all?", DialogMode.Warning); if (result == DialogResult.No) return; } var kid = Repository.Commits.First(x => x.CommitId.ToString() == sel.CommitId).CommitId; var changeDict = changes.ToImmutableDictionary(x => x.File.WorkPath, x => x.File); await RepositoryService.C .SetPointerAndMergeDepotsWithLocalFromRemoteAsync( Repository, kid, changeDict, RepositoryResetMethod.Soft); await DetectLocalChangesAsyncCommand.Execute(); await RendererFileTreeAsync(); SyncCommitsFromRepository(); } } [ReactiveCommand] private async Task RevertFileTreeToSelectedCommitHardAsync() { var sel = Commits.RowSelection?.SelectedItem; if (sel != null) { using var l = UIHelper.MakeLoading($"Reset (Hard) to Commit {sel.CommitId}"); if (!await RepositoryService.C.UpdateCommitsHistoryFromServerAsync(Repository)) return; var changes = new List(); CollectChanges(changes, LocalChangeSetRaw); var result = await UIHelper.SimpleAskAsync( "All files will being matched with this commit. Do you wish to execute it?", DialogMode.Warning); if (result == DialogResult.No) return; var kid = Repository.Commits.First(x => x.CommitId.ToString() == sel.CommitId).CommitId; var changeDict = changes.ToImmutableDictionary(x => x.File.WorkPath, x => x.File); await RepositoryService.C .SetPointerAndMergeDepotsWithLocalFromRemoteAsync( Repository, kid, changeDict, RepositoryResetMethod.Hard); await DetectLocalChangesAsyncCommand.Execute(); await RendererFileTreeAsync(); SyncCommitsFromRepository(); } } [ReactiveCommand] private async Task CreateIssueAsync() { var opt = UIHelper.DefaultOverlayDialogOptionsYesNo(); var vm = new IssueEditDialogViewModel(); var r = await OverlayDialog.ShowModal(vm, AppDefaultValues.HostId, opt); if (r == DialogResult.Yes) { using var l = UIHelper.MakeLoading("Sending issue..."); var tags = vm.Tag?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var issueId = await RepositoryService.C.CreateIssueAsync(Repository, vm.Title, vm.Description, tags); if (issueId == null) return; await HostScreen.Router.Navigate.Execute(new IssueDetailViewModel(HostScreen, Repository, issueId.Value)); } } [ReactiveCommand] private async Task OpenIssueAsync(RepositoryModel.Issue issue) { using var l = UIHelper.MakeLoading("Config issue..."); await HostScreen.Router.Navigate.Execute(new IssueDetailViewModel(HostScreen, Repository, issue.Id)); } [ReactiveCommand] private async Task CloseIssueAsync(RepositoryModel.Issue issue) { using var l = UIHelper.MakeLoading("Config issue..."); await RepositoryService.C.CloseIssueAsync(Repository, issue.Id); } [ReactiveCommand] private async Task ReopenIssueAsync(RepositoryModel.Issue issue) { using var l = UIHelper.MakeLoading("Config issue..."); await RepositoryService.C.ReopenIssueAsync(Repository, issue.Id); } [ReactiveCommand] private async Task PullLatestRepositoryAsync() { using var l = UIHelper.MakeLoading("Pulling latest changes..."); var mayUpdate = await RepositoryService.C.IsPointedToCommitNotPeekResultFromServerAsync(Repository); if (!mayUpdate.HasValue) return; if (mayUpdate.Value == false) { await UIHelper.SimpleAskAsync("Everything is new, no needs to pull."); return; } if (!await RepositoryService.C.UpdateCommitsHistoryFromServerAsync(Repository)) return; if (!await RepositoryService.C.UpdateCommitsHistoryFromServerAsync(Repository)) return; var changes = new List(); CollectChanges(changes, LocalChangeSetRaw); var kid = Repository.Commits.MaxBy(k => k.CommittedOn)!.CommitId; var changeDict = changes.ToImmutableDictionary(x => x.File.WorkPath, x => x.File); await RepositoryService.C.SetPointerAndMergeDepotsWithLocalFromRemoteAsync( Repository, kid, changeDict, RepositoryResetMethod.Keep); await DetectLocalChangesAsyncCommand.Execute(); await RendererFileTreeAsync(); SyncCommitsFromRepository(); } [ReactiveCommand] private async Task CommitSelectedChangesAsync() { var changes = new List(); CollectChanges(changes, LocalChangeSetRaw); if (changes.Count == 0) { await UIHelper.SimpleAlert("You haven't choose any changes yet!"); return; } if (string.IsNullOrWhiteSpace(LocalDatabase.CommitMessage)) { await UIHelper.SimpleAlert("Commit message can not be empty!"); return; } using var l = UIHelper.MakeLoading("Committing changes..."); var manifest = await RepositoryService.C.CommitWorkspaceAsync(Repository, changes, LocalDatabase.CommitMessage!); if (manifest == null) return; LocalDatabase.LocalAccessor.SetBaseline(manifest.Value.FilePaths); LocalDatabase.CommitMessage = string.Empty; await DetectLocalChangesAsyncCommand.Execute(); await RendererFileTreeAsync(); SyncCommitsFromRepository(); } [ReactiveCommand] private async Task RevertSelectedChangesAsync() { var changes = new List(); CollectChanges(changes, LocalChangeSetRaw); if (changes.Count == 0) { await UIHelper.SimpleAlert("You haven't choose any changes yet!"); return; } using var l = UIHelper.MakeLoading("Reverting changes..."); RepositoryService.C.RevertChangesToBaseline(Repository, changes); await DetectLocalChangesAsyncCommand.Execute(); await RendererFileTreeAsync(); SyncCommitsFromRepository(); } [ReactiveCommand] private async Task DeleteRepositoryBothServerAndLocalAsync() { var msg = "THIS IS AN UNREVERT ACTION, YOU SHOULD AWARE THAT THIS MAY CAUSE DATA LOSS. DO YOU CONTINUE DELETE THIS REPOSITORY?"; var confirm = await UIHelper.SimpleAskAsync(msg, DialogMode.Error) == DialogResult.Yes; if (confirm) { using var l = UIHelper.MakeLoading("Delete repository..."); if (!await RepositoryService.C.DeleteRepositoryOnServerAsync(Repository)) return; await HostScreen.Router.NavigateBack.Execute(); await RepositoryService.C.CloseRepositoryAsync(Repository); await RepositoryService.C.DeleteFromDiskAsync(Repository); await RepositoryService.C.UpdateRepositoriesFromServerAsync(); await RepositoryService.C.UpdateRepositoriesDownloadedStatusFromDiskAsync(); } } [ReactiveCommand] private async Task CloseRepositoryAsync() { using var l = UIHelper.MakeLoading("Save changes..."); await RepositoryService.C.CloseRepositoryAsync(Repository); await HostScreen.Router.NavigateBack.Execute(); } [ReactiveCommand] private async Task RefreshWebhooksAsync() { ShowWebHook = IsDeveloperRole && (Api.C.Status.Value?.AllowWebHook ?? false); if (!ShowWebHook) return; using var l = UIHelper.MakeLoading("刷新Webhook列表..."); await RepositoryService.C.UpdateWebhooksFromServerAsync(Repository); } [ReactiveCommand] private async ValueTask RefreshRepositoryRoleInfoAsync() { using var l = UIHelper.MakeLoading("Refreshing member info..."); await RepositoryService.C.UpdateMembersFromServerAsync(Repository); UpdatePermissionOfRepository(); } [ReactiveCommand] private async ValueTask RefreshRepositoryIssuesAsync() { using var l = UIHelper.MakeLoading("Refreshing issues..."); await RepositoryService.C.UpdateIssuesListFromServerAsync(Repository); } [ReactiveCommand] private async ValueTask DeleteMemberFromServerAsync(RepositoryModel.Member member) { if (!IsOwnerRole) { UIHelper.NotifyError("Only repository owner can edit this!", "Permission issue"); return; } var result = await UIHelper.SimpleAskAsync( $"Do you really want to delete this member: \n{member.Username} ({member.Role})", DialogMode.Warning); if (result == DialogResult.Yes) { using var l = UIHelper.MakeLoading("Delete member..."); await RepositoryService.C.DeleteMemberFromServerAsync(Repository, member); } } [ReactiveCommand] private async ValueTask ModifyMemberFromServerAsync(RepositoryModel.Member member) { if (!IsOwnerRole) { UIHelper.NotifyError("Only repository owner can edit this!", "Permission issue"); return; } var style = UIHelper.DefaultOverlayDialogOptionsYesNo(); var vm = new EditRepositoryMemberDialogViewModel(); vm.LockUsername = true; vm.Username = member.Username; vm.SafeRole = member.Role; var result = await OverlayDialog.ShowModal (vm, AppDefaultValues.HostId, style); if (result == DialogResult.Yes) { if (vm.SafeRole == RepositoryModel.RepositoryRole.Owner) { UIHelper.NotifyError("Invalid role level!", "Permission issue"); return; } if (vm.SafeRole == member.Role) { UIHelper.NotifyError("No modification yet.", "Modification issue"); return; } using var l = UIHelper.MakeLoading("Modify member..."); member.Role = vm.SafeRole; await RepositoryService.C.ModifyMemberFromServerAsync(Repository, member); } } [ReactiveCommand] private async ValueTask AddMemberFromServerAsync() { if (!IsOwnerRole) { UIHelper.NotifyError("Only repository owner can edit this!", "Permission issue"); return; } var style = UIHelper.DefaultOverlayDialogOptionsYesNo(); var vm = new EditRepositoryMemberDialogViewModel(); var result = await OverlayDialog.ShowModal (vm, AppDefaultValues.HostId, style); if (result == DialogResult.Yes) { if (vm.SafeRole == RepositoryModel.RepositoryRole.Owner) { UIHelper.NotifyError("Invalid role level!", "Permission issue"); return; } vm.Username = vm.Username.Trim(); if (string.IsNullOrEmpty(vm.Username) || vm.Username.Length < 3) { UIHelper.NotifyError("Not a valid username!", "Parameter error"); return; } using var l = UIHelper.MakeLoading("Add member..."); await RepositoryService.C.AddMemberFromServerAsync(Repository, vm.Username, vm.SafeRole); } } [ReactiveCommand] private async ValueTask DetectLocalChangesAsync() { using var l = UIHelper.MakeLoading("Refreshing local changes..."); var ns = await Task.Run(async () => { LocalDatabase.LocalAccessor.Refresh(); return await CalculateFileTreeOfChangesNodeAsync(LocalDatabase.LocalAccessor.Changes.Values); }); LocalChangeSetRaw.Clear(); LocalChangeSetRaw.AddRange(ns); } [ReactiveCommand] private void SelectAllChanges() { foreach (var n in LocalChangeSetRaw) n.Included = true; } [ReactiveCommand] private void DeselectAllChanges() { foreach (var n in LocalChangeSetRaw) n.Included = false; } }