From 9dfb51dd87c3b8a2ef259f2cd1896a509e336956 Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Thu, 29 Oct 2026 14:24:13 +0100 Subject: [PATCH 1/9] Add history toolbar with Mixed quick search and filtering --- src/Commands/QueryHistoryQuickSearch.cs | 118 ++++++++++ src/Resources/Locales/en_US.axaml | 4 + src/ViewModels/Histories.cs | 174 +++++++++++++- src/ViewModels/Repository.cs | 1 + src/Views/Histories.axaml | 119 +++++++--- src/Views/Histories.axaml.cs | 51 +++- src/Views/Hotkeys.axaml | 8 +- src/Views/Launcher.axaml.cs | 12 + .../HistoryQuickSearchChecks.csproj | 11 + tests/HistoryQuickSearchChecks/Program.cs | 217 ++++++++++++++++++ tests/HistoryQuickSearchChecks/README.md | 23 ++ 11 files changed, 692 insertions(+), 46 deletions(-) create mode 100644 src/Commands/QueryHistoryQuickSearch.cs create mode 100644 tests/HistoryQuickSearchChecks/HistoryQuickSearchChecks.csproj create mode 100644 tests/HistoryQuickSearchChecks/Program.cs create mode 100644 tests/HistoryQuickSearchChecks/README.md diff --git a/src/Commands/QueryHistoryQuickSearch.cs b/src/Commands/QueryHistoryQuickSearch.cs new file mode 100644 index 0000000000..3117b9befb --- /dev/null +++ b/src/Commands/QueryHistoryQuickSearch.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + // Search only the commits currently loaded in history, including their full messages. + public class QueryHistoryQuickSearch : Command + { + public QueryHistoryQuickSearch(string repo, IReadOnlyList commits, string filter, CancellationToken cancellationToken) + { + WorkingDirectory = repo; + Context = repo; + Args = "log --no-walk=unsorted --stdin --no-show-signature --format=%H%x00%aN%x00%aE%x00%B%x00"; + CancellationToken = cancellationToken; + _commits = commits; + _filter = filter; + } + + public async Task> GetResultAsync() + { + var matches = new HashSet(StringComparer.Ordinal); + if (_filter.Length == 0 || _commits.Count == 0) + return matches; + + CancellationToken.ThrowIfCancellationRequested(); + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.StartInfo.RedirectStandardInput = true; + proc.Start(); + var input = WriteCommitsAsync(proc); + var errors = proc.StandardError.ReadToEndAsync(CancellationToken); + try + { + // Stream NUL-delimited fields as Mixed search does. One Git process handles + // the loaded history, without retaining every full message in memory. + var fields = new string[4]; + var fieldIndex = 0; + var field = new StringBuilder(); + var buffer = new char[4096]; + int count; + while ((count = await proc.StandardOutput.ReadAsync(buffer.AsMemory(), CancellationToken).ConfigureAwait(false)) > 0) + { + for (var i = 0; i < count; i++) + { + if (buffer[i] != '\0') + { + field.Append(buffer[i]); + continue; + } + + fields[fieldIndex++] = field.ToString(); + field.Clear(); + if (fieldIndex < fields.Length) + continue; + + var sha = fields[0].TrimStart('\r', '\n'); + // Same literal, case-insensitive OR matching as Commit Search's Mixed mode. + if (sha.StartsWith(_filter, StringComparison.OrdinalIgnoreCase) || + (fields[1] + "±" + fields[2]).Contains(_filter, StringComparison.OrdinalIgnoreCase) || + fields[3].Contains(_filter, StringComparison.OrdinalIgnoreCase)) + matches.Add(sha); + fieldIndex = 0; + } + } + await proc.WaitForExitAsync(CancellationToken).ConfigureAwait(false); + var error = await errors.ConfigureAwait(false); + if (proc.ExitCode != 0) + throw new InvalidOperationException(error); + await input.ConfigureAwait(false); + } + finally + { + try + { + if (!proc.HasExited) + proc.Kill(true); + } + catch (InvalidOperationException) + { + // The process may have exited between HasExited and Kill. + } + await proc.WaitForExitAsync().ConfigureAwait(false); + // Observe the input/error tasks even when Git exits early or the query is cancelled. + try + { + await input.ConfigureAwait(false); + } + catch (Exception) + { + // Preserve the original query error. + } + try + { + await errors.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // The query was superseded. + } + } + return matches; + } + + private async Task WriteCommitsAsync(Process proc) + { + foreach (var commit in _commits) + await proc.StandardInput.WriteLineAsync(commit.SHA.AsMemory(), CancellationToken).ConfigureAwait(false); + proc.StandardInput.Close(); + } + + private readonly IReadOnlyList _commits; + private readonly string _filter; + } +} diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 62ac98e487..f6efba83e6 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -535,6 +535,10 @@ Current Branch & Selected Commits Selected Commits Only Selected Commits (First-parent Only) + Quick search (Mixed) + Show matching commits only + Next match (F3 / Enter) + Previous match (Shift+F3 / Shift+Enter) 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 128b53ff9d..16661a8d4a 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; using System.Threading.Tasks; using Avalonia.Collections; @@ -107,9 +108,160 @@ public List Commits get => _commits; set { - GenerateGraph(value); if (SetProperty(ref _commits, value)) - PostCommitsChanged(); + RefreshQuickSearch(true); + } + } + + public bool IsUpdatingQuickSearchFilter { get; private set; } + + public List DisplayedCommits => _displayedCommits; + + public string QuickSearchText + { + get => _quickSearchText; + set + { + if (SetProperty(ref _quickSearchText, value)) + RefreshQuickSearch(); + } + } + + public bool IsQuickSearchFilterEnabled + { + get => _isQuickSearchFilterEnabled; + set + { + if (SetProperty(ref _isQuickSearchFilterEnabled, value)) + ApplyQuickSearchFilter(); + } + } + + public bool IsQuickSearching + { + get => _isQuickSearching; + private set => SetProperty(ref _isQuickSearching, value); + } + + public bool HasQuickSearchMatches => !IsQuickSearching && _quickSearchMatches.Count > 0; + + public string QuickSearchStatus => string.IsNullOrEmpty(_quickSearchText) ? string.Empty : + $"{(_selectedCommits.Count == 1 ? _quickSearchMatches.FindIndex(c => c.SHA == _selectedCommits[0].SHA) + 1 : 0)} / {_quickSearchMatches.Count}"; + + public void ClearQuickSearch() + { + IsQuickSearchFilterEnabled = false; + QuickSearchText = string.Empty; + } + + public void QuickSearchNext() => MoveQuickSearch(1); + public void QuickSearchPrevious() => MoveQuickSearch(-1); + + private void MoveQuickSearch(int direction) + { + if (!HasQuickSearchMatches) + return; + + var current = _selectedCommits.Count == 1 ? _commits.IndexOf(_selectedCommits[0]) : -1; + if (current < 0) + current = direction > 0 ? -1 : 0; + + var matches = new HashSet(_quickSearchMatches); + for (var step = 1; step <= _commits.Count; step++) + { + var index = (current + direction * step + _commits.Count) % _commits.Count; + if (matches.Contains(_commits[index])) + { + SelectedCommits = [_commits[index]]; + return; + } + } + } + + private async void RefreshQuickSearch(bool commitsChanged = false) + { + _quickSearchCancellation?.Cancel(); + if (string.IsNullOrEmpty(_quickSearchText)) + _quickSearchMatches = []; + else if (commitsChanged) + { + var previousMatches = new HashSet(_quickSearchMatches.ConvertAll(c => c.SHA)); + _quickSearchMatches = _commits.FindAll(c => previousMatches.Contains(c.SHA)); + } + IsQuickSearching = !string.IsNullOrEmpty(_quickSearchText) && _commits.Count > 0; + OnPropertyChanged(nameof(HasQuickSearchMatches)); + OnPropertyChanged(nameof(QuickSearchStatus)); + ApplyQuickSearchFilter(); + if (!IsQuickSearching) + return; + + var cancellation = new CancellationTokenSource(); + _quickSearchCancellation = cancellation; + var commits = _commits; + var filter = _quickSearchText; + try + { + await Task.Delay(200, cancellation.Token); + var matches = await Task.Run(() => new Commands.QueryHistoryQuickSearch(_repo.FullPath, commits, filter, cancellation.Token).GetResultAsync()); + if (cancellation.IsCancellationRequested) + return; + + _quickSearchMatches = commits.FindAll(c => matches.Contains(c.SHA)); + IsQuickSearching = false; + ApplyQuickSearchFilter(); + OnPropertyChanged(nameof(HasQuickSearchMatches)); + OnPropertyChanged(nameof(QuickSearchStatus)); + } + catch (OperationCanceledException) + { + // A newer query or leaving the view supersedes this search. + } + catch (Exception e) + { + if (!cancellation.IsCancellationRequested) + { + _quickSearchMatches = []; + IsQuickSearching = false; + ApplyQuickSearchFilter(); + OnPropertyChanged(nameof(HasQuickSearchMatches)); + OnPropertyChanged(nameof(QuickSearchStatus)); + Models.Notification.Send(_repo.FullPath, e.Message, true); + } + } + finally + { + if (_quickSearchCancellation == cancellation) + _quickSearchCancellation = null; + cancellation.Dispose(); + } + } + + public void CancelQuickSearch() + { + _quickSearchCancellation?.Cancel(); + IsQuickSearching = false; + OnPropertyChanged(nameof(HasQuickSearchMatches)); + } + + public void ResumeQuickSearch() => RefreshQuickSearch(); + + private void ApplyQuickSearchFilter() + { + var visible = _isQuickSearchFilterEnabled && !string.IsNullOrEmpty(_quickSearchText) ? _quickSearchMatches : _commits; + if (ReferenceEquals(visible, _displayedCommits)) + return; + + _displayedCommits = visible; + IsUpdatingQuickSearchFilter = true; + try + { + GenerateGraph(DisplayedCommits); + OnPropertyChanged(nameof(DisplayedCommits)); + PostCommitsChanged(); + } + finally + { + IsUpdatingQuickSearchFilter = false; } } @@ -127,7 +279,7 @@ public Models.CommitGraphHighlighting GraphHighlighting if (_repo.UIStates.GraphHighlighting != value) { _repo.UIStates.GraphHighlighting = value; - GenerateGraph(_commits); + GenerateGraph(DisplayedCommits); } } } @@ -299,6 +451,8 @@ public void NavigateTo(string commitSHA) var commit = _commits.Find(x => x.SHA.StartsWith(commitSHA, StringComparison.Ordinal)); if (commit != null) { + if (!DisplayedCommits.Contains(commit)) + IsQuickSearchFilterEnabled = false; SelectedCommits = [commit]; return; } @@ -494,7 +648,7 @@ private void PostCommitsChanged() if (_selectedCommits.Count == 0) return; - if (_commits.Count == 0 || _selectedCommits.Count > 20) + if (DisplayedCommits.Count == 0 || _selectedCommits.Count > 20) { SelectedCommits = []; return; @@ -505,7 +659,7 @@ private void PostCommitsChanged() set.Add(c.SHA); var selected = new List(); - foreach (var c in _commits) + foreach (var c in DisplayedCommits) { if (set.Contains(c.SHA)) { @@ -521,6 +675,7 @@ private void PostCommitsChanged() private void PostSelectedCommitsChanged() { + OnPropertyChanged(nameof(QuickSearchStatus)); if (_ignoreSelectionChange) return; @@ -559,7 +714,7 @@ private void PostSelectedCommitsChanged() } if (_repo.UIStates.GraphHighlighting >= Models.CommitGraphHighlighting.SelectedCommitsOnly) - GenerateGraph(_commits); + GenerateGraph(DisplayedCommits); } private void GenerateGraph(List commits) @@ -577,6 +732,13 @@ private void GenerateGraph(List commits) Graph = Models.CommitGraph.Generate(commits, firstParentOnly, highlighting, extraHeads); } + private string _quickSearchText = string.Empty; + private bool _isQuickSearchFilterEnabled = false; + private bool _isQuickSearching = false; + private List _displayedCommits = []; + private List _quickSearchMatches = []; + private CancellationTokenSource _quickSearchCancellation; + private Repository _repo = null; private Models.Branch _currentBranch = null; private bool _hasSingleRemote = false; diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index 1ea5951a83..067e53a1b6 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -479,6 +479,7 @@ public void Close() if (_cancellationRefreshStashes is { IsCancellationRequested: false }) _cancellationRefreshStashes.Cancel(); + _histories?.CancelQuickSearch(); _watcher?.Dispose(); _autoFetchTimer.Dispose(); } diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index a3d24529fd..8ecccecac2 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -23,13 +23,86 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -242,40 +315,14 @@ - - - - - - - - - - - - - - - - + PointerReleased="OnCommitListHeaderPointerReleased"/> - - - + { Count: 1 } old && change.NewValue is List { Count: 1 } cur && - old[0] == cur[0]) + old[0] == cur[0] && DataContext is not ViewModels.Histories { IsUpdatingQuickSearchFilter: true }) ScrollIntoView(old[0], null); else ApplySelection(); @@ -150,6 +150,9 @@ protected override void OnSelectionChanged(SelectionChangedEventArgs e) { base.OnSelectionChanged(e); + if (_ignoreSelectionChanged || DataContext is ViewModels.Histories { IsUpdatingQuickSearchFilter: true }) + return; + if (ItemsSource is not IList items) return; @@ -372,6 +375,48 @@ public bool IsDetailsPanelExpanded public Histories() { InitializeComponent(); + AddHandler(KeyDownEvent, OnQuickSearchKeyDown, RoutingStrategies.Tunnel); + Loaded += (_, _) => (DataContext as ViewModels.Histories)?.ResumeQuickSearch(); + Unloaded += (_, _) => (DataContext as ViewModels.Histories)?.CancelQuickSearch(); + } + + public void FocusQuickSearch() + { + QuickSearchBox.Focus(); + QuickSearchBox.SelectAll(); + } + + private void OnQuickSearchKeyDown(object sender, KeyEventArgs e) + { + if (DataContext is not ViewModels.Histories vm) + return; + + if (!QuickSearchBox.IsKeyboardFocusWithin && + e.Source is Visual source && source != CommitListContainer && !CommitListContainer.IsVisualAncestorOf(source)) + return; + + var command = OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control; + if (e.Key == Key.F && e.KeyModifiers == command) + { + FocusQuickSearch(); + e.Handled = true; + } + else if ((e.Key == Key.F3 && (e.KeyModifiers == KeyModifiers.None || e.KeyModifiers == KeyModifiers.Shift)) || + (e.Key == Key.Enter && QuickSearchBox.IsKeyboardFocusWithin && + (e.KeyModifiers == KeyModifiers.None || e.KeyModifiers == KeyModifiers.Shift))) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + vm.QuickSearchPrevious(); + else + vm.QuickSearchNext(); + e.Handled = true; + } + else if (e.Key == Key.Escape && (!string.IsNullOrEmpty(vm.QuickSearchText) || vm.IsQuickSearchFilterEnabled)) + { + vm.ClearQuickSearch(); + CommitListContainer.Focus(); + e.Handled = true; + } } public async Task GotoParent() @@ -789,8 +834,8 @@ private void OnCommitListLayoutUpdated(object _1, EventArgs _2) private void OnScrollToTopPointerPressed(object sender, PointerPressedEventArgs e) { - if (DataContext is ViewModels.Histories histories) - CommitListContainer.ScrollIntoView(histories.Commits[0], null); + if (DataContext is ViewModels.Histories histories && histories.DisplayedCommits.Count > 0) + CommitListContainer.ScrollIntoView(histories.DisplayedCommits[0], null); } private void OnCommitListContextRequested(object sender, ContextRequestedEventArgs e) diff --git a/src/Views/Hotkeys.axaml b/src/Views/Hotkeys.axaml index e15ff17648..b447d13978 100644 --- a/src/Views/Hotkeys.axaml +++ b/src/Views/Hotkeys.axaml @@ -89,7 +89,7 @@ FontSize="{Binding Source={x:Static vm:Preferences.Instance}, Path=DefaultFontSize, Converter={x:Static c:DoubleConverters.Increase}}" Margin="0,8"/> - + @@ -140,6 +140,12 @@ + + + + + + diff --git a/src/Views/Launcher.axaml.cs b/src/Views/Launcher.axaml.cs index 0ac14e6bb3..e1d2877f2f 100644 --- a/src/Views/Launcher.axaml.cs +++ b/src/Views/Launcher.axaml.cs @@ -5,6 +5,7 @@ using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Media; +using Avalonia.VisualTree; namespace SourceGit.Views { @@ -271,6 +272,17 @@ protected override async void OnKeyDown(KeyEventArgs e) vm.CommandPalette = new ViewModels.RepositoryCommandPalette(repo); e.Handled = true; return; + case Key.F when e.KeyModifiers == cmdKey && repo.IsDashboardVisible: + foreach (var visual in this.GetVisualDescendants()) + { + if (visual is Histories histories && histories.DataContext == repo.Histories && histories.IsVisible) + { + histories.FocusQuickSearch(); + e.Handled = true; + return; + } + } + break; case Key.F when e.KeyModifiers.HasFlag(KeyModifiers.Shift): repo.Histories.IsSearchingCommits = !repo.Histories.IsSearchingCommits; e.Handled = true; diff --git a/tests/HistoryQuickSearchChecks/HistoryQuickSearchChecks.csproj b/tests/HistoryQuickSearchChecks/HistoryQuickSearchChecks.csproj new file mode 100644 index 0000000000..cbfcb000ed --- /dev/null +++ b/tests/HistoryQuickSearchChecks/HistoryQuickSearchChecks.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + + + + + + diff --git a/tests/HistoryQuickSearchChecks/Program.cs b/tests/HistoryQuickSearchChecks/Program.cs new file mode 100644 index 0000000000..42b9a25601 --- /dev/null +++ b/tests/HistoryQuickSearchChecks/Program.cs @@ -0,0 +1,217 @@ +using System.Diagnostics; +using System.Reflection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; +using SourceGit.Commands; +using SourceGit.Models; +using SourceGit.ViewModels; +using Commit = SourceGit.Models.Commit; +using HistoryView = SourceGit.Views.Histories; + +internal static class Program +{ + private static int _checks; + + private static void Main(string[] args) + { + var fixture = Path.Combine(Path.GetTempPath(), "sourcegit-history-search-" + 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(); + try + { + Git(fixture, "init", "-b", "main"); + Git(fixture, "config", "user.name", "Alice Shared"); + Git(fixture, "config", "user.email", "alice@example.com"); + Git(fixture, "commit", "--allow-empty", "-m", "Shared Alpha Subject", "-m", "Body-only needle\nLiteral [brackets]. and a \"quote\"\nUnicode: \u00e1rv\u00edzt\u0171r\u0151\n" + new string('x', 9000) + "long-body needle"); + var first = Git(fixture, "rev-parse", "HEAD").Trim(); + Git(fixture, "config", "user.name", "Bob"); + Git(fixture, "config", "user.email", "bob@example.com"); + Git(fixture, "commit", "--allow-empty", "-m", "Shared second"); + var second = Git(fixture, "rev-parse", "HEAD").Trim(); + Git(fixture, "checkout", "-b", "other"); + Git(fixture, "commit", "--allow-empty", "-m", "Outside the loaded history"); + var outside = Git(fixture, "rev-parse", "HEAD").Trim(); + Git(fixture, "checkout", "main"); + var commits = new QueryCommits(fixture, "HEAD").GetResultAsync().GetAwaiter().GetResult(); + Check(Search(fixture, commits, first[..12].ToUpperInvariant()).SetEquals([first]), "SHA prefix ignores case"); + Check(Search(fixture, commits, first.Substring(8, 14)).Count == 0, "interior SHA does not match"); + Check(Search(fixture, commits, "ALICE").SetEquals([first]), "author name"); + Check(Search(fixture, commits, "BOB@EXAMPLE.COM").SetEquals([second]), "author email"); + Check(Search(fixture, commits, "alpha subject").SetEquals([first]), "subject ignores case"); + Check(Search(fixture, commits, "body-only NEEDLE").SetEquals([first]), "full multiline message"); + Check(Search(fixture, commits, "long-body needle").SetEquals([first]), "large message"); + Check(Search(fixture, commits, "[brackets].").SetEquals([first]), "literal punctuation"); + Check(Search(fixture, commits, "\u00e1rv\u00edzt\u0171r\u0151").SetEquals([first]), "Unicode"); + Check(Search(fixture, commits, "Shared").Count == 2, "OR matching across author and message"); + Check(Search(fixture, commits, outside).Count == 0, "only loaded history searched"); + Check(Search(fixture, commits, "").Count == 0, "empty query"); + Check(Search(fixture, [], "Shared").Count == 0, "empty history does not default to HEAD"); + var batched = Enumerable.Repeat(commits[0], 256).Append(commits[1]).ToList(); + Check(Search(fixture, batched, "body-only").SetEquals([first]), "matches beyond the first 256 entries"); + using (var cancelled = new CancellationTokenSource()) + { + cancelled.Cancel(); + try + { + new QueryHistoryQuickSearch(fixture, commits, "Shared", cancelled.Token).GetResultAsync().GetAwaiter().GetResult(); + throw new Exception("Cancelled query completed"); + } + catch (OperationCanceledException) { Check(true, "cancelled query"); } + } + + using (var cancellation = new CancellationTokenSource()) + { + var manyCommits = Enumerable.Repeat(commits[0], 20000).ToList(); + var pending = new QueryHistoryQuickSearch(fixture, manyCommits, "Shared", cancellation.Token).GetResultAsync(); + cancellation.Cancel(); + try + { + pending.WaitAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); + throw new Exception("Running query was not cancelled"); + } + catch (OperationCanceledException) { Check(true, "running Git query cancels promptly"); } + } + + var repo = new Repository(false, fixture, Path.Combine(fixture, ".git")); + var histories = new Histories(repo) { Commits = commits, IsLoading = false }; + // Avoid unrelated detail loading; selection still drives search navigation and status. + typeof(Histories).GetField("_ignoreSelectionChange", BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(histories, true); + histories.QuickSearchText = "Shared"; + WaitFor(() => !histories.IsQuickSearching); + Check(histories.HasQuickSearchMatches && histories.DisplayedCommits.Count == 2, "search retains unfiltered history"); + histories.QuickSearchNext(); + Check(histories.SelectedCommits.Single().SHA == second, "next selects first in history order"); + histories.QuickSearchNext(); + Check(histories.SelectedCommits.Single().SHA == first, "next selects second match"); + histories.QuickSearchNext(); + Check(histories.SelectedCommits.Single().SHA == second, "next wraps"); + histories.QuickSearchPrevious(); + Check(histories.SelectedCommits.Single().SHA == first, "previous wraps"); + Check(histories.QuickSearchStatus == "2 / 2", "match counter follows selection"); + var unfilteredGraph = histories.Graph; + histories.QuickSearchText = "body-only"; + WaitFor(() => !histories.IsQuickSearching); + Check(ReferenceEquals(unfilteredGraph, histories.Graph), "unfiltered search does not rebuild graph"); + histories.IsQuickSearchFilterEnabled = true; + Check(histories.DisplayedCommits.Single().SHA == first && histories.Commits.Count == 2, "filter preserves source history"); + histories.QuickSearchText = "second"; + WaitFor(() => !histories.IsQuickSearching); + Check(histories.DisplayedCommits.Single().SHA == second, "filtered graph tolerates hidden parent"); + histories.IsQuickSearchFilterEnabled = false; + Check(histories.DisplayedCommits.Count == 2, "filter toggle restores history"); + histories.QuickSearchText = "missing"; + histories.QuickSearchText = "Shared"; + WaitFor(() => !histories.IsQuickSearching); + Check(histories.HasQuickSearchMatches, "new query wins over cancelled query"); + histories.Commits = [commits[0]]; + WaitFor(() => !histories.IsQuickSearching); + Check(histories.QuickSearchStatus.EndsWith("/ 1"), "history refresh recomputes matches"); + histories.Commits = commits; + WaitFor(() => !histories.IsQuickSearching); + + var view = new HistoryView { DataContext = histories }; + var window = new Window { Width = 1000, Height = 650, Content = view }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + WaitFor(() => !histories.IsQuickSearching); + view.FindControl("CommitListContainer")!.Focus(); + window.KeyPressQwerty(PhysicalKey.F, RawInputModifiers.Control); + var box = view.FindControl("QuickSearchBox")!; + Check(box.IsKeyboardFocusWithin, "Ctrl+F target focuses search"); + window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); + Check(histories.SelectedCommits.Single().SHA == second, "Enter selects next match"); + window.KeyPressQwerty(PhysicalKey.F3, RawInputModifiers.Shift); + Check(histories.SelectedCommits.Single().SHA == first, "Shift+F3 selects previous match"); + histories.IsQuickSearchFilterEnabled = true; + box.Focus(); + Dispatcher.UIThread.RunJobs(); + var border = box.GetVisualDescendants().OfType().First(b => b.Name == "PART_BorderElement"); + Check(histories.SelectedCommits.Single().SHA == first, "filter toggle preserves visible selection"); + Check(border.Background is ISolidColorBrush brush && brush.Color == Color.Parse("#FFF2CC"), "focused filter box has yellow background"); + if (args.Length > 0) + { + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + using var frame = window.CaptureRenderedFrame(); + frame?.Save(args[0]); + Avalonia.Application.Current!.RequestedThemeVariant = Avalonia.Styling.ThemeVariant.Dark; + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + using var darkFrame = window.CaptureRenderedFrame(); + darkFrame?.Save(Path.Combine(Path.GetDirectoryName(args[0])!, "history-quick-search-dark.png")); + } + window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None); + Check(histories.QuickSearchText.Length == 0 && !histories.IsQuickSearchFilterEnabled && histories.DisplayedCommits.Count == 2, "Escape clears search and restores history"); + view.FindControl("CommitListContainer")!.Focus(); + window.KeyPressQwerty(PhysicalKey.F, RawInputModifiers.Control); + Check(box.IsKeyboardFocusWithin, "Ctrl+F works from commit list after clearing"); + histories.QuickSearchText = "no such commit"; + WaitFor(() => !histories.IsQuickSearching); + histories.IsQuickSearchFilterEnabled = true; + Check(histories.DisplayedCommits.Count == 0 && !histories.HasQuickSearchMatches, "no matches produces empty filtered history"); + window.Close(); + histories.CancelQuickSearch(); + Console.WriteLine($"Passed {_checks} history quick search checks."); + } + finally + { + // Git object files are read-only on Windows. This fixture was created above. + var tempRoot = Path.GetFullPath(Path.GetTempPath()); + if (!Path.GetFullPath(fixture).StartsWith(tempRoot, StringComparison.OrdinalIgnoreCase) || + !Path.GetFileName(fixture).StartsWith("sourcegit-history-search-", StringComparison.Ordinal)) + throw new InvalidOperationException("Unexpected fixture path"); + foreach (var file in Directory.EnumerateFiles(fixture, "*", SearchOption.AllDirectories)) + File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(fixture, true); + } + } + + private static HashSet Search(string repo, List commits, string filter) => + new QueryHistoryQuickSearch(repo, commits, filter, CancellationToken.None).GetResultAsync().GetAwaiter().GetResult(); + + private static void WaitFor(Func condition) + { + var timer = Stopwatch.StartNew(); + while (!condition()) + { + if (timer.Elapsed > TimeSpan.FromSeconds(15)) + throw new Exception("Timed out waiting for search"); + Dispatcher.UIThread.RunJobs(); + Thread.Sleep(10); + } + Dispatcher.UIThread.RunJobs(); + } + + private static void Check(bool condition, string label) + { + if (!condition) + throw new Exception(label); + _checks++; + Console.WriteLine("PASS " + label); + } + + private static string Git(string cwd, params string[] args) + { + var start = new ProcessStartInfo("git") { WorkingDirectory = cwd, RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (var arg in args) + start.ArgumentList.Add(arg); + using var process = Process.Start(start)!; + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new Exception(error); + return output; + } +} diff --git a/tests/HistoryQuickSearchChecks/README.md b/tests/HistoryQuickSearchChecks/README.md new file mode 100644 index 0000000000..78fb865c11 --- /dev/null +++ b/tests/HistoryQuickSearchChecks/README.md @@ -0,0 +1,23 @@ +# History quick search checks + +Run from the repository root: + +```sh +dotnet run --project tests/HistoryQuickSearchChecks +``` + +These checks use a temporary Git repository and Avalonia Headless. They cover +literal, case-insensitive Mixed matching (SHA prefix, author name/email, full +commit message), loaded-history scope, cancellation, streamed messages, navigation, +filter restoration, history refresh, selection preservation, keyboard input, +and the active filter background. Tests leave user preferences untouched. + +An optional PNG path saves light and dark theme previews: + +```sh +dotnet run --project tests/HistoryQuickSearchChecks -- /absolute/path/preview.png +``` + +Quick search is limited to commits already loaded in the history view, honoring +its branch/date/commit-count constraints. It has no dependency on the separate +`mixed-commit-search` branch or changes to the Commit Search panel. From 6033ab6317c6ba95c6c48d829a7ea13625bd8a74 Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Thu, 29 Oct 2026 14:34:00 +0100 Subject: [PATCH 2/9] Keep history quick search matches visible when navigating --- src/ViewModels/Histories.cs | 6 +++ src/Views/Histories.axaml.cs | 3 ++ tests/HistoryQuickSearchChecks/Program.cs | 45 +++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index 16661a8d4a..0ac6e6cf86 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -209,6 +209,12 @@ private async void RefreshQuickSearch(bool commitsChanged = false) _quickSearchMatches = commits.FindAll(c => matches.Contains(c.SHA)); IsQuickSearching = false; ApplyQuickSearchFilter(); + if (_quickSearchMatches.Count > 0) + { + var selected = _selectedCommits.Count == 1 ? + _quickSearchMatches.Find(c => c.SHA == _selectedCommits[0].SHA) : null; + SelectedCommits = [selected ?? _quickSearchMatches[0]]; + } OnPropertyChanged(nameof(HasQuickSearchMatches)); OnPropertyChanged(nameof(QuickSearchStatus)); } diff --git a/src/Views/Histories.axaml.cs b/src/Views/Histories.axaml.cs index 1be6bb289d..b441de13a8 100644 --- a/src/Views/Histories.axaml.cs +++ b/src/Views/Histories.axaml.cs @@ -271,6 +271,9 @@ private void ApplySelection() } _ignoreSelectionChanged = false; + + if (SelectedCommits is { Count: 1 }) + ScrollIntoView(SelectedCommits[0], null); } private void IncrNoSelectionChangeCount() diff --git a/tests/HistoryQuickSearchChecks/Program.cs b/tests/HistoryQuickSearchChecks/Program.cs index 42b9a25601..847fe3ed31 100644 --- a/tests/HistoryQuickSearchChecks/Program.cs +++ b/tests/HistoryQuickSearchChecks/Program.cs @@ -91,6 +91,8 @@ private static void Main(string[] args) histories.QuickSearchText = "Shared"; WaitFor(() => !histories.IsQuickSearching); Check(histories.HasQuickSearchMatches && histories.DisplayedCommits.Count == 2, "search retains unfiltered history"); + Check(histories.SelectedCommits.Single().SHA == second, "search selects first match automatically"); + histories.SelectedCommits = []; histories.QuickSearchNext(); Check(histories.SelectedCommits.Single().SHA == second, "next selects first in history order"); histories.QuickSearchNext(); @@ -121,6 +123,7 @@ private static void Main(string[] args) histories.Commits = commits; WaitFor(() => !histories.IsQuickSearching); + histories.SelectedCommits = [commits[1]]; var view = new HistoryView { DataContext = histories }; var window = new Window { Width = 1000, Height = 650, Content = view }; window.Show(); @@ -160,6 +163,37 @@ private static void Main(string[] args) WaitFor(() => !histories.IsQuickSearching); histories.IsQuickSearchFilterEnabled = true; Check(histories.DisplayedCommits.Count == 0 && !histories.HasQuickSearchMatches, "no matches produces empty filtered history"); + histories.ClearQuickSearch(); + for (var i = 0; i < 60; i++) + Git(fixture, "commit", "--allow-empty", "-m", $"Filler {i}"); + Git(fixture, "commit", "--allow-empty", "-m", "Body-only distant match"); + var longHistory = new QueryCommits(fixture, "HEAD").GetResultAsync().GetAwaiter().GetResult(); + histories.Commits = longHistory; + histories.SelectedCommits = []; + var grid = view.FindControl("CommitListContainer")!; + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + grid.ScrollIntoView(longHistory[^1], null); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Check(!IsRowVisible(grid, longHistory[0]), "first match starts outside viewport"); + histories.QuickSearchText = "body-only"; + WaitFor(() => !histories.IsQuickSearching); + window.UpdateLayout(); + Check(IsRowVisible(grid, longHistory[0]), "initial search scrolls first match into view"); + histories.QuickSearchNext(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Check(histories.SelectedCommits.Single() == longHistory[^1] && IsRowVisible(grid, longHistory[^1]), "next scrolls distant match into view"); + histories.QuickSearchPrevious(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Check(histories.SelectedCommits.Single() == longHistory[0] && IsRowVisible(grid, longHistory[0]), "previous scrolls distant match into view"); + histories.IsQuickSearchFilterEnabled = true; + histories.IsQuickSearchFilterEnabled = false; + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Check(IsRowVisible(grid, histories.SelectedCommits.Single()), "filter toggle keeps selected match visible"); window.Close(); histories.CancelQuickSearch(); Console.WriteLine($"Passed {_checks} history quick search checks."); @@ -180,6 +214,17 @@ private static void Main(string[] args) private static HashSet Search(string repo, List commits, string filter) => new QueryHistoryQuickSearch(repo, commits, filter, CancellationToken.None).GetResultAsync().GetAwaiter().GetResult(); + private static bool IsRowVisible(DataGrid grid, Commit commit) + { + var presenter = grid.GetVisualDescendants().OfType().First(); + return presenter.GetVisualDescendants().OfType().Any(row => + { + var position = row.TranslatePoint(default, presenter); + return row.DataContext == commit && row.IsVisible && position.HasValue && + position.Value.Y >= -0.5 && position.Value.Y + row.Bounds.Height <= presenter.Bounds.Height + 0.5; + }); + } + private static void WaitFor(Func condition) { var timer = Stopwatch.StartNew(); From 67ccf124f98005fc6d13103fa091fd7d8df3f81e Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Thu, 29 Oct 2026 14:40:44 +0100 Subject: [PATCH 3/9] Collapse hidden ancestry in filtered history graphs --- src/Models/CommitGraph.cs | 78 +++++++++++++++++++---- src/ViewModels/Histories.cs | 4 +- tests/HistoryQuickSearchChecks/Program.cs | 28 ++++++++ 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs index 916a79b1bb..1222facdfc 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -71,7 +71,62 @@ public class Dot public List Links { get; } = []; public List Dots { get; } = []; + public static CommitGraph GenerateFiltered(List allCommits, List visibleCommits, bool firstParentOnlyEnabled, CommitGraphHighlighting highlighting, HashSet highlightExtraCommits) + { + var visible = new HashSet(); + foreach (var commit in visibleCommits) + visible.Add(commit.SHA); + + var nearest = new Dictionary>(); + var parents = new Dictionary>(); + // History is ordered children before parents. Resolve hidden ancestors from + // oldest to newest, stopping each path at its first visible commit. + for (var i = allCommits.Count - 1; i >= 0; i--) + { + var commit = allCommits[i]; + var parentCount = firstParentOnlyEnabled ? Math.Min(1, commit.Parents.Count) : commit.Parents.Count; + List resolved = []; + if (parentCount == 1) + { + if (nearest.TryGetValue(commit.Parents[0], out var ancestors)) + resolved = ancestors; + } + else if (parentCount > 1) + { + var seen = new HashSet(); + for (var j = 0; j < parentCount; j++) + { + if (!nearest.TryGetValue(commit.Parents[j], out var ancestors)) + continue; + + foreach (var ancestor in ancestors) + { + if (seen.Add(ancestor)) + resolved.Add(ancestor); + } + } + } + + if (visible.Contains(commit.SHA)) + { + parents[commit.SHA] = resolved; + nearest[commit.SHA] = [commit.SHA]; + } + else + { + nearest[commit.SHA] = resolved; + } + } + + return Generate(visibleCommits, firstParentOnlyEnabled, highlighting, highlightExtraCommits, parents); + } + public static CommitGraph Generate(List commits, bool firstParentOnlyEnabled, CommitGraphHighlighting highlighting, HashSet highlightExtraCommits) + { + return Generate(commits, firstParentOnlyEnabled, highlighting, highlightExtraCommits, null); + } + + private static CommitGraph Generate(List commits, bool firstParentOnlyEnabled, CommitGraphHighlighting highlighting, HashSet highlightExtraCommits, Dictionary> parentOverrides) { const double unitWidth = 12; const double halfWidth = 6; @@ -87,6 +142,7 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna foreach (var commit in commits) { + var parents = parentOverrides != null && parentOverrides.TryGetValue(commit.SHA, out var projected) ? projected : commit.Parents; PathHelper major = null; // Update current y offset @@ -106,9 +162,9 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna major = l; isHighlighted = major.IsHighlighted; - if (commit.Parents.Count > 0) + if (parents.Count > 0) { - major.Next = commit.Parents[0]; + major.Next = parents[0]; major.Goto(offsetX, offsetY, halfHeight); } else @@ -155,8 +211,8 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna { isHighlighted = true; // Highlight first parent, other parents are dealt with later - if (commit.Parents.Count > 0) - highlightExtraCommits.Add(commit.Parents[0]); + if (parents.Count > 0) + highlightExtraCommits.Add(parents[0]); } break; default: // CommitGraphHighlighting.CurrentBranchAndSelectedCommits @@ -168,8 +224,8 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna { isHighlighted = true; // Highlight first parent, other parents are dealt with later - if (commit.Parents.Count > 0) - highlightExtraCommits.Add(commit.Parents[0]); + if (parents.Count > 0) + highlightExtraCommits.Add(parents[0]); } break; } @@ -182,14 +238,14 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna { offsetX += unitWidth; - if (commit.Parents.Count > 0) + if (parents.Count > 0) { - major = new PathHelper(commit.Parents[0], isHighlighted, colorPicker.Next(), new Point(offsetX, offsetY)); + major = new PathHelper(parents[0], isHighlighted, colorPicker.Next(), new Point(offsetX, offsetY)); unsolved.Add(major); temp.Paths.Add(major.Path); } } - else if (isHighlighted && !major.IsHighlighted && commit.Parents.Count > 0) + else if (isHighlighted && !major.IsHighlighted && parents.Count > 0) { major.Highlight(); temp.Paths.Add(major.Path); @@ -213,9 +269,9 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna if (highlighting == CommitGraphHighlighting.SelectedCommitsOnlyFirstParent) isHighlighted = false; - for (int j = 1; j < commit.Parents.Count; j++) + for (int j = 1; j < parents.Count; j++) { - var parentHash = commit.Parents[j]; + var parentHash = parents[j]; var parent = unsolved.Find(x => x.Next.Equals(parentHash, StringComparison.Ordinal)); if (parent != null) { diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index 0ac6e6cf86..2c90221c10 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -735,7 +735,9 @@ private void GenerateGraph(List commits) extraHeads.Add(c.SHA); } - Graph = Models.CommitGraph.Generate(commits, firstParentOnly, highlighting, extraHeads); + Graph = _isQuickSearchFilterEnabled && !string.IsNullOrEmpty(_quickSearchText) ? + Models.CommitGraph.GenerateFiltered(_commits, commits, firstParentOnly, highlighting, extraHeads) : + Models.CommitGraph.Generate(commits, firstParentOnly, highlighting, extraHeads); } private string _quickSearchText = string.Empty; diff --git a/tests/HistoryQuickSearchChecks/Program.cs b/tests/HistoryQuickSearchChecks/Program.cs index 847fe3ed31..d5e9c58811 100644 --- a/tests/HistoryQuickSearchChecks/Program.cs +++ b/tests/HistoryQuickSearchChecks/Program.cs @@ -190,10 +190,14 @@ private static void Main(string[] args) window.UpdateLayout(); Check(histories.SelectedCommits.Single() == longHistory[0] && IsRowVisible(grid, longHistory[0]), "previous scrolls distant match into view"); histories.IsQuickSearchFilterEnabled = true; + Check(histories.Graph.Dots.Count == 2 && histories.Graph.Dots.All(dot => dot.Center.X == 10), "filtered linear history stays in one lane across hidden commits"); + Check(histories.Graph.Paths.Count == 1, "filtered matches share their ancestral path"); histories.IsQuickSearchFilterEnabled = false; + Check(longHistory[0].Parents.Single() == longHistory[1].SHA, "filtering preserves original parent hashes"); Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); Check(IsRowVisible(grid, histories.SelectedCommits.Single()), "filter toggle keeps selected match visible"); + CheckFilteredGraphTopology(); window.Close(); histories.CancelQuickSearch(); Console.WriteLine($"Passed {_checks} history quick search checks."); @@ -211,6 +215,30 @@ private static void Main(string[] args) } } + private static void CheckFilteredGraphTopology() + { + var root = new Commit { SHA = "root" }; + var left = new Commit { SHA = "left", Parents = [root.SHA] }; + var right = new Commit { SHA = "right", Parents = [root.SHA] }; + var hiddenMerge = new Commit { SHA = "hidden-merge", Parents = [left.SHA, right.SHA] }; + var tip = new Commit { SHA = "tip", Parents = [hiddenMerge.SHA] }; + var all = new List { tip, hiddenMerge, left, right, root }; + var graph = CommitGraph.GenerateFiltered(all, [tip, left, right, root], false, CommitGraphHighlighting.All, []); + Check(graph.Dots.Count == 4 && graph.Paths.Count == 2, "hidden merge retains both visible parent paths"); + Check(graph.Dots.Max(dot => dot.Center.X) <= 22, "hidden merge does not create extra lanes"); + graph = CommitGraph.GenerateFiltered(all, [tip, right], true, CommitGraphHighlighting.All, []); + Check(graph.Paths.Count == 0, "first-parent mode does not connect through a hidden second parent"); + graph = CommitGraph.GenerateFiltered(all, [tip], false, CommitGraphHighlighting.All, []); + Check(graph.Paths.Count == 0 && graph.Dots.Single().Center.X == 10, "no dangling paths for ancestors without visible matches"); + Check(hiddenMerge.Parents.SequenceEqual([left.SHA, right.SHA]), "projecting hidden merges preserves original ancestry"); + var sparse = Enumerable.Range(0, 600).Select(i => new Commit { SHA = $"commit-{i}", Parents = [$"commit-{i + 1}"] }).ToList(); + var visible = sparse.Where((_, i) => i % 3 == 0).ToList(); + graph = CommitGraph.GenerateFiltered(sparse, visible, false, CommitGraphHighlighting.All, []); + Check(graph.Paths.Count == 1 && graph.Dots.All(dot => dot.Center.X == 10), "hundreds of sparse matches do not accumulate phantom branches"); + graph = CommitGraph.Generate(sparse, false, CommitGraphHighlighting.All, []); + Check(graph.Dots.Count == sparse.Count && graph.Dots.All(dot => dot.Center.X == 10), "unfiltered graph retains original topology"); + } + private static HashSet Search(string repo, List commits, string filter) => new QueryHistoryQuickSearch(repo, commits, filter, CancellationToken.None).GetResultAsync().GetAwaiter().GetResult(); From 0b2b30155e0357201ba3c37dd8e19874e5b2d11f Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Thu, 29 Oct 2026 14:48:53 +0100 Subject: [PATCH 4/9] Remove transitive ancestor links from filtered history graphs --- src/Models/CommitGraph.cs | 33 +++++++++++++ tests/HistoryQuickSearchChecks/Program.cs | 59 +++++++++++++++++++++++ tests/HistoryQuickSearchChecks/README.md | 11 +++++ 3 files changed, 103 insertions(+) diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs index 1222facdfc..ec51017474 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -77,6 +77,10 @@ public static CommitGraph GenerateFiltered(List allCommits, List foreach (var commit in visibleCommits) visible.Add(commit.SHA); + var visibleOrder = new Dictionary(); + for (var i = 0; i < visibleCommits.Count; i++) + visibleOrder[visibleCommits[i].SHA] = i; + var nearest = new Dictionary>(); var parents = new Dictionary>(); // History is ordered children before parents. Resolve hidden ancestors from @@ -107,6 +111,35 @@ public static CommitGraph GenerateFiltered(List allCommits, List } } + if (parentCount > 1 && resolved.Count > 1) + { + // Hidden merges can expose both a visible commit and its ancestors. + // Keep only the nearest targets: the older targets are already + // reachable through the nearer ones and would add redundant lanes. + var candidates = new HashSet(resolved); + var redundant = new HashSet(); + var visited = new HashSet(); + var pending = new Stack(); + var oldest = 0; + foreach (var target in resolved) + { + oldest = Math.Max(oldest, visibleOrder[target]); + foreach (var parent in parents[target]) + pending.Push(parent); + } + while (pending.TryPop(out var ancestor)) + { + if (visibleOrder[ancestor] > oldest || !visited.Add(ancestor)) + continue; + + if (candidates.Contains(ancestor)) + redundant.Add(ancestor); + foreach (var parent in parents[ancestor]) + pending.Push(parent); + } + resolved.RemoveAll(redundant.Contains); + } + if (visible.Contains(commit.SHA)) { parents[commit.SHA] = resolved; diff --git a/tests/HistoryQuickSearchChecks/Program.cs b/tests/HistoryQuickSearchChecks/Program.cs index d5e9c58811..9f91c88c43 100644 --- a/tests/HistoryQuickSearchChecks/Program.cs +++ b/tests/HistoryQuickSearchChecks/Program.cs @@ -31,6 +31,11 @@ private static void Main(string[] args) .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }).SetupWithoutStarting(); try { + if (args.Length >= 2 && args[0] == "--repository") + { + CheckRepositoryGraph(args[1], args.Length > 2 ? args[2] : null); + return; + } Git(fixture, "init", "-b", "main"); Git(fixture, "config", "user.name", "Alice Shared"); Git(fixture, "config", "user.email", "alice@example.com"); @@ -215,6 +220,44 @@ private static void Main(string[] args) } } + private static void CheckRepositoryGraph(string path, string preview) + { + var commits = new QueryCommits(path, "--branches --remotes --tags HEAD --date-order -20000").GetResultAsync().GetAwaiter().GetResult(); + var matches = Search(path, commits, "fix:"); + var visible = commits.FindAll(c => matches.Contains(c.SHA)); + var full = CommitGraph.Generate(commits, false, CommitGraphHighlighting.All, []); + var filtered = CommitGraph.GenerateFiltered(commits, visible, false, CommitGraphHighlighting.All, []); + var fullWidth = full.Dots.Max(dot => dot.Center.X); + var filteredWidth = filtered.Dots.Max(dot => dot.Center.X); + Console.WriteLine($"Repository: {commits.Count} commits, {visible.Count} matches; full width {fullWidth}, filtered width {filteredWidth}"); + if (preview != null) + { + var repo = new Repository(false, path, Path.Combine(path, ".git")); + var histories = new Histories(repo) { Commits = commits, IsLoading = false, IsCollapseDetails = true }; + typeof(Histories).GetField("_ignoreSelectionChange", BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(histories, true); + var view = new HistoryView { DataContext = histories }; + var window = new Window { Width = 1600, Height = 1000, Content = view }; + Application.Current!.RequestedThemeVariant = Avalonia.Styling.ThemeVariant.Dark; + window.Show(); + histories.QuickSearchText = "fix:"; + histories.IsQuickSearchFilterEnabled = true; + WaitFor(() => !histories.IsQuickSearching); + window.UpdateLayout(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + using var frame = window.CaptureRenderedFrame(); + frame!.Save(preview); + histories.SelectedCommits = [histories.DisplayedCommits[^1]]; + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + using var bottom = window.CaptureRenderedFrame(); + bottom!.Save(Path.Combine(Path.GetDirectoryName(preview)!, Path.GetFileNameWithoutExtension(preview) + "-bottom.png")); + window.Close(); + histories.CancelQuickSearch(); + } + Check(filteredWidth <= fullWidth + 12, "real repository filtered graph does not grow phantom lanes"); + } + private static void CheckFilteredGraphTopology() { var root = new Commit { SHA = "root" }; @@ -231,6 +274,22 @@ private static void CheckFilteredGraphTopology() graph = CommitGraph.GenerateFiltered(all, [tip], false, CommitGraphHighlighting.All, []); Check(graph.Paths.Count == 0 && graph.Dots.Single().Center.X == 10, "no dangling paths for ancestors without visible matches"); Check(hiddenMerge.Parents.SequenceEqual([left.SHA, right.SHA]), "projecting hidden merges preserves original ancestry"); + var chain = Enumerable.Range(0, 200).Select(i => new Commit { SHA = $"visible-{i}" }).ToList(); + for (var i = 0; i < chain.Count - 1; i++) + chain[i].Parents = [chain[i + 1].SHA]; + var hidden = Enumerable.Range(0, chain.Count).Select(i => new Commit { SHA = $"merge-{i}" }).ToList(); + for (var i = 0; i < hidden.Count; i++) + hidden[i].Parents = i + 1 < hidden.Count ? [hidden[i + 1].SHA, chain[i].SHA] : [chain[i].SHA]; + var mergeTip = new Commit { SHA = "merge-tip", Parents = [hidden[0].SHA] }; + var mergedHistory = new List { mergeTip }; + mergedHistory.AddRange(hidden); + mergedHistory.AddRange(chain); + var mergedVisible = new List { mergeTip }; + mergedVisible.AddRange(chain); + graph = CommitGraph.GenerateFiltered(mergedHistory, mergedVisible, false, CommitGraphHighlighting.All, []); + Check(graph.Paths.Count == 1 && graph.Dots.All(dot => dot.Center.X == 10), "hidden merges do not connect redundantly to every visible ancestor"); + Check(chain[0].Parents.Single() == chain[1].SHA && hidden[0].Parents.Count == 2, "removing redundant graph edges preserves real commit parents"); + var sparse = Enumerable.Range(0, 600).Select(i => new Commit { SHA = $"commit-{i}", Parents = [$"commit-{i + 1}"] }).ToList(); var visible = sparse.Where((_, i) => i % 3 == 0).ToList(); graph = CommitGraph.GenerateFiltered(sparse, visible, false, CommitGraphHighlighting.All, []); diff --git a/tests/HistoryQuickSearchChecks/README.md b/tests/HistoryQuickSearchChecks/README.md index 78fb865c11..ec03ea711e 100644 --- a/tests/HistoryQuickSearchChecks/README.md +++ b/tests/HistoryQuickSearchChecks/README.md @@ -21,3 +21,14 @@ dotnet run --project tests/HistoryQuickSearchChecks -- /absolute/path/preview.pn Quick search is limited to commits already loaded in the history view, honoring its branch/date/commit-count constraints. It has no dependency on the separate `mixed-commit-search` branch or changes to the Commit Search panel. + +To reproduce filtered graph issues against a real repository without changing +its files or preferences, run: + +```sh +dotnet run --project tests/HistoryQuickSearchChecks -- --repository /path/to/repo /absolute/path/preview.png +``` + +This searches the loaded branch/tag history for `fix:`, checks graph width, +and optionally saves previews at the first and last match. The fixture suite +also covers hidden merges that expose redundant edges to visible ancestors. From 945dc8af177949a41498f6f4c009b8f02fec3a06 Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Thu, 29 Oct 2026 17:01:14 +0100 Subject: [PATCH 5/9] fix: adjust history filter styling and clear unmatched selection --- src/Resources/Themes.axaml | 3 ++ src/ViewModels/Histories.cs | 20 +++++++++- src/Views/Histories.axaml | 34 +++++++---------- tests/HistoryQuickSearchChecks/Program.cs | 46 ++++++++++++++++++++++- 4 files changed, 79 insertions(+), 24 deletions(-) diff --git a/src/Resources/Themes.axaml b/src/Resources/Themes.axaml index 36b97c8984..be8e166549 100644 --- a/src/Resources/Themes.axaml +++ b/src/Resources/Themes.axaml @@ -9,6 +9,7 @@ #FFFAFAFA #FFFFFFFF #FFFFFFFF + #FFFFF9E6 #FFB0CEE8 #FF1F1F1F #FF836C2E @@ -43,6 +44,7 @@ #FF2B2B2B #FF393939 #FF1C1C1C + #FF302D20 #FF8F8F8F #FFE0E0E0 #FFFAFAD2 @@ -77,6 +79,7 @@ + diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index 2c90221c10..62b4d6d2d2 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -123,7 +123,10 @@ public string QuickSearchText set { if (SetProperty(ref _quickSearchText, value)) + { + OnPropertyChanged(nameof(IsQuickSearchFilterActive)); RefreshQuickSearch(); + } } } @@ -133,10 +136,15 @@ public bool IsQuickSearchFilterEnabled set { if (SetProperty(ref _isQuickSearchFilterEnabled, value)) + { + OnPropertyChanged(nameof(IsQuickSearchFilterActive)); ApplyQuickSearchFilter(); + } } } + public bool IsQuickSearchFilterActive => _isQuickSearchFilterEnabled && !string.IsNullOrEmpty(_quickSearchText); + public bool IsQuickSearching { get => _isQuickSearching; @@ -193,7 +201,11 @@ private async void RefreshQuickSearch(bool commitsChanged = false) OnPropertyChanged(nameof(QuickSearchStatus)); ApplyQuickSearchFilter(); if (!IsQuickSearching) + { + if (!string.IsNullOrEmpty(_quickSearchText)) + SelectedCommits = []; return; + } var cancellation = new CancellationTokenSource(); _quickSearchCancellation = cancellation; @@ -215,6 +227,10 @@ private async void RefreshQuickSearch(bool commitsChanged = false) _quickSearchMatches.Find(c => c.SHA == _selectedCommits[0].SHA) : null; SelectedCommits = [selected ?? _quickSearchMatches[0]]; } + else + { + SelectedCommits = []; + } OnPropertyChanged(nameof(HasQuickSearchMatches)); OnPropertyChanged(nameof(QuickSearchStatus)); } @@ -253,7 +269,7 @@ public void CancelQuickSearch() private void ApplyQuickSearchFilter() { - var visible = _isQuickSearchFilterEnabled && !string.IsNullOrEmpty(_quickSearchText) ? _quickSearchMatches : _commits; + var visible = IsQuickSearchFilterActive ? _quickSearchMatches : _commits; if (ReferenceEquals(visible, _displayedCommits)) return; @@ -735,7 +751,7 @@ private void GenerateGraph(List commits) extraHeads.Add(c.SHA); } - Graph = _isQuickSearchFilterEnabled && !string.IsNullOrEmpty(_quickSearchText) ? + Graph = IsQuickSearchFilterActive ? Models.CommitGraph.GenerateFiltered(_commits, commits, firstParentOnly, highlighting, extraHeads) : Models.CommitGraph.Generate(commits, firstParentOnly, highlighting, extraHeads); } diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index 8ecccecac2..d99287d844 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -56,21 +56,7 @@ BorderBrush="{DynamicResource Brush.Border2}" VerticalContentAlignment="Center" ToolTip.Tip="{OnPlatform Ctrl+F, macOS=⌘+F}" Watermark="{DynamicResource Text.Histories.QuickSearch}" - Text="{Binding QuickSearchText, Mode=TwoWay}" - Classes.filtering="{Binding IsQuickSearchFilterEnabled}"> - - - - - + Text="{Binding QuickSearchText, Mode=TwoWay}"> @@ -99,8 +85,8 @@ + + + +