From 10824c3b243fd38480bfc1f8f9b5de129421d365 Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Thu, 29 Oct 2026 18:06:56 +0100 Subject: [PATCH] feat: show resizable commit message above Changes diff --- src/Resources/Locales/en_US.axaml | 2 + src/ViewModels/CommitDetail.cs | 21 +- src/ViewModels/LayoutInfo.cs | 36 ++- src/Views/CommitChanges.axaml | 83 +++++- src/Views/CommitChanges.axaml.cs | 14 + src/Views/CommitMessagePresenter.cs | 17 +- .../ChangesMessageChecks.csproj | 11 + tests/ChangesMessageChecks/Program.cs | 244 ++++++++++++++++++ tests/ChangesMessageChecks/README.md | 19 ++ 9 files changed, 432 insertions(+), 15 deletions(-) create mode 100644 tests/ChangesMessageChecks/ChangesMessageChecks.csproj create mode 100644 tests/ChangesMessageChecks/Program.cs create mode 100644 tests/ChangesMessageChecks/README.md diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 62ac98e487..f5956c2f33 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -183,6 +183,8 @@ Save as Patch... CHANGES changed file(s) + Collapse commit message + Expand commit message Search Changes... Collapse details FILES diff --git a/src/ViewModels/CommitDetail.cs b/src/ViewModels/CommitDetail.cs index 636aad6654..4668d4322f 100644 --- a/src/ViewModels/CommitDetail.cs +++ b/src/ViewModels/CommitDetail.cs @@ -67,6 +67,12 @@ public Models.CommitFullMessage FullMessage private set => SetProperty(ref _fullMessage, value); } + public Vector ChangesMessageScrollOffset + { + get => _changesMessageScrollOffset; + set => SetProperty(ref _changesMessageScrollOffset, value); + } + public Models.CommitSignInfo SignInfo { get => _signInfo; @@ -453,6 +459,11 @@ await Commands.SaveRevisionFile private void Refresh() { + if (_cancellationSource is { IsCancellationRequested: false }) + _cancellationSource.Cancel(); + + FullMessage = null; + ChangesMessageScrollOffset = Vector.Zero; _requestingRevisionFiles = false; _revisionFiles = null; @@ -472,15 +483,13 @@ private void Refresh() return; } - if (_cancellationSource is { IsCancellationRequested: false }) - _cancellationSource.Cancel(); - _cancellationSource = new CancellationTokenSource(); var token = _cancellationSource.Token; + var messageCommitSHA = _commit.SHA; Task.Run(async () => { - var message = await new Commands.QueryCommitFullMessage(_repo.FullPath, _commit.SHA) + var message = await new Commands.QueryCommitFullMessage(_repo.FullPath, messageCommitSHA) .GetResultAsync() .ConfigureAwait(false); var inlines = await ParseInlinesInMessageAsync(message).ConfigureAwait(false); @@ -488,6 +497,9 @@ private void Refresh() if (!token.IsCancellationRequested) Dispatcher.UIThread.Post(() => { + if (token.IsCancellationRequested) + return; + FullMessage = new Models.CommitFullMessage { Message = message, @@ -732,6 +744,7 @@ private async Task SetViewingCommitAsync(Models.Object file) private CommitDetailSharedData _sharedData = null; private Models.Commit _commit = null; private Models.CommitFullMessage _fullMessage = null; + private Vector _changesMessageScrollOffset = Vector.Zero; private Models.CommitSignInfo _signInfo = null; private List _changes = []; private List _visibleChanges = []; diff --git a/src/ViewModels/LayoutInfo.cs b/src/ViewModels/LayoutInfo.cs index 43b58f5013..a60d2440c8 100644 --- a/src/ViewModels/LayoutInfo.cs +++ b/src/ViewModels/LayoutInfo.cs @@ -1,4 +1,5 @@ -using Avalonia.Controls; +using System.Text.Json.Serialization; +using Avalonia.Controls; using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels @@ -71,6 +72,37 @@ public GridLength CommitDetailChangesLeftWidth set => SetProperty(ref _commitDetailChangesLeftWidth, value); } + public GridLength CommitDetailChangesMessageHeight + { + get => _commitDetailChangesMessageHeight; + set + { + if (SetProperty(ref _commitDetailChangesMessageHeight, value)) + OnPropertyChanged(nameof(CommitDetailChangesMessageRowHeight)); + } + } + + public bool IsCommitDetailChangesMessageExpanded + { + get => _isCommitDetailChangesMessageExpanded; + set + { + if (SetProperty(ref _isCommitDetailChangesMessageExpanded, value)) + OnPropertyChanged(nameof(CommitDetailChangesMessageRowHeight)); + } + } + + [JsonIgnore] + public GridLength CommitDetailChangesMessageRowHeight + { + get => _isCommitDetailChangesMessageExpanded ? _commitDetailChangesMessageHeight : GridLength.Auto; + set + { + if (_isCommitDetailChangesMessageExpanded) + CommitDetailChangesMessageHeight = value; + } + } + public GridLength CommitDetailFilesLeftWidth { get => _commitDetailFilesLeftWidth; @@ -81,6 +113,8 @@ public GridLength CommitDetailFilesLeftWidth private GridLength _stashesLeftWidth = new GridLength(300, GridUnitType.Pixel); private GridLength _commitDetailChangesLeftWidth = new GridLength(256, GridUnitType.Pixel); private GridLength _commitDetailFilesLeftWidth = new GridLength(256, GridUnitType.Pixel); + private GridLength _commitDetailChangesMessageHeight = new GridLength(96, GridUnitType.Pixel); + private bool _isCommitDetailChangesMessageExpanded = true; private bool _isSidebarCollapsedInLocalChanges = false; } } diff --git a/src/Views/CommitChanges.axaml b/src/Views/CommitChanges.axaml index d14b14eca4..6d00870568 100644 --- a/src/Views/CommitChanges.axaml +++ b/src/Views/CommitChanges.axaml @@ -3,6 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:SourceGit.ViewModels" + xmlns:m="using:SourceGit.Models" xmlns:v="using:SourceGit.Views" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.CommitChanges" @@ -81,8 +82,84 @@ Background="Transparent" Focusable="False"/> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/src/Views/CommitChanges.axaml.cs b/src/Views/CommitChanges.axaml.cs index 5ff13d5938..dd2801360f 100644 --- a/src/Views/CommitChanges.axaml.cs +++ b/src/Views/CommitChanges.axaml.cs @@ -4,6 +4,7 @@ using System.Text; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.Platform.Storage; using Avalonia.VisualTree; @@ -16,6 +17,19 @@ public CommitChanges() InitializeComponent(); } + private void OnDiffAreaSizeChanged(object sender, SizeChangedEventArgs e) + { + // Keep room for the diff even when restoring a taller message pane. + ChangesDiffArea.RowDefinitions[0].MaxHeight = Math.Max(28, e.NewSize.Height - 84); + } + + private async void OnCopyCommitMessage(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.CommitDetail { FullMessage: { } message }) + await this.CopyTextAsync(message.Message); + e.Handled = true; + } + private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) { e.Handled = true; diff --git a/src/Views/CommitMessagePresenter.cs b/src/Views/CommitMessagePresenter.cs index 3ee4c3c07d..6dd333eb9b 100644 --- a/src/Views/CommitMessagePresenter.cs +++ b/src/Views/CommitMessagePresenter.cs @@ -126,8 +126,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) if (type == Models.InlineElementType.CommitSHA) { - var parentView = this.FindAncestorOfType(); - if (parentView is { DataContext: ViewModels.CommitDetail detail }) + if (GetCommitDetail() is { } detail) { if (point.Properties.IsLeftButtonPressed) { @@ -249,6 +248,12 @@ protected override void OnPointerExited(PointerEventArgs e) ClearHoveredIssueLink(); } + private ViewModels.CommitDetail GetCommitDetail() + { + return DataContext as ViewModels.CommitDetail ?? + this.FindAncestorOfType()?.DataContext as ViewModels.CommitDetail; + } + private void ProcessHoverCommitLink(Models.InlineElement link) { var sha = link.Link; @@ -259,8 +264,7 @@ private void ProcessHoverCommitLink(Models.InlineElement link) return; } - var parentView = this.FindAncestorOfType(); - if (parentView is { DataContext: ViewModels.CommitDetail detail }) + if (GetCommitDetail() is { Commit: { } } detail) { var lastDetailCommit = detail.Commit.SHA; Task.Run(async () => @@ -269,9 +273,8 @@ private void ProcessHoverCommitLink(Models.InlineElement link) Dispatcher.UIThread.Post(() => { - var currentParent = this.FindAncestorOfType(); - if (currentParent is { DataContext: ViewModels.CommitDetail currentDetail } && - currentDetail.Commit.SHA.Equals(lastDetailCommit, StringComparison.Ordinal)) + if (GetCommitDetail() is { Commit: { } currentCommit } && + currentCommit.SHA.Equals(lastDetailCommit, StringComparison.Ordinal)) { _inlineCommits.TryAdd(sha, c); if (_lastHover == link && c != null) diff --git a/tests/ChangesMessageChecks/ChangesMessageChecks.csproj b/tests/ChangesMessageChecks/ChangesMessageChecks.csproj new file mode 100644 index 0000000000..cbfcb000ed --- /dev/null +++ b/tests/ChangesMessageChecks/ChangesMessageChecks.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + + + + + + diff --git a/tests/ChangesMessageChecks/Program.cs b/tests/ChangesMessageChecks/Program.cs new file mode 100644 index 0000000000..58565a30d2 --- /dev/null +++ b/tests/ChangesMessageChecks/Program.cs @@ -0,0 +1,244 @@ +using System.Diagnostics; +using System.Reflection; +using System.Text.Json; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Input; +using Avalonia.Media.Fonts; +using Avalonia.Styling; +using Avalonia.Threading; +using Avalonia.VisualTree; +using SourceGit; +using SourceGit.Commands; +using SourceGit.ViewModels; +using Views = SourceGit.Views; + +internal static class Program +{ + private static int _checks; + + private static void Main(string[] args) + { + var fixture = Path.Combine(Path.GetTempPath(), "sourcegit-changes-message-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(fixture); + SourceGit.Native.OS.GitExecutable = "git"; + SourceGit.Native.OS.BasicDirectories.ConfigDir = fixture; + SourceGit.Native.OS.BasicDirectories.CacheDir = fixture; + typeof(Preferences).GetField("_instance", BindingFlags.NonPublic | BindingFlags.Static)!.SetValue(null, new Preferences()); + AppBuilder.Configure().UseSkia().WithInterFont() + .ConfigureFonts(manager => manager.AddFontCollection(new EmbeddedFontCollection( + new Uri("fonts:SourceGit"), new Uri("avares://SourceGit/Resources/Fonts")))) + .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }).SetupWithoutStarting(); + Window window = null; + CommitDetail detail = null; + try + { + Git(fixture, "init", "-b", "main"); + Git(fixture, "config", "user.name", "Test Author"); + Git(fixture, "config", "user.email", "test@example.com"); + File.WriteAllText(Path.Combine(fixture, "example.txt"), "original\n"); + Git(fixture, "add", "example.txt"); + Git(fixture, "commit", "-m", "Initial file"); + var originalSHA = Git(fixture, "rev-parse", "HEAD").Trim(); + var body = string.Join("\n", Enumerable.Range(1, 24).Select(i => $"Message line {i}: full explanation of the change.")) + + "\n\nRelated commit: " + originalSHA + "\nhttps://example.com/issues/42\nInline `code` and Unicode: \u00e1rv\u00edzt\u0171r\u0151\nFinal body line"; + File.WriteAllText(Path.Combine(fixture, "example.txt"), "updated\n"); + Git(fixture, "add", "example.txt"); + Git(fixture, "commit", "-m", "Explain the complete change", "-m", body); + var messageSHA = Git(fixture, "rev-parse", "HEAD").Trim(); + File.WriteAllText(Path.Combine(fixture, "example.txt"), "follow-up\n"); + Git(fixture, "add", "example.txt"); + Git(fixture, "commit", "-m", "Short follow-up", "-m", "A short commit body."); + var commits = new QueryCommits(fixture, "HEAD").GetResultAsync().GetAwaiter().GetResult(); + var longCommit = commits.Single(c => c.SHA == messageSHA); + var shortCommit = commits[0]; + var repo = new Repository(false, fixture, Path.Combine(fixture, ".git")); + detail = new CommitDetail(repo, new CommitDetailSharedData { ActiveTabIndex = 1 }) { Commit = longCommit }; + var view = new Views.CommitDetail { DataContext = detail }; + window = new Window { Width = 1100, Height = 650, Content = view }; + window.Show(); + WaitFor(() => detail.FullMessage?.Message.EndsWith("Final body line") == true && detail.DiffContext != null); + Arrange(window); + var changes = view.GetVisualDescendants().OfType().Single(); + var area = changes.FindControl("ChangesDiffArea")!; + var messageArea = changes.FindControl("CommitMessageArea")!; + var scroll = changes.FindControl("CommitMessageScrollViewer")!; + var message = changes.FindControl("ChangesCommitMessage")!; + var preview = changes.FindControl("CollapsedCommitMessage")!; + var toggle = changes.FindControl("CommitMessageToggle")!; + var splitter = changes.FindControl("CommitMessageSplitter")!; + var diff = changes.FindControl("ChangesDiffContent")!; + var files = changes.GetVisualDescendants().OfType().Single(); + var layout = Preferences.Instance.Layout; + var initialHeight = messageArea.Bounds.Height; + Check(layout.IsCommitDetailChangesMessageExpanded && Near(initialHeight, 96), "message pane starts expanded with a few lines reserved"); + Check(message.Inlines!.Text == detail.FullMessage.Message && message.Inlines.Text.Contains("Message line 24") && + message.Inlines.Text.Contains("Final body line"), "all message lines are available without truncation"); + Check(ReferenceEquals(message.FullMessage, detail.FullMessage) && detail.FullMessage.Inlines.Count >= 3, + "message reuses parsed links, commit references, and inline formatting"); + Check(scroll.Extent.Height > scroll.Viewport.Height && scroll.VerticalScrollBarVisibility == ScrollBarVisibility.Auto, + "long messages scroll inside the reserved area"); + Check(diff.Bounds.Top >= messageArea.Bounds.Bottom + area.RowDefinitions[1].ActualHeight - 1 && diff.Bounds.Height > 80, + "diff remains below message and splitter"); + Check(files.Bounds.Height > messageArea.Bounds.Height * 3, "changed-file list keeps its full height"); + var filesHeight = files.Bounds.Height; + Click(window, toggle); + Arrange(window); + Check(!layout.IsCommitDetailChangesMessageExpanded && messageArea.Bounds.Height <= 30 && preview.IsVisible && preview.Text == longCommit.Subject, + "collapse shows only a single subject line"); + Check(!scroll.IsVisible && !splitter.IsEnabled, "collapsed message does not leave a scrollable body or active resize handle"); + Click(window, toggle); + Arrange(window); + Check(Near(messageArea.Bounds.Height, initialHeight), "expand restores previous height"); + var start = splitter.TranslatePoint(new Point(splitter.Bounds.Width / 2, splitter.Bounds.Height / 2), window)!.Value; + window.MouseDown(start, MouseButton.Left, RawInputModifiers.None); + window.MouseMove(start + new Vector(0, 40), RawInputModifiers.LeftMouseButton); + window.MouseUp(start + new Vector(0, 40), MouseButton.Left, RawInputModifiers.None); + Arrange(window); + var resizedHeight = messageArea.Bounds.Height; + Check(resizedHeight > initialHeight + 20 && Near(resizedHeight, layout.CommitDetailChangesMessageHeight.Value), + "dragging horizontal splitter resizes and remembers message height"); + Check(Near(files.Bounds.Height, filesHeight), "resizing message leaves changed-file list height unchanged"); + Click(window, toggle); + Arrange(window); + var layoutOptions = new JsonSerializerOptions(); + layoutOptions.Converters.Add(new GridLengthConverter()); + var saved = JsonSerializer.Serialize(layout, layoutOptions); + var restored = JsonSerializer.Deserialize(saved, layoutOptions)!; + Check(!restored.IsCommitDetailChangesMessageExpanded && Near(restored.CommitDetailChangesMessageHeight.Value, resizedHeight), + "saved preferences preserve collapsed state and expanded height independently"); + Click(window, toggle); + Arrange(window); + Check(Near(messageArea.Bounds.Height, resizedHeight), "expanding after resize restores resized height"); + scroll.Offset = new Vector(0, scroll.Extent.Height); + Arrange(window); + Check(scroll.Offset.Y > 0, "message can scroll to its last lines"); + detail.Commit = shortCommit; + WaitFor(() => detail.FullMessage?.Message.Contains("A short commit body.") == true); + Arrange(window); + Check(message.Inlines!.Text.Contains("A short commit body.") && !message.Inlines.Text.Contains("Message line 24"), + "changing selected commit updates the displayed full message"); + Check(scroll.Offset.Y == 0 && Near(messageArea.Bounds.Height, resizedHeight), "commit changes reset message scroll and retain chosen size"); + Click(window, toggle); + detail.Commit = longCommit; + WaitFor(() => detail.FullMessage?.Message.EndsWith("Final body line") == true); + Arrange(window); + Check(preview.Text == longCommit.Subject && messageArea.Bounds.Height <= 30, "collapsed subject follows selected commit"); + Click(window, toggle); + detail.ActiveTabIndex = 0; + Arrange(window); + detail.ActiveTabIndex = 1; + Arrange(window); + Check(layout.IsCommitDetailChangesMessageExpanded && Near(messageArea.Bounds.Height, resizedHeight), "switching detail tabs retains pane size"); + window.Height = 220; + Arrange(window); + Check(messageArea.Bounds.Height <= Math.Max(28, area.Bounds.Height - 84) + 1 && diff.Bounds.Height >= 79, + "small layouts leave room for the diff"); + window.Height = 650; + Arrange(window); + Check(Near(messageArea.Bounds.Height, resizedHeight), "larger layout restores preferred message size"); + if (args.Length > 0) + { + foreach (var theme in new[] { ThemeVariant.Light, ThemeVariant.Dark }) + { + Application.Current!.RequestedThemeVariant = theme; + Arrange(window); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + using var frame = window.CaptureRenderedFrame(); + frame!.Save(Path.Combine(args[0], $"changes-message-{theme.ToString().ToLowerInvariant()}.png")); + } + Click(window, toggle); + Arrange(window); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + using var collapsed = window.CaptureRenderedFrame(); + collapsed!.Save(Path.Combine(args[0], "changes-message-collapsed.png")); + } + detail.Commit = shortCommit; + detail.Commit = longCommit; + detail.Commit = shortCommit; + WaitFor(() => detail.FullMessage?.Message.Contains("A short commit body.") == true); + Arrange(window); + Check(!message.Inlines!.Text.Contains("Final body line"), "rapid commit selection does not retain an earlier message"); + detail.Commit = longCommit; + detail.Commit = null; + WaitFor(() => detail.FullMessage == null); + Arrange(window); + Check(message.Inlines!.Count == 0, "clearing commit clears the message pane"); + Console.WriteLine($"Passed {_checks} Changes message checks."); + } + finally + { + window?.Close(); + if (detail != null) + detail.Commit = null; + if (!Path.GetFullPath(fixture).StartsWith(Path.GetFullPath(Path.GetTempPath()), StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Unexpected fixture path"); + for (var attempt = 0; attempt < 30; attempt++) + { + try + { + foreach (var file in Directory.EnumerateFiles(fixture, "*", SearchOption.AllDirectories)) + File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(fixture, true); + break; + } + catch (IOException) when (attempt < 29) { Dispatcher.UIThread.RunJobs(); Thread.Sleep(100); } + catch (UnauthorizedAccessException) when (attempt < 29) { Dispatcher.UIThread.RunJobs(); Thread.Sleep(100); } + } + } + } + + private static void Click(Window window, Control control) + { + var point = control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)!.Value; + window.MouseDown(point, MouseButton.Left, RawInputModifiers.None); + window.MouseUp(point, MouseButton.Left, RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + } + + private static void Arrange(Window window) + { + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + } + + private static bool Near(double left, double right) => Math.Abs(left - right) < 1; + + private static void WaitFor(Func condition) + { + var timer = Stopwatch.StartNew(); + while (!condition()) + { + if (timer.Elapsed > TimeSpan.FromSeconds(15)) + throw new Exception("Timed out waiting for commit details"); + Dispatcher.UIThread.RunJobs(); + Thread.Sleep(10); + } + Dispatcher.UIThread.RunJobs(); + } + + private static void Check(bool condition, string label) + { + if (!condition) + throw new Exception(label); + _checks++; + Console.WriteLine("PASS " + label); + } + + private static string Git(string cwd, params string[] args) + { + var start = new ProcessStartInfo("git") { WorkingDirectory = cwd, RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (var arg in args) + start.ArgumentList.Add(arg); + using var process = Process.Start(start)!; + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new Exception(error); + return output; + } +} diff --git a/tests/ChangesMessageChecks/README.md b/tests/ChangesMessageChecks/README.md new file mode 100644 index 0000000000..fea13c3771 --- /dev/null +++ b/tests/ChangesMessageChecks/README.md @@ -0,0 +1,19 @@ +# Changes tab commit-message checks + +Run from the repository root: + +```sh +dotnet run --project tests/ChangesMessageChecks +``` + +The checks use Avalonia Headless and a temporary Git repository. They cover +complete multiline messages and parsed formatting, one-line collapse, restoring +the expanded size, actual splitter dragging, saved layout preferences, commit +switching, scroll reset, tab changes, and preserving diff space in small windows. +User preferences are isolated inside the temporary fixture. + +Pass an existing output directory to save light, dark, and collapsed previews: + +```sh +dotnet run --project tests/ChangesMessageChecks -- /absolute/path/to/previews +```