From bd219defd8dd995a135fd35204035356f8e8d3cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:26:37 +0000 Subject: [PATCH] enhance: show the first page of history while the rest is still loading On large repositories the history view stayed empty until all commits (up to `MaxHistoryCommits`) were read and the graph was built. Now the first 1000 commits (with graph) are shown as soon as `git log` has produced them, while the same process keeps streaming the rest, which replaces the list when done. A small spinner next to the history options shows that more commits are being loaded. Swapping in the full list keeps the current selection and scroll position. Navigating to a commit that is not loaded yet (search results, links, parent/child navigation, etc.) shows its details immediately and selects it in the graph once it has been loaded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KCxH1zwDXLd9kfAAkcf6PZ --- src/Commands/QueryCommits.cs | 5 +- src/Resources/Locales/en_US.axaml | 1 + src/ViewModels/Histories.cs | 57 ++++++++++++++++++++++- src/ViewModels/Repository.cs | 77 ++++++++++++++++++++----------- src/Views/Histories.axaml | 7 +++ src/Views/Histories.axaml.cs | 56 +++++++++++++++++++++- 6 files changed, 171 insertions(+), 32 deletions(-) diff --git a/src/Commands/QueryCommits.cs b/src/Commands/QueryCommits.cs index 1a5073988d..4e23d556b6 100644 --- a/src/Commands/QueryCommits.cs +++ b/src/Commands/QueryCommits.cs @@ -50,7 +50,7 @@ public QueryCommits(string repo, string filter, Models.CommitSearchMethod method _markMerged = false; } - public async Task> GetResultAsync() + public async Task> GetResultAsync(int firstPageSize = 0, Action> onFirstPageLoaded = null) { var commits = new List(); try @@ -78,6 +78,9 @@ public QueryCommits(string repo, string filter, Models.CommitSearchMethod method if (!findHead && commit.IsMerged) findHead = true; + + if (commits.Count == firstPageSize) + onFirstPageLoaded?.Invoke(commits.GetRange(0, firstPageSize)); } await proc.WaitForExitAsync().ConfigureAwait(false); diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 603d223a9c..b6178cba0a 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -535,6 +535,7 @@ Current Branch & Selected Commits Selected Commits Only Selected Commits (First-parent Only) + Loading more commits... SELECTED {0} COMMITS SHOW COLUMNS Hold 'Ctrl' or 'Shift' to select multiple commits. diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index a22f810cdb..38e242441d 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -18,6 +18,12 @@ public bool IsLoading set => SetProperty(ref _isLoading, value); } + public bool IsLoadingMoreCommits + { + get => _isLoadingMoreCommits; + set => SetProperty(ref _isLoadingMoreCommits, value); + } + public bool IsSearchingCommits { get => _isSearchingCommits; @@ -113,7 +119,7 @@ public List Commits set { GenerateGraph(value); - if (SetProperty(ref _commits, value)) + if (SetProperty(ref _commits, value) && !NavigateToPendingCommit()) PostCommitsChanged(); } } @@ -299,6 +305,24 @@ public Models.BisectState UpdateBisectInfo() return Models.BisectState.WaitingForMark; } + public bool CanShowPartialCommits(List commits) + { + if (_selectedCommits.Count == 0) + return true; + + var set = new HashSet(); + foreach (var c in commits) + set.Add(c.SHA); + + foreach (var c in _selectedCommits) + { + if (!set.Contains(c.SHA)) + return false; + } + + return true; + } + public void NavigateTo(string commitSHA) { var commit = _commits.Find(x => x.SHA.StartsWith(commitSHA, StringComparison.Ordinal)); @@ -308,6 +332,9 @@ public void NavigateTo(string commitSHA) return; } + // The target commit may be in the part of history that is still loading. Select it once it's loaded. + _pendingNavigateTarget = (_isLoading || _isLoadingMoreCommits) ? commitSHA : null; + Task.Run(async () => { var c = await new Commands.QuerySingleCommit(_repo.FullPath, commitSHA) @@ -316,6 +343,9 @@ public void NavigateTo(string commitSHA) Dispatcher.UIThread.Post(() => { + if (_selectedCommits.Count == 1 && _selectedCommits[0].SHA.StartsWith(commitSHA, StringComparison.Ordinal)) + return; + _ignoreSelectionChange = true; SelectedCommits = []; @@ -529,6 +559,9 @@ private void PostSelectedCommitsChanged() if (_ignoreSelectionChange) return; + if (_selectedCommits.Count > 0) + _pendingNavigateTarget = null; + if (_selectedCommits.Count == 0) { _searchCommitContext.Selected = null; @@ -567,6 +600,26 @@ private void PostSelectedCommitsChanged() GenerateGraph(_commits); } + private bool NavigateToPendingCommit() + { + if (string.IsNullOrEmpty(_pendingNavigateTarget)) + return false; + + var commit = _commits.Find(x => x.SHA.StartsWith(_pendingNavigateTarget, StringComparison.Ordinal)); + if (commit != null) + { + _pendingNavigateTarget = null; + SelectedCommits = [commit]; + return true; + } + + if (_isLoading || _isLoadingMoreCommits) + return true; + + _pendingNavigateTarget = null; + return false; + } + private void GenerateGraph(List commits) { var firstParentOnly = _repo.UIStates.HistoryShowFlags.HasFlag(Models.HistoryShowFlags.FirstParentOnly); @@ -587,6 +640,8 @@ private void GenerateGraph(List commits) private bool _hasSingleRemote = false; private CommitDetailSharedData _commitDetailSharedData = null; private bool _isLoading = true; + private bool _isLoadingMoreCommits = false; + private string _pendingNavigateTarget = null; private List _commits = []; private Models.CommitGraph _graph = null; private List _selectedCommits = []; diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index 4d3b5541c9..71738c6b5a 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -1257,10 +1257,11 @@ public void RefreshCommits() { await Dispatcher.UIThread.InvokeAsync(() => _histories.IsLoading = true); + var maxHistoryCommits = Preferences.Instance.MaxHistoryCommits; var builder = new StringBuilder(); builder .Append('-') - .Append(Preferences.Instance.MaxHistoryCommits) + .Append(maxHistoryCommits) .Append(' '); if (_uiStates.FocusOnCurrentBranch) @@ -1275,39 +1276,27 @@ public void RefreshCommits() builder.Append(_uiStates.BuildHistoryParams_NormalMode(GitDir)); } + // Shows the first page of commits as soon as it's ready, and keeps loading the rest in background. + var firstPageSize = maxHistoryCommits > 1000 ? 1000 : 0; var commits = await new Commands.QueryCommits(FullPath, builder.ToString()) - .GetResultAsync() - .ConfigureAwait(false); + .GetResultAsync(firstPageSize, firstPage => + { + MarkMergedCommits(firstPage); - var merged = new HashSet(); - foreach (var c in commits) - { - if (merged.Remove(c.SHA)) - c.IsMerged = true; + Dispatcher.UIThread.Invoke(() => + { + if (!token.IsCancellationRequested && _histories != null && _histories.CanShowPartialCommits(firstPage)) + SetHistoryCommits(firstPage, true); + }); + }) + .ConfigureAwait(false); - if (c.IsMerged) - { - foreach (var p in c.Parents) - merged.Add(p); - } - } + MarkMergedCommits(commits); Dispatcher.UIThread.Invoke(() => { - if (token.IsCancellationRequested) - return; - - if (_histories != null) - { - _histories.IsLoading = false; - _histories.Commits = commits; - BisectState = _histories.UpdateBisectInfo(); - - if (!string.IsNullOrEmpty(_navigateToCommitDelayed)) - NavigateToCommit(_navigateToCommitDelayed); - } - - _navigateToCommitDelayed = string.Empty; + if (!token.IsCancellationRequested) + SetHistoryCommits(commits, false); }); }, token); } @@ -1811,6 +1800,38 @@ private object BuildVisibleSubmodules() return new SubmoduleCollectionAsList() { Submodules = visible }; } + private static void MarkMergedCommits(List commits) + { + var merged = new HashSet(); + foreach (var c in commits) + { + if (merged.Remove(c.SHA)) + c.IsMerged = true; + + if (c.IsMerged) + { + foreach (var p in c.Parents) + merged.Add(p); + } + } + } + + private void SetHistoryCommits(List commits, bool hasMore) + { + if (_histories != null) + { + _histories.IsLoading = false; + _histories.IsLoadingMoreCommits = hasMore; + _histories.Commits = commits; + BisectState = _histories.UpdateBisectInfo(); + + if (!string.IsNullOrEmpty(_navigateToCommitDelayed)) + NavigateToCommit(_navigateToCommitDelayed); + } + + _navigateToCommitDelayed = string.Empty; + } + private void RefreshHistoryFilters(bool refresh) { HistoryFilterMode = _uiStates.GetHistoryFilterMode(); diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index a3d24529fd..710fcf3385 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -272,6 +272,13 @@ + + diff --git a/src/Views/Histories.axaml.cs b/src/Views/Histories.axaml.cs index d42529fe2e..175cb3a8ff 100644 --- a/src/Views/Histories.axaml.cs +++ b/src/Views/Histories.axaml.cs @@ -12,6 +12,7 @@ using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Platform.Storage; +using Avalonia.Threading; using Avalonia.VisualTree; namespace SourceGit.Views @@ -135,11 +136,17 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { base.OnPropertyChanged(change); - if (change.Property == SelectedCommitsProperty && IsLoaded && !_ignoreSelectionChanged) + if (change.Property == ItemsSourceProperty && IsLoaded) + { + KeepScrollPosition(change.OldValue as List, change.NewValue as List); + } + else 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]) + old[0] == cur[0] && + SelectedItems is [Models.Commit selected] && + selected == cur[0]) ScrollIntoView(old[0], null); else ApplySelection(); @@ -246,6 +253,51 @@ protected override async void OnKeyDown(KeyEventArgs e) base.OnKeyDown(e); } + private void KeepScrollPosition(List oldCommits, List newCommits) + { + if (newCommits == null) + return; + + // Changing `ItemsSource` always resets the scroll offset. When the new list only appends commits to the + // old one (more history has been loaded), remember the last fully visible row and scroll back to it. + Models.Commit anchor = null; + if (oldCommits is { Count: > 0 } && newCommits.Count > oldCommits.Count && newCommits[oldCommits.Count - 1] == oldCommits[^1]) + { + var rowsPresenter = this.FindDescendantOfType(); + if (rowsPresenter != null) + { + var lastIndex = -1; + foreach (var child in rowsPresenter.Children) + { + if (child is DataGridRow { IsVisible: true } row && + row.Bounds.Top >= 0 && + row.Bounds.Bottom <= rowsPresenter.Bounds.Height + 0.5 && + row.Index > lastIndex) + lastIndex = row.Index; + } + + if (lastIndex >= 0 && lastIndex < oldCommits.Count) + anchor = oldCommits[lastIndex]; + } + } + + var oldSelected = SelectedItems is [Models.Commit c] ? c.SHA : null; + Dispatcher.UIThread.Post(() => + { + if (ItemsSource != newCommits) + return; + + // Rows are not available until the next layout pass, so `ScrollIntoView` doesn't work before it. + UpdateLayout(); + + // A commit that is navigated to while loading should be visible after it is selected. + if (SelectedItems is [Models.Commit selected] && !selected.SHA.Equals(oldSelected, StringComparison.Ordinal)) + ScrollIntoView(selected, null); + else if (anchor != null) + ScrollIntoView(anchor, null); + }); + } + private void ApplySelection() { _ignoreSelectionChanged = true;