From b2f1e1961b5a09d99897bcfe775ef808740b5ab5 Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Wed, 28 Oct 2026 17:27:27 +0100 Subject: [PATCH 1/2] Add Wide Details history layout option --- src/Resources/Locales/en_US.axaml | 1 + src/ViewModels/Preferences.cs | 17 +- src/Views/Histories.axaml | 536 +++++++++++++------------ src/Views/Histories.axaml.cs | 16 +- src/Views/Repository.axaml | 18 +- src/Views/Repository.axaml.cs | 45 +++ tests/LayoutChecks/LayoutChecks.csproj | 10 + tests/LayoutChecks/Program.cs | 206 ++++++++++ 8 files changed, 575 insertions(+), 274 deletions(-) create mode 100644 tests/LayoutChecks/LayoutChecks.csproj create mode 100644 tests/LayoutChecks/Program.cs diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 1b1f29d8fb..27a2fc2c6b 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 9d9fb3f9f9..f4b1058f8a 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 a3d24529fd..682babf9d0 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 7005773478..80b2531c09 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 0000000000..fd214d7b72 --- /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 0000000000..ae517b9f1a --- /dev/null +++ b/tests/LayoutChecks/Program.cs @@ -0,0 +1,206 @@ +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"); + 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"); + } + + 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"); + pref.UseTwoColumnsLayoutInHistories = false; + Arrange(window); + Check(ReferenceEquals(sidebar.Parent, mainBody) && details.Bounds.Width < mainBody.Bounds.Width - 100, "Vertical preserves original sidebar arrangement"); + } + + 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 CheckAdvancedOptions(Views.Histories histories, Window window) + { + var pref = Preferences.Instance; + var button = histories.GetVisualDescendants().OfType