From 47ca8bee540a6f20bcf609878e06324c6c3e0ddb Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 12 Oct 2026 20:20:03 +0200 Subject: [PATCH] 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 @@ - + - + + + + + + - -