Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Resources/Locales/en_US.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,7 @@
<x:String x:Key="Text.Preferences.Appearance.MonospaceFont" xml:space="preserve">Monospace Font</x:String>
<x:String x:Key="Text.Preferences.Appearance.Theme" xml:space="preserve">Theme</x:String>
<x:String x:Key="Text.Preferences.Appearance.ThemeOverrides" xml:space="preserve">Theme Overrides</x:String>
<x:String x:Key="Text.Preferences.Appearance.TrimLeadingPathsInChanges" xml:space="preserve">Shorten leading paths in file lists</x:String>
<x:String x:Key="Text.Preferences.Appearance.UseAutoHideScrollBars" xml:space="preserve">Use auto-hide scrollbars</x:String>
<x:String x:Key="Text.Preferences.Appearance.UseFixedTabWidth" xml:space="preserve">Use fixed tab width in titlebar</x:String>
<x:String x:Key="Text.Preferences.Appearance.UseNativeWindowFrame" xml:space="preserve">Use native window frame</x:String>
Expand Down
7 changes: 7 additions & 0 deletions src/ViewModels/Preferences.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -823,6 +829,7 @@ private bool RemoveInvalidRepositoriesRecursive(List<RepositoryNode> 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;

Expand Down
12 changes: 7 additions & 5 deletions src/Views/ChangeCollectionView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@
SelectionChanged="OnRowSelectionChanged">
<ListBox.ItemTemplate>
<DataTemplate DataType="m:Change">
<StackPanel Orientation="Horizontal"
<v:ChangeListRow ColumnDefinitions="Auto,Auto,*"
TrimLeadingPaths="{Binding Source={x:Static vm:Preferences.Instance}, Path=TrimLeadingPathsInChanges}"
Height="24"
Background="Transparent"
DoubleTapped="OnRowDoubleTapped"
Expand All @@ -132,15 +133,16 @@
IsUnstagedChange="{Binding #ThisControl.IsUnstagedChange}"
Change="{Binding}" />

<TextBlock Text="{Binding ConflictMarker}"
<TextBlock Grid.Column="1" Text="{Binding ConflictMarker}"
Foreground="DarkOrange"
FontWeight="Bold"
Margin="4,0,0,0"
IsVisible="{Binding IsConflicted}"/>

<TextBlock Margin="4,0,0,0"
Text="{Binding Path}"/>
</StackPanel>
<v:ChangePathTextBlock Grid.Column="2" Margin="4,0,0,0"
DirectoryForeground="{DynamicResource Brush.FG2}"
Path="{Binding Path}"/>
</v:ChangeListRow>
</DataTemplate>
</ListBox.ItemTemplate>
</v:ChangeCollectionContainer>
Expand Down
167 changes: 167 additions & 0 deletions src/Views/ChangePathTextBlock.cs
Original file line number Diff line number Diff line change
@@ -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<bool> TrimLeadingPathsProperty =
AvaloniaProperty.Register<ChangeListRow, bool>(nameof(TrimLeadingPaths), true);

public bool TrimLeadingPaths
{
get => GetValue(TrimLeadingPathsProperty);
set => SetValue(TrimLeadingPathsProperty, value);
}
public static readonly StyledProperty<double> ViewportWidthProperty =
AvaloniaProperty.Register<ChangeListRow, double>(nameof(ViewportWidth), double.PositiveInfinity);

public double ViewportWidth
{
get => GetValue(ViewportWidthProperty);
set => SetValue(ViewportWidthProperty, value);
}

static ChangeListRow()
{
AffectsMeasure<ChangeListRow>(ViewportWidthProperty, TrimLeadingPathsProperty);
}

protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
_scrollViewer = this.FindAncestorOfType<ScrollViewer>();
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<IBrush> DirectoryForegroundProperty =
AvaloniaProperty.Register<ChangePathTextBlock, IBrush>(nameof(DirectoryForeground), Brushes.Gray);

public IBrush DirectoryForeground
{
get => GetValue(DirectoryForegroundProperty);
set => SetValue(DirectoryForegroundProperty, value);
}

public static readonly StyledProperty<string> PathProperty =
AvaloniaProperty.Register<ChangePathTextBlock, string>(nameof(Path), string.Empty);

public string Path
{
get => GetValue(PathProperty);
set => SetValue(PathProperty, value);
}

static ChangePathTextBlock()
{
AffectsMeasure<ChangePathTextBlock>(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<TextRunProperties>(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);
}
}
}
6 changes: 5 additions & 1 deletion src/Views/Preferences.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@
<TabItem.Header>
<TextBlock Classes="tab_header" Text="{DynamicResource Text.Preferences.Appearance}"/>
</TabItem.Header>
<Grid Margin="8" RowDefinitions="32,32,32,32,32,32,32,32,Auto" ColumnDefinitions="Auto,*">
<Grid Margin="8" RowDefinitions="32,32,32,32,32,32,32,32,Auto,32" ColumnDefinitions="Auto,*">
<TextBlock Grid.Row="0" Grid.Column="0"
Text="{DynamicResource Text.Preferences.Appearance.Theme}"
HorizontalAlignment="Right"
Expand Down Expand Up @@ -301,6 +301,10 @@
Content="{DynamicResource Text.Preferences.Appearance.UseAutoHideScrollBars}"
IsChecked="{Binding UseAutoHideScrollBars, Mode=TwoWay}"/>

<CheckBox Grid.Row="9" Grid.Column="1"
Height="32"
Content="{DynamicResource Text.Preferences.Appearance.TrimLeadingPathsInChanges}"
IsChecked="{Binding TrimLeadingPathsInChanges, Mode=TwoWay}"/>
<CheckBox Grid.Row="8" Grid.Column="1"
Height="32"
Content="{DynamicResource Text.Preferences.Appearance.UseNativeWindowFrame}"
Expand Down
10 changes: 10 additions & 0 deletions tests/ChangePathChecks/ChangePathChecks.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/SourceGit.csproj" />
</ItemGroup>
</Project>
113 changes: 113 additions & 0 deletions tests/ChangePathChecks/Program.cs
Original file line number Diff line number Diff line change
@@ -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<ChangePathTextBlock>().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);
}
}