diff --git a/src/Commands/QueryHistoryQuickSearch.cs b/src/Commands/QueryHistoryQuickSearch.cs new file mode 100644 index 000000000..3117b9bef --- /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/Models/CommitGraph.cs b/src/Models/CommitGraph.cs index 916a79b1b..ec5101747 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -71,7 +71,95 @@ 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 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 + // 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 (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; + 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 +175,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 +195,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 +244,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 +257,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 +271,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 +302,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/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 62ac98e48..f6efba83e 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/Resources/Styles.axaml b/src/Resources/Styles.axaml index d1ba76c82..c2c5e13b3 100644 --- a/src/Resources/Styles.axaml +++ b/src/Resources/Styles.axaml @@ -584,6 +584,16 @@ + + + +