From 47ca8bee540a6f20bcf609878e06324c6c3e0ddb Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 12 Oct 2026 20:20:03 +0200 Subject: [PATCH 1/3] feature: add `Avatars` commit graph style Adds a "Commit Graph Style" option in Preferences > Appearance (Classic stays the default). The `Avatars` style shows: - a resizable BRANCH / TAG column with left-aligned refs, connector lines to the commit, a `+N` badge with a dropdown for refs that do not fit, and tooltips for trimmed names - a resizable GRAPH column with author avatars (or initials) as nodes, small dots for merges, rounded straight lines and lane colored row bands - a separate COMMIT MESSAGE column, with left-aligned column headers Also: - tooltip on graph avatars with author and date - right-click on column headers opens the history options menu - align the graph overlay with the rows below the header separator Co-Authored-By: Claude Opus 5.5 --- src/Converters/CommitGraphStyleConverters.cs | 34 ++ src/Converters/DoubleConverters.cs | 6 +- src/Models/Commit.cs | 1 + src/Models/CommitGraph.cs | 14 +- src/Models/RepositoryUIStates.cs | 18 + src/Resources/Locales/en_US.axaml | 6 + src/ViewModels/Histories.cs | 28 ++ src/ViewModels/Preferences.cs | 7 + src/Views/Avatar.cs | 57 +-- src/Views/CommitGraph.cs | 372 ++++++++++++++++- src/Views/CommitRefsPresenter.cs | 170 +++++++- src/Views/Histories.axaml | 139 ++++++- src/Views/Histories.axaml.cs | 403 ++++++++++++++++--- src/Views/Preferences.axaml | 21 +- 14 files changed, 1140 insertions(+), 136 deletions(-) create mode 100644 src/Converters/CommitGraphStyleConverters.cs diff --git a/src/Converters/CommitGraphStyleConverters.cs b/src/Converters/CommitGraphStyleConverters.cs new file mode 100644 index 0000000000..ffa8505b13 --- /dev/null +++ b/src/Converters/CommitGraphStyleConverters.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +using Avalonia; +using Avalonia.Data.Converters; + +namespace SourceGit.Converters +{ + public static class CommitGraphStyleConverters + { + public static readonly FuncValueConverter IsClassic = + new(v => v == Models.CommitGraphStyle.Classic); + + public static readonly FuncValueConverter IsAvatars = + new(v => v == Models.CommitGraphStyle.Avatars); + + public static readonly FuncValueConverter ToRowHeight = + new(v => v == Models.CommitGraphStyle.Avatars ? 28 : 26); + + // Places a hit-area centered on the commit's avatar in the graph column. + public static readonly FuncValueConverter ToAvatarHitAreaMargin = + new(v => new Thickness(Views.CommitGraph.GetAvatarsStyleX(v) - 12, 0, 0, 0)); + + // Values: [Commit.LeftMargin, CommitGraphStyle]. Only the classic style draws the graph under the subject. + public static readonly FuncMultiValueConverter ToSubjectMargin = + new(v => + { + var values = new List(v); + if (values.Count == 2 && values[0] is double margin && values[1] is Models.CommitGraphStyle.Classic) + return new Thickness(margin, 0, 0, 0); + + return new Thickness(4, 0, 0, 0); + }); + } +} diff --git a/src/Converters/DoubleConverters.cs b/src/Converters/DoubleConverters.cs index 871a80b3e5..b68437b398 100644 --- a/src/Converters/DoubleConverters.cs +++ b/src/Converters/DoubleConverters.cs @@ -1,5 +1,4 @@ -using Avalonia; -using Avalonia.Data.Converters; +using Avalonia.Data.Converters; namespace SourceGit.Converters { @@ -16,8 +15,5 @@ public static class DoubleConverters public static readonly FuncValueConverter OneMinusToPercentage = new FuncValueConverter(v => ((1.0 - v) * 100).ToString("F0") + "%"); - - public static readonly FuncValueConverter ToLeftMargin = - new FuncValueConverter(v => new Thickness(v, 0, 0, 0)); } } diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index de299ed6b7..7b97ac4750 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -27,6 +27,7 @@ public class Commit : ObservableObject public bool IsMerged { get; set; } = false; public int Color { get; set; } = 0; public double LeftMargin { get; set; } = 0; + public double GraphNodeX { get; set; } = 0; public bool IsHighlightedInGraph { diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs index 916a79b1bb..2d18bc70b4 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -6,7 +6,7 @@ namespace SourceGit.Models { - public record CommitGraphLayout(double StartY, double ClipWidth, double RowHeight); + public record CommitGraphLayout(double StartY, double OffsetX, double ClipWidth, double RowHeight); public enum CommitGraphHighlighting { @@ -17,6 +17,12 @@ public enum CommitGraphHighlighting SelectedCommitsOnlyFirstParent, } + public enum CommitGraphStyle + { + Classic = 0, + Avatars, + } + public class CommitGraph { public static List Pens { get; } = []; @@ -65,11 +71,13 @@ public class Dot public Point Center; public int Color; public bool IsHighlighted; + public Commit Commit; } public List Paths { get; } = []; public List Links { get; } = []; public List Dots { get; } = []; + public double Width { get; private set; } = 0; public static CommitGraph Generate(List commits, bool firstParentOnlyEnabled, CommitGraphHighlighting highlighting, HashSet highlightExtraCommits) { @@ -198,7 +206,7 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna // Calculate link position of this commit. var position = new Point(major?.LastX ?? offsetX, offsetY); var dotColor = major?.Path.Color ?? 0; - var anchor = new Dot() { Center = position, Color = dotColor, IsHighlighted = isHighlighted }; + var anchor = new Dot() { Center = position, Color = dotColor, IsHighlighted = isHighlighted, Commit = commit }; if (commit.IsCurrentHead) anchor.Type = DotType.Head; else if (commit.Parents.Count > 1) @@ -249,7 +257,9 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna // Margins & colors (used by Views.Histories). commit.Color = dotColor; + commit.GraphNodeX = position.X; commit.LeftMargin = Math.Max(offsetX, maxOffsetOld) + halfWidth + 2; + temp.Width = Math.Max(temp.Width, commit.LeftMargin); } // Deal with curves haven't ended yet. diff --git a/src/Models/RepositoryUIStates.cs b/src/Models/RepositoryUIStates.cs index 0b35f8d618..c3b61150e5 100644 --- a/src/Models/RepositoryUIStates.cs +++ b/src/Models/RepositoryUIStates.cs @@ -45,6 +45,24 @@ public double AuthorColumnWidth set; } = 120; + public bool IsRefsColumnVisibleInHistory + { + get; + set; + } = true; + + public double RefsColumnWidth + { + get; + set; + } = 180; + + public double GraphColumnWidth + { + get; + set; + } = 0; + public bool EnableTopoOrderInHistory { get; diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 76ac9e4dcb..0fb48ed5f2 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -517,9 +517,12 @@ Advanced Options AUTHOR AUTHOR TIME + BRANCH / TAG COMMIT TIME + GRAPH GRAPH & SUBJECT SHA + COMMIT MESSAGE HIGHLIGHTS IN GRAPH All Current Branch Only @@ -674,6 +677,9 @@ Reasoning Effort Level Server APPEARANCE + Commit Graph Style + Avatars (branches, graph and message in separate columns) + Classic Default Font Editor Tab Width Font Size diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index 128b53ff9d..edee0c2e7f 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -63,6 +63,19 @@ public bool IsAuthorColumnVisible } } + public bool IsRefsColumnVisible + { + get => _repo.UIStates.IsRefsColumnVisibleInHistory; + set + { + if (_repo.UIStates.IsRefsColumnVisibleInHistory != value) + { + _repo.UIStates.IsRefsColumnVisibleInHistory = value; + OnPropertyChanged(); + } + } + } + public bool IsSHAColumnVisible { get => _repo.UIStates.IsSHAColumnVisibleInHistory; @@ -210,6 +223,21 @@ public double AuthorColumnWidth set => _repo.UIStates.AuthorColumnWidth = value; } + public double RefsColumnWidth + { + get => _repo.UIStates.RefsColumnWidth; + set => _repo.UIStates.RefsColumnWidth = value; + } + + /// + /// Width of the graph column in `Avatars` style. Zero means fit to the graph. + /// + public double GraphColumnWidth + { + get => _repo.UIStates.GraphColumnWidth; + set => _repo.UIStates.GraphColumnWidth = value; + } + public bool IsOpenAsStandaloneVisible { get => DetailContext is CommitDetail or RevisionCompare; diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs index 4803ec4296..fd0cf8a286 100644 --- a/src/ViewModels/Preferences.cs +++ b/src/ViewModels/Preferences.cs @@ -211,6 +211,12 @@ public bool UseCompactBranchNamesInGraph set => SetProperty(ref _useCompactBranchNamesInGraph, value); } + public Models.CommitGraphStyle CommitGraphStyle + { + get => _commitGraphStyle; + set => SetProperty(ref _commitGraphStyle, value); + } + public bool UseTwoColumnsLayoutInHistories { get => _useTwoColumnsLayoutInHistories; @@ -824,6 +830,7 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) private bool _useAutoHideScrollBars = true; private bool _useGitHubStyleAvatar = true; private bool _useCompactBranchNamesInGraph = true; + private Models.CommitGraphStyle _commitGraphStyle = Models.CommitGraphStyle.Classic; private bool _check4UpdatesOnStartup = true; private double _lastCheckUpdateTime = 0; diff --git a/src/Views/Avatar.cs b/src/Views/Avatar.cs index 82fa0a8473..6ab95d641d 100644 --- a/src/Views/Avatar.cs +++ b/src/Views/Avatar.cs @@ -68,31 +68,7 @@ public override void Render(DrawingContext context) } else if (!_useGitHubStyleAvatar) { - var fallback = GetFallbackString(_user.Name); - var typeface = new Typeface("fonts:SourceGit#JetBrains Mono NL"); - var label = new FormattedText( - fallback, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - Math.Max(Bounds.Width * 0.65, 10), - Brushes.White); - - var chars = fallback.ToCharArray(); - var sum = 0; - foreach (var c in chars) - sum += Math.Abs(c); - - var bg = new LinearGradientBrush() - { - GradientStops = FALLBACK_GRADIENTS[sum % FALLBACK_GRADIENTS.Length], - StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), - EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), - }; - - Point textOrigin = new Point((Bounds.Width - label.Width) * 0.5, (Bounds.Height - label.Height) * 0.5); - context.DrawRectangle(bg, null, new Rect(0, 0, Bounds.Width, Bounds.Height), corner, corner); - context.DrawText(label, textOrigin); + DrawFallback(context, _user.Name, rect, corner); } else { @@ -275,7 +251,36 @@ private void OnContextRequested(object sender, ContextRequestedEventArgs e) menu.Open(this); } - private string GetFallbackString(string name) + public static void DrawFallback(DrawingContext context, string name, Rect rect, double corner) + { + var fallback = GetFallbackString(name); + var typeface = new Typeface("fonts:SourceGit#JetBrains Mono NL"); + var label = new FormattedText( + fallback, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + Math.Max(rect.Width * 0.65, 10), + Brushes.White); + + var chars = fallback.ToCharArray(); + var sum = 0; + foreach (var c in chars) + sum += Math.Abs(c); + + var bg = new LinearGradientBrush() + { + GradientStops = FALLBACK_GRADIENTS[sum % FALLBACK_GRADIENTS.Length], + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }; + + var textOrigin = new Point(rect.X + (rect.Width - label.Width) * 0.5, rect.Y + (rect.Height - label.Height) * 0.5); + context.DrawRectangle(bg, null, rect, corner, corner); + context.DrawText(label, textOrigin); + } + + private static string GetFallbackString(string name) { if (string.IsNullOrWhiteSpace(name)) return "?"; diff --git a/src/Views/CommitGraph.cs b/src/Views/CommitGraph.cs index 88a8217c6b..1f90282187 100644 --- a/src/Views/CommitGraph.cs +++ b/src/Views/CommitGraph.cs @@ -1,10 +1,15 @@ -using Avalonia; +using System; +using System.Collections.Generic; + +using Avalonia; using Avalonia.Controls; +using Avalonia.Interactivity; using Avalonia.Media; +using Avalonia.Media.Imaging; namespace SourceGit.Views { - public class CommitGraph : Control + public class CommitGraph : Control, Models.IAvatarHost { public static readonly DirectProperty GraphProperty = AvaloniaProperty.RegisterDirect( @@ -39,6 +44,70 @@ public IBrush DotBrush set => SetValue(DotBrushProperty, value); } + public static readonly StyledProperty GraphStyleProperty = + AvaloniaProperty.Register(nameof(GraphStyle)); + + public Models.CommitGraphStyle GraphStyle + { + get => GetValue(GraphStyleProperty); + set => SetValue(GraphStyleProperty, value); + } + + public static readonly StyledProperty ShowRefConnectorsProperty = + AvaloniaProperty.Register(nameof(ShowRefConnectors)); + + public bool ShowRefConnectors + { + get => GetValue(ShowRefConnectorsProperty); + set => SetValue(ShowRefConnectorsProperty, value); + } + + public static readonly StyledProperty ShowTagsProperty = + AvaloniaProperty.Register(nameof(ShowTags), true); + + public bool ShowTags + { + get => GetValue(ShowTagsProperty); + set => SetValue(ShowTagsProperty, value); + } + + /// + /// Width required to draw the given graph without clipping in `Avatars` style. + /// + public static double GetAvatarsStyleWidth(Models.CommitGraph graph) + { + if (graph == null || graph.Width <= 0) + return 0; + + // `Width` is the right edge of the last lane plus 8px padding (in classic units). + return MapX(graph.Width - 8, AVATARS_LANE_SCALE) + AVATAR_RADIUS + 6; + } + + /// + /// X position of a graph node (generated in classic units) in `Avatars` style. + /// + public static double GetAvatarsStyleX(double x) + { + return MapX(x, AVATARS_LANE_SCALE); + } + + public static bool HasVisibleRefs(Models.Commit commit, bool showTags) + { + if (commit == null) + return false; + + if (showTags) + return commit.HasDecorators; + + foreach (var d in commit.Decorators) + { + if (d.Type != Models.DecoratorType.Tag) + return true; + } + + return false; + } + public override void Render(DrawingContext context) { base.Render(context); @@ -47,30 +116,73 @@ public override void Render(DrawingContext context) return; var startY = _layout.StartY; + var offsetX = _layout.OffsetX; var clipWidth = _layout.ClipWidth; var clipHeight = Bounds.Height; var rowHeight = _layout.RowHeight; var endY = startY + clipHeight + 28; + var isAvatars = GraphStyle == Models.CommitGraphStyle.Avatars; + var scale = isAvatars ? AVATARS_LANE_SCALE : 1.0; - using (context.PushClip(new Rect(0, 0, clipWidth, clipHeight))) - using (context.PushTransform(Matrix.CreateTranslation(0, -startY))) + using (context.PushClip(new Rect(offsetX, 0, clipWidth, clipHeight))) + using (context.PushTransform(Matrix.CreateTranslation(offsetX, -startY))) { - DrawCurves(context, _graph, startY, endY, rowHeight); - DrawAnchors(context, _graph, startY, endY, rowHeight); + if (isAvatars) + { + DrawRowDecorations(context, _graph, startY, endY, rowHeight, clipWidth); + DrawRoundedLines(context, _graph, startY, endY, rowHeight, scale); + DrawAvatarAnchors(context, _graph, startY, endY, rowHeight); + } + else + { + DrawCurves(context, _graph, startY, endY, rowHeight, scale); + DrawAnchors(context, _graph, startY, endY, rowHeight); + } } } + public void OnAvatarResourceChanged(string email, Bitmap image) + { + if (_avatars.ContainsKey(email)) + { + _avatars[email] = image; + InvalidateVisual(); + } + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + Models.AvatarManager.Instance.Subscribe(this); + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + base.OnUnloaded(e); + Models.AvatarManager.Instance.Unsubscribe(this); + _avatars.Clear(); + } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if (change.Property == GraphProperty || change.Property == LayoutProperty || - change.Property == DotBrushProperty) + change.Property == DotBrushProperty || + change.Property == GraphStyleProperty || + change.Property == ShowRefConnectorsProperty || + change.Property == ShowTagsProperty) InvalidateVisual(); } - private void DrawCurves(DrawingContext context, Models.CommitGraph graph, double top, double bottom, double rowHeight) + private static double MapX(double x, double scale) + { + // Graph is generated with 12px lanes starting at x = 4. Stretch lanes horizontally for other styles. + return scale == 1.0 ? x : 4 + (x - 4) * scale; + } + + private void DrawCurves(DrawingContext context, Models.CommitGraph graph, double top, double bottom, double rowHeight, double scale) { var grayedPen = new Pen(new SolidColorBrush(Colors.Gray, 0.4), Models.CommitGraph.Pens[0].Thickness); @@ -87,8 +199,8 @@ private void DrawCurves(DrawingContext context, Models.CommitGraph graph, double var geo = new StreamGeometry(); using (var ctx = geo.Open()) { - ctx.BeginFigure(new Point(link.Start.X, startY), false); - ctx.QuadraticBezierTo(new Point(link.Control.X, link.Control.Y * rowHeight), new Point(link.End.X, endY)); + ctx.BeginFigure(new Point(MapX(link.Start.X, scale), startY), false); + ctx.QuadraticBezierTo(new Point(MapX(link.Control.X, scale), link.Control.Y * rowHeight), new Point(MapX(link.End.X, scale), endY)); } var pen = link.IsHighlighted ? Models.CommitGraph.Pens[link.Color] : grayedPen; @@ -97,7 +209,7 @@ private void DrawCurves(DrawingContext context, Models.CommitGraph graph, double foreach (var line in graph.Paths) { - var last = new Point(line.Points[0].X, line.Points[0].Y * rowHeight); + var last = new Point(MapX(line.Points[0].X, scale), line.Points[0].Y * rowHeight); var size = line.Points.Count; var endY = line.Points[size - 1].Y * rowHeight; @@ -115,7 +227,7 @@ private void DrawCurves(DrawingContext context, Models.CommitGraph graph, double var ended = false; for (int i = 1; i < size; i++) { - var cur = new Point(line.Points[i].X, line.Points[i].Y * rowHeight); + var cur = new Point(MapX(line.Points[i].X, scale), line.Points[i].Y * rowHeight); if (cur.Y < top) { last = cur; @@ -165,6 +277,146 @@ private void DrawCurves(DrawingContext context, Models.CommitGraph graph, double } } + /// + /// Same as `DrawCurves`, but uses horizontal/vertical lines with rounded corners instead of splines. + /// + private void DrawRoundedLines(DrawingContext context, Models.CommitGraph graph, double top, double bottom, double rowHeight, double scale) + { + var grayedPen = new Pen(new SolidColorBrush(Colors.Gray, 0.4), Models.CommitGraph.Pens[0].Thickness); + var vertices = new List(); + + foreach (var link in graph.Links) + { + var startY = link.Start.Y * rowHeight; + var endY = link.End.Y * rowHeight; + + if (endY < top) + continue; + if (startY > bottom) + break; + + // Leave the merge commit horizontally, then turn down into the parent's lane. + var start = new Point(MapX(link.Start.X, scale), startY); + var end = new Point(MapX(link.End.X, scale), endY); + vertices.Clear(); + vertices.Add(start); + vertices.Add(new Point(end.X, start.Y)); + vertices.Add(end); + + var pen = link.IsHighlighted ? Models.CommitGraph.Pens[link.Color] : grayedPen; + DrawRoundedPolyline(context, pen, vertices); + } + + foreach (var line in graph.Paths) + { + var last = new Point(MapX(line.Points[0].X, scale), line.Points[0].Y * rowHeight); + var size = line.Points.Count; + var endY = line.Points[size - 1].Y * rowHeight; + + if (endY < top) + continue; + if (last.Y > bottom) + break; + + vertices.Clear(); + for (int i = 1; i < size; i++) + { + var cur = new Point(MapX(line.Points[i].X, scale), line.Points[i].Y * rowHeight); + if (cur.Y < top) + { + last = cur; + continue; + } + + if (vertices.Count == 0) + vertices.Add(last); + + if (cur.X > last.X) + { + vertices.Add(new Point(cur.X, last.Y)); + } + else if (cur.X < last.X) + { + if (i < size - 1) + { + var midY = (last.Y + cur.Y) / 2; + vertices.Add(new Point(last.X, midY)); + vertices.Add(new Point(cur.X, midY)); + } + else + { + vertices.Add(new Point(last.X, cur.Y)); + } + } + + vertices.Add(cur); + + if (cur.Y > bottom) + break; + last = cur; + } + + var pen = line.IsHighlighted ? Models.CommitGraph.Pens[line.Color] : grayedPen; + DrawRoundedPolyline(context, pen, vertices); + } + } + + private static void DrawRoundedPolyline(DrawingContext context, Pen pen, List vertices) + { + // Drop duplicated and collinear vertices, so only real corners remain. + var points = new List(vertices.Count); + foreach (var v in vertices) + { + if (points.Count > 0 && Math.Abs(points[^1].X - v.X) < 0.01 && Math.Abs(points[^1].Y - v.Y) < 0.01) + continue; + + if (points.Count > 1) + { + var a = points[^2]; + var b = points[^1]; + var cross = (b.X - a.X) * (v.Y - b.Y) - (b.Y - a.Y) * (v.X - b.X); + if (Math.Abs(cross) < 0.01) + points.RemoveAt(points.Count - 1); + } + + points.Add(v); + } + + if (points.Count < 2) + return; + + var geo = new StreamGeometry(); + using (var ctx = geo.Open()) + { + ctx.BeginFigure(points[0], false); + + var lastIdx = points.Count - 1; + for (var i = 1; i < lastIdx; i++) + { + var prev = points[i - 1]; + var corner = points[i]; + var next = points[i + 1]; + + var inLen = Math.Sqrt((corner.X - prev.X) * (corner.X - prev.X) + (corner.Y - prev.Y) * (corner.Y - prev.Y)); + var outLen = Math.Sqrt((next.X - corner.X) * (next.X - corner.X) + (next.Y - corner.Y) * (next.Y - corner.Y)); + + // Segments between two corners are shared, so each corner may only use half of it. + var inMax = i == 1 ? inLen : inLen * 0.5; + var outMax = i + 1 == lastIdx ? outLen : outLen * 0.5; + var radius = Math.Min(CORNER_RADIUS, Math.Min(inMax, outMax)); + + var from = new Point(corner.X + (prev.X - corner.X) / inLen * radius, corner.Y + (prev.Y - corner.Y) / inLen * radius); + var to = new Point(corner.X + (next.X - corner.X) / outLen * radius, corner.Y + (next.Y - corner.Y) / outLen * radius); + ctx.LineTo(from); + ctx.QuadraticBezierTo(corner, to); + } + + ctx.LineTo(points[lastIdx]); + } + + context.DrawGeometry(null, pen, geo); + } + private void DrawAnchors(DrawingContext context, Models.CommitGraph graph, double top, double bottom, double rowHeight) { var dotFill = DotBrush; @@ -199,7 +451,103 @@ private void DrawAnchors(DrawingContext context, Models.CommitGraph graph, doubl } } + private void DrawRowDecorations(DrawingContext context, Models.CommitGraph graph, double top, double bottom, double rowHeight, double clipWidth) + { + var showConnectors = ShowRefConnectors; + var showTags = ShowTags; + var grayedPen = new Pen(new SolidColorBrush(Colors.Gray, 0.4), Models.CommitGraph.Pens[0].Thickness); + var bandHeight = Math.Max(rowHeight - 6, 4); + + foreach (var dot in graph.Dots) + { + var x = MapX(dot.Center.X, AVATARS_LANE_SCALE); + var y = dot.Center.Y * rowHeight; + + if (y < top) + continue; + if (y > bottom) + break; + + var pen = dot.IsHighlighted ? Models.CommitGraph.Pens[dot.Color] : grayedPen; + + // Translucent band from the node to the commit message column. + if (clipWidth > x) + { + using (context.PushOpacity(dot.IsHighlighted ? 0.12 : 0.05)) + context.FillRectangle(pen.Brush, new Rect(x, y - bandHeight * 0.5, clipWidth - x, bandHeight)); + } + + // Connector from the BRANCH / TAG column to the node. + if (showConnectors && HasVisibleRefs(dot.Commit, showTags)) + context.DrawLine(pen, new Point(0, y), new Point(x, y)); + } + } + + private void DrawAvatarAnchors(DrawingContext context, Models.CommitGraph graph, double top, double bottom, double rowHeight) + { + var dotFill = DotBrush; + var grayedBrush = Brushes.Gray; + + foreach (var dot in graph.Dots) + { + var center = new Point(MapX(dot.Center.X, AVATARS_LANE_SCALE), dot.Center.Y * rowHeight); + + if (center.Y < top) + continue; + if (center.Y > bottom) + break; + + var brush = dot.IsHighlighted ? Models.CommitGraph.Pens[dot.Color].Brush : grayedBrush; + + // Merge commits do not have an avatar. + if (dot.Type == Models.CommitGraph.DotType.Merge) + { + context.DrawEllipse(brush, null, center, MERGE_DOT_RADIUS, MERGE_DOT_RADIUS); + continue; + } + + if (dot.Type == Models.CommitGraph.DotType.Head) + context.DrawEllipse(dotFill, new Pen(brush, 1.5), center, AVATAR_RADIUS + 2.5, AVATAR_RADIUS + 2.5); + + context.DrawEllipse(brush, null, center, AVATAR_RADIUS, AVATAR_RADIUS); + + var inner = AVATAR_RADIUS - 2; + var rect = new Rect(center.X - inner, center.Y - inner, inner * 2, inner * 2); + var author = dot.Commit?.Author; + using (context.PushOpacity(dot.IsHighlighted ? 1.0 : 0.4)) + using (context.PushGeometryClip(new EllipseGeometry(rect))) + { + var img = author != null ? GetAvatar(author.Email) : null; + if (img != null) + context.DrawImage(img, rect); + else + Avatar.DrawFallback(context, author?.Name, rect, inner); + } + } + } + + private Bitmap GetAvatar(string email) + { + if (string.IsNullOrEmpty(email)) + return null; + + // Only ask `AvatarManager` once per email. Updates are pushed back via `OnAvatarResourceChanged`. + if (!_avatars.TryGetValue(email, out var img)) + { + img = Models.AvatarManager.Instance.Request(email, false); + _avatars.Add(email, img); + } + + return img; + } + + private const double AVATARS_LANE_SCALE = 2.0; + private const double AVATAR_RADIUS = 10.0; + private const double MERGE_DOT_RADIUS = 5.0; + private const double CORNER_RADIUS = 8.0; + private Models.CommitGraph _graph = null; private Models.CommitGraphLayout _layout = null; + private Dictionary _avatars = new Dictionary(); } } diff --git a/src/Views/CommitRefsPresenter.cs b/src/Views/CommitRefsPresenter.cs index 85e33962a1..06454ffeac 100644 --- a/src/Views/CommitRefsPresenter.cs +++ b/src/Views/CommitRefsPresenter.cs @@ -73,6 +73,8 @@ public class RenderItem public IBrush Brush { get; set; } = null; public bool IsHead { get; set; } = false; public double Width { get; set; } = 0.0; + public double LabelWidth { get; set; } = 0.0; + public bool IsLabelTrimmed { get; set; } = false; public List Remotes { get; set; } = []; } @@ -157,11 +159,47 @@ public bool ShowTags set => SetValue(ShowTagsProperty, value); } + public static readonly StyledProperty FitToWidthProperty = + AvaloniaProperty.Register(nameof(FitToWidth)); + + /// + /// Only shows the refs that fit in the available width, followed by a `+N` badge. + /// + public bool FitToWidth + { + get => GetValue(FitToWidthProperty); + set => SetValue(FitToWidthProperty, value); + } + + public static readonly StyledProperty ShowConnectorProperty = + AvaloniaProperty.Register(nameof(ShowConnector)); + + /// + /// Draws a line from the last ref to the right edge (towards the commit node in graph). + /// + public bool ShowConnector + { + get => GetValue(ShowConnectorProperty); + set => SetValue(ShowConnectorProperty, value); + } + + public Rect? OverflowBadgeBounds + { + get => _overflowRect; + } + + public bool IsOverflowBadgeAt(Point point) + { + return _overflowRect.HasValue && _overflowRect.Value.Inflate(2).Contains(point); + } + public Models.Decorator DecoratorAt(Point point) { var x = 0.0; - foreach (var item in _items) + var count = FitToWidth ? _visibleCount : _items.Count; + for (var i = 0; i < count; i++) { + var item = _items[i]; x += item.Width; if (point.X < x) return item.Decorator; @@ -182,12 +220,15 @@ public override void Render(DrawingContext context) var x = 1.5; var y = 0.5; var remoteIcon = CommitRefsIconCache.Instance.GetIcon(Models.DecoratorType.RemoteBranchHead); - var hasSingleRemote = HasSingleRemote; + var hasSingleRemote = HasSingleRemote || FitToWidth; - context.FillRectangle(Brushes.Transparent, Bounds); + var count = FitToWidth ? _visibleCount : _items.Count; - foreach (var item in _items) + context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size)); + + for (var i = 0; i < count; i++) { + var item = _items[i]; if (allowWrap && x > 1.5 && x + item.Width > Bounds.Width) { x = 1.5; @@ -250,6 +291,34 @@ public override void Render(DrawingContext context) x += item.Width + 4; } + + _overflowRect = null; + if (count < _items.Count && _overflowLabel != null) + { + var badgeRect = new RoundedRect(new Rect(x, y, _overflowLabel.Width + 8, 16), new CornerRadius(4)); + _overflowRect = badgeRect.Rect; + if (bg != null) + context.DrawRectangle(bg, null, badgeRect); + context.DrawRectangle(null, new Pen(Brushes.Gray), badgeRect); + context.DrawText(_overflowLabel, new Point(x + 4, y + 8.0 - _overflowLabel.Height * 0.5)); + x += badgeRect.Rect.Width + 4; + } + + if (ShowConnector && DataContext is Models.Commit commit && x < Bounds.Width) + { + var graphPen = Models.CommitGraph.Pens[commit.Color]; + var pen = useGraphColor ? graphPen : new Pen(new SolidColorBrush(Colors.Gray, 0.4), graphPen.Thickness); + var cy = y - 0.5 + 8.0; + context.DrawLine(pen, new Point(x - 4, cy), new Point(Bounds.Width, cy)); + } + } + + protected override Size ArrangeOverride(Size finalSize) + { + if (FitToWidth) + UpdateVisibleItems(finalSize.Width); + + return base.ArrangeOverride(finalSize); } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) @@ -263,8 +332,11 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang change.Property == UseCompactBranchNamesProperty || change.Property == HasSingleRemoteProperty || change.Property == BackgroundProperty || - change.Property == ShowTagsProperty) + change.Property == ShowTagsProperty || + change.Property == FitToWidthProperty) InvalidateMeasure(); + else if (change.Property == ShowConnectorProperty) + InvalidateVisual(); } protected override void OnDataContextChanged(EventArgs e) @@ -289,7 +361,7 @@ protected override Size MeasureOverride(Size availableSize) } var useCompactBranchNames = UseCompactBranchNames; - var hasSingleRemote = HasSingleRemote; + var hasSingleRemote = HasSingleRemote || FitToWidth; var typeface = new Typeface(FontFamily); var typefaceHead = new Typeface(FontFamily, FontStyle.Normal, FontWeight.Bold); var typefaceRemote = new Typeface(FontFamily, FontStyle.Italic, FontWeight.Bold); @@ -340,6 +412,7 @@ protected override Size MeasureOverride(Size availableSize) fg); } + item.LabelWidth = item.Label.Width; item.Width = item.Label.Width + 24; var findRemotes = useCompactBranchNames && (decorator.Type == Models.DecoratorType.CurrentBranchHead || decorator.Type == Models.DecoratorType.LocalBranchHead); @@ -368,8 +441,12 @@ protected override Size MeasureOverride(Size availableSize) item.Remotes.Add(remote); + // Only one remote icon is drawn in this mode, whatever the number of remotes. if (hasSingleRemote) - item.Width += 18; + { + if (item.Remotes.Count == 1) + item.Width += 18; + } else item.Width += remote.WidthIncludingTrailingWhitespace + 22; @@ -392,7 +469,9 @@ protected override Size MeasureOverride(Size availableSize) double requiredWidth = 0; if (_items.Count > 0) { - if (allowWrap && requiredHeight > 16.0) + if (FitToWidth && !double.IsInfinity(availableSize.Width)) + requiredWidth = Math.Min(x + 2, availableSize.Width); + else if (allowWrap && requiredHeight > 16.0) requiredWidth = double.IsInfinity(availableSize.Width) ? x + 2 : availableSize.Width; else requiredWidth = x + 2; @@ -402,6 +481,81 @@ protected override Size MeasureOverride(Size availableSize) return new Size(requiredWidth, requiredHeight); } + private void UpdateVisibleItems(double width) + { + var count = _items.Count; + var visible = count; + var x = 1.5; + + // Restore labels that may have been trimmed by previous arrange. + foreach (var item in _items) + { + if (item.IsLabelTrimmed) + { + var trimmedWidth = item.Label.Width; + item.Label.MaxTextWidth = item.LabelWidth + 1; + item.Label.Trimming = TextTrimming.None; + item.Width += item.LabelWidth - trimmedWidth; + item.IsLabelTrimmed = false; + } + } + + // Trim the label of the first ref when it does not fit, so the connector is still visible. + if (count > 0) + { + var first = _items[0]; + var reserved = count > 1 ? MeasureOverflowBadge(count - 1).Width + 12 : 0; + var overflow = 1.5 + first.Width + reserved + 12 - width; + if (overflow > 0) + { + var labelWidth = Math.Max(first.LabelWidth - overflow, 24); + if (labelWidth < first.LabelWidth) + { + first.Label.MaxTextWidth = labelWidth; + first.Label.MaxLineCount = 1; + first.Label.Trimming = TextTrimming.CharacterEllipsis; + first.Width -= first.LabelWidth - first.Label.Width; + first.IsLabelTrimmed = true; + } + } + } + + for (var i = 0; i < count; i++) + { + var remains = count - i - 1; + var reserved = remains > 0 ? MeasureOverflowBadge(remains).Width + 12 : 0; + if (i > 0 && x + _items[i].Width + reserved > width) + { + visible = i; + break; + } + + x += _items[i].Width + 4; + } + + _visibleCount = visible; + _overflowLabel = visible < count ? MeasureOverflowBadge(count - visible) : null; + + // Show the full name of a trimmed ref on hover. + var trimmed = count > 0 && _items[0].IsLabelTrimmed ? _items[0].Decorator.Name : null; + if (!Equals(ToolTip.GetTip(this), trimmed)) + ToolTip.SetTip(this, trimmed); + } + + private FormattedText MeasureOverflowBadge(int hidden) + { + return new FormattedText( + $"+{hidden}", + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + new Typeface(FontFamily), + FontSize, + Foreground); + } + private List _items = new List(); + private int _visibleCount = 0; + private FormattedText _overflowLabel = null; + private Rect? _overflowRect = null; } } diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index a3d24529fd..342507d4a0 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -26,13 +26,14 @@ @@ -55,6 +56,14 @@ + + + @@ -103,16 +112,111 @@ - + + + + + + + + - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HasSingleRemote="{Binding $parent[v:Histories].HasSingleRemote}"> + + + + + + + - + @@ -185,7 +296,7 @@ - + @@ -202,7 +313,7 @@ - + @@ -222,7 +333,7 @@ - + @@ -247,7 +358,8 @@ HorizontalAlignment="Stretch" VerticalAlignment="Top" PointerMoved="OnCommitListHeaderPointerMoved" PointerPressed="OnCommitListHeaderPointerPressed" - PointerReleased="OnCommitListHeaderPointerReleased"> + PointerReleased="OnCommitListHeaderPointerReleased" + ContextRequested="OnCommitListHeaderContextRequested"> -4) + var resizable = GetResizableColumnAt(pos.X) >= 0; + if (resizable) { if (border.Cursor != _resizingCursor) border.Cursor = _resizingCursor; @@ -506,21 +528,144 @@ private void OnCommitListHeaderPointerPressed(object sender, PointerPressedEvent if (sender is not Border border) return; - var pos = e.GetPosition(border); - var dis = CommitListContainer.Columns[0].ActualWidth - 4 - pos.X; - if (dis > 4 || dis < -4) + var col = GetResizableColumnAt(e.GetPosition(border).X); + if (col < 0) return; if (e.GetCurrentPoint(border).Properties.IsLeftButtonPressed) { - _resizingAuthorColumn = true; + _resizingColumn = col; e.Handled = true; } } private void OnCommitListHeaderPointerReleased(object sender, PointerReleasedEventArgs e) { - _resizingAuthorColumn = false; + _resizingColumn = -1; + } + + private void OnCommitListHeaderContextRequested(object sender, ContextRequestedEventArgs e) + { + e.Handled = true; + + if (sender is not Border border || DataContext is not ViewModels.Histories histories) + return; + + // Same options as the settings button in the top-left corner of the list. + var menu = CreateAdvancedOptionsMenu(histories); + menu.Placement = PlacementMode.Pointer; + menu.Open(border); + } + + private double GetColumnRightEdge(int idx) + { + var columns = CommitListContainer.Columns; + var edge = 0.0; + for (var i = 0; i <= idx; i++) + { + if (columns[i].IsVisible) + edge += columns[i].ActualWidth; + } + return edge; + } + + private int GetResizableColumnAt(double x) + { + if (DataContext is not ViewModels.Histories vm) + return -1; + + var columns = CommitListContainer.Columns; + if (columns[COL_REFS].IsVisible) + { + var dis = GetColumnRightEdge(COL_REFS) - 4 - x; + if (dis < 4 && dis > -4) + return COL_REFS; + } + + if (columns[COL_GRAPH].IsVisible) + { + var dis = GetColumnRightEdge(COL_GRAPH) - 4 - x; + if (dis < 4 && dis > -4) + return COL_GRAPH; + } + + if (vm.IsAuthorColumnVisible) + { + var dis = GetColumnRightEdge(COL_SUBJECT) - 4 - x; + if (dis < 4 && dis > -4) + return COL_AUTHOR; + } + + return -1; + } + + private List CreateColumnsMenuItems(ViewModels.Histories histories) + { + var columnsHeader = new MenuItem(); + columnsHeader.Header = new TextBlock() { Text = App.Text("Histories.ShowColumns"), FontWeight = FontWeight.Bold }; + columnsHeader.IsEnabled = false; + + var items = new List() { columnsHeader }; + + if (ViewModels.Preferences.Instance.CommitGraphStyle == Models.CommitGraphStyle.Avatars) + { + var refsColumn = new MenuItem(); + refsColumn.Header = App.Text("Histories.Header.BranchesAndTags"); + if (histories.IsRefsColumnVisible) + refsColumn.Icon = this.CreateMenuIcon("Icons.Check"); + refsColumn.Click += (_, ev) => + { + histories.IsRefsColumnVisible = !histories.IsRefsColumnVisible; + ev.Handled = true; + }; + items.Add(refsColumn); + } + + var authorColumn = new MenuItem(); + authorColumn.Header = App.Text("Histories.Header.Author"); + if (histories.IsAuthorColumnVisible) + authorColumn.Icon = this.CreateMenuIcon("Icons.Check"); + authorColumn.Click += (_, ev) => + { + histories.IsAuthorColumnVisible = !histories.IsAuthorColumnVisible; + ev.Handled = true; + }; + items.Add(authorColumn); + + var shaColumn = new MenuItem(); + shaColumn.Header = App.Text("Histories.Header.SHA"); + if (histories.IsSHAColumnVisible) + shaColumn.Icon = this.CreateMenuIcon("Icons.Check"); + shaColumn.Click += (_, ev) => + { + histories.IsSHAColumnVisible = !histories.IsSHAColumnVisible; + ev.Handled = true; + }; + items.Add(shaColumn); + + var authorTimeColumn = new MenuItem(); + authorTimeColumn.Header = App.Text("Histories.Header.AuthorTime"); + if (histories.IsAuthorTimeColumnVisible) + authorTimeColumn.Icon = this.CreateMenuIcon("Icons.Check"); + authorTimeColumn.Click += (_, ev) => + { + histories.IsAuthorTimeColumnVisible = !histories.IsAuthorTimeColumnVisible; + ev.Handled = true; + }; + items.Add(authorTimeColumn); + + var commitTimeColumn = new MenuItem(); + commitTimeColumn.Header = App.Text("Histories.Header.CommitTime"); + if (histories.IsCommitTimeColumnVisible) + commitTimeColumn.Icon = this.CreateMenuIcon("Icons.Check"); + commitTimeColumn.Click += (_, ev) => + { + histories.IsCommitTimeColumnVisible = !histories.IsCommitTimeColumnVisible; + ev.Handled = true; + }; + items.Add(commitTimeColumn); + + return items; } private void OnOpenAdvancedOptions(object sender, RoutedEventArgs e) @@ -531,6 +676,13 @@ private void OnOpenAdvancedOptions(object sender, RoutedEventArgs e) if (DataContext is not ViewModels.Histories histories) return; + var menu = CreateAdvancedOptionsMenu(histories); + menu.Placement = PlacementMode.BottomEdgeAlignedLeft; + menu.Open(button); + } + + private ContextMenu CreateAdvancedOptionsMenu(ViewModels.Histories histories) + { var pref = ViewModels.Preferences.Instance; var layout = new MenuItem(); @@ -675,52 +827,7 @@ private void OnOpenAdvancedOptions(object sender, RoutedEventArgs e) ev.Handled = true; }; - var columnsHeader = new MenuItem(); - columnsHeader.Header = new TextBlock() { Text = App.Text("Histories.ShowColumns"), FontWeight = FontWeight.Bold }; - columnsHeader.IsEnabled = false; - - var authorColumn = new MenuItem(); - authorColumn.Header = App.Text("Histories.Header.Author"); - if (histories.IsAuthorColumnVisible) - authorColumn.Icon = this.CreateMenuIcon("Icons.Check"); - authorColumn.Click += (_, ev) => - { - histories.IsAuthorColumnVisible = !histories.IsAuthorColumnVisible; - ev.Handled = true; - }; - - var shaColumn = new MenuItem(); - shaColumn.Header = App.Text("Histories.Header.SHA"); - if (histories.IsSHAColumnVisible) - shaColumn.Icon = this.CreateMenuIcon("Icons.Check"); - shaColumn.Click += (_, ev) => - { - histories.IsSHAColumnVisible = !histories.IsSHAColumnVisible; - ev.Handled = true; - }; - - var authorTimeColumn = new MenuItem(); - authorTimeColumn.Header = App.Text("Histories.Header.AuthorTime"); - if (histories.IsAuthorTimeColumnVisible) - authorTimeColumn.Icon = this.CreateMenuIcon("Icons.Check"); - authorTimeColumn.Click += (_, ev) => - { - histories.IsAuthorTimeColumnVisible = !histories.IsAuthorTimeColumnVisible; - ev.Handled = true; - }; - - var commitTimeColumn = new MenuItem(); - commitTimeColumn.Header = App.Text("Histories.Header.CommitTime"); - if (histories.IsCommitTimeColumnVisible) - commitTimeColumn.Icon = this.CreateMenuIcon("Icons.Check"); - commitTimeColumn.Click += (_, ev) => - { - histories.IsCommitTimeColumnVisible = !histories.IsCommitTimeColumnVisible; - ev.Handled = true; - }; - var menu = new ContextMenu(); - menu.Placement = PlacementMode.BottomEdgeAlignedLeft; menu.Items.Add(layout); menu.Items.Add(horizontal); menu.Items.Add(vertical); @@ -741,12 +848,9 @@ private void OnOpenAdvancedOptions(object sender, RoutedEventArgs e) menu.Items.Add(selectedCommitsOnlyFirstParent); menu.Items.Add(currentBranchAndSelectedCommits); menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(columnsHeader); - menu.Items.Add(authorColumn); - menu.Items.Add(shaColumn); - menu.Items.Add(authorTimeColumn); - menu.Items.Add(commitTimeColumn); - menu.Open(button); + foreach (var item in CreateColumnsMenuItems(histories)) + menu.Items.Add(item); + return menu; } private void OnCommitListLayoutUpdated(object _1, EventArgs _2) @@ -778,13 +882,39 @@ private void OnCommitListLayoutUpdated(object _1, EventArgs _2) IsScrollToTopVisible = startY >= rowHeight; - var clipWidth = dataGrid.Columns[0].ActualWidth - 4; + // Rows do not start exactly at the top of the graph (there's a separator below the column headers). + var rowsTop = rowsPresenter.TranslatePoint(new Point(0, 0), CommitGraph); + if (rowsTop.HasValue) + startY -= rowsTop.Value.Y; + + var offsetX = 0.0; + var clipWidth = dataGrid.Columns[COL_SUBJECT].ActualWidth - 4; + if (ViewModels.Preferences.Instance.CommitGraphStyle == Models.CommitGraphStyle.Avatars && + DataContext is ViewModels.Histories vm) + { + // The graph has its own column. Unless resized by user, size it to fit the graph (at most 30% of the list). + var graphColumn = dataGrid.Columns[COL_GRAPH]; + var graphWidth = vm.GraphColumnWidth; + if (graphWidth <= 0) + { + var maxGraphWidth = Math.Max(dataGrid.Bounds.Width * 0.3, 64); + graphWidth = Math.Round(Math.Clamp(CommitGraph.GetAvatarsStyleWidth(CommitGraph.Graph), 32, maxGraphWidth)); + } + + if (_resizingColumn != COL_GRAPH && (!graphColumn.Width.IsAbsolute || Math.Abs(graphColumn.Width.Value - graphWidth) > 0.5)) + graphColumn.Width = new(graphWidth, DataGridLengthUnitType.Pixel); + + offsetX = GetColumnRightEdge(COL_REFS); + clipWidth = graphColumn.ActualWidth; + } + var lastLayout = CommitGraph.Layout; if (lastLayout == null || Math.Abs(lastLayout.StartY - startY) > 0.01 || + Math.Abs(lastLayout.OffsetX - offsetX) > 0.01 || Math.Abs(lastLayout.ClipWidth - clipWidth) > 0.01 || Math.Abs(lastLayout.RowHeight - rowHeight) > 0.01) - CommitGraph.Layout = new(startY, clipWidth, rowHeight); + CommitGraph.Layout = new(startY, offsetX, clipWidth, rowHeight); } private void OnScrollToTopPointerPressed(object sender, PointerPressedEventArgs e) @@ -850,6 +980,139 @@ private async void OnCommitListDoubleTapped(object sender, TappedEventArgs e) } } + private void OnRefsColumnPointerPressed(object sender, PointerPressedEventArgs e) + { + if (sender is not CommitRefsPresenter { DataContext: Models.Commit commit } presenter) + return; + + if (!e.GetCurrentPoint(presenter).Properties.IsLeftButtonPressed || !presenter.IsOverflowBadgeAt(e.GetPosition(presenter))) + return; + + ShowRefsDropdown(presenter, commit); + e.Handled = true; + } + + private void ShowRefsDropdown(CommitRefsPresenter anchor, Models.Commit commit) + { + if (_refsDropdown is { IsOpen: true }) + { + if (_refsDropdown.Target == anchor) + return; + + _refsDropdown.Hide(); + } + + var repoView = this.FindAncestorOfType(); + if (repoView is not { DataContext: ViewModels.Repository repo } || DataContext is not ViewModels.Histories histories) + return; + + // Open right below the `+N` badge. + var showTags = ViewModels.Preferences.Instance.ShowTagsInGraph; + var flyout = new Flyout() + { + Placement = PlacementMode.BottomEdgeAlignedLeft, + HorizontalOffset = anchor.OverflowBadgeBounds?.X ?? 0, + }; + var list = new ListBox() + { + MaxHeight = 320, + MinWidth = 160, + Background = Brushes.Transparent, + SelectionMode = SelectionMode.Single, + }; + + foreach (var decorator in commit.Decorators) + { + if (!showTags && decorator.Type == Models.DecoratorType.Tag) + continue; + + var iconKey = decorator.Type switch + { + Models.DecoratorType.CurrentBranchHead or Models.DecoratorType.CurrentCommitHead => "Icons.Head", + Models.DecoratorType.RemoteBranchHead => "Icons.Remote", + Models.DecoratorType.Tag => "Icons.Tag", + _ => "Icons.Branch", + }; + + var row = new StackPanel() { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 6 }; + row.Children.Add(this.CreateMenuIcon(iconKey)); + row.Children.Add(new TextBlock() + { + Text = decorator.Name, + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + FontWeight = decorator.Type is Models.DecoratorType.CurrentBranchHead or Models.DecoratorType.CurrentCommitHead ? FontWeight.Bold : FontWeight.Normal, + }); + + var item = new ListBoxItem() { Content = row, Padding = new Thickness(8, 4) }; + item.DoubleTapped += async (_, ev) => + { + ev.Handled = true; + flyout.Hide(); + await histories.CheckoutBranchByDecoratorAsync(decorator); + }; + item.ContextRequested += (_, ev) => + { + ev.Handled = true; + list.SelectedItem = item; + + var menu = CreateContextMenuForDecorator(repo, commit, decorator); + if (menu == null) + return; + + menu.AddHandler(MenuItem.ClickEvent, (_, _) => flyout.Hide()); + menu.Open(item); + }; + list.Items.Add(item); + } + + flyout.Content = list; + flyout.ShowAt(anchor); + _refsDropdown = flyout; + } + + private ContextMenu CreateContextMenuForDecorator(ViewModels.Repository repo, Models.Commit commit, Models.Decorator decorator) + { + var current = repo.CurrentBranch; + if (current == null) + return null; + + // Reuse the per-ref submenu of the commit's context menu, but show its items directly. + var holder = new ContextMenu(); + switch (decorator.Type) + { + case Models.DecoratorType.CurrentBranchHead: + FillCurrentBranchMenu(holder, repo, current); + break; + case Models.DecoratorType.LocalBranchHead: + var lb = repo.Branches.Find(x => x.IsLocal && decorator.Name.Equals(x.Name, StringComparison.Ordinal)); + if (lb != null) + FillOtherLocalBranchMenu(holder, repo, lb, current, commit.IsMerged); + break; + case Models.DecoratorType.RemoteBranchHead: + var rb = repo.Branches.Find(x => !x.IsLocal && decorator.Name.Equals(x.FriendlyName, StringComparison.Ordinal)); + FillRemoteBranchMenu(holder, repo, rb, current, commit.IsMerged); + break; + case Models.DecoratorType.Tag: + var tag = repo.Tags.Find(x => decorator.Name.Equals(x.Name, StringComparison.Ordinal)); + if (tag != null) + FillTagMenu(holder, repo, tag, current); + break; + } + + if (holder.Items.Count == 0 || holder.Items[0] is not MenuItem submenu) + return null; + + var items = new List(); + foreach (var item in submenu.Items) + items.Add(item); + submenu.Items.Clear(); + + var menu = new ContextMenu(); + foreach (var item in items) + menu.Items.Add(item); + return menu; + } + private void OnCommitGraphLoaded(object sender, RoutedEventArgs e) { // Force-update the graph layout to ensure the graph is correctly rendered when it's loaded. @@ -1969,7 +2232,13 @@ private async Task InteractiveRebaseWithPrefillActionAsync(ViewModels.Repository private AvaloniaList _issueTrackers = null; private bool _isScrollToTopVisible = false; private bool _isDetailsPanelExpanded = true; - private bool _resizingAuthorColumn = false; + private const int COL_REFS = 0; + private const int COL_GRAPH = 1; + private const int COL_SUBJECT = 2; + private const int COL_AUTHOR = 3; + + private int _resizingColumn = -1; + private Flyout _refsDropdown = null; private Cursor _resizingCursor = new(StandardCursorType.SizeWestEast); } } diff --git a/src/Views/Preferences.axaml b/src/Views/Preferences.axaml index 55c2b17276..d516468d3a 100644 --- a/src/Views/Preferences.axaml +++ b/src/Views/Preferences.axaml @@ -188,7 +188,7 @@ - + - + + + + + + - - Date: Thu, 12 Nov 2026 10:13:09 +0100 Subject: [PATCH 2/3] refactor: show branches/tags inside the commit message column again Drops the separate BRANCH / TAG column of the `Avatars` commit graph style. Refs are drawn in front of the commit message again, like in the classic style, so the graph is now the first column. Removes the code that only existed for that column: the `+N` overflow badge and dropdown, ref connector lines, label trimming, the column toggle and its persisted width/visibility. Co-Authored-By: Claude Opus 5.5 --- src/Models/RepositoryUIStates.cs | 12 --- src/Resources/Locales/en_US.axaml | 3 +- src/ViewModels/Histories.cs | 19 ---- src/Views/CommitGraph.cs | 45 +------- src/Views/CommitRefsPresenter.cs | 170 ++--------------------------- src/Views/Histories.axaml | 52 +-------- src/Views/Histories.axaml.cs | 173 +----------------------------- 7 files changed, 16 insertions(+), 458 deletions(-) diff --git a/src/Models/RepositoryUIStates.cs b/src/Models/RepositoryUIStates.cs index c3b61150e5..3e7358d552 100644 --- a/src/Models/RepositoryUIStates.cs +++ b/src/Models/RepositoryUIStates.cs @@ -45,18 +45,6 @@ public double AuthorColumnWidth set; } = 120; - public bool IsRefsColumnVisibleInHistory - { - get; - set; - } = true; - - public double RefsColumnWidth - { - get; - set; - } = 180; - public double GraphColumnWidth { get; diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 0fb48ed5f2..2e31a96f7a 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -517,7 +517,6 @@ Advanced Options AUTHOR AUTHOR TIME - BRANCH / TAG COMMIT TIME GRAPH GRAPH & SUBJECT @@ -678,7 +677,7 @@ Server APPEARANCE Commit Graph Style - Avatars (branches, graph and message in separate columns) + Avatars (graph and message in separate columns) Classic Default Font Editor Tab Width diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index edee0c2e7f..b7df02d6db 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -63,19 +63,6 @@ public bool IsAuthorColumnVisible } } - public bool IsRefsColumnVisible - { - get => _repo.UIStates.IsRefsColumnVisibleInHistory; - set - { - if (_repo.UIStates.IsRefsColumnVisibleInHistory != value) - { - _repo.UIStates.IsRefsColumnVisibleInHistory = value; - OnPropertyChanged(); - } - } - } - public bool IsSHAColumnVisible { get => _repo.UIStates.IsSHAColumnVisibleInHistory; @@ -223,12 +210,6 @@ public double AuthorColumnWidth set => _repo.UIStates.AuthorColumnWidth = value; } - public double RefsColumnWidth - { - get => _repo.UIStates.RefsColumnWidth; - set => _repo.UIStates.RefsColumnWidth = value; - } - /// /// Width of the graph column in `Avatars` style. Zero means fit to the graph. /// diff --git a/src/Views/CommitGraph.cs b/src/Views/CommitGraph.cs index 1f90282187..567d16c553 100644 --- a/src/Views/CommitGraph.cs +++ b/src/Views/CommitGraph.cs @@ -53,24 +53,6 @@ public Models.CommitGraphStyle GraphStyle set => SetValue(GraphStyleProperty, value); } - public static readonly StyledProperty ShowRefConnectorsProperty = - AvaloniaProperty.Register(nameof(ShowRefConnectors)); - - public bool ShowRefConnectors - { - get => GetValue(ShowRefConnectorsProperty); - set => SetValue(ShowRefConnectorsProperty, value); - } - - public static readonly StyledProperty ShowTagsProperty = - AvaloniaProperty.Register(nameof(ShowTags), true); - - public bool ShowTags - { - get => GetValue(ShowTagsProperty); - set => SetValue(ShowTagsProperty, value); - } - /// /// Width required to draw the given graph without clipping in `Avatars` style. /// @@ -91,23 +73,6 @@ public static double GetAvatarsStyleX(double x) return MapX(x, AVATARS_LANE_SCALE); } - public static bool HasVisibleRefs(Models.Commit commit, bool showTags) - { - if (commit == null) - return false; - - if (showTags) - return commit.HasDecorators; - - foreach (var d in commit.Decorators) - { - if (d.Type != Models.DecoratorType.Tag) - return true; - } - - return false; - } - public override void Render(DrawingContext context) { base.Render(context); @@ -170,9 +135,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (change.Property == GraphProperty || change.Property == LayoutProperty || change.Property == DotBrushProperty || - change.Property == GraphStyleProperty || - change.Property == ShowRefConnectorsProperty || - change.Property == ShowTagsProperty) + change.Property == GraphStyleProperty) InvalidateVisual(); } @@ -453,8 +416,6 @@ private void DrawAnchors(DrawingContext context, Models.CommitGraph graph, doubl private void DrawRowDecorations(DrawingContext context, Models.CommitGraph graph, double top, double bottom, double rowHeight, double clipWidth) { - var showConnectors = ShowRefConnectors; - var showTags = ShowTags; var grayedPen = new Pen(new SolidColorBrush(Colors.Gray, 0.4), Models.CommitGraph.Pens[0].Thickness); var bandHeight = Math.Max(rowHeight - 6, 4); @@ -476,10 +437,6 @@ private void DrawRowDecorations(DrawingContext context, Models.CommitGraph graph using (context.PushOpacity(dot.IsHighlighted ? 0.12 : 0.05)) context.FillRectangle(pen.Brush, new Rect(x, y - bandHeight * 0.5, clipWidth - x, bandHeight)); } - - // Connector from the BRANCH / TAG column to the node. - if (showConnectors && HasVisibleRefs(dot.Commit, showTags)) - context.DrawLine(pen, new Point(0, y), new Point(x, y)); } } diff --git a/src/Views/CommitRefsPresenter.cs b/src/Views/CommitRefsPresenter.cs index 06454ffeac..85e33962a1 100644 --- a/src/Views/CommitRefsPresenter.cs +++ b/src/Views/CommitRefsPresenter.cs @@ -73,8 +73,6 @@ public class RenderItem public IBrush Brush { get; set; } = null; public bool IsHead { get; set; } = false; public double Width { get; set; } = 0.0; - public double LabelWidth { get; set; } = 0.0; - public bool IsLabelTrimmed { get; set; } = false; public List Remotes { get; set; } = []; } @@ -159,47 +157,11 @@ public bool ShowTags set => SetValue(ShowTagsProperty, value); } - public static readonly StyledProperty FitToWidthProperty = - AvaloniaProperty.Register(nameof(FitToWidth)); - - /// - /// Only shows the refs that fit in the available width, followed by a `+N` badge. - /// - public bool FitToWidth - { - get => GetValue(FitToWidthProperty); - set => SetValue(FitToWidthProperty, value); - } - - public static readonly StyledProperty ShowConnectorProperty = - AvaloniaProperty.Register(nameof(ShowConnector)); - - /// - /// Draws a line from the last ref to the right edge (towards the commit node in graph). - /// - public bool ShowConnector - { - get => GetValue(ShowConnectorProperty); - set => SetValue(ShowConnectorProperty, value); - } - - public Rect? OverflowBadgeBounds - { - get => _overflowRect; - } - - public bool IsOverflowBadgeAt(Point point) - { - return _overflowRect.HasValue && _overflowRect.Value.Inflate(2).Contains(point); - } - public Models.Decorator DecoratorAt(Point point) { var x = 0.0; - var count = FitToWidth ? _visibleCount : _items.Count; - for (var i = 0; i < count; i++) + foreach (var item in _items) { - var item = _items[i]; x += item.Width; if (point.X < x) return item.Decorator; @@ -220,15 +182,12 @@ public override void Render(DrawingContext context) var x = 1.5; var y = 0.5; var remoteIcon = CommitRefsIconCache.Instance.GetIcon(Models.DecoratorType.RemoteBranchHead); - var hasSingleRemote = HasSingleRemote || FitToWidth; - - var count = FitToWidth ? _visibleCount : _items.Count; + var hasSingleRemote = HasSingleRemote; - context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size)); + context.FillRectangle(Brushes.Transparent, Bounds); - for (var i = 0; i < count; i++) + foreach (var item in _items) { - var item = _items[i]; if (allowWrap && x > 1.5 && x + item.Width > Bounds.Width) { x = 1.5; @@ -291,34 +250,6 @@ public override void Render(DrawingContext context) x += item.Width + 4; } - - _overflowRect = null; - if (count < _items.Count && _overflowLabel != null) - { - var badgeRect = new RoundedRect(new Rect(x, y, _overflowLabel.Width + 8, 16), new CornerRadius(4)); - _overflowRect = badgeRect.Rect; - if (bg != null) - context.DrawRectangle(bg, null, badgeRect); - context.DrawRectangle(null, new Pen(Brushes.Gray), badgeRect); - context.DrawText(_overflowLabel, new Point(x + 4, y + 8.0 - _overflowLabel.Height * 0.5)); - x += badgeRect.Rect.Width + 4; - } - - if (ShowConnector && DataContext is Models.Commit commit && x < Bounds.Width) - { - var graphPen = Models.CommitGraph.Pens[commit.Color]; - var pen = useGraphColor ? graphPen : new Pen(new SolidColorBrush(Colors.Gray, 0.4), graphPen.Thickness); - var cy = y - 0.5 + 8.0; - context.DrawLine(pen, new Point(x - 4, cy), new Point(Bounds.Width, cy)); - } - } - - protected override Size ArrangeOverride(Size finalSize) - { - if (FitToWidth) - UpdateVisibleItems(finalSize.Width); - - return base.ArrangeOverride(finalSize); } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) @@ -332,11 +263,8 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang change.Property == UseCompactBranchNamesProperty || change.Property == HasSingleRemoteProperty || change.Property == BackgroundProperty || - change.Property == ShowTagsProperty || - change.Property == FitToWidthProperty) + change.Property == ShowTagsProperty) InvalidateMeasure(); - else if (change.Property == ShowConnectorProperty) - InvalidateVisual(); } protected override void OnDataContextChanged(EventArgs e) @@ -361,7 +289,7 @@ protected override Size MeasureOverride(Size availableSize) } var useCompactBranchNames = UseCompactBranchNames; - var hasSingleRemote = HasSingleRemote || FitToWidth; + var hasSingleRemote = HasSingleRemote; var typeface = new Typeface(FontFamily); var typefaceHead = new Typeface(FontFamily, FontStyle.Normal, FontWeight.Bold); var typefaceRemote = new Typeface(FontFamily, FontStyle.Italic, FontWeight.Bold); @@ -412,7 +340,6 @@ protected override Size MeasureOverride(Size availableSize) fg); } - item.LabelWidth = item.Label.Width; item.Width = item.Label.Width + 24; var findRemotes = useCompactBranchNames && (decorator.Type == Models.DecoratorType.CurrentBranchHead || decorator.Type == Models.DecoratorType.LocalBranchHead); @@ -441,12 +368,8 @@ protected override Size MeasureOverride(Size availableSize) item.Remotes.Add(remote); - // Only one remote icon is drawn in this mode, whatever the number of remotes. if (hasSingleRemote) - { - if (item.Remotes.Count == 1) - item.Width += 18; - } + item.Width += 18; else item.Width += remote.WidthIncludingTrailingWhitespace + 22; @@ -469,9 +392,7 @@ protected override Size MeasureOverride(Size availableSize) double requiredWidth = 0; if (_items.Count > 0) { - if (FitToWidth && !double.IsInfinity(availableSize.Width)) - requiredWidth = Math.Min(x + 2, availableSize.Width); - else if (allowWrap && requiredHeight > 16.0) + if (allowWrap && requiredHeight > 16.0) requiredWidth = double.IsInfinity(availableSize.Width) ? x + 2 : availableSize.Width; else requiredWidth = x + 2; @@ -481,81 +402,6 @@ protected override Size MeasureOverride(Size availableSize) return new Size(requiredWidth, requiredHeight); } - private void UpdateVisibleItems(double width) - { - var count = _items.Count; - var visible = count; - var x = 1.5; - - // Restore labels that may have been trimmed by previous arrange. - foreach (var item in _items) - { - if (item.IsLabelTrimmed) - { - var trimmedWidth = item.Label.Width; - item.Label.MaxTextWidth = item.LabelWidth + 1; - item.Label.Trimming = TextTrimming.None; - item.Width += item.LabelWidth - trimmedWidth; - item.IsLabelTrimmed = false; - } - } - - // Trim the label of the first ref when it does not fit, so the connector is still visible. - if (count > 0) - { - var first = _items[0]; - var reserved = count > 1 ? MeasureOverflowBadge(count - 1).Width + 12 : 0; - var overflow = 1.5 + first.Width + reserved + 12 - width; - if (overflow > 0) - { - var labelWidth = Math.Max(first.LabelWidth - overflow, 24); - if (labelWidth < first.LabelWidth) - { - first.Label.MaxTextWidth = labelWidth; - first.Label.MaxLineCount = 1; - first.Label.Trimming = TextTrimming.CharacterEllipsis; - first.Width -= first.LabelWidth - first.Label.Width; - first.IsLabelTrimmed = true; - } - } - } - - for (var i = 0; i < count; i++) - { - var remains = count - i - 1; - var reserved = remains > 0 ? MeasureOverflowBadge(remains).Width + 12 : 0; - if (i > 0 && x + _items[i].Width + reserved > width) - { - visible = i; - break; - } - - x += _items[i].Width + 4; - } - - _visibleCount = visible; - _overflowLabel = visible < count ? MeasureOverflowBadge(count - visible) : null; - - // Show the full name of a trimmed ref on hover. - var trimmed = count > 0 && _items[0].IsLabelTrimmed ? _items[0].Decorator.Name : null; - if (!Equals(ToolTip.GetTip(this), trimmed)) - ToolTip.SetTip(this, trimmed); - } - - private FormattedText MeasureOverflowBadge(int hidden) - { - return new FormattedText( - $"+{hidden}", - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - new Typeface(FontFamily), - FontSize, - Foreground); - } - private List _items = new List(); - private int _visibleCount = 0; - private FormattedText _overflowLabel = null; - private Rect? _overflowRect = null; } } diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index 342507d4a0..4899346ee7 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -90,7 +90,7 @@ - @@ -112,47 +112,10 @@ - - - - - - - - - - - - - - - - - - - - - - - - + @@ -238,14 +201,7 @@ VerticalAlignment="Center" UseGraphColor="{Binding IsHighlightedInGraph, Mode=OneWay}" UseCompactBranchNames="{Binding Source={x:Static vm:Preferences.Instance}, Path=UseCompactBranchNamesInGraph}" - HasSingleRemote="{Binding $parent[v:Histories].HasSingleRemote}"> - - - - - - - + HasSingleRemote="{Binding $parent[v:Histories].HasSingleRemote}"/> = 0; @@ -575,13 +565,6 @@ private int GetResizableColumnAt(double x) return -1; var columns = CommitListContainer.Columns; - if (columns[COL_REFS].IsVisible) - { - var dis = GetColumnRightEdge(COL_REFS) - 4 - x; - if (dis < 4 && dis > -4) - return COL_REFS; - } - if (columns[COL_GRAPH].IsVisible) { var dis = GetColumnRightEdge(COL_GRAPH) - 4 - x; @@ -607,20 +590,6 @@ private List CreateColumnsMenuItems(ViewModels.Histories histories) var items = new List() { columnsHeader }; - if (ViewModels.Preferences.Instance.CommitGraphStyle == Models.CommitGraphStyle.Avatars) - { - var refsColumn = new MenuItem(); - refsColumn.Header = App.Text("Histories.Header.BranchesAndTags"); - if (histories.IsRefsColumnVisible) - refsColumn.Icon = this.CreateMenuIcon("Icons.Check"); - refsColumn.Click += (_, ev) => - { - histories.IsRefsColumnVisible = !histories.IsRefsColumnVisible; - ev.Handled = true; - }; - items.Add(refsColumn); - } - var authorColumn = new MenuItem(); authorColumn.Header = App.Text("Histories.Header.Author"); if (histories.IsAuthorColumnVisible) @@ -904,7 +873,6 @@ private void OnCommitListLayoutUpdated(object _1, EventArgs _2) if (_resizingColumn != COL_GRAPH && (!graphColumn.Width.IsAbsolute || Math.Abs(graphColumn.Width.Value - graphWidth) > 0.5)) graphColumn.Width = new(graphWidth, DataGridLengthUnitType.Pixel); - offsetX = GetColumnRightEdge(COL_REFS); clipWidth = graphColumn.ActualWidth; } @@ -980,139 +948,6 @@ private async void OnCommitListDoubleTapped(object sender, TappedEventArgs e) } } - private void OnRefsColumnPointerPressed(object sender, PointerPressedEventArgs e) - { - if (sender is not CommitRefsPresenter { DataContext: Models.Commit commit } presenter) - return; - - if (!e.GetCurrentPoint(presenter).Properties.IsLeftButtonPressed || !presenter.IsOverflowBadgeAt(e.GetPosition(presenter))) - return; - - ShowRefsDropdown(presenter, commit); - e.Handled = true; - } - - private void ShowRefsDropdown(CommitRefsPresenter anchor, Models.Commit commit) - { - if (_refsDropdown is { IsOpen: true }) - { - if (_refsDropdown.Target == anchor) - return; - - _refsDropdown.Hide(); - } - - var repoView = this.FindAncestorOfType(); - if (repoView is not { DataContext: ViewModels.Repository repo } || DataContext is not ViewModels.Histories histories) - return; - - // Open right below the `+N` badge. - var showTags = ViewModels.Preferences.Instance.ShowTagsInGraph; - var flyout = new Flyout() - { - Placement = PlacementMode.BottomEdgeAlignedLeft, - HorizontalOffset = anchor.OverflowBadgeBounds?.X ?? 0, - }; - var list = new ListBox() - { - MaxHeight = 320, - MinWidth = 160, - Background = Brushes.Transparent, - SelectionMode = SelectionMode.Single, - }; - - foreach (var decorator in commit.Decorators) - { - if (!showTags && decorator.Type == Models.DecoratorType.Tag) - continue; - - var iconKey = decorator.Type switch - { - Models.DecoratorType.CurrentBranchHead or Models.DecoratorType.CurrentCommitHead => "Icons.Head", - Models.DecoratorType.RemoteBranchHead => "Icons.Remote", - Models.DecoratorType.Tag => "Icons.Tag", - _ => "Icons.Branch", - }; - - var row = new StackPanel() { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 6 }; - row.Children.Add(this.CreateMenuIcon(iconKey)); - row.Children.Add(new TextBlock() - { - Text = decorator.Name, - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, - FontWeight = decorator.Type is Models.DecoratorType.CurrentBranchHead or Models.DecoratorType.CurrentCommitHead ? FontWeight.Bold : FontWeight.Normal, - }); - - var item = new ListBoxItem() { Content = row, Padding = new Thickness(8, 4) }; - item.DoubleTapped += async (_, ev) => - { - ev.Handled = true; - flyout.Hide(); - await histories.CheckoutBranchByDecoratorAsync(decorator); - }; - item.ContextRequested += (_, ev) => - { - ev.Handled = true; - list.SelectedItem = item; - - var menu = CreateContextMenuForDecorator(repo, commit, decorator); - if (menu == null) - return; - - menu.AddHandler(MenuItem.ClickEvent, (_, _) => flyout.Hide()); - menu.Open(item); - }; - list.Items.Add(item); - } - - flyout.Content = list; - flyout.ShowAt(anchor); - _refsDropdown = flyout; - } - - private ContextMenu CreateContextMenuForDecorator(ViewModels.Repository repo, Models.Commit commit, Models.Decorator decorator) - { - var current = repo.CurrentBranch; - if (current == null) - return null; - - // Reuse the per-ref submenu of the commit's context menu, but show its items directly. - var holder = new ContextMenu(); - switch (decorator.Type) - { - case Models.DecoratorType.CurrentBranchHead: - FillCurrentBranchMenu(holder, repo, current); - break; - case Models.DecoratorType.LocalBranchHead: - var lb = repo.Branches.Find(x => x.IsLocal && decorator.Name.Equals(x.Name, StringComparison.Ordinal)); - if (lb != null) - FillOtherLocalBranchMenu(holder, repo, lb, current, commit.IsMerged); - break; - case Models.DecoratorType.RemoteBranchHead: - var rb = repo.Branches.Find(x => !x.IsLocal && decorator.Name.Equals(x.FriendlyName, StringComparison.Ordinal)); - FillRemoteBranchMenu(holder, repo, rb, current, commit.IsMerged); - break; - case Models.DecoratorType.Tag: - var tag = repo.Tags.Find(x => decorator.Name.Equals(x.Name, StringComparison.Ordinal)); - if (tag != null) - FillTagMenu(holder, repo, tag, current); - break; - } - - if (holder.Items.Count == 0 || holder.Items[0] is not MenuItem submenu) - return null; - - var items = new List(); - foreach (var item in submenu.Items) - items.Add(item); - submenu.Items.Clear(); - - var menu = new ContextMenu(); - foreach (var item in items) - menu.Items.Add(item); - return menu; - } - private void OnCommitGraphLoaded(object sender, RoutedEventArgs e) { // Force-update the graph layout to ensure the graph is correctly rendered when it's loaded. @@ -2232,13 +2067,11 @@ private async Task InteractiveRebaseWithPrefillActionAsync(ViewModels.Repository private AvaloniaList _issueTrackers = null; private bool _isScrollToTopVisible = false; private bool _isDetailsPanelExpanded = true; - private const int COL_REFS = 0; - private const int COL_GRAPH = 1; - private const int COL_SUBJECT = 2; - private const int COL_AUTHOR = 3; + private const int COL_GRAPH = 0; + private const int COL_SUBJECT = 1; + private const int COL_AUTHOR = 2; private int _resizingColumn = -1; - private Flyout _refsDropdown = null; private Cursor _resizingCursor = new(StandardCursorType.SizeWestEast); } } From 3f546c9e6adc77beab35c9296cb37564dc2af4d1 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Thu, 12 Nov 2026 10:15:21 +0100 Subject: [PATCH 3/3] fix: keep the GRAPH header clear of the search and options buttons Co-Authored-By: Claude Opus 5.5 --- src/Views/Histories.axaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index 4899346ee7..9aee602b2d 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -115,7 +115,9 @@ - + + +