diff --git a/src/Commands/QueryReflog.cs b/src/Commands/QueryReflog.cs new file mode 100644 index 000000000..7cf03e8fd --- /dev/null +++ b/src/Commands/QueryReflog.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryReflog : Command + { + public QueryReflog(string repo, int maxCount = 0) + { + WorkingDirectory = repo; + Context = repo; + + var limit = maxCount > 0 ? $"-n {maxCount} " : string.Empty; + Args = $"log -g -z {limit}--no-show-signature --date=unix --format=%H%n%gd%n%gs HEAD"; + } + + public async Task> GetResultAsync() + { + var outs = new List(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return outs; + + var items = rs.StdOut.Split('\0', StringSplitOptions.RemoveEmptyEntries); + foreach (var item in items) + { + var parts = item.Split('\n', 3); + if (parts.Length < 2) + continue; + + // With `--date=unix`, `%gd` is printed as `HEAD@{}` + var entry = new Models.ReflogEntry(); + entry.SHA = parts[0]; + entry.Selector = $"HEAD@{{{outs.Count}}}"; + + var selector = parts[1]; + var start = selector.IndexOf('{'); + var end = selector.LastIndexOf('}'); + if (start > 0 && end > start && ulong.TryParse(selector.AsSpan(start + 1, end - start - 1), out var time)) + entry.Time = time; + + if (parts.Length > 2) + entry.Message = parts[2].TrimEnd(); + + outs.Add(entry); + } + + return outs; + } + } +} diff --git a/src/Models/ReflogEntry.cs b/src/Models/ReflogEntry.cs new file mode 100644 index 000000000..fc7a290bd --- /dev/null +++ b/src/Models/ReflogEntry.cs @@ -0,0 +1,10 @@ +namespace SourceGit.Models +{ + public class ReflogEntry + { + public string SHA { get; set; } = string.Empty; + public string Selector { get; set; } = string.Empty; + public ulong Time { get; set; } = 0; + public string Message { get; set; } = string.Empty; + } +} diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 603d223a9..6b98ad8aa 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -153,6 +153,7 @@ Editor Branches Branches & Tags + Reflog of HEAD Repository Custom Actions Revision Files Checkout Commit... @@ -784,6 +785,8 @@ Rebase can be done without conflicts Unknown error occurs while testing for rebase Rebase will cause conflicts + Reflog + Reset current branch to here... Add Remote Edit Remote Name: @@ -887,6 +890,7 @@ Manually add a branch/tag history filter will disable this feature automatically Reset Mode: Move To: Current Branch: + Undo: Reset Branch (Without Checkout) Move To: Branch: @@ -1021,6 +1025,9 @@ Manually add a branch/tag history filter will disable this feature automatically Git refused to access this repository: Path: {0} repositories were skipped because git does not trust them. Please open them manually and confirm whether to trust them. + Undo Last Operation + Nothing to undo. No previous HEAD state found in reflog. + Last operation '{0}' can NOT be undone by resetting the current branch. Use Reflog instead. Update Submodules All submodules Initialize as needed diff --git a/src/ViewModels/ReflogCommandPalette.cs b/src/ViewModels/ReflogCommandPalette.cs new file mode 100644 index 000000000..de42173cc --- /dev/null +++ b/src/ViewModels/ReflogCommandPalette.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Threading; + +namespace SourceGit.ViewModels +{ + public class ReflogCommandPalette : ICommandPalette + { + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public List VisibleEntries + { + get => _visibleEntries; + private set => SetProperty(ref _visibleEntries, value); + } + + public Models.ReflogEntry SelectedEntry + { + get => _selectedEntry; + set => SetProperty(ref _selectedEntry, value); + } + + public string Filter + { + get => _filter; + set + { + if (SetProperty(ref _filter, value)) + UpdateVisible(); + } + } + + public ReflogCommandPalette(Repository repo) + { + _repo = repo; + _isLoading = true; + + Task.Run(async () => + { + var entries = await new Commands.QueryReflog(_repo.FullPath) + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + IsLoading = false; + _entries = entries; + UpdateVisible(); + }); + }); + } + + public void ClearFilter() + { + Filter = string.Empty; + } + + public void NavigateTo() + { + var entry = _selectedEntry; + Close(); + + if (entry != null) + _repo.NavigateToCommit(entry.SHA); + } + + public async Task ResetToAsync(Models.ReflogEntry entry) + { + Close(); + + var current = _repo.CurrentBranch; + if (entry == null || current == null || !_repo.CanCreatePopup()) + return; + + var commit = await new Commands.QuerySingleCommit(_repo.FullPath, entry.SHA).GetResultAsync(); + if (commit != null) + _repo.ShowPopup(new Reset(_repo, current, commit)); + } + + private void UpdateVisible() + { + if (string.IsNullOrEmpty(_filter)) + { + VisibleEntries = _entries; + } + else + { + var visible = new List(); + foreach (var entry in _entries) + { + if (entry.SHA.StartsWith(_filter, StringComparison.OrdinalIgnoreCase) || + entry.Selector.Contains(_filter, StringComparison.OrdinalIgnoreCase) || + entry.Message.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(entry); + } + + VisibleEntries = visible; + } + + if (_visibleEntries.Count == 0) + SelectedEntry = null; + else if (_selectedEntry == null || !_visibleEntries.Contains(_selectedEntry)) + SelectedEntry = _visibleEntries[0]; + } + + private Repository _repo = null; + private bool _isLoading = false; + private List _entries = []; + private List _visibleEntries = []; + private Models.ReflogEntry _selectedEntry = null; + private string _filter = string.Empty; + } +} diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index 4d3b5541c..ce2c11e18 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -759,6 +759,31 @@ public async Task CleanupAsync() await ShowAndStartPopupAsync(new Cleanup(this)); } + public async Task UndoLastOperationAsync() + { + if (IsBare || _currentBranch == null || InProgressContext != null || !CanCreatePopup()) + return; + + var entries = await new Commands.QueryReflog(FullPath, 2).GetResultAsync(); + if (entries.Count < 2) + { + SendNotification(App.Text("UndoLastOperation.Empty")); + return; + } + + var last = entries[0]; + var prev = entries[1]; + if (last.SHA.Equals(prev.SHA, StringComparison.Ordinal) || last.Message.StartsWith("checkout:", StringComparison.Ordinal)) + { + SendNotification(App.Text("UndoLastOperation.Unavailable", last.Message)); + return; + } + + var commit = await new Commands.QuerySingleCommit(FullPath, prev.SHA).GetResultAsync(); + if (commit != null) + ShowPopup(new Reset(this, _currentBranch, commit, last.Message)); + } + public void ClearFilter() { Filter = string.Empty; diff --git a/src/ViewModels/RepositoryCommandPalette.cs b/src/ViewModels/RepositoryCommandPalette.cs index 796629e37..a9708a694 100644 --- a/src/ViewModels/RepositoryCommandPalette.cs +++ b/src/ViewModels/RepositoryCommandPalette.cs @@ -65,6 +65,9 @@ public RepositoryCommandPalette(Repository repo) _cmds.Add(new("OpenFile", "open", "OpenWith", new OpenFileCommandPalette(repo.FullPath))); _cmds.Add(new("Repository.CustomActions", "custom actions", "Action", new ExecuteCustomActionCommandPalette(repo))); + if (!repo.IsBare) + _cmds.Add(new("Reflog", "reflog", "Logs", new ReflogCommandPalette(repo))); + // Raw-Actions _cmds.Add(new("Repository.NewBranch", "create branch", "Branch.Add", () => repo.CreateNewBranch())); _cmds.Add(new("CreateTag.Title", "create tag", "Tag.Add", () => repo.CreateNewTag())); @@ -75,6 +78,9 @@ public RepositoryCommandPalette(Repository repo) _cmds.Add(new("Apply.Title", "apply", "ApplyPatch", () => repo.ApplyPatch())); _cmds.Add(new("Repository.DiscardAll", "discard", "Undo", () => repo.WorkingCopy.DiscardAllChanges())); + if (!repo.IsBare && repo.InProgressContext == null) + _cmds.Add(new("UndoLastOperation", "undo", "Reset", async () => await repo.UndoLastOperationAsync())); + _cmds.Sort((l, r) => l.Label.CompareTo(r.Label)); _visibleCmds = _cmds; _selectedCmd = _cmds[0]; diff --git a/src/ViewModels/Reset.cs b/src/ViewModels/Reset.cs index 11adf1ea7..e7d0e41a7 100644 --- a/src/ViewModels/Reset.cs +++ b/src/ViewModels/Reset.cs @@ -20,11 +20,17 @@ public Models.ResetMode SelectedMode set; } - public Reset(Repository repo, Models.Branch current, Models.Commit to) + public string UndoOperation + { + get; + } + + public Reset(Repository repo, Models.Branch current, Models.Commit to, string undoOperation = null) { _repo = repo; Current = current; To = to; + UndoOperation = undoOperation; SelectedMode = Models.ResetMode.Supported[1]; } diff --git a/src/Views/ReflogCommandPalette.axaml b/src/Views/ReflogCommandPalette.axaml new file mode 100644 index 000000000..972dc71ec --- /dev/null +++ b/src/Views/ReflogCommandPalette.axaml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/ReflogCommandPalette.axaml.cs b/src/Views/ReflogCommandPalette.axaml.cs new file mode 100644 index 000000000..2f0b3cb8d --- /dev/null +++ b/src/Views/ReflogCommandPalette.axaml.cs @@ -0,0 +1,73 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class ReflogCommandPalette : UserControl + { + public ReflogCommandPalette() + { + InitializeComponent(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (DataContext is not ViewModels.ReflogCommandPalette vm) + return; + + if (e.Key == Key.Enter) + { + vm.NavigateTo(); + e.Handled = true; + } + else if (e.Key == Key.Up) + { + if (EntryListBox.IsKeyboardFocusWithin) + { + FilterTextBox.Focus(NavigationMethod.Directional); + e.Handled = true; + return; + } + } + else if (e.Key == Key.Down || e.Key == Key.Tab) + { + if (FilterTextBox.IsKeyboardFocusWithin) + { + if (vm.VisibleEntries.Count > 0) + EntryListBox.Focus(NavigationMethod.Directional); + + e.Handled = true; + return; + } + + if (EntryListBox.IsKeyboardFocusWithin && e.Key == Key.Tab) + { + FilterTextBox.Focus(NavigationMethod.Directional); + e.Handled = true; + return; + } + } + } + + private void OnItemTapped(object sender, TappedEventArgs e) + { + if (DataContext is ViewModels.ReflogCommandPalette vm) + { + vm.NavigateTo(); + e.Handled = true; + } + } + + private async void OnResetToEntry(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.ReflogCommandPalette vm && sender is Button { DataContext: Models.ReflogEntry entry }) + { + e.Handled = true; + await vm.ResetToAsync(entry); + } + } + } +} diff --git a/src/Views/Reset.axaml b/src/Views/Reset.axaml index f521bae60..6bf8556be 100644 --- a/src/Views/Reset.axaml +++ b/src/Views/Reset.axaml @@ -19,21 +19,36 @@ Text="{DynamicResource Text.Reset}"/> - - + + + + + + - + - - + - -