Skip to content

Commit f4b4b87

Browse files
committed
feat: add 'Always show current HEAD on the left' option for commit graph
This feature ensures that the current HEAD lineage is always positioned in the leftmost column (Slot 0) of the commit graph. Key technical improvements: 1. **Forward Lineage Pre-scanning**: Correctly identifies HEAD and its descendants to ensure position stability even with complex parallel merges. 2. **Absolute Column Reservation**: Reserves Slot 0 exclusively for the HEAD lineage, preventing other branches from occupying it. 3. **Dynamic Slot Reordering**: Reorders the active path list in every row to keep the HEAD path at the visual start. 4. **Optimized Layout**: Refined margin calculations to eliminate empty column gaps and ensure a compact, readable graph. 5. **Clean Implementation**: Follows project standards with zero LINQ usage and preserved original code comments. Includes UI toggle in Preferences and full localization (EN/ZH).
1 parent 7d4baf0 commit f4b4b87

6 files changed

Lines changed: 89 additions & 25 deletions

File tree

src/Models/CommitGraph.cs

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Collections.Generic;
33

44
using Avalonia;
@@ -62,7 +62,7 @@ public class Dot
6262
public List<Link> Links { get; } = [];
6363
public List<Dot> Dots { get; } = [];
6464

65-
public static CommitGraph Parse(List<Commit> commits, bool firstParentOnlyEnabled)
65+
public static CommitGraph Parse(List<Commit> commits, bool firstParentOnlyEnabled, bool alwaysShowCurrentHeadOnLeft)
6666
{
6767
const double unitWidth = 12;
6868
const double halfWidth = 6;
@@ -75,6 +75,23 @@ public static CommitGraph Parse(List<Commit> commits, bool firstParentOnlyEnable
7575
var offsetY = -halfHeight;
7676
var colorPicker = new ColorPicker();
7777

78+
// 1. Pre-scan for HEAD lineage to ensure stability
79+
var headPathSHAs = new HashSet<string>();
80+
if (alwaysShowCurrentHeadOnLeft)
81+
{
82+
var head = commits.Find(x => x.IsCurrentHead);
83+
if (head != null)
84+
{
85+
headPathSHAs.Add(head.SHA);
86+
for (int i = commits.IndexOf(head) - 1; i >= 0; i--)
87+
{
88+
if (commits[i].Parents.Count > 0 && headPathSHAs.Contains(commits[i].Parents[0]))
89+
headPathSHAs.Add(commits[i].SHA);
90+
}
91+
}
92+
}
93+
94+
PathHelper headPath = null;
7895
foreach (var commit in commits)
7996
{
8097
PathHelper major = null;
@@ -83,26 +100,44 @@ public static CommitGraph Parse(List<Commit> commits, bool firstParentOnlyEnable
83100
// Update current y offset
84101
offsetY += unitHeight;
85102

86-
// Find first curves that links to this commit and marks others that links to this commit ended.
87-
var offsetX = 4 - halfWidth;
88-
var maxOffsetOld = unsolved.Count > 0 ? unsolved[^1].LastX : offsetX + unitWidth;
103+
// 2. Reorder unsolved list to keep HEAD lineage at Slot 0
104+
if (alwaysShowCurrentHeadOnLeft && headPath != null && unsolved.Contains(headPath))
105+
{
106+
unsolved.Remove(headPath);
107+
unsolved.Insert(0, headPath);
108+
}
109+
110+
// 3. Track max X used in previous rows for margin calculation
111+
var maxOffsetOld = 0.0;
112+
for (int i = 0; i < unsolved.Count; i++)
113+
{
114+
if (unsolved[i].LastX > maxOffsetOld)
115+
maxOffsetOld = unsolved[i].LastX;
116+
}
117+
118+
// 4. Process existing paths with slot reservation
119+
var currentOffsetX = 4 - halfWidth;
89120
foreach (var l in unsolved)
90121
{
122+
currentOffsetX += unitWidth;
123+
124+
// Reserve Slot 0 (X=10) exclusively for HEAD lineage
125+
if (alwaysShowCurrentHeadOnLeft && headPathSHAs.Count > 0 && l != headPath && currentOffsetX == 10.0)
126+
currentOffsetX += unitWidth;
127+
91128
if (l.Next.Equals(commit.SHA, StringComparison.Ordinal))
92129
{
93130
if (major == null)
94131
{
95-
offsetX += unitWidth;
96132
major = l;
97-
98133
if (commit.Parents.Count > 0)
99134
{
100135
major.Next = commit.Parents[0];
101-
major.Goto(offsetX, offsetY, halfHeight);
136+
major.Goto(currentOffsetX, offsetY, halfHeight);
102137
}
103138
else
104139
{
105-
major.End(offsetX, offsetY, halfHeight);
140+
major.End(currentOffsetX, offsetY, halfHeight);
106141
ended.Add(l);
107142
}
108143
}
@@ -116,8 +151,7 @@ public static CommitGraph Parse(List<Commit> commits, bool firstParentOnlyEnable
116151
}
117152
else
118153
{
119-
offsetX += unitWidth;
120-
l.Pass(offsetX, offsetY, halfHeight);
154+
l.Pass(currentOffsetX, offsetY, halfHeight);
121155
}
122156
}
123157

@@ -129,15 +163,28 @@ public static CommitGraph Parse(List<Commit> commits, bool firstParentOnlyEnable
129163
}
130164
ended.Clear();
131165

132-
// If no path found, create new curve for branch head
133-
// Otherwise, create new curve for new merged commit
166+
// 5. Create new curves for branch tips or merge commits
134167
if (major == null)
135168
{
136-
offsetX += unitWidth;
137-
138-
if (commit.Parents.Count > 0)
169+
if (alwaysShowCurrentHeadOnLeft && headPathSHAs.Contains(commit.SHA))
139170
{
140-
major = new PathHelper(commit.Parents[0], isMerged, colorPicker.Next(), new Point(offsetX, offsetY));
171+
if (commit.Parents.Count > 0)
172+
{
173+
var headX = 10.0;
174+
major = new PathHelper(commit.Parents[0], isMerged, colorPicker.Next(), new Point(headX, offsetY));
175+
headPath = major;
176+
unsolved.Insert(0, major);
177+
temp.Paths.Add(major.Path);
178+
if (headX > currentOffsetX) currentOffsetX = headX;
179+
}
180+
}
181+
else if (commit.Parents.Count > 0)
182+
{
183+
currentOffsetX += unitWidth;
184+
if (alwaysShowCurrentHeadOnLeft && headPathSHAs.Count > 0 && currentOffsetX == 10.0)
185+
currentOffsetX += unitWidth;
186+
187+
major = new PathHelper(commit.Parents[0], isMerged, colorPicker.Next(), new Point(currentOffsetX, offsetY));
141188
unsolved.Add(major);
142189
temp.Paths.Add(major.Path);
143190
}
@@ -148,8 +195,9 @@ public static CommitGraph Parse(List<Commit> commits, bool firstParentOnlyEnable
148195
temp.Paths.Add(major.Path);
149196
}
150197

198+
151199
// Calculate link position of this commit.
152-
var position = new Point(major?.LastX ?? offsetX, offsetY);
200+
var position = new Point(major?.LastX ?? Math.Max(currentOffsetX, 10.0), offsetY);
153201
var dotColor = major?.Path.Color ?? 0;
154202
var anchor = new Dot() { Center = position, Color = dotColor, IsMerged = isMerged };
155203
if (commit.IsCurrentHead)
@@ -187,10 +235,12 @@ public static CommitGraph Parse(List<Commit> commits, bool firstParentOnlyEnable
187235
}
188236
else
189237
{
190-
offsetX += unitWidth;
238+
currentOffsetX += unitWidth;
239+
if (alwaysShowCurrentHeadOnLeft && headPathSHAs.Count > 0 && currentOffsetX == 10.0)
240+
currentOffsetX += unitWidth;
191241

192242
// Create new curve for parent commit that not includes before
193-
var l = new PathHelper(parentHash, isMerged, colorPicker.Next(), position, new Point(offsetX, position.Y + halfHeight));
243+
var l = new PathHelper(parentHash, isMerged, colorPicker.Next(), position, new Point(currentOffsetX, position.Y + halfHeight));
194244
unsolved.Add(l);
195245
temp.Paths.Add(l.Path);
196246
}
@@ -200,7 +250,7 @@ public static CommitGraph Parse(List<Commit> commits, bool firstParentOnlyEnable
200250
// Margins & merge state (used by Views.Histories).
201251
commit.IsMerged = isMerged;
202252
commit.Color = dotColor;
203-
commit.LeftMargin = Math.Max(offsetX, maxOffsetOld) + halfWidth + 2;
253+
commit.LeftMargin = Math.Max(currentOffsetX, maxOffsetOld) + halfWidth + 2;
204254
}
205255

206256
// Deal with curves haven't ended yet.

src/Resources/Locales/en_US.axaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,7 @@
653653
<x:String x:Key="Text.Preferences.DiffMerge.Path.Placeholder" xml:space="preserve">Input path for diff/merge tool</x:String>
654654
<x:String x:Key="Text.Preferences.DiffMerge.Type" xml:space="preserve">Tool</x:String>
655655
<x:String x:Key="Text.Preferences.General" xml:space="preserve">GENERAL</x:String>
656+
<x:String x:Key="Text.Preferences.General.AlwaysShowCurrentHeadOnLeft" xml:space="preserve">Always show current HEAD on the left</x:String>
656657
<x:String x:Key="Text.Preferences.General.Check4UpdatesOnStartup" xml:space="preserve">Check for updates on startup</x:String>
657658
<x:String x:Key="Text.Preferences.General.DateFormat" xml:space="preserve">Date Format</x:String>
658659
<x:String x:Key="Text.Preferences.General.EnableCompactFolders" xml:space="preserve">Enable compact folders in changes tree</x:String>

src/Resources/Locales/zh_CN.axaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,7 @@
657657
<x:String x:Key="Text.Preferences.DiffMerge.Path.Placeholder" xml:space="preserve">填写工具可执行文件所在位置</x:String>
658658
<x:String x:Key="Text.Preferences.DiffMerge.Type" xml:space="preserve">工具</x:String>
659659
<x:String x:Key="Text.Preferences.General" xml:space="preserve">通用配置</x:String>
660+
<x:String x:Key="Text.Preferences.General.AlwaysShowCurrentHeadOnLeft" xml:space="preserve">HEAD 路径永远显示在最左边</x:String>
660661
<x:String x:Key="Text.Preferences.General.Check4UpdatesOnStartup" xml:space="preserve">启动时检测软件更新</x:String>
661662
<x:String x:Key="Text.Preferences.General.DateFormat" xml:space="preserve">日期时间格式</x:String>
662663
<x:String x:Key="Text.Preferences.General.EnableCompactFolders" xml:space="preserve">在变更列表树中启用紧凑文件夹模式</x:String>

src/ViewModels/Preferences.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,12 @@ public string IgnoreUpdateTag
211211
set => SetProperty(ref _ignoreUpdateTag, value);
212212
}
213213

214+
public bool AlwaysShowCurrentHeadOnLeftInGraph
215+
{
216+
get => _alwaysShowCurrentHeadOnLeftInGraph;
217+
set => SetProperty(ref _alwaysShowCurrentHeadOnLeftInGraph, value);
218+
}
219+
214220
public bool ShowTagsInGraph
215221
{
216222
get => _showTagsInGraph;
@@ -833,6 +839,7 @@ private bool RemoveInvalidRepositoriesRecursive(List<RepositoryNode> collection)
833839
private string _ignoreUpdateTag = string.Empty;
834840

835841
private bool _showTagsInGraph = true;
842+
private bool _alwaysShowCurrentHeadOnLeftInGraph = false;
836843
private bool _useTwoColumnsLayoutInHistories = false;
837844
private bool _displayTimeAsPeriodInHistories = false;
838845
private bool _useSideBySideDiff = false;

src/ViewModels/Repository.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1212,7 +1212,7 @@ public void RefreshCommits()
12121212
.Append(_uiStates.BuildHistoryParams());
12131213

12141214
var commits = await new Commands.QueryCommits(FullPath, builder.ToString()).GetResultAsync().ConfigureAwait(false);
1215-
var graph = Models.CommitGraph.Parse(commits, _uiStates.HistoryShowFlags.HasFlag(Models.HistoryShowFlags.FirstParentOnly));
1215+
var graph = Models.CommitGraph.Parse(commits, _uiStates.HistoryShowFlags.HasFlag(Models.HistoryShowFlags.FirstParentOnly), Preferences.Instance.AlwaysShowCurrentHeadOnLeftInGraph);
12161216

12171217
Dispatcher.UIThread.Invoke(() =>
12181218
{

src/Views/Preferences.axaml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
<TabItem.Header>
4848
<TextBlock Classes="tab_header" Text="{DynamicResource Text.Preferences.General}"/>
4949
</TabItem.Header>
50-
<Grid Margin="8" RowDefinitions="32,32,32,32,32,32,32,32,32,32,32,32,Auto" ColumnDefinitions="Auto,*">
50+
<Grid Margin="8" RowDefinitions="32,32,32,32,32,32,32,32,32,32,32,32,32,Auto" ColumnDefinitions="Auto,*">
5151
<TextBlock Grid.Row="0" Grid.Column="0"
5252
Text="{DynamicResource Text.Preferences.General.Locale}"
5353
HorizontalAlignment="Right"
@@ -167,16 +167,21 @@
167167
IsChecked="{Binding ShowChildren, Mode=TwoWay}"/>
168168

169169
<CheckBox Grid.Row="10" Grid.Column="1"
170+
Height="32"
171+
Content="{DynamicResource Text.Preferences.General.AlwaysShowCurrentHeadOnLeft}"
172+
IsChecked="{Binding AlwaysShowCurrentHeadOnLeftInGraph, Mode=TwoWay}"/>
173+
174+
<CheckBox Grid.Row="11" Grid.Column="1"
170175
Height="32"
171176
Content="{DynamicResource Text.Preferences.General.EnableCompactFolders}"
172177
IsChecked="{Binding EnableCompactFoldersInChangesTree, Mode=TwoWay}"/>
173178

174-
<CheckBox Grid.Row="11" Grid.Column="1"
179+
<CheckBox Grid.Row="12" Grid.Column="1"
175180
Height="32"
176181
Content="{DynamicResource Text.Preferences.General.UseGitHubStyleAvatar}"
177182
IsChecked="{Binding UseGitHubStyleAvatar, Mode=TwoWay}"/>
178183

179-
<CheckBox Grid.Row="12" Grid.Column="1"
184+
<CheckBox Grid.Row="13" Grid.Column="1"
180185
Height="32"
181186
Content="{DynamicResource Text.Preferences.General.Check4UpdatesOnStartup}"
182187
IsVisible="{x:Static s:App.IsCheckForUpdateCommandVisible}"

0 commit comments

Comments
 (0)