Skip to content

Commit ddc1ef5

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 ddc1ef5

6 files changed

Lines changed: 102 additions & 32 deletions

File tree

src/Models/CommitGraph.cs

Lines changed: 84 additions & 28 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,49 +75,114 @@ public static CommitGraph Parse(List<Commit> commits, bool firstParentOnlyEnable
7575
var offsetY = -halfHeight;
7676
var colorPicker = new ColorPicker();
7777

78+
// 1. Pre-scan for the Grand Lineage (Trunk)
79+
var headPathSHAs = new HashSet<string>();
80+
string topTrunkSHA = null;
81+
if (alwaysShowCurrentHeadOnLeft)
82+
{
83+
var head = commits.Find(x => x.IsCurrentHead);
84+
if (head != null)
85+
{
86+
headPathSHAs.Add(head.SHA);
87+
88+
// Trace DOWN (Ancestors) via first parent
89+
string currentDown = head.SHA;
90+
for (int i = commits.IndexOf(head); i < commits.Count; i++)
91+
{
92+
if (commits[i].SHA == currentDown)
93+
{
94+
headPathSHAs.Add(currentDown);
95+
if (commits[i].Parents.Count > 0)
96+
currentDown = commits[i].Parents[0];
97+
}
98+
}
99+
100+
// Trace UP (Descendants) via strict single line
101+
string currentUp = head.SHA;
102+
for (int i = commits.IndexOf(head) - 1; i >= 0; i--)
103+
{
104+
if (commits[i].Parents.Count > 0 && commits[i].Parents[0] == currentUp)
105+
{
106+
headPathSHAs.Add(commits[i].SHA);
107+
currentUp = commits[i].SHA;
108+
}
109+
}
110+
111+
// Find the top-most trunk commit to anchor the Phantom Path
112+
foreach (var c in commits)
113+
{
114+
if (headPathSHAs.Contains(c.SHA))
115+
{
116+
topTrunkSHA = c.SHA;
117+
break;
118+
}
119+
}
120+
}
121+
}
122+
123+
// 2. Inject Phantom Path for Trunk at Y=0.
124+
// This forces the Trunk to occupy Slot 0 (X=10) permanently, simulating an uncommitted node.
125+
if (topTrunkSHA != null)
126+
{
127+
var phantom = new PathHelper(topTrunkSHA, false, colorPicker.Next(), new Point(10.0, offsetY));
128+
phantom.IsTrunk = true;
129+
unsolved.Add(phantom);
130+
temp.Paths.Add(phantom.Path);
131+
}
132+
78133
foreach (var commit in commits)
79134
{
80135
PathHelper major = null;
81136
var isMerged = commit.IsMerged;
137+
bool isCommitTrunk = alwaysShowCurrentHeadOnLeft && headPathSHAs.Contains(commit.SHA);
82138

83139
// Update current y offset
84140
offsetY += unitHeight;
85141

86142
// 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;
143+
var maxOffsetOld = 0.0;
144+
for (int i = 0; i < unsolved.Count; i++)
145+
{
146+
if (unsolved[i].LastX > maxOffsetOld)
147+
maxOffsetOld = unsolved[i].LastX;
148+
}
149+
150+
var currentOffsetX = 4 - halfWidth;
89151
foreach (var l in unsolved)
90152
{
153+
currentOffsetX += unitWidth;
154+
91155
if (l.Next.Equals(commit.SHA, StringComparison.Ordinal))
92156
{
93-
if (major == null)
157+
// Only Trunk paths can claim major status for Trunk commits.
158+
bool canBeMajor = !isCommitTrunk || l.IsTrunk;
159+
160+
if (major == null && canBeMajor)
94161
{
95-
offsetX += unitWidth;
96162
major = l;
97163

98164
if (commit.Parents.Count > 0)
99165
{
100166
major.Next = commit.Parents[0];
101-
major.Goto(offsetX, offsetY, halfHeight);
167+
major.Goto(currentOffsetX, offsetY, halfHeight);
102168
}
103169
else
104170
{
105-
major.End(offsetX, offsetY, halfHeight);
171+
major.End(currentOffsetX, offsetY, halfHeight);
106172
ended.Add(l);
107173
}
108174
}
109175
else
110176
{
111-
l.End(major.LastX, offsetY, halfHeight);
177+
l.End(major?.LastX ?? currentOffsetX, offsetY, halfHeight);
112178
ended.Add(l);
113179
}
114180

115181
isMerged = isMerged || l.IsMerged;
116182
}
117183
else
118184
{
119-
offsetX += unitWidth;
120-
l.Pass(offsetX, offsetY, halfHeight);
185+
l.Pass(currentOffsetX, offsetY, halfHeight);
121186
}
122187
}
123188

@@ -133,11 +198,10 @@ public static CommitGraph Parse(List<Commit> commits, bool firstParentOnlyEnable
133198
// Otherwise, create new curve for new merged commit
134199
if (major == null)
135200
{
136-
offsetX += unitWidth;
137-
138201
if (commit.Parents.Count > 0)
139202
{
140-
major = new PathHelper(commit.Parents[0], isMerged, colorPicker.Next(), new Point(offsetX, offsetY));
203+
currentOffsetX += unitWidth;
204+
major = new PathHelper(commit.Parents[0], isMerged, colorPicker.Next(), new Point(currentOffsetX, offsetY));
141205
unsolved.Add(major);
142206
temp.Paths.Add(major.Path);
143207
}
@@ -149,7 +213,7 @@ public static CommitGraph Parse(List<Commit> commits, bool firstParentOnlyEnable
149213
}
150214

151215
// Calculate link position of this commit.
152-
var position = new Point(major?.LastX ?? offsetX, offsetY);
216+
var position = new Point(major?.LastX ?? Math.Max(currentOffsetX, 10.0), offsetY);
153217
var dotColor = major?.Path.Color ?? 0;
154218
var anchor = new Dot() { Center = position, Color = dotColor, IsMerged = isMerged };
155219
if (commit.IsCurrentHead)
@@ -187,10 +251,10 @@ public static CommitGraph Parse(List<Commit> commits, bool firstParentOnlyEnable
187251
}
188252
else
189253
{
190-
offsetX += unitWidth;
254+
currentOffsetX += unitWidth;
191255

192256
// 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));
257+
var l = new PathHelper(parentHash, isMerged, colorPicker.Next(), position, new Point(currentOffsetX, position.Y + halfHeight));
194258
unsolved.Add(l);
195259
temp.Paths.Add(l.Path);
196260
}
@@ -200,7 +264,7 @@ public static CommitGraph Parse(List<Commit> commits, bool firstParentOnlyEnable
200264
// Margins & merge state (used by Views.Histories).
201265
commit.IsMerged = isMerged;
202266
commit.Color = dotColor;
203-
commit.LeftMargin = Math.Max(offsetX, maxOffsetOld) + halfWidth + 2;
267+
commit.LeftMargin = Math.Max(currentOffsetX, maxOffsetOld) + halfWidth + 2;
204268
}
205269

206270
// Deal with curves haven't ended yet.
@@ -246,6 +310,7 @@ private class PathHelper
246310
public Path Path { get; private set; }
247311
public string Next { get; set; }
248312
public double LastX { get; private set; }
313+
public bool IsTrunk { get; set; } = false;
249314

250315
public bool IsMerged => Path.IsMerged;
251316

@@ -273,9 +338,6 @@ public PathHelper(string next, bool isMerged, int color, Point start, Point to)
273338
/// <summary>
274339
/// A path that just passed this row.
275340
/// </summary>
276-
/// <param name="x"></param>
277-
/// <param name="y"></param>
278-
/// <param name="halfHeight"></param>
279341
public void Pass(double x, double y, double halfHeight)
280342
{
281343
if (x > LastX)
@@ -297,9 +359,6 @@ public void Pass(double x, double y, double halfHeight)
297359
/// <summary>
298360
/// A path that has commit in this row but not ended
299361
/// </summary>
300-
/// <param name="x"></param>
301-
/// <param name="y"></param>
302-
/// <param name="halfHeight"></param>
303362
public void Goto(double x, double y, double halfHeight)
304363
{
305364
if (x > LastX)
@@ -324,9 +383,6 @@ public void Goto(double x, double y, double halfHeight)
324383
/// <summary>
325384
/// A path that has commit in this row and end.
326385
/// </summary>
327-
/// <param name="x"></param>
328-
/// <param name="y"></param>
329-
/// <param name="halfHeight"></param>
330386
public void End(double x, double y, double halfHeight)
331387
{
332388
if (x > LastX)
@@ -385,4 +441,4 @@ private void Add(double x, double y)
385441
Colors.Teal,
386442
];
387443
}
388-
}
444+
}

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)