diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index de299ed6b..c45a22f5c 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 916a79b1b..3b716bc09 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 7d985e316..cc310e7ae 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 8657b93b6..6f0da49ff 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 1b1f29d8f..2b3a58306 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 128b53ff9..73898985e 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 000000000..9fc70a77d --- /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 000000000..75e62c3ed --- /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 85e33962a..e34f9b784 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,46 @@ 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 ShowBranchContextProperty = + AvaloniaProperty.Register(nameof(ShowBranchContext)); + + public bool ShowBranchContext + { + 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; + 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 +235,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 +253,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 +286,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,7 +314,10 @@ 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 == ShowBranchContextProperty) InvalidateMeasure(); } @@ -276,11 +330,74 @@ protected override void OnDataContextChanged(EventArgs e) 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], + _ => [], + }; + 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) + { + 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; + + 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++; + } - var refs = commit.Decorators; + _summaryAdditionalCount = Math.Max(0, branchCount - 1); + refs = primary != null ? [primary] : []; + } var count = refs.Count; if (count == 0) { @@ -294,12 +411,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 +429,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 +439,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 +457,7 @@ protected override Size MeasureOverride(Size availableSize) else { item.Label = new FormattedText( - decorator.Name, + label, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, @@ -341,8 +466,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 +529,63 @@ 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 string _branchSummaryToolTip; + 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 694907274..0405bd00e 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 a3d24529f..a85574994 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -103,7 +103,7 @@ - + @@ -113,7 +113,7 @@ - + - - - + + + + + @@ -239,10 +242,48 @@ + + + + + + + + + + + + + + + + + + + + - + + + + + { + 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,84 @@ 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.IsContextBranchSummary) + 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 OnScrollToTopPointerPressed(object sender, PointerPressedEventArgs e) @@ -1970,6 +2102,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 000000000..fd214d7b7 --- /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 000000000..94ad6c141 --- /dev/null +++ b/tests/BranchColumnChecks/Program.cs @@ -0,0 +1,306 @@ +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Input.Raw; +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"); + 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"); + } + + + 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 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"); + 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")); + } + 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); + + 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 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(); + 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 000000000..40c7a81a0 --- /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, 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.