diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml
index 603d223a9..5323b51bd 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 file lists
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 9d9fb3f9f..aad981812 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 be9143d1b..ba8a5abe6 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 000000000..697ff92c5
--- /dev/null
+++ b/src/Views/ChangePathTextBlock.cs
@@ -0,0 +1,167 @@
+using System;
+using System.Globalization;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Media.TextFormatting;
+using Avalonia.Utilities;
+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 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);
+
+ public string Path
+ {
+ get => GetValue(PathProperty);
+ set => SetValue(PathProperty, value);
+ }
+
+ 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,
+ 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 6b3aa863c..e1af0a73d 100644
--- a/src/Views/Preferences.axaml
+++ b/src/Views/Preferences.axaml
@@ -188,7 +188,7 @@
-
+
+
+
+ Exe
+ net10.0
+ enable
+
+
+
+
+
diff --git a/tests/ChangePathChecks/Program.cs b/tests/ChangePathChecks/Program.cs
new file mode 100644
index 000000000..3ae3c16cc
--- /dev/null
+++ b/tests/ChangePathChecks/Program.cs
@@ -0,0 +1,113 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+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 { 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");
+ 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");
+ 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();
+ 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 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)
+ throw new InvalidOperationException(message);
+ }
+}