diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml
index 1b1f29d8f..27a2fc2c6 100644
--- a/src/Resources/Locales/en_US.axaml
+++ b/src/Resources/Locales/en_US.axaml
@@ -830,6 +830,7 @@
LAYOUT
Horizontal
Vertical
+ Wide Details
COMMITS ORDER
Commit Date
Topologically
diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs
index 9d9fb3f9f..f4b1058f8 100644
--- a/src/ViewModels/Preferences.cs
+++ b/src/ViewModels/Preferences.cs
@@ -214,7 +214,21 @@ public bool UseCompactBranchNamesInGraph
public bool UseTwoColumnsLayoutInHistories
{
get => _useTwoColumnsLayoutInHistories;
- set => SetProperty(ref _useTwoColumnsLayoutInHistories, value);
+ set
+ {
+ if (SetProperty(ref _useTwoColumnsLayoutInHistories, value) && value)
+ UseWideDetailsLayoutInHistories = false;
+ }
+ }
+
+ public bool UseWideDetailsLayoutInHistories
+ {
+ get => _useWideDetailsLayoutInHistories;
+ set
+ {
+ if (SetProperty(ref _useWideDetailsLayoutInHistories, value) && value)
+ UseTwoColumnsLayoutInHistories = false;
+ }
}
public bool DisplayTimeAsPeriodInHistories
@@ -813,6 +827,7 @@ private bool RemoveInvalidRepositoriesRecursive(List collection)
private bool _showTagsInGraph = true;
private bool _useTwoColumnsLayoutInHistories = false;
+ private bool _useWideDetailsLayoutInHistories = false;
private bool _displayTimeAsPeriodInHistories = false;
private bool _useSideBySideDiff = false;
private bool _ignoreWhitespaceChangesInDiff = false;
diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml
index a3d24529f..682babf9d 100644
--- a/src/Views/Histories.axaml
+++ b/src/Views/Histories.axaml
@@ -23,282 +23,284 @@
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bisect="{Binding $parent[v:Histories].Bisect}"
+ VerticalAlignment="Center"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ Opacity="{Binding IsMerged, Converter={x:Static c:BoolConverters.IsMergedToOpacity}}"/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
HistoryArea;
+
public static readonly DirectProperty CurrentBranchProperty =
AvaloniaProperty.RegisterDirect(
nameof(CurrentBranch),
@@ -550,11 +552,22 @@ private void OnOpenAdvancedOptions(object sender, RoutedEventArgs e)
var vertical = new MenuItem();
vertical.Header = App.Text("Repository.HistoriesLayout.Vertical");
- if (!isHorizontal)
+ if (!isHorizontal && !pref.UseWideDetailsLayoutInHistories)
vertical.Icon = this.CreateMenuIcon("Icons.Check");
vertical.Click += (_, ev) =>
{
pref.UseTwoColumnsLayoutInHistories = false;
+ pref.UseWideDetailsLayoutInHistories = false;
+ ev.Handled = true;
+ };
+
+ var wideDetails = new MenuItem();
+ wideDetails.Header = App.Text("Repository.HistoriesLayout.WideDetails");
+ if (pref.UseWideDetailsLayoutInHistories)
+ wideDetails.Icon = this.CreateMenuIcon("Icons.Check");
+ wideDetails.Click += (_, ev) =>
+ {
+ pref.UseWideDetailsLayoutInHistories = true;
ev.Handled = true;
};
@@ -724,6 +737,7 @@ private void OnOpenAdvancedOptions(object sender, RoutedEventArgs e)
menu.Items.Add(layout);
menu.Items.Add(horizontal);
menu.Items.Add(vertical);
+ menu.Items.Add(wideDetails);
menu.Items.Add(new MenuItem() { Header = "-" });
menu.Items.Add(showFlags);
menu.Items.Add(reflog);
diff --git a/src/Views/Repository.axaml b/src/Views/Repository.axaml
index 700577347..704316fc8 100644
--- a/src/Views/Repository.axaml
+++ b/src/Views/Repository.axaml
@@ -10,6 +10,12 @@
x:Class="SourceGit.Views.Repository"
x:DataType="vm:Repository"
x:Name="ThisControl">
+
+
+
+
+
+
@@ -250,7 +256,7 @@
-
+
@@ -258,7 +264,9 @@
-
+
-
-
- IsWideDetailsLayoutActiveProperty =
+ AvaloniaProperty.RegisterDirect(
+ nameof(IsWideDetailsLayoutActive),
+ static o => o.IsWideDetailsLayoutActive,
+ static (o, v) => o.IsWideDetailsLayoutActive = v);
+
+ public bool IsWideDetailsLayoutActive
+ {
+ get => _isWideDetailsLayoutActive;
+ set
+ {
+ if (SetAndRaise(IsWideDetailsLayoutActiveProperty, ref _isWideDetailsLayoutActive, value))
+ UpdateSidebarPlacement();
+ }
+ }
+
public static readonly DirectProperty IsCommitSearchPanelVisibleProperty =
AvaloniaProperty.RegisterDirect(
nameof(IsCommitSearchPanelVisible),
@@ -85,6 +101,33 @@ public Repository()
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
+ UpdateSidebarPlacement();
+ UpdateLeftSidebarLayout();
+ }
+
+ private void UpdateSidebarPlacement()
+ {
+ if (MainBody == null || DashboardHistories == null || _sidebarInHistory == _isWideDetailsLayoutActive)
+ return;
+
+ var historyArea = DashboardHistories.SidebarHost;
+ var source = _sidebarInHistory ? historyArea : MainBody;
+ var target = _isWideDetailsLayoutActive ? historyArea : MainBody;
+
+ // Move the existing sidebar and its bound width together so resizing and
+ // collapse/search state work identically in either location.
+ source.Children.Remove(Sidebar);
+ source.Children.Remove(CommitSearchPanel);
+ source.Children.Remove(SidebarSplitter);
+
+ var column = source.ColumnDefinitions[0];
+ source.ColumnDefinitions[0] = target.ColumnDefinitions[0];
+ target.ColumnDefinitions[0] = column;
+
+ target.Children.Add(Sidebar);
+ target.Children.Add(CommitSearchPanel);
+ target.Children.Add(SidebarSplitter);
+ _sidebarInHistory = _isWideDetailsLayoutActive;
UpdateLeftSidebarLayout();
}
@@ -531,6 +574,8 @@ private void CalculateSidebarWidth()
}
private bool _isNormalSidebarVisible = true;
+ private bool _isWideDetailsLayoutActive = false;
+ private bool _sidebarInHistory = false;
private bool _isCommitSearchPanelVisible = false;
private double _sidebarMinWidth = 200;
private GridLength _sidebarWidth;
diff --git a/tests/LayoutChecks/LayoutChecks.csproj b/tests/LayoutChecks/LayoutChecks.csproj
new file mode 100644
index 000000000..fd214d7b7
--- /dev/null
+++ b/tests/LayoutChecks/LayoutChecks.csproj
@@ -0,0 +1,10 @@
+
+
+ Exe
+ net10.0
+ enable
+
+
+
+
+
diff --git a/tests/LayoutChecks/Program.cs b/tests/LayoutChecks/Program.cs
new file mode 100644
index 000000000..10a1ef880
--- /dev/null
+++ b/tests/LayoutChecks/Program.cs
@@ -0,0 +1,236 @@
+using System.Diagnostics;
+using System.Reflection;
+using System.Text.Json;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using SourceGit;
+using SourceGit.ViewModels;
+using Views = SourceGit.Views;
+
+internal static class Program
+{
+ private static int _checks;
+
+ [STAThread]
+ private static void Main(string[] args)
+ {
+ var fixture = Path.Combine(Path.GetTempPath(), "sourcegit-layout-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(fixture);
+ SourceGit.Native.OS.BasicDirectories.ConfigDir = fixture;
+ SourceGit.Native.OS.BasicDirectories.CacheDir = fixture;
+ Repository repo = null;
+ Window window = null;
+ try
+ {
+ App.BuildAvaloniaApp().SetupWithoutStarting();
+ App.SetLocale("en_US");
+ Git(fixture, "init -b main");
+ Git(fixture, "config user.name Test");
+ Git(fixture, "config user.email test@example.com");
+ Git(fixture, "commit --allow-empty -m initial");
+ repo = new Repository(false, fixture, Path.Combine(fixture, ".git"));
+ repo.Open();
+ var pref = Preferences.Instance;
+ pref.SetCanModify();
+ pref.UseWideDetailsLayoutInHistories = true;
+ var view = new Views.Repository { DataContext = repo };
+ window = new Window { Title = "SourceGit Layout Checks", Width = 1100, Height = 800, Content = view };
+ window.Show();
+ PumpUntil(() => !repo.Histories.IsLoading && repo.Histories.Commits.Count == 1);
+ repo.Histories.SelectedCommits = [repo.Histories.Commits[0]];
+ PumpUntil(() => repo.Histories.DetailContext is CommitDetail);
+ CheckLayouts(repo, view, window, args.Contains("--render"));
+ Console.WriteLine($"PASS: {_checks} layout checks");
+ }
+ finally
+ {
+ window?.Close();
+ repo?.Close();
+ if (!Path.GetFullPath(fixture).StartsWith(Path.GetFullPath(Path.GetTempPath()), StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException("Fixture must stay inside the temporary directory");
+ foreach (var file in Directory.EnumerateFiles(fixture, "*", SearchOption.AllDirectories))
+ File.SetAttributes(file, FileAttributes.Normal);
+ Directory.Delete(fixture, true);
+ }
+ }
+
+ private static void CheckLayouts(Repository repo, Views.Repository view, Window window, bool render)
+ {
+ var pref = Preferences.Instance;
+ var histories = view.FindControl("DashboardHistories");
+ var layout = histories.GetVisualDescendants().OfType().Single();
+ var historyArea = histories.FindControl("HistoryArea");
+ var sidebar = view.FindControl("Sidebar");
+ var mainBody = view.FindControl("MainBody");
+ var details = layout.Children[2];
+ var list = histories.FindControl("CommitListContainer");
+ var splitter = view.FindControl("SidebarSplitter");
+
+ foreach (var width in new[] { 1100, 800 })
+ {
+ window.Width = width;
+ pref.UseWideDetailsLayoutInHistories = true;
+ Arrange(window);
+ Check(ReferenceEquals(sidebar.Parent, historyArea), "startup/switch to Wide Details moves sidebar beside history");
+ Check(ReferenceEquals(sidebar.DataContext, repo), "sidebar retains repository context");
+ Check(ReferenceEquals(splitter.Parent, historyArea), "sidebar splitter moves with sidebar");
+ Check(Near(details.Bounds.Width, mainBody.Bounds.Width), "Wide Details fills entire main body width");
+ Check(Near(sidebar.TranslatePoint(default, view)!.Value.Y + sidebar.Bounds.Height,
+ details.TranslatePoint(default, view)!.Value.Y - 3), "sidebar ends above details splitter");
+ Check(list.Bounds.Width < details.Bounds.Width - 100, "sidebar reduces history width only");
+
+ var detailsWidth = details.Bounds.Width;
+ var historyWidth = list.Bounds.Width;
+ var sidebarWidth = view.SidebarWidth;
+ historyArea.ColumnDefinitions[0].Width = new GridLength(sidebarWidth.Value + 40);
+ Arrange(window);
+ Check(Near(view.SidebarWidth.Value, sidebarWidth.Value + 40), "resizing sidebar updates bound width");
+ Check(Near(details.Bounds.Width, detailsWidth) && Near(list.Bounds.Width, historyWidth - 40), "sidebar resizing leaves details unchanged");
+ view.SidebarWidth = sidebarWidth;
+
+ view.IsNormalSidebarVisible = false;
+ Arrange(window);
+ Check(Near(list.Bounds.Width, details.Bounds.Width), "collapsed sidebar returns all width to history");
+ view.IsCommitSearchPanelVisible = true;
+ Arrange(window);
+ Check(view.FindControl("CommitSearchPanel").Bounds.Width >= 200, "commit search uses upper sidebar");
+ CheckCommitSearch(repo, view, window, true);
+ Check(Near(details.Bounds.Width, detailsWidth), "commit search leaves full width details");
+ view.IsCommitSearchPanelVisible = false;
+ view.IsNormalSidebarVisible = true;
+
+ repo.Histories.IsCollapseDetails = true;
+ Arrange(window);
+ Check(Near(details.Bounds.Height, 28) && Near(details.Bounds.Width, mainBody.Bounds.Width), "Wide Details collapses to a full width tab bar");
+ repo.Histories.IsCollapseDetails = false;
+
+ foreach (var page in new[] { 1, 2, 0 })
+ {
+ repo.SelectedViewIndex = page;
+ Arrange(window);
+ Check(ReferenceEquals(sidebar.Parent, page == 0 ? historyArea : mainBody), "sidebar follows page selection");
+ CheckCommitSearch(repo, view, window);
+ }
+
+ if (render)
+ {
+ using var bitmap = new RenderTargetBitmap(new PixelSize(width, 800));
+ bitmap.Render(window);
+ bitmap.Save(Path.Combine(AppContext.BaseDirectory, $"wide-details-{width}.png"));
+ }
+
+ pref.UseTwoColumnsLayoutInHistories = true;
+ Arrange(window);
+ Check(!pref.UseWideDetailsLayoutInHistories && ReferenceEquals(sidebar.Parent, mainBody), "Horizontal restores outer sidebar");
+ Check(Near(details.Bounds.Height, layout.Bounds.Height), "Horizontal details are beside history");
+ CheckCommitSearch(repo, view, window);
+ pref.UseTwoColumnsLayoutInHistories = false;
+ Arrange(window);
+ Check(ReferenceEquals(sidebar.Parent, mainBody) && details.Bounds.Width < mainBody.Bounds.Width - 100, "Vertical preserves original sidebar arrangement");
+ CheckCommitSearch(repo, view, window);
+ }
+
+ pref.UseWideDetailsLayoutInHistories = true;
+ CheckAdvancedOptions(histories, window);
+ pref.Save();
+ using var saved = JsonDocument.Parse(File.ReadAllText(Path.Combine(SourceGit.Native.OS.BasicDirectories.ConfigDir, "preference.json")));
+ Check(saved.RootElement.GetProperty("UseWideDetailsLayoutInHistories").GetBoolean(), "Wide Details preference is persisted");
+ }
+
+ private static void CheckCommitSearch(Repository repo, Views.Repository view, Window window, bool runQuery = false)
+ {
+ var search = view.FindControl("CommitSearchPanel");
+ var context = repo.Histories.SearchCommitContext;
+ Check(ReferenceEquals(search.DataContext, context), "commit search retains its context after reparenting");
+ var spinner = search.GetVisualDescendants().OfType().Single();
+ Check(!spinner.IsVisible && !context.IsQuerying, "idle commit search hides spinner before any query");
+
+ if (!runQuery)
+ return;
+
+ repo.Histories.IsSearchingCommits = true;
+ var input = search.FindControl("TxtSearchCommitsBox");
+ input.Text = "initial";
+ Arrange(window);
+ Check(context.Filter == "initial", "search input updates the search context");
+ context.StartSearch();
+ Check(spinner.IsVisible, "active commit search shows spinner");
+ PumpUntil(() => !context.IsQuerying);
+ Check(!spinner.IsVisible && context.Results?.Count == 1, "commit search completes with a result and hides spinner");
+ context.ClearFilter();
+ repo.Histories.IsSearchingCommits = false;
+ Arrange(window);
+ Check(string.IsNullOrEmpty(input.Text) && !spinner.IsVisible, "clearing and closing commit search resets its controls");
+ }
+
+ private static void CheckAdvancedOptions(Views.Histories histories, Window window)
+ {
+ var pref = Preferences.Instance;
+ var button = histories.GetVisualDescendants().OfType