diff --git a/src/Models/CommitNavigationHistory.cs b/src/Models/CommitNavigationHistory.cs new file mode 100644 index 000000000..1dda5df8e --- /dev/null +++ b/src/Models/CommitNavigationHistory.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class CommitNavigationHistory + { + public bool CanGoBack => _position > 0; + public bool CanGoForward => _position + 1 < _commits.Count; + public string Current => _position >= 0 ? _commits[_position] : null; + public string Previous => CanGoBack ? _commits[_position - 1] : null; + public string Next => CanGoForward ? _commits[_position + 1] : null; + + public void Visit(string sha) + { + if (_position >= 0 && _commits[_position] == sha) + return; + + _commits.RemoveRange(_position + 1, _commits.Count - _position - 1); + _commits.Add(sha); + _position++; + } + + public string Back() + { + return CanGoBack ? _commits[--_position] : null; + } + + public string Forward() + { + return CanGoForward ? _commits[++_position] : null; + } + + public void Forget(string sha) + { + for (var i = _commits.Count - 1; i >= 0; i--) + { + if (_commits[i] != sha) + continue; + + _commits.RemoveAt(i); + if (i <= _position) + _position--; + } + } + + private readonly List _commits = []; + private int _position = -1; + } +} diff --git a/src/Resources/Icons.axaml b/src/Resources/Icons.axaml index f9afba45e..6aa49d9ff 100644 --- a/src/Resources/Icons.axaml +++ b/src/Resources/Icons.axaml @@ -1,4 +1,7 @@ + M9 2L2 9L9 16L11 14L7 10H18V8H7L11 4Z + M11 2L18 9L11 16L9 14L13 10H2V8H13L9 4Z + M10 1L0 10H3V19H8V13H12V19H17V10H20Z M41 512c0-128 46-241 138-333C271 87 384 41 512 41s241 46 333 138c92 92 138 205 138 333s-46 241-138 333c-92 92-205 138-333 138s-241-46-333-138C87 753 41 640 41 512zm87 0c0 108 36 195 113 271s164 113 271 113c108 0 195-36 271-113s113-164 113-271-36-195-113-271c-77-77-164-113-271-113-108 0-195 36-271 113C164 317 128 404 128 512zm256 148V292l195 113L768 512l-195 113-195 113v-77zm148-113-61 36V440l61 36 61 36-61 36z M306 761c3-3 8-7 16-11 15-7 35-14 60-19 4-1 14-3 11-18l-3-16c-4-18-13-14-18-13C301 699 256 724 256 763c0 58 112 91 252 91 140 0 252-33 252-91 0-38-45-64-116-78-5-1-16-3-18 13-1 6-2 11-3 15-4 18 8 18 14 19 24 5 44 11 58 18 8 4 13 8 16 11 1 2 2 2 2 1 0 3-17 15-50 24-40 12-95 19-155 19-59 0-114-7-155-19-32-9-49-21-50-24 0 0 0-0 2-2zm73-123c54 79 107 142 129 142 22 0 75-62 129-142 62-91 101-173 101-231C738 280 635 176 508 176c-127 0-230 104-230 232 0 57 39 139 101 231zm150 64a446 446 0 01-21 23c-6-6-13-14-21-23-21-25-46-57-68-91-57-84-92-159-92-204 0-102 82-184 182-184 100 0 182 82 182 184 0 45-36 120-92 204-23 33-47 66-68 91zm-21-200a102 102 0 100-204 102 102 0 000 204zm0-48a54 54 0 110-108 54 54 0 010 108z M304 464a128 128 0 01128-128c71 0 128 57 128 128v224a32 32 0 01-64 0V592h-128v95a32 32 0 01-64 0v-224zm64 1v64h128v-64a64 64 0 00-64-64c-35 0-64 29-64 64zM688 337c18 0 32 14 32 32v319a32 32 0 01-32 32c-18 0-32-14-32-32v-319a32 32 0 0132-32zM84 911l60-143A446 446 0 0164 512C64 265 265 64 512 64s448 201 448 448-201 448-448 448c-54 0-105-9-153-27l-242 22a32 32 0 01-32-44zm133-150-53 126 203-18 13 5c41 15 85 23 131 23 212 0 384-172 384-384S724 128 512 128 128 300 128 512c0 82 26 157 69 220l20 29z diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 603d223a9..70f16961c 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -524,6 +524,9 @@ Track all *{0} files Select Commit Advanced Options + Back + Forward + Go to current HEAD AUTHOR AUTHOR TIME COMMIT TIME diff --git a/src/ViewModels/Histories.Navigation.cs b/src/ViewModels/Histories.Navigation.cs new file mode 100644 index 000000000..7238ec6c7 --- /dev/null +++ b/src/ViewModels/Histories.Navigation.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Avalonia.Threading; + +namespace SourceGit.ViewModels +{ + public partial class Histories + { + public bool CanNavigateBack => !_isResolvingNavigation && (_navigationHistory.CanGoBack || HasUnrecordedNavigationSelection); + public bool CanNavigateForward => !_isResolvingNavigation && _navigationHistory.CanGoForward; + + public async Task NavigateHomeAsync() + { + CancelNavigationVisit(); + var request = _navigationRequest; + // Resolve HEAD in this worktree, even if the loaded history omits it + // or its decorations belong to an earlier checkout. + var commit = await GetCommitAsync("HEAD"); + if (request != _navigationRequest) + return; + + if (commit == null && _commits.Count > 0) + commit = _commits[0]; + if (commit != null) + SelectedCommits = [EnsureNavigationCommitVisible(commit)]; + } + + public Task NavigateBackAsync() => NavigateHistoryAsync(-1); + public Task NavigateForwardAsync() => NavigateHistoryAsync(1); + + public void ResumeNavigationVisits() + { + _isNavigationActive = true; + ScheduleNavigationVisit(); + } + + public void SuspendNavigationVisits() + { + _isNavigationActive = false; + CancelNavigationVisit(); + } + + public void CancelNavigationVisit() + { + _navigationRequest++; + _navigationVisitTimer?.Stop(); + } + + private void ScheduleNavigationVisit() + { + CancelNavigationVisit(); + NotifyNavigationChanged(); + if (!_isNavigationActive || _isNavigatingHistory || _ignoreSelectionChange || _selectedCommits.Count != 1) + return; + + if (_navigationVisitTimer == null) + { + _navigationVisitTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + _navigationVisitTimer.Tick += (_, _) => + { + _navigationVisitTimer.Stop(); + if (_selectedCommits.Count == 1) + { + _navigationHistory.Visit(_selectedCommits[0].SHA); + NotifyNavigationChanged(); + } + }; + } + + _navigationVisitTimer.Start(); + } + + private async Task NavigateHistoryAsync(int direction) + { + if (direction < 0 ? !CanNavigateBack : !CanNavigateForward) + return; + + CancelNavigationVisit(); + var request = _navigationRequest; + _isResolvingNavigation = true; + NotifyNavigationChanged(); + try + { + while (true) + { + var returnToCurrent = direction < 0 && HasUnrecordedNavigationSelection; + var sha = direction < 0 ? + returnToCurrent ? _navigationHistory.Current : _navigationHistory.Previous : + _navigationHistory.Next; + if (sha == null) + return; + + var commit = _commits.Find(c => c.SHA == sha); + if (commit == null) + { + commit = await GetCommitAsync(sha); + if (request != _navigationRequest) + return; + if (commit == null) + { + // A rewritten, unreachable commit may have been pruned by Git. + _navigationHistory.Forget(sha); + continue; + } + } + + if (!returnToCurrent) + { + if (direction < 0) + _navigationHistory.Back(); + else + _navigationHistory.Forward(); + } + + _isNavigatingHistory = true; + SelectedCommits = [EnsureNavigationCommitVisible(commit)]; + return; + } + } + finally + { + _isNavigatingHistory = false; + _isResolvingNavigation = false; + NotifyNavigationChanged(); + } + } + + private void NotifyNavigationChanged() + { + OnPropertyChanged(nameof(CanNavigateBack)); + OnPropertyChanged(nameof(CanNavigateForward)); + } + + private Models.Commit EnsureNavigationCommitVisible(Models.Commit commit) + { + var existing = _commits.Find(c => c.SHA == commit.SHA); + if (existing != null) + return existing; + + var commits = new List(_commits); + // Keep children above their parents when restoring an omitted row. + var parentIndex = commits.FindIndex(c => commit.Parents.Contains(c.SHA)); + commits.Insert(parentIndex < 0 ? commits.Count : parentIndex, commit); + Commits = commits; + return commit; + } + + private bool HasUnrecordedNavigationSelection => _navigationHistory.Current != null && + (_selectedCommits.Count != 1 || _selectedCommits[0].SHA != _navigationHistory.Current); + + private readonly Models.CommitNavigationHistory _navigationHistory = new(); + private DispatcherTimer _navigationVisitTimer; + private bool _isNavigatingHistory; + private bool _isResolvingNavigation; + private bool _isNavigationActive; + private int _navigationRequest; + } +} diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index a22f810cd..b7a25d2fa 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -10,7 +10,7 @@ namespace SourceGit.ViewModels { - public class Histories : ObservableObject + public partial class Histories : ObservableObject { public bool IsLoading { @@ -526,6 +526,7 @@ private void PostCommitsChanged() private void PostSelectedCommitsChanged() { + ScheduleNavigationVisit(); if (_ignoreSelectionChange) return; diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index a3d24529f..c307ffc97 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -272,6 +272,27 @@ + + + diff --git a/src/Views/Histories.axaml.cs b/src/Views/Histories.axaml.cs index d42529fe2..e915ff117 100644 --- a/src/Views/Histories.axaml.cs +++ b/src/Views/Histories.axaml.cs @@ -137,12 +137,9 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (change.Property == SelectedCommitsProperty && IsLoaded && !_ignoreSelectionChanged) { - if (change.OldValue is List { Count: 1 } old && - change.NewValue is List { Count: 1 } cur && - old[0] == cur[0]) - ScrollIntoView(old[0], null); - else - ApplySelection(); + ApplySelection(); + if (change.NewValue is List { Count: 1 } selected) + ScrollIntoView(selected[0], null); } } @@ -372,6 +369,32 @@ public bool IsDetailsPanelExpanded public Histories() { InitializeComponent(); + AddHandler(KeyDownEvent, OnNavigationKeyDown, RoutingStrategies.Tunnel); + Loaded += (_, _) => (DataContext as ViewModels.Histories)?.ResumeNavigationVisits(); + Unloaded += (_, _) => (DataContext as ViewModels.Histories)?.SuspendNavigationVisits(); + } + + private async void OnNavigationKeyDown(object sender, KeyEventArgs e) + { + if (DataContext is not ViewModels.Histories vm) + return; + + var macCommand = OperatingSystem.IsMacOS() && e.KeyModifiers == KeyModifiers.Meta; + if (e.KeyModifiers == KeyModifiers.Alt && e.Key == Key.Home) + { + e.Handled = true; + await vm.NavigateHomeAsync(); + } + else if ((e.KeyModifiers == KeyModifiers.Alt && e.Key == Key.Left) || (macCommand && e.Key == Key.OemOpenBrackets)) + { + e.Handled = true; + await vm.NavigateBackAsync(); + } + else if ((e.KeyModifiers == KeyModifiers.Alt && e.Key == Key.Right) || (macCommand && e.Key == Key.OemCloseBrackets)) + { + e.Handled = true; + await vm.NavigateForwardAsync(); + } } public async Task GotoParent() @@ -462,8 +485,13 @@ public async Task GotoChild() protected override void OnDataContextChanged(EventArgs e) { + _navigationHistories?.SuspendNavigationVisits(); base.OnDataContextChanged(e); + _navigationHistories = DataContext as ViewModels.Histories; + if (IsLoaded) + _navigationHistories?.ResumeNavigationVisits(); + if (DataContext is ViewModels.Histories vm) CommitListContainer.Columns[1].Width = new(vm.AuthorColumnWidth, DataGridLengthUnitType.Pixel); } @@ -1964,6 +1992,7 @@ private async Task InteractiveRebaseWithPrefillActionAsync(ViewModels.Repository await this.ShowDialogAsync(new ViewModels.InteractiveRebase(repo, on, prefill)); } + private ViewModels.Histories _navigationHistories = null; private Models.Branch _currentBranch = null; private Models.Bisect _bisect = null; private bool _hasSingleRemote = false; diff --git a/tests/HistoryNavigationChecks/HistoryNavigationChecks.csproj b/tests/HistoryNavigationChecks/HistoryNavigationChecks.csproj new file mode 100644 index 000000000..cbfcb000e --- /dev/null +++ b/tests/HistoryNavigationChecks/HistoryNavigationChecks.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + + + + + + diff --git a/tests/HistoryNavigationChecks/Program.cs b/tests/HistoryNavigationChecks/Program.cs new file mode 100644 index 000000000..cf4f6ee5a --- /dev/null +++ b/tests/HistoryNavigationChecks/Program.cs @@ -0,0 +1,209 @@ +using System.Diagnostics; +using System.Reflection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; +using SourceGit.Models; +using SourceGit.ViewModels; + +var fixture = Path.Combine(Path.GetTempPath(), "sourcegit-navigation-" + Guid.NewGuid().ToString("N")); +Directory.CreateDirectory(fixture); +SourceGit.Native.OS.GitExecutable = "git"; +SourceGit.Native.OS.BasicDirectories.ConfigDir = fixture; +SourceGit.Native.OS.BasicDirectories.CacheDir = fixture; +typeof(Preferences).GetField("_instance", BindingFlags.NonPublic | BindingFlags.Static)!.SetValue(null, new Preferences()); +AppBuilder.Configure().UseSkia().WithInterFont() + .ConfigureFonts(manager => manager.AddFontCollection(new Avalonia.Media.Fonts.EmbeddedFontCollection( + new Uri("fonts:SourceGit"), new Uri("avares://SourceGit/Resources/Fonts")))) + .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }).SetupWithoutStarting(); + +Git("init", "-b", "main"); +Git("config", "user.name", "Navigation Checks"); +Git("config", "user.email", "navigation@example.com"); +for (var i = 0; i < 3; i++) + Git("commit", "--allow-empty", "-m", $"Commit {i}"); +var commits = new SourceGit.Commands.QueryCommits(fixture, "HEAD").GetResultAsync().GetAwaiter().GetResult(); +var head = commits[0]; +var second = commits[1]; +var oldest = commits[2]; +// Put HEAD below the top row to distinguish Home from scrolling to the first row. +commits.InsertRange(0, Enumerable.Range(1, 60).Select(i => new Commit { SHA = i.ToString("x40"), Subject = $"Row {i}" })); +var repo = new Repository(false, fixture, Path.Combine(fixture, ".git")); +var vm = new Histories(repo) { Commits = commits, IsLoading = false }; +var view = new SourceGit.Views.Histories { DataContext = vm }; +var window = new Window { Content = view, Width = 1000, Height = 650 }; +window.Show(); +Pump(200); +var grid = view.FindControl("CommitListContainer")!; +var homeButton = view.FindControl