From c6c928d1d246c3019f01ef7f859e29a338b7abb4 Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Thu, 12 Nov 2026 19:02:16 +0100 Subject: [PATCH 1/4] Preserve filenames when shortening changed-file paths --- src/Resources/Locales/en_US.axaml | 1 + src/ViewModels/Preferences.cs | 7 + src/Views/ChangeCollectionView.axaml | 11 +- src/Views/ChangePathTextBlock.cs | 133 ++++++++++++++++++ src/Views/Preferences.axaml | 6 +- .../ChangePathChecks/ChangePathChecks.csproj | 10 ++ tests/ChangePathChecks/Program.cs | 90 ++++++++++++ 7 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 src/Views/ChangePathTextBlock.cs create mode 100644 tests/ChangePathChecks/ChangePathChecks.csproj create mode 100644 tests/ChangePathChecks/Program.cs diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 603d223a9c..2e92fc6df6 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -691,6 +691,7 @@ Monospace Font Theme Theme Overrides + Shorten leading paths in changed-file lists (keep filenames) Use auto-hide scrollbars Use fixed tab width in titlebar Use native window frame diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs index 9d9fb3f9f9..aad981812a 100644 --- a/src/ViewModels/Preferences.cs +++ b/src/ViewModels/Preferences.cs @@ -295,6 +295,12 @@ public int ImageDiffActiveIdx set => SetProperty(ref _imageDiffActiveIdx, value); } + public bool TrimLeadingPathsInChanges + { + get => _trimLeadingPathsInChanges; + set => SetProperty(ref _trimLeadingPathsInChanges, value); + } + public bool EnableCompactFoldersInChangesTree { get => _enableCompactFoldersInChangesTree; @@ -823,6 +829,7 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) private bool _useFullTextDiff = false; private int _lfsImageActiveIdx = 0; private int _imageDiffActiveIdx = 0; + private bool _trimLeadingPathsInChanges = false; private bool _enableCompactFoldersInChangesTree = false; private Models.ChangeViewMode _changeViewMode = Models.ChangeViewMode.List; diff --git a/src/Views/ChangeCollectionView.axaml b/src/Views/ChangeCollectionView.axaml index be9143d1bb..6600ae6a91 100644 --- a/src/Views/ChangeCollectionView.axaml +++ b/src/Views/ChangeCollectionView.axaml @@ -122,7 +122,8 @@ SelectionChanged="OnRowSelectionChanged"> - - - - + + diff --git a/src/Views/ChangePathTextBlock.cs b/src/Views/ChangePathTextBlock.cs new file mode 100644 index 0000000000..3ec3e26ae0 --- /dev/null +++ b/src/Views/ChangePathTextBlock.cs @@ -0,0 +1,133 @@ +using System; +using System.Globalization; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + // Measure against the viewport while allowing an exceptionally long filename + // to contribute its full width to the horizontal scroll extent. + public class ChangeListRow : Grid + { + public static readonly StyledProperty TrimLeadingPathsProperty = + AvaloniaProperty.Register(nameof(TrimLeadingPaths), true); + + public bool TrimLeadingPaths + { + get => GetValue(TrimLeadingPathsProperty); + set => SetValue(TrimLeadingPathsProperty, value); + } + public static readonly StyledProperty ViewportWidthProperty = + AvaloniaProperty.Register(nameof(ViewportWidth), double.PositiveInfinity); + + public double ViewportWidth + { + get => GetValue(ViewportWidthProperty); + set => SetValue(ViewportWidthProperty, value); + } + + static ChangeListRow() + { + AffectsMeasure(ViewportWidthProperty, TrimLeadingPathsProperty); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _scrollViewer = this.FindAncestorOfType(); + if (_scrollViewer != null) + { + _scrollViewer.PropertyChanged += OnScrollViewerPropertyChanged; + SetCurrentValue(ViewportWidthProperty, _scrollViewer.Viewport.Width); + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + if (_scrollViewer != null) + _scrollViewer.PropertyChanged -= OnScrollViewerPropertyChanged; + _scrollViewer = null; + base.OnDetachedFromVisualTree(e); + } + + private void OnScrollViewerPropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == ScrollViewer.ViewportProperty) + SetCurrentValue(ViewportWidthProperty, _scrollViewer.Viewport.Width); + } + + private ScrollViewer _scrollViewer; + protected override Size MeasureOverride(Size availableSize) + { + if (!TrimLeadingPaths) + return base.MeasureOverride(availableSize); + + var size = base.MeasureOverride(new Size(Math.Min(availableSize.Width, Math.Max(0, ViewportWidth)), availableSize.Height)); + var minimum = 0.0; + foreach (var child in Children) + { + if (child.IsVisible) + minimum += child is ChangePathTextBlock path ? path.MinWidth + path.Margin.Left + path.Margin.Right : child.DesiredSize.Width; + } + return new Size(Math.Max(size.Width, minimum), size.Height); + } + } + + public class ChangePathTextBlock : TextBlock + { + public static readonly StyledProperty PathProperty = + AvaloniaProperty.Register(nameof(Path), string.Empty); + + public string Path + { + get => GetValue(PathProperty); + set => SetValue(PathProperty, value); + } + + static ChangePathTextBlock() + { + AffectsMeasure(PathProperty); + } + + private Size MeasureText(string text) + { + var formatted = new FormattedText(text, CultureInfo.CurrentCulture, FlowDirection, + new Typeface(FontFamily, FontStyle, FontWeight, FontStretch), FontSize, Foreground); + return new Size(formatted.WidthIncludingTrailingWhitespace, formatted.Height); + } + + protected override Size MeasureOverride(Size availableSize) + { + var path = Path ?? string.Empty; + var fileStart = path.LastIndexOf('/') + 1; + Text = (fileStart > 0 ? "…" : string.Empty) + path[fileStart..]; + MinWidth = MeasureText(Text).Width; + Text = path; + var fullSize = MeasureText(Text); + var separator = path.LastIndexOf('/'); + if (fullSize.Width <= availableSize.Width || separator < 0) + return base.MeasureOverride(Size.Infinity); + + // Search text-element boundaries so trimming cannot split a surrogate + // pair or a combining sequence. Only the directory may be removed. + var starts = StringInfo.ParseCombiningCharacters(path[..(separator + 1)]); + var low = 1; + var high = starts.Length; + while (low < high) + { + var mid = low + (high - low) / 2; + Text = "…" + path[starts[mid]..]; + if (MeasureText(Text).Width <= availableSize.Width) + high = mid; + else + low = mid + 1; + } + + Text = "…" + path[(low < starts.Length ? starts[low] : separator + 1)..]; + return base.MeasureOverride(Size.Infinity); + } + } +} diff --git a/src/Views/Preferences.axaml b/src/Views/Preferences.axaml index 6b3aa863ca..358c3001e4 100644 --- a/src/Views/Preferences.axaml +++ b/src/Views/Preferences.axaml @@ -156,6 +156,10 @@ Content="{DynamicResource Text.Preferences.General.ShowTagsInGraph}" IsChecked="{Binding ShowTagsInGraph, Mode=TwoWay}"/> + - + + + Exe + net10.0 + enable + + + + + diff --git a/tests/ChangePathChecks/Program.cs b/tests/ChangePathChecks/Program.cs new file mode 100644 index 0000000000..f3d0de03cf --- /dev/null +++ b/tests/ChangePathChecks/Program.cs @@ -0,0 +1,90 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Threading; +using Avalonia.VisualTree; +using SourceGit.Views; + +internal static class Program +{ + [STAThread] + private static void Main() + { + SourceGit.App.BuildAvaloniaApp().SetupWithoutStarting(); + var text = new ChangePathTextBlock { Path = "very/long/directory/structure/filename.cs" }; + Measure(text, 1000); + Check(text.Text == text.Path, "Full path fits"); + Measure(text, 180); + Check(text.Text.StartsWith("…") && text.Text.EndsWith("filename.cs"), "Leading ellipsis preserves filename"); + Check(text.DesiredSize.Width <= 180, "Trimmed path fits"); + Measure(text, 1); + Check(text.Text == "…filename.cs" && text.MinWidth > 1, "Narrow viewport preserves whole filename"); + Measure(text, 1000); + Check(text.Text == text.Path, "Widening restores path"); + text.Path = "filename-without-directory.cs"; + Measure(text, 1); + Check(text.Text == text.Path, "Root filename never trimmed"); + text.Path = "a/😀e\u0301/very/long/filename.cs"; + Measure(text, 160); + Check(text.Text.EndsWith("filename.cs") && !text.Text.Contains('\uFFFD'), "Unicode filename preserved"); + text.Path = ""; + Measure(text, 1); + Check(string.IsNullOrEmpty(text.Text), "Empty path"); + + var row = new ChangeListRow { ColumnDefinitions = ColumnDefinitions.Parse("Auto,Auto,*"), ViewportWidth = 180 }; + var icon = new Border { Width = 18 }; + text.Path = "very/long/directory/structure/filename.cs"; + Grid.SetColumn(text, 2); + row.Children.Add(icon); + row.Children.Add(text); + row.Measure(Size.Infinity); + Check(text.Text.StartsWith("…") && text.DesiredSize.Width <= 162, "Row constrains path to viewport"); + row.ViewportWidth = 25; + row.Measure(Size.Infinity); + Check(text.Text == "…filename.cs" && row.DesiredSize.Width > 25, "Long filename keeps horizontal scroll extent"); + SourceGit.ViewModels.Preferences.Instance.SetCanModify(); + SourceGit.ViewModels.Preferences.Instance.TrimLeadingPathsInChanges = true; + foreach (var unstaged in new[] { true, false }) + { + var view = new ChangeCollectionView + { + IsUnstagedChange = unstaged, + ViewMode = SourceGit.Models.ChangeViewMode.List, + Changes = [new SourceGit.Models.Change { Path = "very/long/directory/structure/filename.cs" }], + }; + var window = new Window { Width = 180, Height = 150, Content = view }; + window.Show(); + for (var i = 0; i < 10; i++) + Dispatcher.UIThread.RunJobs(); + var label = view.GetVisualDescendants().OfType().Single(); + Check(label.Text.StartsWith("…") && label.Text.EndsWith("filename.cs"), "Actual staged/unstaged template truncates correctly"); + var preferences = SourceGit.ViewModels.Preferences.Instance; + preferences.SetCanModify(); + preferences.TrimLeadingPathsInChanges = false; + for (var i = 0; i < 10; i++) + Dispatcher.UIThread.RunJobs(); + Check(label.Text == label.Path, "Preference disables trimming immediately"); + preferences.TrimLeadingPathsInChanges = true; + for (var i = 0; i < 10; i++) + Dispatcher.UIThread.RunJobs(); + Check(label.Text.StartsWith("…"), "Preference enables trimming immediately"); + window.Width = 800; + for (var i = 0; i < 10; i++) + Dispatcher.UIThread.RunJobs(); + Check(label.Text == label.Path, "Actual template responds to resize"); + window.Close(); + } + Console.WriteLine("PASS: 18 changed-path layout checks"); + } + + private static void Measure(Control control, double width) + { + control.InvalidateMeasure(); + control.Measure(new Size(width, 24)); + } + + private static void Check(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +} From 76c20ef9af29520c7f748dd84d7c726c6fee2836 Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Thu, 12 Nov 2026 19:02:41 +0100 Subject: [PATCH 2/4] Place path shortening option on Appearance tab --- src/Views/Preferences.axaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Views/Preferences.axaml b/src/Views/Preferences.axaml index 358c3001e4..e1af0a73d7 100644 --- a/src/Views/Preferences.axaml +++ b/src/Views/Preferences.axaml @@ -156,10 +156,6 @@ Content="{DynamicResource Text.Preferences.General.ShowTagsInGraph}" IsChecked="{Binding ShowTagsInGraph, Mode=TwoWay}"/> - + Date: Thu, 12 Nov 2026 19:03:28 +0100 Subject: [PATCH 3/4] Use concise label for path shortening preference --- src/Resources/Locales/en_US.axaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 2e92fc6df6..5323b51bd0 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -691,7 +691,7 @@ Monospace Font Theme Theme Overrides - Shorten leading paths in changed-file lists (keep filenames) + Shorten leading paths in file lists Use auto-hide scrollbars Use fixed tab width in titlebar Use native window frame From 2c1168e284f7be979fda96279b3fe91ba3aff5d2 Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Fri, 13 Nov 2026 07:53:04 +0100 Subject: [PATCH 4/4] Dim directories in changed-file lists to emphasize filenames --- src/Views/ChangeCollectionView.axaml | 1 + src/Views/ChangePathTextBlock.cs | 34 ++++++++++++++++++++++++++++ tests/ChangePathChecks/Program.cs | 27 ++++++++++++++++++++-- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/Views/ChangeCollectionView.axaml b/src/Views/ChangeCollectionView.axaml index 6600ae6a91..ba8a5abe63 100644 --- a/src/Views/ChangeCollectionView.axaml +++ b/src/Views/ChangeCollectionView.axaml @@ -140,6 +140,7 @@ IsVisible="{Binding IsConflicted}"/> diff --git a/src/Views/ChangePathTextBlock.cs b/src/Views/ChangePathTextBlock.cs index 3ec3e26ae0..697ff92c5a 100644 --- a/src/Views/ChangePathTextBlock.cs +++ b/src/Views/ChangePathTextBlock.cs @@ -4,6 +4,8 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Avalonia.Utilities; using Avalonia.VisualTree; namespace SourceGit.Views @@ -78,6 +80,15 @@ protected override Size MeasureOverride(Size availableSize) public class ChangePathTextBlock : TextBlock { + public static readonly StyledProperty DirectoryForegroundProperty = + AvaloniaProperty.Register(nameof(DirectoryForeground), Brushes.Gray); + + public IBrush DirectoryForeground + { + get => GetValue(DirectoryForegroundProperty); + set => SetValue(DirectoryForegroundProperty, value); + } + public static readonly StyledProperty PathProperty = AvaloniaProperty.Register(nameof(Path), string.Empty); @@ -92,6 +103,29 @@ static ChangePathTextBlock() AffectsMeasure(PathProperty); } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == DirectoryForegroundProperty) + InvalidateTextLayout(); + } + + protected override TextLayout CreateTextLayout(string text) + { + var path = Path ?? string.Empty; + var filenameLength = path.Length - (path.LastIndexOf('/') + 1); + var directoryLength = text.Length - filenameLength; + if (directoryLength <= 0) + return base.CreateTextLayout(text); + + var typeface = new Typeface(FontFamily, FontStyle, FontWeight, FontStretch); + var properties = new GenericTextRunProperties(typeface, FontSize, foregroundBrush: DirectoryForeground); + return new TextLayout(text, typeface, FontSize, Foreground, + textAlignment: TextAlignment, flowDirection: FlowDirection, + lineHeight: LineHeight, letterSpacing: LetterSpacing, + textStyleOverrides: [new ValueSpan(0, directoryLength, properties)]); + } + private Size MeasureText(string text) { var formatted = new FormattedText(text, CultureInfo.CurrentCulture, FlowDirection, diff --git a/tests/ChangePathChecks/Program.cs b/tests/ChangePathChecks/Program.cs index f3d0de03cf..3ae3c16cc5 100644 --- a/tests/ChangePathChecks/Program.cs +++ b/tests/ChangePathChecks/Program.cs @@ -1,5 +1,6 @@ using Avalonia; using Avalonia.Controls; +using Avalonia.Media; using Avalonia.Threading; using Avalonia.VisualTree; using SourceGit.Views; @@ -10,9 +11,11 @@ internal static class Program private static void Main() { SourceGit.App.BuildAvaloniaApp().SetupWithoutStarting(); - var text = new ChangePathTextBlock { Path = "very/long/directory/structure/filename.cs" }; + var text = new ChangePathTextBlock { Foreground = Brushes.White, Path = "very/long/directory/structure/filename.cs" }; Measure(text, 1000); Check(text.Text == text.Path, "Full path fits"); + text.DirectoryForeground = Brushes.DarkGray; + Measure(text, 1000); Measure(text, 180); Check(text.Text.StartsWith("…") && text.Text.EndsWith("filename.cs"), "Leading ellipsis preserves filename"); Check(text.DesiredSize.Width <= 180, "Trimmed path fits"); @@ -57,12 +60,14 @@ private static void Main() Dispatcher.UIThread.RunJobs(); var label = view.GetVisualDescendants().OfType().Single(); Check(label.Text.StartsWith("…") && label.Text.EndsWith("filename.cs"), "Actual staged/unstaged template truncates correctly"); + CheckPathColors(label); var preferences = SourceGit.ViewModels.Preferences.Instance; preferences.SetCanModify(); preferences.TrimLeadingPathsInChanges = false; for (var i = 0; i < 10; i++) Dispatcher.UIThread.RunJobs(); Check(label.Text == label.Path, "Preference disables trimming immediately"); + CheckPathColors(label); preferences.TrimLeadingPathsInChanges = true; for (var i = 0; i < 10; i++) Dispatcher.UIThread.RunJobs(); @@ -73,15 +78,33 @@ private static void Main() Check(label.Text == label.Path, "Actual template responds to resize"); window.Close(); } - Console.WriteLine("PASS: 18 changed-path layout checks"); + Console.WriteLine("PASS: 18 changed-path layout checks and directory/filename color checks"); } private static void Measure(Control control, double width) { control.InvalidateMeasure(); control.Measure(new Size(width, 24)); + if (control is ChangePathTextBlock path) + CheckPathColors(path); } + private static void CheckPathColors(ChangePathTextBlock label) + { + var filenameLength = label.Path.Length - (label.Path.LastIndexOf('/') + 1); + var directoryLength = label.Text.Length - filenameLength; + var position = 0; + foreach (var run in label.TextLayout.TextLines.SelectMany(line => line.TextRuns)) + { + if (position >= label.Text.Length) + break; + var expected = position < directoryLength ? label.DirectoryForeground : label.Foreground; + Check(Equals(run.Properties.ForegroundBrush, expected), "Directory is gray and filename keeps normal foreground"); + Check(position >= directoryLength || position + run.Length <= directoryLength, "Color boundary preserves full filename"); + position += run.Length; + } + Check(position >= label.Text.Length, "Color checks cover displayed text"); + } private static void Check(bool condition, string message) { if (!condition)