From cb8162ad4d6a8b350c7da013ebeec8a4648ef8b5 Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Wed, 28 Oct 2026 13:57:41 +0100 Subject: [PATCH 1/2] feature: add optional branches column to history --- src/Models/Commit.cs | 10 + src/Models/CommitGraph.cs | 8 + src/Models/Decorator.cs | 1 + src/Models/RepositoryUIStates.cs | 4 + src/Resources/Locales/en_US.axaml | 1 + src/ViewModels/Histories.cs | 19 ++ src/Views/CommitBranchConnections.cs | 60 +++++ src/Views/CommitMessagePanel.cs | 57 ++++ src/Views/CommitRefsPresenter.cs | 193 ++++++++++++- src/Views/CommitSubjectPresenter.cs | 23 ++ src/Views/Histories.axaml | 92 +++++-- src/Views/Histories.axaml.cs | 163 ++++++++++- .../BranchColumnChecks.csproj | 10 + tests/BranchColumnChecks/Program.cs | 254 ++++++++++++++++++ tests/BranchColumnChecks/README.md | 9 + 15 files changed, 864 insertions(+), 40 deletions(-) create mode 100644 src/Views/CommitBranchConnections.cs create mode 100644 src/Views/CommitMessagePanel.cs create mode 100644 tests/BranchColumnChecks/BranchColumnChecks.csproj create mode 100644 tests/BranchColumnChecks/Program.cs create mode 100644 tests/BranchColumnChecks/README.md diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index de299ed6b7..c45a22f5cb 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -23,6 +23,7 @@ public class Commit : ObservableObject public string Subject { get; set; } = string.Empty; public List Parents { get; set; } = new(); public List Decorators { get; set; } = new(); + public List GraphBranches { get; set; } = []; public bool IsMerged { get; set; } = false; public int Color { get; set; } = 0; @@ -37,6 +38,15 @@ public bool IsHighlightedInGraph public bool IsCommitterVisible => !Author.Equals(Committer) || AuthorTime != CommitterTime; public bool IsCurrentHead => Decorators.Find(x => x.Type is DecoratorType.CurrentBranchHead or DecoratorType.CurrentCommitHead) != null; public bool HasDecorators => Decorators.Count > 0; + public string BranchToolTip + { + get + { + var branches = Decorators.FindAll(x => x.IsBranch); + return branches.Count > 0 ? string.Join("\n", branches.ConvertAll(x => x.Name)) : null; + } + } + public string FirstParentToCompare => Parents.Count > 0 ? $"{SHA}^" : EmptyTreeHash.Guess(SHA); public string GetFriendlyName() diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs index 916a79b1bb..3b716bc096 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -195,6 +195,12 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna temp.Paths.Add(major.Path); } + // Carry branch identity along the lane, independently of recycled graph colors. + var branches = commit.Decorators.FindAll(x => x.IsBranch); + commit.GraphBranches = branches.Count > 0 ? branches : major?.Branches ?? []; + if (major != null && branches.Count > 0) + major.Branches = branches; + // Calculate link position of this commit. var position = new Point(major?.LastX ?? offsetX, offsetY); var dotColor = major?.Path.Color ?? 0; @@ -241,6 +247,7 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna // Create new curve for parent commit that not includes before var l = new PathHelper(parentHash, isHighlighted, colorPicker.Next(), position, new Point(offsetX, position.Y + halfHeight)); + l.Branches = commit.GraphBranches; unsolved.Add(l); temp.Paths.Add(l.Path); } @@ -292,6 +299,7 @@ public void Recycle(int idx) private class PathHelper { + public List Branches { get; set; } = []; public Path Path { get; private set; } public string Next { get; set; } public double LastX { get; private set; } diff --git a/src/Models/Decorator.cs b/src/Models/Decorator.cs index 7d985e3165..cc310e7ae2 100644 --- a/src/Models/Decorator.cs +++ b/src/Models/Decorator.cs @@ -15,5 +15,6 @@ public class Decorator public DecoratorType Type { get; set; } = DecoratorType.None; public string Name { get; set; } = ""; public bool IsTag => Type == DecoratorType.Tag; + public bool IsBranch => Type is DecoratorType.CurrentBranchHead or DecoratorType.LocalBranchHead or DecoratorType.RemoteBranchHead; } } diff --git a/src/Models/RepositoryUIStates.cs b/src/Models/RepositoryUIStates.cs index 8657b93b65..6f0da49ff5 100644 --- a/src/Models/RepositoryUIStates.cs +++ b/src/Models/RepositoryUIStates.cs @@ -15,6 +15,10 @@ public HistoryShowFlags HistoryShowFlags set; } = HistoryShowFlags.None; + public bool IsBranchesColumnVisibleInHistory { get; set; } = false; + + public double BranchesColumnWidth { get; set; } = 180; + public bool IsAuthorColumnVisibleInHistory { get; diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 1b1f29d8fb..2b3a583060 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -524,6 +524,7 @@ Advanced Options AUTHOR AUTHOR TIME + BRANCHES COMMIT TIME GRAPH & SUBJECT SHA diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index 128b53ff9d..73898985e2 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -50,6 +50,19 @@ public bool EnableTopoOrder } } + public bool IsBranchesColumnVisible + { + get => _repo.UIStates.IsBranchesColumnVisibleInHistory; + set + { + if (_repo.UIStates.IsBranchesColumnVisibleInHistory != value) + { + _repo.UIStates.IsBranchesColumnVisibleInHistory = value; + OnPropertyChanged(); + } + } + } + public bool IsAuthorColumnVisible { get => _repo.UIStates.IsAuthorColumnVisibleInHistory; @@ -204,6 +217,12 @@ public GridLength BottomArea } } + public double BranchesColumnWidth + { + get => _repo.UIStates.BranchesColumnWidth; + set => _repo.UIStates.BranchesColumnWidth = value; + } + public double AuthorColumnWidth { get => _repo.UIStates.AuthorColumnWidth; diff --git a/src/Views/CommitBranchConnections.cs b/src/Views/CommitBranchConnections.cs new file mode 100644 index 0000000000..9fc70a77de --- /dev/null +++ b/src/Views/CommitBranchConnections.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public record CommitBranchConnection(Point Start, Point End, IBrush Brush, bool IsActive, double TextGapWidth = 0); + + public class CommitBranchConnections : Control + { + public IReadOnlyList Connections => _connections; + + public void UpdateConnections(List connections) + { + if (_connections.Count == connections.Count) + { + var unchanged = true; + for (var i = 0; i < connections.Count; i++) + unchanged &= _connections[i] == connections[i]; + if (unchanged) + return; + } + + _connections = connections; + InvalidateVisual(); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + using (context.PushClip(new Rect(Bounds.Size))) + { + foreach (var connection in _connections) + { + using (context.PushOpacity(connection.IsActive ? 1 : 0.7)) + { + var pen = new Pen(connection.Brush, 1); + if (connection.TextGapWidth > 0) + { + // Leave the plain-text branch count readable between the badge and the line. + context.DrawLine(pen, connection.Start, new Point(System.Math.Min(connection.Start.X + 2, connection.End.X), connection.Start.Y)); + var resumeX = connection.Start.X + connection.TextGapWidth; + if (resumeX < connection.End.X) + context.DrawLine(pen, new Point(resumeX, connection.Start.Y), connection.End); + } + else + { + context.DrawLine(pen, connection.Start, connection.End); + } + } + } + } + } + + private List _connections = []; + } +} diff --git a/src/Views/CommitMessagePanel.cs b/src/Views/CommitMessagePanel.cs new file mode 100644 index 0000000000..75e62c3edb --- /dev/null +++ b/src/Views/CommitMessagePanel.cs @@ -0,0 +1,57 @@ +using System; + +using Avalonia; +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public class CommitMessagePanel : Panel + { + public static readonly StyledProperty RefsAfterSubjectProperty = + AvaloniaProperty.Register(nameof(RefsAfterSubject)); + + static CommitMessagePanel() + { + AffectsMeasure(RefsAfterSubjectProperty); + } + + public bool RefsAfterSubject + { + get => GetValue(RefsAfterSubjectProperty); + set => SetValue(RefsAfterSubjectProperty, value); + } + + protected override Size MeasureOverride(Size availableSize) + { + var refs = Children[0]; + var subject = Children[1]; + refs.Measure(availableSize); + var gap = RefsAfterSubject && refs.DesiredSize.Width > 0 ? 4 : 0; + subject.Measure(new Size(Math.Max(0, availableSize.Width - refs.DesiredSize.Width - gap), availableSize.Height)); + return new Size(refs.DesiredSize.Width + subject.DesiredSize.Width + gap, + Math.Max(refs.DesiredSize.Height, subject.DesiredSize.Height)); + } + + protected override Size ArrangeOverride(Size finalSize) + { + var refs = Children[0]; + var subject = Children[1]; + var refsWidth = Math.Min(refs.DesiredSize.Width, finalSize.Width); + var gap = RefsAfterSubject && refsWidth > 0 ? 4 : 0; + var subjectWidth = Math.Max(0, finalSize.Width - refsWidth - gap); + if (RefsAfterSubject) + { + subjectWidth = Math.Min(subjectWidth, subject.DesiredSize.Width); + subject.Arrange(new Rect(0, 0, subjectWidth, finalSize.Height)); + refs.Arrange(new Rect(subjectWidth + gap, 0, refsWidth, finalSize.Height)); + } + else + { + refs.Arrange(new Rect(0, 0, refsWidth, finalSize.Height)); + subject.Arrange(new Rect(refsWidth, 0, subjectWidth, finalSize.Height)); + } + + return finalSize; + } + } +} diff --git a/src/Views/CommitRefsPresenter.cs b/src/Views/CommitRefsPresenter.cs index 85e33962a1..798298bb93 100644 --- a/src/Views/CommitRefsPresenter.cs +++ b/src/Views/CommitRefsPresenter.cs @@ -73,6 +73,7 @@ public class RenderItem public IBrush Brush { get; set; } = null; public bool IsHead { get; set; } = false; public double Width { get; set; } = 0.0; + public bool HasMatchingRemote { get; set; } = false; public List Remotes { get; set; } = []; } @@ -157,6 +158,40 @@ public bool ShowTags set => SetValue(ShowTagsProperty, value); } + public static readonly StyledProperty ShowBranchesProperty = + AvaloniaProperty.Register(nameof(ShowBranches), true); + + public bool ShowBranches + { + get => GetValue(ShowBranchesProperty); + set => SetValue(ShowBranchesProperty, value); + } + + public static readonly StyledProperty ShowBranchSummaryProperty = + AvaloniaProperty.Register(nameof(ShowBranchSummary)); + + public bool ShowBranchSummary + { + get => GetValue(ShowBranchSummaryProperty); + set => SetValue(ShowBranchSummaryProperty, value); + } + + public static readonly StyledProperty> BranchSummaryRefsProperty = + AvaloniaProperty.Register>(nameof(BranchSummaryRefs)); + + public List BranchSummaryRefs + { + get => GetValue(BranchSummaryRefsProperty); + set => SetValue(BranchSummaryRefsProperty, value); + } + + public double BranchSummaryRight => ShowBranchSummary && _items.Count > 0 ? _items[0].Width + 1.5 : 0; + public IBrush BranchSummaryBrush => ShowBranchSummary && _items.Count > 0 ? _items[0].Brush : null; + public double BranchSummarySuffixWidth => _branchCountLabel != null ? _branchCountLabel.WidthIncludingTrailingWhitespace + 8 : 0; + public bool BranchSummaryHasRemote => ShowBranchSummary && _items.Count > 0 && _items[0].HasMatchingRemote; + public string BranchSummaryText => _summaryText; + public int BranchSummaryAdditionalCount => _summaryAdditionalCount; + public Models.Decorator DecoratorAt(Point point) { var x = 0.0; @@ -194,6 +229,7 @@ public override void Render(DrawingContext context) y += 20.0; } + var iconWidth = item.HasMatchingRemote ? 28 : 16; var entireRect = new RoundedRect(new Rect(x, y, item.Width, 16), new CornerRadius(4)); if (item.IsHead) { @@ -211,13 +247,13 @@ public override void Render(DrawingContext context) if (bg != null) context.DrawRectangle(bg, null, entireRect); - var labelRect = new RoundedRect(new Rect(x + 16, y, item.Width - 16, 16), new CornerRadius(4, 0, 0, 4)); + var labelRect = new RoundedRect(new Rect(x + iconWidth, y, item.Width - iconWidth, 16), new CornerRadius(4, 0, 0, 4)); using (context.PushOpacity(.2)) context.DrawRectangle(item.Brush, null, labelRect); } - context.DrawLine(new Pen(item.Brush), new Point(x + 16, y), new Point(x + 16, y + 16)); - context.DrawText(item.Label, new Point(x + 20, y + 8.0 - item.Label.Height * 0.5)); + context.DrawLine(new Pen(item.Brush), new Point(x + iconWidth, y), new Point(x + iconWidth, y + 16)); + context.DrawText(item.Label, new Point(x + iconWidth + 4, y + 8.0 - item.Label.Height * 0.5)); if (item.Remotes.Count > 0) { @@ -244,10 +280,19 @@ public override void Render(DrawingContext context) context.DrawRectangle(null, new Pen(item.Brush), entireRect); - var icon = CommitRefsIconCache.Instance.GetIcon(item.Decorator.Type); + var icon = CommitRefsIconCache.Instance.GetIcon(item.HasMatchingRemote ? Models.DecoratorType.LocalBranchHead : item.Decorator.Type); using (context.PushTransform(Matrix.CreateTranslation(x + 3, y + 3))) context.DrawGeometry(fg, null, icon); + if (item.HasMatchingRemote) + { + using (context.PushTransform(Matrix.CreateTranslation(x + 15, y + 3))) + context.DrawGeometry(fg, null, remoteIcon); + } + + if (_branchCountLabel != null) + context.DrawText(_branchCountLabel, new Point(x + item.Width + 4, y + 8 - _branchCountLabel.Height * 0.5)); + x += item.Width + 4; } } @@ -263,24 +308,80 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang change.Property == UseCompactBranchNamesProperty || change.Property == HasSingleRemoteProperty || change.Property == BackgroundProperty || - change.Property == ShowTagsProperty) + change.Property == ShowTagsProperty || + change.Property == ShowBranchSummaryProperty || + change.Property == ShowBranchesProperty || + change.Property == BranchSummaryRefsProperty) InvalidateMeasure(); } protected override void OnDataContextChanged(EventArgs e) { base.OnDataContextChanged(e); + if (BranchSummaryRefs != null) + { + SetCurrentValue(BranchSummaryRefsProperty, null); + SetCurrentValue(OpacityProperty, 1.0); + } InvalidateMeasure(); } protected override Size MeasureOverride(Size availableSize) { _items.Clear(); + _branchCountLabel = null; + _summaryText = string.Empty; + _summaryName = string.Empty; + _summaryAdditionalCount = 0; - if (DataContext is not Models.Commit commit) - return new Size(0, 0); + var commit = DataContext as Models.Commit; + List refs = DataContext switch + { + Models.Commit c => c.Decorators, + Models.Decorator decorator => [decorator], + _ => [], + }; + if (ShowBranchSummary && BranchSummaryRefs != null) + refs = BranchSummaryRefs; + var branchCount = 0; + var primaryHasRemote = false; + if (ShowBranchSummary) + { + Models.Decorator primary = null; + var localNames = new HashSet(StringComparer.Ordinal); + foreach (var decorator in refs) + { + if (decorator.IsBranch) + { + if (decorator.Type is Models.DecoratorType.CurrentBranchHead or Models.DecoratorType.LocalBranchHead) + localNames.Add(decorator.Name); + if (primary == null || decorator.Type < primary.Type) + primary = decorator; + } + } + + foreach (var decorator in refs) + { + if (!decorator.IsBranch) + continue; - var refs = commit.Decorators; + if (decorator.Type == Models.DecoratorType.RemoteBranchHead) + { + var slash = decorator.Name.IndexOf('/'); + var name = slash >= 0 ? decorator.Name.Substring(slash + 1) : decorator.Name; + if (slash >= 0 && localNames.Contains(name)) + { + primaryHasRemote |= primary?.Name == name; + continue; + } + } + + branchCount++; + } + + _summaryAdditionalCount = Math.Max(0, branchCount - 1); + refs = primary != null ? [primary] : []; + } var count = refs.Count; if (count == 0) { @@ -294,12 +395,16 @@ protected override Size MeasureOverride(Size availableSize) var typefaceHead = new Typeface(FontFamily, FontStyle.Normal, FontWeight.Bold); var typefaceRemote = new Typeface(FontFamily, FontStyle.Italic, FontWeight.Bold); var fg = Foreground; - var normalBG = UseGraphColor ? Models.CommitGraph.Pens[commit.Color].Brush : Brushes.Gray; + var normalBG = UseGraphColor && commit != null ? Models.CommitGraph.Pens[commit.Color].Brush : Brushes.Gray; var labelSize = FontSize; + if (branchCount > 1) + _branchCountLabel = new FormattedText($"+{branchCount - 1}", CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, typeface, labelSize, fg); var requiredHeight = 16.0; var x = 0.0; var allowWrap = AllowWrap; var showTags = ShowTags; + var showBranches = ShowBranches; var skippedIdx = new HashSet(); for (var i = 0; i < count; i++) @@ -308,6 +413,8 @@ protected override Size MeasureOverride(Size availableSize) continue; var decorator = refs[i]; + if (!showBranches && decorator.IsBranch) + continue; if (!showTags && decorator.Type == Models.DecoratorType.Tag) continue; @@ -316,13 +423,15 @@ protected override Size MeasureOverride(Size availableSize) Decorator = decorator, Brush = decorator.Type == Models.DecoratorType.Tag ? Brushes.Gray : normalBG, IsHead = decorator.Type is Models.DecoratorType.CurrentBranchHead or Models.DecoratorType.CurrentCommitHead, + HasMatchingRemote = ShowBranchSummary && primaryHasRemote, }; _items.Add(item); + var label = decorator.Name; if (item.IsHead) { item.Label = new FormattedText( - decorator.Name, + label, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typefaceHead, @@ -332,7 +441,7 @@ protected override Size MeasureOverride(Size availableSize) else { item.Label = new FormattedText( - decorator.Name, + label, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, @@ -341,8 +450,13 @@ protected override Size MeasureOverride(Size availableSize) } item.Width = item.Label.Width + 24; + if (ShowBranchSummary) + { + _summaryName = decorator.Name; + FitBranchSummary(availableSize.Width); + } - var findRemotes = useCompactBranchNames && (decorator.Type == Models.DecoratorType.CurrentBranchHead || decorator.Type == Models.DecoratorType.LocalBranchHead); + var findRemotes = !ShowBranchSummary && useCompactBranchNames && (decorator.Type == Models.DecoratorType.CurrentBranchHead || decorator.Type == Models.DecoratorType.LocalBranchHead); if (findRemotes) { for (var j = i + 1; j < count; j++) @@ -399,9 +513,62 @@ protected override Size MeasureOverride(Size availableSize) } InvalidateVisual(); - return new Size(requiredWidth, requiredHeight); + if (ShowBranchSummary && _items.Count > 0) + return new Size(_items[0].Width + SummaryOuterSpacing + BranchSummarySuffixWidth, requiredHeight); + return new Size(requiredWidth + BranchSummarySuffixWidth, requiredHeight); } private List _items = new List(); + private FormattedText _branchCountLabel; + private string _summaryName = string.Empty; + private string _summaryText = string.Empty; + private int _summaryAdditionalCount; + private const double SummaryOuterSpacing = 3; + + protected override Size ArrangeOverride(Size finalSize) + { + if (ShowBranchSummary && _items.Count > 0) + { + FitBranchSummary(finalSize.Width); + InvalidateVisual(); + } + + return base.ArrangeOverride(finalSize); + } + + private void FitBranchSummary(double availableWidth) + { + var item = _items[0]; + var fixedWidth = item.HasMatchingRemote ? 36 : 24; + var textWidth = Math.Max(0, availableWidth - BranchSummarySuffixWidth - fixedWidth - SummaryOuterSpacing); + var typeface = new Typeface(FontFamily, FontStyle.Normal, item.IsHead ? FontWeight.Bold : FontWeight.Normal); + var fontSize = FontSize + (item.IsHead ? 1 : 0); + FormattedText Format(string value) => new(value, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, fontSize, Foreground); + + _summaryText = _summaryName; + item.Label = Format(_summaryText); + if (item.Label.WidthIncludingTrailingWhitespace > textWidth) + { + // Truncate at grapheme boundaries so Unicode branch names remain valid. + var boundaries = StringInfo.ParseCombiningCharacters(_summaryName); + var low = 0; + var high = boundaries.Length; + while (low < high) + { + var mid = (low + high + 1) / 2; + var end = mid < boundaries.Length ? boundaries[mid] : _summaryName.Length; + if (Format(_summaryName.Substring(0, end) + "...").WidthIncludingTrailingWhitespace <= textWidth) + low = mid; + else + high = mid - 1; + } + + var prefixEnd = low < boundaries.Length ? boundaries[low] : _summaryName.Length; + _summaryText = Format("...").WidthIncludingTrailingWhitespace <= textWidth ? _summaryName.Substring(0, prefixEnd) + "..." : string.Empty; + item.Label = Format(_summaryText); + } + + item.Width = item.Label.WidthIncludingTrailingWhitespace + fixedWidth; + } } } diff --git a/src/Views/CommitSubjectPresenter.cs b/src/Views/CommitSubjectPresenter.cs index 6949072746..0405bd00ed 100644 --- a/src/Views/CommitSubjectPresenter.cs +++ b/src/Views/CommitSubjectPresenter.cs @@ -119,6 +119,26 @@ public AvaloniaList IssueTrackers set => SetAndRaise(IssueTrackersProperty, ref _issueTrackers, value); } + protected override Size MeasureOverride(Size availableSize) + { + if (_needRebuildInlines) + { + _needRebuildInlines = false; + GenerateFormattedTextElements(); + } + + var width = 0.0; + var height = 0.0; + foreach (var inline in _inlines) + { + var padding = inline.Element is { Type: Models.InlineElementType.Code } ? 8 : 0; + width = inline.X + inline.Text.WidthIncludingTrailingWhitespace + padding; + height = Math.Max(height, inline.Text.Height); + } + + return new Size(Math.Min(width, availableSize.Width), Math.Min(height, availableSize.Height)); + } + public override void Render(DrawingContext context) { if (_needRebuildInlines) @@ -174,6 +194,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { _needRebuildInlines = true; GenerateInlineElements(); + InvalidateMeasure(); InvalidateVisual(); } else if (change.Property == IssueTrackersProperty) @@ -193,6 +214,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang change.Property == LinkForegroundProperty) { _needRebuildInlines = true; + InvalidateMeasure(); InvalidateVisual(); } else if (change.Property == InlineCodeBackgroundProperty || @@ -243,6 +265,7 @@ private void OnIssueTrackersChanged(object sender, NotifyCollectionChangedEventA { _needRebuildInlines = true; GenerateInlineElements(); + InvalidateMeasure(); InvalidateVisual(); } diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index a3d24529fd..74d8e25acd 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -103,7 +103,7 @@ - + @@ -113,7 +113,7 @@ - + - - - + + + + + @@ -239,10 +242,42 @@ + + + + + + + + + + + + + - + + + + + { + histories.IsBranchesColumnVisible = !histories.IsBranchesColumnVisible; + ev.Handled = true; + }; + var authorColumn = new MenuItem(); authorColumn.Header = App.Text("Histories.Header.Author"); if (histories.IsAuthorColumnVisible) @@ -742,6 +802,7 @@ private void OnOpenAdvancedOptions(object sender, RoutedEventArgs e) menu.Items.Add(currentBranchAndSelectedCommits); menu.Items.Add(new MenuItem() { Header = "-" }); menu.Items.Add(columnsHeader); + menu.Items.Add(branchesColumn); menu.Items.Add(authorColumn); menu.Items.Add(shaColumn); menu.Items.Add(authorTimeColumn); @@ -778,13 +839,110 @@ private void OnCommitListLayoutUpdated(object _1, EventArgs _2) IsScrollToTopVisible = startY >= rowHeight; - var clipWidth = dataGrid.Columns[0].ActualWidth - 4; + var graphColumn = dataGrid.Columns[0]; + double graphOffset = 0; + foreach (var column in dataGrid.Columns) + { + if (column.IsVisible && column.DisplayIndex < graphColumn.DisplayIndex) + graphOffset += column.ActualWidth; + } + if (Math.Abs(CommitGraph.Margin.Left - graphOffset) > 0.01) + CommitGraph.Margin = new Thickness(graphOffset, 24, 0, 0); + if (Math.Abs(HistoryHeaderTools.Margin.Left - graphOffset) > 0.01) + HistoryHeaderTools.Margin = new Thickness(graphOffset, 0, 0, 0); + + var branchesColumn = dataGrid.Columns[5]; + var branchesRight = branchesColumn.ActualWidth; + foreach (var column in dataGrid.Columns) + { + if (column.IsVisible && column.DisplayIndex < branchesColumn.DisplayIndex) + branchesRight += column.ActualWidth; + } + var branchesResizeOffset = Math.Max(0, branchesRight - 2); + if (Math.Abs(BranchesColumnResizeHandle.Margin.Left - branchesResizeOffset) > 0.01) + BranchesColumnResizeHandle.Margin = new Thickness(branchesResizeOffset, 0, 0, 0); + + var clipWidth = Math.Max(0, dataGrid.Columns[0].ActualWidth - 4); var lastLayout = CommitGraph.Layout; if (lastLayout == null || Math.Abs(lastLayout.StartY - startY) > 0.01 || Math.Abs(lastLayout.ClipWidth - clipWidth) > 0.01 || Math.Abs(lastLayout.RowHeight - rowHeight) > 0.01) CommitGraph.Layout = new(startY, clipWidth, rowHeight); + + UpdateBranchConnections(rowsPresenter, graphColumn, startY, rowHeight); + } + + private void UpdateBranchConnections(DataGridRowsPresenter rowsPresenter, DataGridColumn graphColumn, double startY, double rowHeight) + { + var connections = new List(); + var branchesColumn = CommitListContainer.Columns[5]; + var adjacent = branchesColumn.IsVisible && branchesColumn.DisplayIndex < graphColumn.DisplayIndex; + foreach (var column in CommitListContainer.Columns) + { + if (column.IsVisible && column.DisplayIndex > branchesColumn.DisplayIndex && column.DisplayIndex < graphColumn.DisplayIndex) + adjacent = false; + } + + if (adjacent && CommitGraph.Graph is { } graph) + { + foreach (var child in rowsPresenter.Children) + { + if (child is not DataGridRow { IsVisible: true, DataContext: Models.Commit } row || + row.Index < 0 || row.Index >= graph.Dots.Count) + continue; + + foreach (var visual in row.GetVisualDescendants()) + { + if (visual is not CommitRefsPresenter { ShowBranchSummary: true, IsEffectivelyVisible: true } label || + label.BranchSummaryBrush == null || label.BranchSummaryRefs != null) + continue; + + var dot = graph.Dots[row.Index]; + var end = CommitGraph.TranslatePoint(new Point(dot.Center.X, dot.Center.Y * rowHeight - startY), BranchConnections); + var start = label.TranslatePoint(new Point(System.Math.Min(label.BranchSummaryRight, label.Bounds.Width), 0), BranchConnections); + if (start.HasValue && end.HasValue && start.Value.X < end.Value.X && + end.Value.Y >= 0 && end.Value.Y < BranchConnections.Bounds.Height) + { + var branch = label.DecoratorAt(new Point(0, 0)); + var active = branch?.Type == Models.DecoratorType.CurrentBranchHead || + (branch?.Type == Models.DecoratorType.LocalBranchHead && + DataContext is ViewModels.Histories { CurrentBranch: { IsDetachedHead: false } current } && + string.Equals(branch.Name, current.Name, StringComparison.Ordinal)); + connections.Add(new(new Point(start.Value.X, end.Value.Y), end.Value, label.BranchSummaryBrush, active, label.BranchSummarySuffixWidth)); + } + break; + } + } + } + + BranchConnections.UpdateConnections(connections); + } + + private void OnBranchCellPointerEntered(object sender, PointerEventArgs e) + { + if (sender is not Border { DataContext: Models.Commit commit, Child: CommitRefsPresenter label } border || + commit.Decorators.Exists(x => x.IsBranch) || commit.GraphBranches.Count == 0) + return; + + // Ancestors belong to the branch but are not its HEAD. + label.BranchSummaryRefs = commit.GraphBranches.ConvertAll(x => new Models.Decorator + { + Name = x.Name, + Type = x.Type == Models.DecoratorType.CurrentBranchHead ? Models.DecoratorType.LocalBranchHead : x.Type, + }); + label.Opacity = 0.7; + border.SetCurrentValue(ToolTip.TipProperty, string.Join("\n", commit.GraphBranches.ConvertAll(x => x.Name))); + } + + private void OnBranchCellPointerExited(object sender, PointerEventArgs e) + { + if (sender is Border { Child: CommitRefsPresenter { BranchSummaryRefs: not null } label } border) + { + label.BranchSummaryRefs = null; + label.Opacity = 1; + border.SetCurrentValue(ToolTip.TipProperty, (border.DataContext as Models.Commit)?.BranchToolTip); + } } private void OnScrollToTopPointerPressed(object sender, PointerPressedEventArgs e) @@ -1970,6 +2128,9 @@ private async Task InteractiveRebaseWithPrefillActionAsync(ViewModels.Repository private bool _isScrollToTopVisible = false; private bool _isDetailsPanelExpanded = true; private bool _resizingAuthorColumn = false; + private bool _resizingBranchesColumn = false; + private double _branchesResizeStartX = 0; + private double _branchesResizeStartWidth = 0; private Cursor _resizingCursor = new(StandardCursorType.SizeWestEast); } } diff --git a/tests/BranchColumnChecks/BranchColumnChecks.csproj b/tests/BranchColumnChecks/BranchColumnChecks.csproj new file mode 100644 index 0000000000..fd214d7b72 --- /dev/null +++ b/tests/BranchColumnChecks/BranchColumnChecks.csproj @@ -0,0 +1,10 @@ + + + Exe + net10.0 + enable + + + + + diff --git a/tests/BranchColumnChecks/Program.cs b/tests/BranchColumnChecks/Program.cs new file mode 100644 index 0000000000..e00037cccb --- /dev/null +++ b/tests/BranchColumnChecks/Program.cs @@ -0,0 +1,254 @@ +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using Avalonia.VisualTree; +using SourceGit; +using SourceGit.Models; +using SourceGit.ViewModels; +using Views = SourceGit.Views; + +internal static class Program +{ + private static int _checks; + + [STAThread] + private static void Main(string[] args) + { + var fixture = Path.Combine(Path.GetTempPath(), "sourcegit-branches-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(fixture); + SourceGit.Native.OS.BasicDirectories.ConfigDir = fixture; + SourceGit.Native.OS.BasicDirectories.CacheDir = fixture; + Repository repo = null; + Window window = null; + try + { + App.BuildAvaloniaApp().SetupWithoutStarting(); + App.SetLocale("en_US"); + CheckBranchSummaryRendering(); + CheckGraphBranchContext(); + Git(fixture, "init -b main"); + Git(fixture, "config user.name Test"); + Git(fixture, "config user.email test@example.com"); + Git(fixture, "commit --allow-empty -m ancestor"); + Git(fixture, "commit --allow-empty -m subject"); + Git(fixture, "tag v1"); + Git(fixture, "update-ref refs/remotes/origin/main HEAD"); + repo = new Repository(false, fixture, Path.Combine(fixture, ".git")); + repo.Open(); + window = new Window { Title = "SourceGit Branch Column Checks", Width = 1100, Height = 760, + Content = new Views.Repository { DataContext = repo } }; + window.Show(); + PumpUntil(() => !repo.Histories.IsLoading && repo.Histories.Commits.Count == 2); + CheckColumn(repo, window, fixture, args.Contains("--render")); + Console.WriteLine($"PASS: {_checks} branch-column checks"); + } + finally + { + window?.Close(); + repo?.Close(); + for (var attempt = 0; attempt < 10; attempt++) + { + try + { + if (!Path.GetFullPath(fixture).StartsWith(Path.GetFullPath(Path.GetTempPath()), StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Fixture must stay inside the temporary directory"); + foreach (var file in Directory.EnumerateFiles(fixture, "*", SearchOption.AllDirectories)) + File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(fixture, true); + break; + } + catch (IOException) when (attempt < 9) { Thread.Sleep(50); } + catch (UnauthorizedAccessException) when (attempt < 9) { Thread.Sleep(50); } + } + } + } + + private static void CheckBranchSummaryRendering() + { + var commit = new Commit(); + commit.ParseDecorators("HEAD -> refs/heads/main, refs/remotes/origin/main, tag: refs/tags/v1"); + var label = new Views.CommitRefsPresenter { ShowBranchSummary = true, FontSize = 12, DataContext = commit }; + label.Measure(new Size(double.PositiveInfinity, 26)); + Check(label.BranchSummaryHasRemote && label.BranchSummaryText == "main", "matching local and remote use one local badge with both icons"); + Check(label.BranchSummaryAdditionalCount == 0 && label.BranchSummarySuffixWidth == 0, "matching local and remote count as one label"); + Check(Math.Abs(label.DesiredSize.Width - label.BranchSummaryRight - 1.5) <= 1, + "single summary badge reserves no count space or multi-label gutter"); + var pairedWidth = label.BranchSummaryRight; + var local = new Commit(); + local.ParseDecorators("HEAD -> refs/heads/main"); + label.DataContext = local; + label.Measure(new Size(double.PositiveInfinity, 26)); + Check(Math.Abs(pairedWidth - label.BranchSummaryRight - 12) < 0.01, "combined badge reserves compact space for the cloud icon"); + label.DataContext = commit; + commit.ParseDecorators("refs/heads/topic, refs/remotes/origin/topic, refs/remotes/upstream/topic, refs/remotes/origin/other"); + label.Measure(new Size(double.PositiveInfinity, 26)); + Check(label.BranchSummaryAdditionalCount == 2, "extra count groups matching branches even when they are not the displayed label"); + Check(Math.Abs(label.DesiredSize.Width - label.BranchSummaryRight - 1.5 - label.BranchSummarySuffixWidth) <= 1, + "summary reserves postfix space only when additional labels exist"); + Check(commit.BranchToolTip.Contains("upstream/topic") && commit.BranchToolTip.Contains("origin/main"), "tooltip retains every grouped ref name"); + + var remoteOnly = new Commit(); + remoteOnly.ParseDecorators("refs/remotes/origin/topic, refs/remotes/upstream/topic"); + label.DataContext = remoteOnly; + label.Measure(new Size(double.PositiveInfinity, 26)); + Check(!label.BranchSummaryHasRemote && label.BranchSummaryAdditionalCount == 1, "remote refs stay separate when no matching local is on the commit"); + Check(label.BranchSummaryText == "origin/topic", "remote-only labels retain the remote prefix"); + + var longName = "feature/" + string.Concat(Enumerable.Repeat("árvíztűrő-e\u0301-😀/", 15)); + var longCommit = new Commit(); + longCommit.ParseDecorators($"HEAD -> refs/heads/{longName}, refs/remotes/origin/{longName}, refs/heads/other"); + label.DataContext = longCommit; + label.Measure(new Size(84, 26)); + label.Arrange(new Rect(0, 0, 84, 26)); + Check(label.BranchSummaryText.EndsWith("...", StringComparison.Ordinal), "long branch names use a trailing ellipsis"); + Check(label.BranchSummaryRight + label.BranchSummarySuffixWidth <= 84, "badge and external count fit inside a minimum-width column"); + Check(label.BranchSummaryHasRemote && label.BranchSummaryAdditionalCount == 1, "truncation preserves icons and the grouped count"); + Check(!label.BranchSummaryText.EnumerateRunes().Any(x => x.Value == 0xFFFD), "truncation preserves Unicode characters"); + Check(longCommit.BranchToolTip.Contains(longName), "tooltip retains the full untruncated branch name"); + label.Measure(new Size(4096, 26)); + label.Arrange(new Rect(0, 0, 4096, 26)); + Check(label.BranchSummaryText == longName, "widening the column restores the full name"); + label.Arrange(new Rect(0, 0, 84, 26)); + Check(label.BranchSummaryText.EndsWith("...", StringComparison.Ordinal) && label.BranchSummaryRight + label.BranchSummarySuffixWidth <= 84, + "arrange also constrains a badge measured at a larger width"); + label.ShowBranchSummary = false; + label.Measure(new Size(double.PositiveInfinity, 26)); + Check(!label.BranchSummaryHasRemote && label.BranchSummarySuffixWidth == 0 && label.DesiredSize.Width > 84, + "other ref presenters retain their existing rendering behavior"); + } + + private static void CheckGraphBranchContext() + { + var root = new Commit { SHA = "root" }; + var mainParent = new Commit { SHA = "main-parent", Parents = [root.SHA] }; + var featureParent = new Commit { SHA = "feature-parent", Parents = [root.SHA] }; + var feature = new Commit { SHA = "feature", Parents = [featureParent.SHA] }; + feature.ParseDecorators("refs/heads/feature, refs/remotes/origin/feature"); + var main = new Commit { SHA = "main", Parents = [mainParent.SHA, feature.SHA] }; + main.ParseDecorators("HEAD -> refs/heads/main"); + CommitGraph.Generate([main, feature, mainParent, featureParent, root], false, CommitGraphHighlighting.All, []); + Check(mainParent.GraphBranches.Single().Name == "main", "main ancestry inherits the main lane branch"); + Check(featureParent.GraphBranches.Select(x => x.Name).SequenceEqual(new[] { "feature", "origin/feature" }), + "merged feature ancestry inherits its own lane branch and remote"); + Check(root.GraphBranches.Single().Name == "main", "shared ancestry follows the active branch lane"); + CommitGraph.Generate([root], false, CommitGraphHighlighting.All, []); + Check(root.GraphBranches.Count == 0, "graph regeneration clears branch context when no known branch reaches a commit"); + } + + + private static void CheckColumn(Repository repo, Window window, string fixture, bool render) + { + var vm = repo.Histories; + var list = window.GetVisualDescendants().OfType().Single(); + var column = list.Columns[5]; + var graph = window.GetVisualDescendants().OfType().Single(); + var connections = window.GetVisualDescendants().OfType().Single(); + Check(!vm.IsBranchesColumnVisible && !column.IsVisible, "branch column defaults to hidden"); + Check(column.DisplayIndex == 0 && list.Columns[0].DisplayIndex == 1, "branch column is before the graph without changing source indexes"); + vm.IsBranchesColumnVisible = true; + PumpUntil(() => column.IsVisible && column.ActualWidth > 0 && graph.Margin.Left == column.ActualWidth); + var tip = vm.Commits.Single(x => x.IsCurrentHead); + var ancestor = vm.Commits.Single(x => !x.IsCurrentHead); + var tipRow = list.GetVisualDescendants().OfType().Single(x => ReferenceEquals(x.DataContext, tip)); + var tipCell = tipRow.GetVisualDescendants().OfType().Single(x => x.Child is Views.CommitRefsPresenter { ShowBranchSummary: true }); + var label = (Views.CommitRefsPresenter)tipCell.Child; + Check(label.BranchSummaryText == "main" && label.BranchSummaryHasRemote && label.BranchSummaryAdditionalCount == 0, + "real history combines local and remote branch labels"); + Check(ToolTip.GetTip(tipCell) as string == "main\norigin/main", "branch cell tooltip retains all full ref names"); + var message = tipRow.GetVisualDescendants().OfType().Single(); + var messageRefs = (Views.CommitRefsPresenter)message.Children[0]; + Check(!messageRefs.ShowBranches && messageRefs.DecoratorAt(new Point(4, 4))?.Type == DecoratorType.Tag, + "branch column hides message branch labels while preserving tags"); + Check(message.Children[0].Bounds.Left == message.Children[1].Bounds.Right + 4, "tags follow immediately after the subject"); + PumpUntil(() => connections.Connections.Count == 1); + var connection = connections.Connections[0]; + Check(connection.IsActive && connection.Start.Y == connection.End.Y, "active branch connects horizontally at full opacity"); + var dot = vm.Graph.Dots[0]; + var expectedEnd = graph.TranslatePoint(new Point(dot.Center.X, dot.Center.Y * graph.Layout.RowHeight - graph.Layout.StartY), connections).Value; + Check(connection.End == expectedEnd, "connection ends at the commit graph dot"); + + var row = list.GetVisualDescendants().OfType().Single(x => ReferenceEquals(x.DataContext, ancestor)); + var cell = row.GetVisualDescendants().OfType().Single(x => x.Child is Views.CommitRefsPresenter { ShowBranchSummary: true }); + var hover = (Views.CommitRefsPresenter)cell.Child; + Check(hover.BranchSummaryRight == 0, "unlabeled commits stay blank before hover"); + var pointer = new Pointer(8, PointerType.Mouse, true); + var position = cell.TranslatePoint(new Point(10, 10), window).Value; + cell.RaiseEvent(new PointerEventArgs(InputElement.PointerEnteredEvent, cell, pointer, window, position, 0, + new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.Other), KeyModifiers.None)); + PumpUntil(() => hover.BranchSummaryText == "main"); + Check(hover.Opacity == 0.7 && hover.BranchSummaryHasRemote, "hover badge uses lane identity, combined icons, and 70 percent opacity"); + Check(hover.DecoratorAt(new Point(4, 4))?.Type == DecoratorType.LocalBranchHead && ancestor.Decorators.Count == 0, + "hover never invents HEAD or branch refs on an ancestor"); + Check(connections.Connections.Count == 1, "hover badge never adds a connector"); + if (render) + { + using var bitmap = new RenderTargetBitmap(new PixelSize(1100, 760)); + bitmap.Render(window); + bitmap.Save(Path.Combine(AppContext.BaseDirectory, "preview-branches.png")); + } + cell.RaiseEvent(new PointerEventArgs(InputElement.PointerExitedEvent, cell, pointer, window, position, 0, + new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.Other), KeyModifiers.None)); + PumpUntil(() => hover.BranchSummaryRight == 0); + Check(hover.BranchSummaryRefs == null && ToolTip.GetTip(cell) == null, "leaving the branch cell clears the hover badge and tooltip"); + + var handle = window.GetVisualDescendants().OfType().Single(x => x.Name == "BranchesColumnResizeHandle"); + var start = handle.TranslatePoint(new Point(2, 12), window).Value; + var end = start + new Vector(60, 0); + var originalWidth = column.ActualWidth; + Check(ReferenceEquals(window.InputHitTest(start), handle), "branch resize handle is reachable"); + handle.RaiseEvent(new PointerPressedEventArgs(handle, pointer, window, start, 0, + new PointerPointProperties(RawInputModifiers.LeftMouseButton, PointerUpdateKind.LeftButtonPressed), KeyModifiers.None)); + handle.RaiseEvent(new PointerEventArgs(InputElement.PointerMovedEvent, handle, pointer, window, end, 0, + new PointerPointProperties(RawInputModifiers.LeftMouseButton, PointerUpdateKind.Other), KeyModifiers.None)); + handle.RaiseEvent(new PointerReleasedEventArgs(handle, pointer, window, end, 0, + new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.LeftButtonReleased), KeyModifiers.None, MouseButton.Left)); + PumpUntil(() => column.ActualWidth > originalWidth + 50); + Check(vm.BranchesColumnWidth == column.ActualWidth, "dragging the branch header saves its width"); + PumpUntil(() => connections.Connections[0].End.X > connection.End.X + 50); + Check(Math.Abs(connections.Connections[0].End.X - connection.End.X - 60) < 0.01, "graph and connector follow column resizing"); + repo.UIStates.Save(); + var saved = RepositoryUIStates.Load(Path.Combine(fixture, ".git")); + Check(saved.IsBranchesColumnVisibleInHistory && saved.BranchesColumnWidth == column.ActualWidth, + "branch visibility and width persist per repository"); + var restored = new Views.Histories { DataContext = vm }; + Check(restored.FindControl("CommitListContainer").Columns[5].Width.Value == saved.BranchesColumnWidth, + "new history views restore the saved width"); + vm.IsBranchesColumnVisible = false; + PumpUntil(() => !column.IsVisible && graph.Margin.Left == 0 && connections.Connections.Count == 0); + Check(messageRefs.ShowBranches && messageRefs.DecoratorAt(new Point(4, 4))?.IsBranch == true, + "hiding the column restores message branch labels and graph position"); + } + + private static void PumpUntil(Func condition) + { + var timer = Stopwatch.StartNew(); + do + { + Dispatcher.UIThread.RunJobs(); + Thread.Sleep(20); + if (timer.Elapsed.TotalSeconds > 15) + throw new Exception("Timed out waiting for UI state"); + } while (!condition()); + Dispatcher.UIThread.RunJobs(); + } + + private static void Check(bool condition, string name) + { + if (!condition) + throw new Exception("FAIL: " + name); + _checks++; + } + + private static void Git(string path, string args) + { + using var process = Process.Start(new ProcessStartInfo("git", args) { WorkingDirectory = path, + UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new Exception(process.StandardError.ReadToEnd()); + } +} diff --git a/tests/BranchColumnChecks/README.md b/tests/BranchColumnChecks/README.md new file mode 100644 index 0000000000..160931648c --- /dev/null +++ b/tests/BranchColumnChecks/README.md @@ -0,0 +1,9 @@ +# Branch column checks + +Run on a Windows desktop with .NET 10 and Git available: + +```powershell +dotnet run --project tests/BranchColumnChecks -p:UsedAvaloniaProducts= +``` + +The executable uses an isolated temporary Git repository and configuration directory. It checks grouped branch labels, Unicode truncation, hover behavior, tooltips, graph connections, actual header dragging, saved column settings, and restoring the original message labels. Pass `--render` to save `preview-branches.png` beside the executable. From e964897391d4da5bce0def33cc9ceaed2f517868 Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Wed, 28 Oct 2026 16:20:39 +0100 Subject: [PATCH 2/2] fix: retain branch context for hovered and selected history rows --- src/Views/CommitRefsPresenter.cs | 43 +++++++++++++------ src/Views/Histories.axaml | 16 ++++--- src/Views/Histories.axaml.cs | 28 +----------- tests/BranchColumnChecks/Program.cs | 66 ++++++++++++++++++++++++++--- tests/BranchColumnChecks/README.md | 2 +- 5 files changed, 102 insertions(+), 53 deletions(-) diff --git a/src/Views/CommitRefsPresenter.cs b/src/Views/CommitRefsPresenter.cs index 798298bb93..e34f9b7847 100644 --- a/src/Views/CommitRefsPresenter.cs +++ b/src/Views/CommitRefsPresenter.cs @@ -176,15 +176,21 @@ public bool ShowBranchSummary set => SetValue(ShowBranchSummaryProperty, value); } - public static readonly StyledProperty> BranchSummaryRefsProperty = - AvaloniaProperty.Register>(nameof(BranchSummaryRefs)); + public static readonly StyledProperty ShowBranchContextProperty = + AvaloniaProperty.Register(nameof(ShowBranchContext)); - public List BranchSummaryRefs + public bool ShowBranchContext { - get => GetValue(BranchSummaryRefsProperty); - set => SetValue(BranchSummaryRefsProperty, value); + get => GetValue(ShowBranchContextProperty); + set => SetValue(ShowBranchContextProperty, value); } + public static readonly DirectProperty BranchSummaryToolTipProperty = + AvaloniaProperty.RegisterDirect(nameof(BranchSummaryToolTip), static o => o.BranchSummaryToolTip); + + public string BranchSummaryToolTip => _branchSummaryToolTip; + public bool IsContextBranchSummary { get; private set; } + public double BranchSummaryRight => ShowBranchSummary && _items.Count > 0 ? _items[0].Width + 1.5 : 0; public IBrush BranchSummaryBrush => ShowBranchSummary && _items.Count > 0 ? _items[0].Brush : null; public double BranchSummarySuffixWidth => _branchCountLabel != null ? _branchCountLabel.WidthIncludingTrailingWhitespace + 8 : 0; @@ -311,18 +317,13 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang change.Property == ShowTagsProperty || change.Property == ShowBranchSummaryProperty || change.Property == ShowBranchesProperty || - change.Property == BranchSummaryRefsProperty) + change.Property == ShowBranchContextProperty) InvalidateMeasure(); } protected override void OnDataContextChanged(EventArgs e) { base.OnDataContextChanged(e); - if (BranchSummaryRefs != null) - { - SetCurrentValue(BranchSummaryRefsProperty, null); - SetCurrentValue(OpacityProperty, 1.0); - } InvalidateMeasure(); } @@ -341,8 +342,23 @@ protected override Size MeasureOverride(Size availableSize) Models.Decorator decorator => [decorator], _ => [], }; - if (ShowBranchSummary && BranchSummaryRefs != null) - refs = BranchSummaryRefs; + IsContextBranchSummary = ShowBranchSummary && ShowBranchContext && commit != null && + !commit.Decorators.Exists(x => x.IsBranch) && commit.GraphBranches.Count > 0; + if (IsContextBranchSummary) + { + // Show lane identity without presenting an ancestor as the branch tip. + refs = commit.GraphBranches.ConvertAll(x => new Models.Decorator + { + Name = x.Name, + Type = x.Type == Models.DecoratorType.CurrentBranchHead ? Models.DecoratorType.LocalBranchHead : x.Type, + }); + } + if (ShowBranchSummary) + { + SetCurrentValue(OpacityProperty, IsContextBranchSummary ? 0.7 : 1.0); + var names = refs.FindAll(x => x.IsBranch).ConvertAll(x => x.Name); + SetAndRaise(BranchSummaryToolTipProperty, ref _branchSummaryToolTip, names.Count > 0 ? string.Join("\n", names) : null); + } var branchCount = 0; var primaryHasRemote = false; if (ShowBranchSummary) @@ -523,6 +539,7 @@ protected override Size MeasureOverride(Size availableSize) private string _summaryName = string.Empty; private string _summaryText = string.Empty; private int _summaryAdditionalCount; + private string _branchSummaryToolTip; private const double SummaryOuterSpacing = 3; protected override Size ArrangeOverride(Size finalSize) diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index 74d8e25acd..a855749940 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -249,15 +249,21 @@ - - + + UseGraphColor="{Binding IsHighlightedInGraph, Mode=OneWay}"> + + + + + + + diff --git a/src/Views/Histories.axaml.cs b/src/Views/Histories.axaml.cs index 370ca49570..1f0de4b881 100644 --- a/src/Views/Histories.axaml.cs +++ b/src/Views/Histories.axaml.cs @@ -895,7 +895,7 @@ private void UpdateBranchConnections(DataGridRowsPresenter rowsPresenter, DataGr foreach (var visual in row.GetVisualDescendants()) { if (visual is not CommitRefsPresenter { ShowBranchSummary: true, IsEffectivelyVisible: true } label || - label.BranchSummaryBrush == null || label.BranchSummaryRefs != null) + label.BranchSummaryBrush == null || label.IsContextBranchSummary) continue; var dot = graph.Dots[row.Index]; @@ -919,32 +919,6 @@ private void UpdateBranchConnections(DataGridRowsPresenter rowsPresenter, DataGr BranchConnections.UpdateConnections(connections); } - private void OnBranchCellPointerEntered(object sender, PointerEventArgs e) - { - if (sender is not Border { DataContext: Models.Commit commit, Child: CommitRefsPresenter label } border || - commit.Decorators.Exists(x => x.IsBranch) || commit.GraphBranches.Count == 0) - return; - - // Ancestors belong to the branch but are not its HEAD. - label.BranchSummaryRefs = commit.GraphBranches.ConvertAll(x => new Models.Decorator - { - Name = x.Name, - Type = x.Type == Models.DecoratorType.CurrentBranchHead ? Models.DecoratorType.LocalBranchHead : x.Type, - }); - label.Opacity = 0.7; - border.SetCurrentValue(ToolTip.TipProperty, string.Join("\n", commit.GraphBranches.ConvertAll(x => x.Name))); - } - - private void OnBranchCellPointerExited(object sender, PointerEventArgs e) - { - if (sender is Border { Child: CommitRefsPresenter { BranchSummaryRefs: not null } label } border) - { - label.BranchSummaryRefs = null; - label.Opacity = 1; - border.SetCurrentValue(ToolTip.TipProperty, (border.DataContext as Models.Commit)?.BranchToolTip); - } - } - private void OnScrollToTopPointerPressed(object sender, PointerPressedEventArgs e) { if (DataContext is ViewModels.Histories histories) diff --git a/tests/BranchColumnChecks/Program.cs b/tests/BranchColumnChecks/Program.cs index e00037cccb..94ad6c1417 100644 --- a/tests/BranchColumnChecks/Program.cs +++ b/tests/BranchColumnChecks/Program.cs @@ -3,6 +3,7 @@ using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Input; +using Avalonia.Input.Raw; using Avalonia.Media.Imaging; using Avalonia.Threading; using Avalonia.VisualTree; @@ -135,8 +136,19 @@ private static void CheckGraphBranchContext() Check(featureParent.GraphBranches.Select(x => x.Name).SequenceEqual(new[] { "feature", "origin/feature" }), "merged feature ancestry inherits its own lane branch and remote"); Check(root.GraphBranches.Single().Name == "main", "shared ancestry follows the active branch lane"); + var label = new Views.CommitRefsPresenter { ShowBranchSummary = true, ShowBranchContext = true, FontSize = 12, DataContext = featureParent }; + label.Measure(new Size(180, 26)); + Check(label.IsContextBranchSummary && label.BranchSummaryText == "feature" && label.Opacity == 0.7, + "visible branch context uses the commit's lane identity"); + label.DataContext = feature; + label.Measure(new Size(180, 26)); + Check(!label.IsContextBranchSummary && label.Opacity == 1, "reused presenter keeps real branch-tip badges opaque"); CommitGraph.Generate([root], false, CommitGraphHighlighting.All, []); Check(root.GraphBranches.Count == 0, "graph regeneration clears branch context when no known branch reaches a commit"); + label.DataContext = root; + label.Measure(new Size(180, 26)); + Check(!label.IsContextBranchSummary && label.BranchSummaryRight == 0 && label.BranchSummaryToolTip == null, + "reused presenter clears context when the new commit has no branch identity"); } @@ -176,10 +188,15 @@ private static void CheckColumn(Repository repo, Window window, string fixture, var hover = (Views.CommitRefsPresenter)cell.Child; Check(hover.BranchSummaryRight == 0, "unlabeled commits stay blank before hover"); var pointer = new Pointer(8, PointerType.Mouse, true); - var position = cell.TranslatePoint(new Point(10, 10), window).Value; - cell.RaiseEvent(new PointerEventArgs(InputElement.PointerEnteredEvent, cell, pointer, window, position, 0, - new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.Other), KeyModifiers.None)); - PumpUntil(() => hover.BranchSummaryText == "main"); + var mouse = (MouseDevice)Activator.CreateInstance(typeof(MouseDevice), [pointer]); + var outsideRows = new Point(window.Bounds.Width - 10, window.Bounds.Height - 10); + foreach (var hoveredCell in row.GetVisualDescendants().OfType().Where(x => x.IsEffectivelyVisible && x.Bounds.Width > 0)) + { + var position = hoveredCell.TranslatePoint(new Point(hoveredCell.Bounds.Width / 2, hoveredCell.Bounds.Height / 2), window).Value; + MoveMouse(mouse, window, position); + PumpUntil(() => row.IsPointerOver && hover.BranchSummaryText == "main"); + Check(hover.IsContextBranchSummary && hover.Opacity == 0.7, "hovering any visible commit cell reveals the translucent branch badge"); + } Check(hover.Opacity == 0.7 && hover.BranchSummaryHasRemote, "hover badge uses lane identity, combined icons, and 70 percent opacity"); Check(hover.DecoratorAt(new Point(4, 4))?.Type == DecoratorType.LocalBranchHead && ancestor.Decorators.Count == 0, "hover never invents HEAD or branch refs on an ancestor"); @@ -190,10 +207,31 @@ private static void CheckColumn(Repository repo, Window window, string fixture, bitmap.Render(window); bitmap.Save(Path.Combine(AppContext.BaseDirectory, "preview-branches.png")); } - cell.RaiseEvent(new PointerEventArgs(InputElement.PointerExitedEvent, cell, pointer, window, position, 0, - new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.Other), KeyModifiers.None)); + list.SelectedItem = ancestor; + PumpUntil(() => row.IsSelected); + MoveMouse(mouse, window, outsideRows); + PumpUntil(() => !row.IsPointerOver); + Check(hover.IsContextBranchSummary && hover.BranchSummaryText == "main" && hover.Opacity == 0.7, + "selected row keeps its translucent branch badge after the pointer leaves"); + Check(connections.Connections.Count == 1, "selected context badge never adds a connector"); + list.SelectedItem = tip; + PumpUntil(() => hover.BranchSummaryRight == 0); + Check(!hover.IsContextBranchSummary && ToolTip.GetTip(cell) == null, "deselecting an unhovered row clears its context badge and tooltip"); + list.SelectedItems.Add(ancestor); + PumpUntil(() => row.IsSelected && hover.IsContextBranchSummary); + Check(hover.BranchSummaryText == "main" && list.SelectedItems.Count == 2, "context badge remains visible for a row in a multi-selection"); + list.SelectedItems.Remove(ancestor); + PumpUntil(() => hover.BranchSummaryRight == 0); + var subjectCell = row.GetVisualDescendants().OfType().Single(); + var overSubject = subjectCell.TranslatePoint(new Point(2, 12), window).Value; + MoveMouse(mouse, window, overSubject); + PumpUntil(() => hover.IsContextBranchSummary); + list.SelectedItem = ancestor; + list.SelectedItems.Clear(); + Dispatcher.UIThread.RunJobs(); + Check(row.IsPointerOver && !row.IsSelected && hover.IsContextBranchSummary, "deselection preserves the badge while the row is still hovered"); + MoveMouse(mouse, window, outsideRows); PumpUntil(() => hover.BranchSummaryRight == 0); - Check(hover.BranchSummaryRefs == null && ToolTip.GetTip(cell) == null, "leaving the branch cell clears the hover badge and tooltip"); var handle = window.GetVisualDescendants().OfType().Single(x => x.Name == "BranchesColumnResizeHandle"); var start = handle.TranslatePoint(new Point(2, 12), window).Value; @@ -223,6 +261,20 @@ private static void CheckColumn(Repository repo, Window window, string fixture, "hiding the column restores message branch labels and graph position"); } + private static void MoveMouse(MouseDevice mouse, Window window, Point position) + { + // Drive Avalonia's raw input pipeline so IsPointerOver updates on the whole visual ancestry. + var constructor = typeof(RawPointerEventArgs).GetConstructors() + .Single(x => x.GetParameters().Length == 6 && x.GetParameters()[4].ParameterType == typeof(Point)); + var input = constructor.Invoke([mouse, 0UL, window, RawPointerEventType.Move, position, RawInputModifiers.None]); + var hit = window.InputHitTest(position); + typeof(RawPointerEventArgs).GetProperty("InputHitTestResult", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic) + .SetValue(input, (hit, hit)); + var managerType = typeof(MouseDevice).Assembly.GetType("Avalonia.Input.InputManager", true); + var manager = managerType.GetProperty("Instance").GetValue(null); + managerType.GetMethod("ProcessInput").Invoke(manager, [input]); + } + private static void PumpUntil(Func condition) { var timer = Stopwatch.StartNew(); diff --git a/tests/BranchColumnChecks/README.md b/tests/BranchColumnChecks/README.md index 160931648c..40c7a81a0d 100644 --- a/tests/BranchColumnChecks/README.md +++ b/tests/BranchColumnChecks/README.md @@ -6,4 +6,4 @@ Run on a Windows desktop with .NET 10 and Git available: dotnet run --project tests/BranchColumnChecks -p:UsedAvaloniaProducts= ``` -The executable uses an isolated temporary Git repository and configuration directory. It checks grouped branch labels, Unicode truncation, hover behavior, tooltips, graph connections, actual header dragging, saved column settings, and restoring the original message labels. Pass `--render` to save `preview-branches.png` beside the executable. +The executable uses an isolated temporary Git repository and configuration directory. It checks grouped branch labels, Unicode truncation, whole-row hover, selected-row and multi-selection visibility, tooltips, graph connections, actual header dragging, saved column settings, and restoring the original message labels. Pass `--render` to save `preview-branches.png` beside the executable.