Skip to content

Commit 2819ec4

Browse files
Marek MaślankaMaslankaMarek
authored andcommitted
Add option to compare unstaged image with staged version
- Add FromStagedAsync to query staged image from git index - Support toggling baseline image between HEAD and STAGED in ImageDiff - Add toolbar control to switch baseline image for unstaged changes - Update image diff headers and statistics dynamically based on selected base
1 parent fd5cbce commit 2819ec4

7 files changed

Lines changed: 218 additions & 18 deletions

File tree

src/Commands/QueryFileContent.cs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Diagnostics;
33
using System.IO;
44
using System.Threading.Tasks;
@@ -34,6 +34,40 @@ public static async Task<Stream> RunAsync(string repo, string revision, string f
3434
return stream;
3535
}
3636

37+
public static async Task<Stream> FromStagedAsync(string repo, string file)
38+
{
39+
if (string.IsNullOrEmpty(file) || file.Equals("/dev/null", StringComparison.Ordinal))
40+
return null;
41+
42+
var starter = new ProcessStartInfo();
43+
starter.WorkingDirectory = repo;
44+
starter.FileName = Native.OS.GitExecutable;
45+
starter.Arguments = $"show :{file.Quoted()}";
46+
starter.UseShellExecute = false;
47+
starter.CreateNoWindow = true;
48+
starter.WindowStyle = ProcessWindowStyle.Hidden;
49+
starter.RedirectStandardOutput = true;
50+
51+
var stream = new MemoryStream();
52+
try
53+
{
54+
using var proc = Process.Start(starter)!;
55+
await proc.StandardOutput.BaseStream.CopyToAsync(stream).ConfigureAwait(false);
56+
await proc.WaitForExitAsync().ConfigureAwait(false);
57+
if (proc.ExitCode != 0)
58+
{
59+
return new MemoryStream();
60+
}
61+
}
62+
catch
63+
{
64+
return new MemoryStream();
65+
}
66+
67+
stream.Position = 0;
68+
return stream;
69+
}
70+
3771
public static async Task<Stream> FromLFSAsync(string repo, string oid, long size)
3872
{
3973
var starter = new ProcessStartInfo();

src/Models/DiffResult.cs

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Collections.Generic;
22
using System.IO;
33
using Avalonia.Media.Imaging;
4+
using CommunityToolkit.Mvvm.ComponentModel;
45

56
namespace SourceGit.Models
67
{
@@ -71,17 +72,117 @@ public class BinaryDiff
7172
public long NewSize { get; set; } = 0;
7273
}
7374

74-
public class ImageDiff
75+
public class ImageDiff : ObservableObject
7576
{
76-
public Bitmap Old { get; set; } = null;
77-
public Bitmap New { get; set; } = null;
77+
public Bitmap Old
78+
{
79+
get => _old;
80+
set
81+
{
82+
if (SetProperty(ref _old, value))
83+
{
84+
if (HeadImage == null && !_isComparingWithStaged)
85+
HeadImage = value;
86+
_detectionResult = null;
87+
OnPropertyChanged(nameof(OldImageSize));
88+
OnPropertyChanged(nameof(DetectionResult));
89+
OnPropertyChanged(nameof(ChangeOutlines));
90+
OnPropertyChanged(nameof(ChangeCount));
91+
OnPropertyChanged(nameof(ChangedPixelCount));
92+
OnPropertyChanged(nameof(ChangedPixelPercentage));
93+
OnPropertyChanged(nameof(DiffPixelStatsText));
94+
OnPropertyChanged(nameof(DiffAreaStatsText));
95+
}
96+
}
97+
}
7898

79-
public long OldFileSize { get; set; } = 0;
80-
public long NewFileSize { get; set; } = 0;
99+
public Bitmap New
100+
{
101+
get => _new;
102+
set
103+
{
104+
if (SetProperty(ref _new, value))
105+
{
106+
_detectionResult = null;
107+
OnPropertyChanged(nameof(NewImageSize));
108+
OnPropertyChanged(nameof(DetectionResult));
109+
OnPropertyChanged(nameof(ChangeOutlines));
110+
OnPropertyChanged(nameof(ChangeCount));
111+
OnPropertyChanged(nameof(ChangedPixelCount));
112+
OnPropertyChanged(nameof(ChangedPixelPercentage));
113+
OnPropertyChanged(nameof(DiffPixelStatsText));
114+
OnPropertyChanged(nameof(DiffAreaStatsText));
115+
}
116+
}
117+
}
118+
119+
public long OldFileSize
120+
{
121+
get => _oldFileSize;
122+
set
123+
{
124+
if (SetProperty(ref _oldFileSize, value))
125+
{
126+
if (HeadFileSize == 0 && !_isComparingWithStaged)
127+
HeadFileSize = value;
128+
}
129+
}
130+
}
131+
132+
public long NewFileSize
133+
{
134+
get => _newFileSize;
135+
set => SetProperty(ref _newFileSize, value);
136+
}
81137

82138
public string OldImageSize => Old != null ? $"{Old.PixelSize.Width} x {Old.PixelSize.Height}" : "0 x 0";
83139
public string NewImageSize => New != null ? $"{New.PixelSize.Width} x {New.PixelSize.Height}" : "0 x 0";
84140

141+
public Bitmap HeadImage { get; set; } = null;
142+
public long HeadFileSize { get; set; } = 0;
143+
144+
public Bitmap StagedImage { get; set; } = null;
145+
public long StagedFileSize { get; set; } = 0;
146+
147+
public bool CanCompareWithStaged => StagedImage != null && (HeadImage != null || IsUnstaged);
148+
public bool IsUnstaged { get; set; } = false;
149+
150+
public bool IsComparingWithStaged
151+
{
152+
get => _isComparingWithStaged;
153+
set
154+
{
155+
if (SetProperty(ref _isComparingWithStaged, value))
156+
{
157+
if (value && StagedImage != null)
158+
{
159+
_old = StagedImage;
160+
_oldFileSize = StagedFileSize;
161+
}
162+
else
163+
{
164+
_old = HeadImage;
165+
_oldFileSize = HeadFileSize;
166+
}
167+
168+
_detectionResult = null;
169+
OnPropertyChanged(nameof(Old));
170+
OnPropertyChanged(nameof(OldFileSize));
171+
OnPropertyChanged(nameof(OldImageSize));
172+
OnPropertyChanged(nameof(OldBadgeTitle));
173+
OnPropertyChanged(nameof(DetectionResult));
174+
OnPropertyChanged(nameof(ChangeOutlines));
175+
OnPropertyChanged(nameof(ChangeCount));
176+
OnPropertyChanged(nameof(ChangedPixelCount));
177+
OnPropertyChanged(nameof(ChangedPixelPercentage));
178+
OnPropertyChanged(nameof(DiffPixelStatsText));
179+
OnPropertyChanged(nameof(DiffAreaStatsText));
180+
}
181+
}
182+
}
183+
184+
public string OldBadgeTitle => _isComparingWithStaged ? "STAGED" : "OLD";
185+
85186
public ImageDiffDetectionResult DetectionResult => _detectionResult ??= ImageDifferenceDetector.Detect(Old, New);
86187

87188
public IReadOnlyList<Avalonia.Rect> ChangeOutlines => DetectionResult.ChangeBoxes;
@@ -90,8 +191,13 @@ public class ImageDiff
90191
public double ChangedPixelPercentage => DetectionResult.ChangedPercentage;
91192

92193
public string DiffPixelStatsText => $"{ChangedPixelCount:N0} px ({ChangedPixelPercentage:F2}%)";
93-
public string DiffAreaStatsText => ChangeCount == 1 ? "1 area" : $"{ChangeCount} areas";
194+
public string DiffAreaStatsText => ChangeCount == 1 ? " · 1 area" : $" · {ChangeCount} areas";
94195

196+
private Bitmap _old = null;
197+
private Bitmap _new = null;
198+
private long _oldFileSize = 0;
199+
private long _newFileSize = 0;
200+
private bool _isComparingWithStaged = false;
95201
private ImageDiffDetectionResult _detectionResult = null;
96202
}
97203

src/Resources/Locales/en_US.axaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,10 +368,12 @@
368368
<x:String x:Key="Text.Diff.First" xml:space="preserve">First Difference</x:String>
369369
<x:String x:Key="Text.Diff.IgnoreWhitespace" xml:space="preserve">Ignore Whitespace Changes</x:String>
370370
<x:String x:Key="Text.Diff.Image.Blend" xml:space="preserve">BLEND</x:String>
371+
<x:String x:Key="Text.Diff.Image.CompareWith" xml:space="preserve">Compare with:</x:String>
371372
<x:String x:Key="Text.Diff.Image.Difference" xml:space="preserve">DIFFERENCE</x:String>
372373
<x:String x:Key="Text.Diff.Image.HighlightChanges" xml:space="preserve">Outline Changes</x:String>
373374
<x:String x:Key="Text.Diff.Image.ResetZoom" xml:space="preserve">Reset (1:1)</x:String>
374375
<x:String x:Key="Text.Diff.Image.SideBySide" xml:space="preserve">SIDE-BY-SIDE</x:String>
376+
<x:String x:Key="Text.Diff.Image.Staged" xml:space="preserve">STAGED</x:String>
375377
<x:String x:Key="Text.Diff.Image.Swipe" xml:space="preserve">SWIPE</x:String>
376378
<x:String x:Key="Text.Diff.Image.Zoom" xml:space="preserve">Zoom:</x:String>
377379
<x:String x:Key="Text.Diff.Last" xml:space="preserve">Last Difference</x:String>

src/ViewModels/DiffContext.cs

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.IO;
33
using System.Threading.Tasks;
44
using Avalonia.Threading;
@@ -214,12 +214,16 @@ private void LoadContent()
214214
if (_option.Revisions[0].Equals("-R", StringComparison.Ordinal))
215215
{
216216
var oldImage = await ImageSource.FromFileAsync(fullPath, imgDecoder).ConfigureAwait(false);
217+
imgDiff.HeadImage = oldImage.Bitmap;
218+
imgDiff.HeadFileSize = oldImage.Size;
217219
imgDiff.Old = oldImage.Bitmap;
218220
imgDiff.OldFileSize = oldImage.Size;
219221
}
220222
else
221223
{
222224
var oldImage = await ImageSource.FromRevisionAsync(_repo, _option.Revisions[0], oldPath, imgDecoder).ConfigureAwait(false);
225+
imgDiff.HeadImage = oldImage.Bitmap;
226+
imgDiff.HeadFileSize = oldImage.Size;
223227
imgDiff.Old = oldImage.Bitmap;
224228
imgDiff.OldFileSize = oldImage.Size;
225229
}
@@ -230,16 +234,39 @@ private void LoadContent()
230234
}
231235
else
232236
{
237+
imgDiff.IsUnstaged = _option.IsUnstaged;
238+
233239
if (!oldPath.Equals("/dev/null", StringComparison.Ordinal))
234240
{
235-
var oldImage = await ImageSource.FromRevisionAsync(_repo, "HEAD", oldPath, imgDecoder).ConfigureAwait(false);
236-
imgDiff.Old = oldImage.Bitmap;
237-
imgDiff.OldFileSize = oldImage.Size;
241+
var headImage = await ImageSource.FromRevisionAsync(_repo, "HEAD", oldPath, imgDecoder).ConfigureAwait(false);
242+
imgDiff.HeadImage = headImage.Bitmap;
243+
imgDiff.HeadFileSize = headImage.Size;
244+
imgDiff.Old = headImage.Bitmap;
245+
imgDiff.OldFileSize = headImage.Size;
246+
}
247+
248+
if (_option.IsUnstaged)
249+
{
250+
var stagedPath = !oldPath.Equals("/dev/null", StringComparison.Ordinal) ? oldPath : _option.Path;
251+
if (!string.IsNullOrEmpty(stagedPath) && !stagedPath.Equals("/dev/null", StringComparison.Ordinal))
252+
{
253+
var stagedImage = await ImageSource.FromStagedAsync(_repo, stagedPath, imgDecoder).ConfigureAwait(false);
254+
if (stagedImage.Bitmap != null)
255+
{
256+
imgDiff.StagedImage = stagedImage.Bitmap;
257+
imgDiff.StagedFileSize = stagedImage.Size;
258+
}
259+
}
238260
}
239261

240262
var newImage = await ImageSource.FromFileAsync(fullPath, imgDecoder).ConfigureAwait(false);
241263
imgDiff.New = newImage.Bitmap;
242264
imgDiff.NewFileSize = newImage.Size;
265+
266+
if (_option.IsUnstaged && imgDiff.HeadImage == null && imgDiff.StagedImage != null)
267+
{
268+
imgDiff.IsComparingWithStaged = true;
269+
}
243270
}
244271

245272
return imgDiff;

src/ViewModels/ImageSource.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Globalization;
33
using System.IO;
44
using System.Runtime.InteropServices;
@@ -61,6 +61,12 @@ public static async Task<ImageSource> FromRevisionAsync(string repo, string revi
6161
return await Task.Run(() => LoadFromStream(stream, decoder)).ConfigureAwait(false);
6262
}
6363

64+
public static async Task<ImageSource> FromStagedAsync(string repo, string file, Models.ImageDecoder decoder)
65+
{
66+
await using var stream = await Commands.QueryFileContent.FromStagedAsync(repo, file).ConfigureAwait(false);
67+
return await Task.Run(() => LoadFromStream(stream, decoder)).ConfigureAwait(false);
68+
}
69+
6470
public static async Task<ImageSource> FromLFSObjectAsync(string repo, Models.LFSObject lfs, Models.ImageDecoder decoder)
6571
{
6672
if (string.IsNullOrEmpty(lfs.Oid) || lfs.Size == 0)

src/Views/ImageDiffView.axaml

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,23 @@
7373
IsVisible="{Binding Source={x:Static vm:Preferences.Instance}, Path=HighlightImageDiffChanges}"/>
7474
</StackPanel>
7575
</Border>
76+
77+
<Rectangle Width="1" Height="14" Fill="{DynamicResource Brush.Border1}" Margin="4,0" IsVisible="{Binding CanCompareWithStaged}"/>
78+
79+
<!-- Base Image Switcher (HEAD vs STAGED) for unstaged changes -->
80+
<Button Classes="flat"
81+
Padding="6,2"
82+
Click="OnToggleBaseImage"
83+
IsVisible="{Binding CanCompareWithStaged}"
84+
ToolTip.Tip="{DynamicResource Text.Diff.Image.CompareWith}">
85+
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
86+
<Path Width="12" Height="12" Data="{StaticResource Icons.Compare}" Fill="{DynamicResource Brush.FG1}" VerticalAlignment="Center"/>
87+
<TextBlock Text="{DynamicResource Text.Diff.Image.CompareWith}" FontSize="10" Foreground="{DynamicResource Brush.FG2}" VerticalAlignment="Center"/>
88+
<Border Background="{DynamicResource Brush.Badge}" CornerRadius="4" Padding="5,1" VerticalAlignment="Center">
89+
<TextBlock Text="{Binding OldBadgeTitle}" FontSize="9" FontWeight="Bold" Foreground="{DynamicResource Brush.BadgeFG}" VerticalAlignment="Center"/>
90+
</Border>
91+
</StackPanel>
92+
</Button>
7693
</StackPanel>
7794
</Border>
7895

@@ -92,7 +109,7 @@
92109
<Grid Grid.Column="0" RowDefinitions="Auto,*" Margin="8,0" IsVisible="{Binding Old, Converter={x:Static ObjectConverters.IsNotNull}}">
93110
<StackPanel Grid.Row="0" Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,6">
94111
<Border Height="16" Background="{DynamicResource Brush.Badge}" CornerRadius="8" VerticalAlignment="Center">
95-
<TextBlock Text="{DynamicResource Text.Diff.Old}" Margin="8,0" FontSize="10" Foreground="{DynamicResource Brush.BadgeFG}"/>
112+
<TextBlock Text="{Binding OldBadgeTitle}" Margin="8,0" FontSize="10" Foreground="{DynamicResource Brush.BadgeFG}"/>
96113
</Border>
97114

98115
<TextBlock Text="{Binding OldImageSize}" Margin="8,0,0,0"/>
@@ -163,7 +180,7 @@
163180
<Grid RowDefinitions="Auto,*" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Margin="8,8">
164181
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" HorizontalAlignment="Center" Margin="0,0,0,6">
165182
<Border Grid.Column="0" Height="16" Background="{DynamicResource Brush.Badge}" CornerRadius="8" VerticalAlignment="Center">
166-
<TextBlock Text="{DynamicResource Text.Diff.Old}" Margin="8,0" FontSize="10" Foreground="{DynamicResource Brush.BadgeFG}"/>
183+
<TextBlock Text="{Binding OldBadgeTitle}" Margin="8,0" FontSize="10" Foreground="{DynamicResource Brush.BadgeFG}"/>
167184
</Border>
168185

169186
<TextBlock Grid.Column="1" Text="{Binding OldImageSize}" Margin="8,0,0,0"/>
@@ -208,7 +225,7 @@
208225
<Grid RowDefinitions="Auto,*,Auto" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Margin="8,8,8,0">
209226
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" HorizontalAlignment="Center" Margin="0,0,0,6">
210227
<Border Grid.Column="0" Height="16" Background="{DynamicResource Brush.Badge}" CornerRadius="8" VerticalAlignment="Center">
211-
<TextBlock Text="{DynamicResource Text.Diff.Old}" Margin="8,0" FontSize="10" Foreground="{DynamicResource Brush.BadgeFG}"/>
228+
<TextBlock Text="{Binding OldBadgeTitle}" Margin="8,0" FontSize="10" Foreground="{DynamicResource Brush.BadgeFG}"/>
212229
</Border>
213230

214231
<TextBlock Grid.Column="1" Text="{Binding OldImageSize}" Margin="8,0,0,0"/>
@@ -248,7 +265,7 @@
248265
<TextBlock Margin="0,0,8,0"
249266
Text="{Binding #ImageBlendSlider.Value, Converter={x:Static c:DoubleConverters.OneMinusToPercentage}}"
250267
Foreground="{DynamicResource Brush.FG2}"/>
251-
<TextBlock Text="{DynamicResource Text.Diff.Old}"/>
268+
<TextBlock Text="{Binding OldBadgeTitle}"/>
252269
</StackPanel>
253270

254271
<Slider Grid.Column="1"
@@ -280,7 +297,7 @@
280297
<Grid RowDefinitions="Auto,*,Auto" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Margin="8,8,8,0">
281298
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" HorizontalAlignment="Center" Margin="0,0,0,6">
282299
<Border Grid.Column="0" Height="16" Background="{DynamicResource Brush.Badge}" CornerRadius="8" VerticalAlignment="Center">
283-
<TextBlock Text="{DynamicResource Text.Diff.Old}" Margin="8,0" FontSize="10" Foreground="{DynamicResource Brush.BadgeFG}"/>
300+
<TextBlock Text="{Binding OldBadgeTitle}" Margin="8,0" FontSize="10" Foreground="{DynamicResource Brush.BadgeFG}"/>
284301
</Border>
285302

286303
<TextBlock Grid.Column="1" Text="{Binding OldImageSize}" Margin="8,0,0,0"/>
@@ -320,7 +337,7 @@
320337
<TextBlock Margin="0,0,8,0"
321338
Text="{Binding #ImageDifferenceSlider.Value, Converter={x:Static c:DoubleConverters.OneMinusToPercentage}}"
322339
Foreground="{DynamicResource Brush.FG2}"/>
323-
<TextBlock Text="{DynamicResource Text.Diff.Old}"/>
340+
<TextBlock Text="{Binding OldBadgeTitle}"/>
324341
</StackPanel>
325342

326343
<Slider Grid.Column="1"

src/Views/ImageDiffView.axaml.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,5 +119,13 @@ private void OnNewImageScrollChanged(object sender, ScrollChangedEventArgs e)
119119
OldImageScrollViewer.Offset = NewImageScrollViewer.Offset;
120120
_isSyncingScroll = false;
121121
}
122+
123+
private void OnToggleBaseImage(object sender, RoutedEventArgs e)
124+
{
125+
if (DataContext is Models.ImageDiff diff && diff.CanCompareWithStaged)
126+
{
127+
diff.IsComparingWithStaged = !diff.IsComparingWithStaged;
128+
}
129+
}
122130
}
123131
}

0 commit comments

Comments
 (0)