Compare commits

...

6 commits

Author SHA1 Message Date
goran-w
cd946d2ba5 Prev/next will (re-)scroll to first/last change-block in edge-cases
I.e when unset or already at first/last change-block (or the only one).
2024-11-15 13:43:05 +01:00
goran-w
0f852e2c24 Unset current change-block in RefreshContent() 2024-11-15 12:39:52 +01:00
goran-w
0fb89039ff Implemented change-block navigation 2024-11-15 11:46:52 +01:00
goran-w
096da3452e Corrected misspelled local variable nextHigh(t)light 2024-11-15 10:14:49 +01:00
goran-w
5c72e999ae Added 2 new buttons for prev/next change in Diff
These new buttons in DiffView toolbar are visible when IsTextDiff.
They invoke new (and currently empty) methods PrevChange() / NextChange() in DiffContext.
2024-11-15 10:14:49 +01:00
goran-w
91af25d58a Added icons for "Previous/Next Difference"
New StreamGeometry "Icons.Diff.Prev" / "Icons.Diff.Next" using SVG paths from "arrow_up_regular" / "arrow_down_regular" at https://avaloniaui.github.io/icons.html.
2024-11-15 10:14:49 +01:00
8 changed files with 268 additions and 31 deletions

View file

@ -51,6 +51,9 @@ namespace SourceGit.Commands
_result.TextDiff.MaxLineNumber = Math.Max(_newLine, _oldLine);
}
if (_result.TextDiff != null)
_result.TextDiff.ProcessChangeBlocks();
return _result;
}

View file

@ -2,6 +2,8 @@
using System.Text;
using System.Text.RegularExpressions;
using CommunityToolkit.Mvvm.ComponentModel;
using Avalonia;
using Avalonia.Media.Imaging;
@ -59,16 +61,70 @@ namespace SourceGit.Models
}
}
public partial class TextDiff
public class TextDiffChangeBlock
{
public TextDiffChangeBlock(int startLine, int endLine)
{
StartLine = startLine;
EndLine = endLine;
}
public int StartLine { get; set; } = 0;
public int EndLine { get; set; } = 0;
public bool IsInRange(int line)
{
return line >= StartLine && line <= EndLine;
}
}
public partial class TextDiff : ObservableObject
{
public string File { get; set; } = string.Empty;
public List<TextDiffLine> Lines { get; set; } = new List<TextDiffLine>();
public Vector ScrollOffset { get; set; } = Vector.Zero;
public int MaxLineNumber = 0;
public int CurrentChangeBlockIdx
{
get => _currentChangeBlockIdx;
set => SetProperty(ref _currentChangeBlockIdx, value);
}
public string Repo { get; set; } = null;
public DiffOption Option { get; set; } = null;
public List<TextDiffChangeBlock> ChangeBlocks { get; set; } = [];
public void ProcessChangeBlocks()
{
ChangeBlocks.Clear();
int lineIdx = 0, blockStartIdx = 0;
bool isNewBlock = true;
foreach (var line in Lines)
{
lineIdx++;
if (line.Type == Models.TextDiffLineType.Added ||
line.Type == Models.TextDiffLineType.Deleted ||
line.Type == Models.TextDiffLineType.None) // Empty
{
if (isNewBlock)
{
isNewBlock = false;
blockStartIdx = lineIdx;
}
}
else
{
if (!isNewBlock)
{
ChangeBlocks.Add(new TextDiffChangeBlock(blockStartIdx, lineIdx - 1));
isNewBlock = true;
}
}
}
}
public TextDiffSelection MakeSelection(int startLine, int endLine, bool isCombined, bool isOldSide)
{
var rs = new TextDiffSelection();
@ -626,6 +682,8 @@ namespace SourceGit.Models
return true;
}
private int _currentChangeBlockIdx = -1; // NOTE: Use -1 as "not set".
[GeneratedRegex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@")]
private static partial Regex REG_INDICATOR();
}

View file

@ -25,6 +25,8 @@
<StreamGeometry x:Key="Icons.Detached">M128 183C128 154 154 128 183 128h521c30 0 55 26 55 55v38c0 17-17 34-34 34s-34-17-34-34v-26H196v495h26c17 0 34 17 34 34s-17 34-34 34h-38c-30 0-55-26-55-55V183zM380 896h-34c-26 0-47-21-47-47v-90h68V828h64V896H380c4 0 0 0 0 0zM759 828V896h90c26 0 47-21 47-47v-90h-68V828h-68zM828 435H896V346c0-26-21-47-47-47h-90v68H828v68zM435 299v68H367V439H299V346C299 320 320 299 346 299h90zM367 649H299v-107h68v107zM546 367V299h107v68h-107zM828 546H896v107h-68v-107zM649 828V896h-107v-68h107zM730 508v188c0 17-17 34-34 34h-188c-17 0-34-17-34-34s17-34 34-34h102l-124-124c-13-13-13-34 0-47 13-13 34-13 47 0l124 124V512c0-17 17-34 34-34 21-4 38 9 38 30z</StreamGeometry>
<StreamGeometry x:Key="Icons.Detail">M889 0H135c-32 0-59 26-59 59v906c0 32 26 59 59 59h753c32 0 59-26 59-59v-906c1-33-26-59-58-59zm-165 177c31 0 56 25 56 56s-25 56-56 56-56-25-56-56 25-56 56-56zm-212 0c31 0 56 25 56 56S543 288 512 288s-56-25-56-56S481 177 512 177zm-212 0c31 0 56 25 56 56s-25 56-56 56-56-25-56-56 25-56 56-56zm209 606H285c-25 0-44-20-44-44 0-25 20-44 44-44h224c25 0 44 20 44 44 0 24-20 44-44 44zm230-212H285c-25 0-44-20-44-44 0-25 20-44 44-44h453c25 0 44 20 44 44 1 24-20 44-44 44z</StreamGeometry>
<StreamGeometry x:Key="Icons.Diff">M854 307 611 73c-6-6-14-9-22-9H296c-4 0-8 4-8 8v56c0 4 4 8 8 8h277l219 211V824c0 4 4 8 8 8h56c4 0 8-4 8-8V330c0-9-4-17-10-23zM553 201c-6-6-14-9-23-9H192c-18 0-32 14-32 32v704c0 18 14 32 32 32h512c18 0 32-14 32-32V397c0-9-3-17-9-23L553 201zM568 753c0 4-3 7-8 7h-225c-4 0-8-3-8-7v-42c0-4 3-7 8-7h225c4 0 8 3 8 7v42zm0-220c0 4-3 7-8 7H476v85c0 4-3 7-7 7h-42c-4 0-7-3-7-7V540h-85c-4 0-8-3-8-7v-42c0-4 3-7 8-7H420v-85c0-4 3-7 7-7h42c4 0 7 3 7 7V484h85c4 0 8 3 8 7v42z</StreamGeometry>
<StreamGeometry x:Key="Icons.Diff.Prev">M4.21157,12.7326 C3.9244,13.0312 3.93361,13.5059 4.23213,13.7931 C4.53064,14.0803 5.00543,14.0711 5.29259,13.7725 L13.2521,5.49831 L13.2521,24.2511 C13.2521,24.6653 13.5879,25.0011 14.0021,25.0011 C14.4163,25.0011 14.7521,24.6653 14.7521,24.2511 L14.7521,5.4993 L22.7106,13.7725 C22.9978,14.0711 23.4726,14.0803 23.7711,13.7931 C24.0696,13.5059 24.0788,13.0312 23.7916,12.7326 L14.7223,3.30466 C14.3289,2.89568 13.6743,2.89568 13.2809,3.30466 L4.21157,12.7326 Z</StreamGeometry>
<StreamGeometry x:Key="Icons.Diff.Next">M23.7916,15.2664 C24.0788,14.9679 24.0696,14.4931 23.7711,14.206 C23.4726,13.9188 22.9978,13.928 22.7106,14.2265 L14.7511,22.5007 L14.7511,3.74792 C14.7511,3.33371 14.4153,2.99792 14.0011,2.99792 C13.5869,2.99792 13.2511,3.33371 13.2511,3.74793 L13.2511,22.4998 L5.29259,14.2265 C5.00543,13.928 4.53064,13.9188 4.23213,14.206 C3.93361,14.4931 3.9244,14.9679 4.21157,15.2664 L13.2809,24.6944 C13.6743,25.1034 14.3289,25.1034 14.7223,24.6944 L23.7916,15.2664 Z</StreamGeometry>
<StreamGeometry x:Key="Icons.DoubleDown">M256 224l0 115L512 544l256-205 0-115-256 205L256 224zM512 685l-256-205L256 595 512 800 768 595l0-115L512 685z</StreamGeometry>
<StreamGeometry x:Key="Icons.DoubleUp">M768 800V685L512 480 256 685V800l256-205L768 800zM512 339 768 544V429L512 224 256 429V544l256-205z</StreamGeometry>
<StreamGeometry x:Key="Icons.Down">M509 546l271-271 91 91-348 349-1-1-13 13-363-361 91-91z</StreamGeometry>

View file

@ -73,6 +73,40 @@ namespace SourceGit.ViewModels
LoadDiffContent();
}
public void PrevChange()
{
if (_content is Models.TextDiff textDiff)
{
if (textDiff.CurrentChangeBlockIdx > 0)
{
textDiff.CurrentChangeBlockIdx--;
}
else if (textDiff.ChangeBlocks.Count > 0)
{
// Force property value change and (re-)jump to first change block
textDiff.CurrentChangeBlockIdx = -1;
textDiff.CurrentChangeBlockIdx = 0;
}
}
}
public void NextChange()
{
if (_content is Models.TextDiff textDiff)
{
if (textDiff.CurrentChangeBlockIdx < textDiff.ChangeBlocks.Count - 1)
{
textDiff.CurrentChangeBlockIdx++;
}
else if (textDiff.ChangeBlocks.Count > 0)
{
// Force property value change and (re-)jump to last change block
textDiff.CurrentChangeBlockIdx = -1;
textDiff.CurrentChangeBlockIdx = textDiff.ChangeBlocks.Count - 1;
}
}
}
public void ToggleFullTextDiff()
{
Preference.Instance.UseFullTextDiff = !Preference.Instance.UseFullTextDiff;

View file

@ -45,10 +45,43 @@ namespace SourceGit.ViewModels
FillEmptyLines();
ProcessChangeBlocks();
if (previous != null && previous.File == File)
_syncScrollOffset = previous._syncScrollOffset;
}
public List<Models.TextDiffChangeBlock> ChangeBlocks { get; set; } = [];
public void ProcessChangeBlocks()
{
ChangeBlocks.Clear();
int lineIdx = 0, blockStartIdx = 0;
bool isNewBlock = true;
foreach (var line in Old) // NOTE: Same block size in both Old and New lines.
{
lineIdx++;
if (line.Type == Models.TextDiffLineType.Added ||
line.Type == Models.TextDiffLineType.Deleted ||
line.Type == Models.TextDiffLineType.None) // Empty
{
if (isNewBlock)
{
isNewBlock = false;
blockStartIdx = lineIdx;
}
}
else
{
if (!isNewBlock)
{
ChangeBlocks.Add(new Models.TextDiffChangeBlock(blockStartIdx, lineIdx - 1));
isNewBlock = true;
}
}
}
}
public void ConvertsToCombinedRange(Models.TextDiff combined, ref int startLine, ref int endLine, bool isOldSide)
{
endLine = Math.Min(endLine, combined.Lines.Count - 1);

View file

@ -34,6 +34,20 @@
<!-- Toolbar Buttons -->
<StackPanel Grid.Column="3" Margin="8,0,0,0" Orientation="Horizontal" VerticalAlignment="Center">
<Button Classes="icon_button"
Width="32"
Command="{Binding PrevChange}"
IsVisible="{Binding IsTextDiff}"
ToolTip.Tip="{DynamicResource Text.Diff.Prev}">
<Path Width="12" Height="12" Stretch="Uniform" Margin="0,6,0,0" Data="{StaticResource Icons.Diff.Prev}"/>
</Button>
<Button Classes="icon_button"
Width="32"
Command="{Binding NextChange}"
IsVisible="{Binding IsTextDiff}"
ToolTip.Tip="{DynamicResource Text.Diff.Next}">
<Path Width="12" Height="12" Stretch="Uniform" Margin="0,6,0,0" Data="{StaticResource Icons.Diff.Next}"/>
</Button>
<Button Classes="icon_button"
Width="32"
Command="{Binding IncrUnified}"
@ -232,7 +246,8 @@
<DataTemplate DataType="m:TextDiff">
<v:TextDiffView
UseSideBySideDiff="{Binding Source={x:Static vm:Preference.Instance}, Path=UseSideBySideDiff, Mode=OneWay}"
UseFullTextDiff="{Binding Source={x:Static vm:Preference.Instance}, Path=UseFullTextDiff, Mode=OneWay}"/>
UseFullTextDiff="{Binding Source={x:Static vm:Preference.Instance}, Path=UseFullTextDiff, Mode=OneWay}"
CurrentChangeBlockIdx="{Binding CurrentChangeBlockIdx, Mode=OneWay}"/>
</DataTemplate>
<!-- Empty or only EOL changes -->

View file

@ -27,6 +27,7 @@
UseSyntaxHighlighting="{Binding Source={x:Static vm:Preference.Instance}, Path=UseSyntaxHighlighting}"
WordWrap="{Binding Source={x:Static vm:Preference.Instance}, Path=EnableDiffViewWordWrap}"
ShowHiddenSymbols="{Binding Source={x:Static vm:Preference.Instance}, Path=ShowHiddenSymbolsInDiffView}"
CurrentChangeBlockIdx="{Binding #ThisControl.CurrentChangeBlockIdx}"
EnableChunkSelection="{Binding #ThisControl.EnableChunkSelection}"
SelectedChunk="{Binding #ThisControl.SelectedChunk, Mode=TwoWay}"/>
</DataTemplate>
@ -49,6 +50,7 @@
UseSyntaxHighlighting="{Binding Source={x:Static vm:Preference.Instance}, Path=UseSyntaxHighlighting}"
WordWrap="False"
ShowHiddenSymbols="{Binding Source={x:Static vm:Preference.Instance}, Path=ShowHiddenSymbolsInDiffView}"
CurrentChangeBlockIdx="{Binding #ThisControl.CurrentChangeBlockIdx}"
EnableChunkSelection="{Binding #ThisControl.EnableChunkSelection}"
SelectedChunk="{Binding #ThisControl.SelectedChunk, Mode=TwoWay}"/>
@ -70,6 +72,7 @@
UseSyntaxHighlighting="{Binding Source={x:Static vm:Preference.Instance}, Path=UseSyntaxHighlighting}"
WordWrap="False"
ShowHiddenSymbols="{Binding Source={x:Static vm:Preference.Instance}, Path=ShowHiddenSymbolsInDiffView}"
CurrentChangeBlockIdx="{Binding #ThisControl.CurrentChangeBlockIdx}"
EnableChunkSelection="{Binding #ThisControl.EnableChunkSelection}"
SelectedChunk="{Binding #ThisControl.SelectedChunk, Mode=TwoWay}"/>
</Grid>

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using Avalonia;
@ -243,6 +244,10 @@ namespace SourceGit.Views
if (_presenter.Document == null || !textView.VisualLinesValid)
return;
var changeBlock = _presenter.GetCurrentChangeBlock();
Brush changeBlockBG = new SolidColorBrush(Colors.Gray, 0.25);
Pen changeBlockFG = new Pen(Brushes.Gray, 1);
var lines = _presenter.GetLines();
var width = textView.Bounds.Width;
foreach (var line in textView.VisualLines)
@ -255,51 +260,63 @@ namespace SourceGit.Views
break;
var info = lines[index - 1];
var bg = GetBrushByLineType(info.Type);
if (bg == null)
continue;
var startY = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineTop) - textView.VerticalOffset;
var endY = line.GetTextLineVisualYPosition(line.TextLines[^1], VisualYPosition.LineBottom) - textView.VerticalOffset;
drawingContext.DrawRectangle(bg, null, new Rect(0, startY, width, endY - startY));
if (info.Highlights.Count > 0)
var bg = GetBrushByLineType(info.Type);
if (bg != null)
{
var highlightBG = info.Type == Models.TextDiffLineType.Added ? _presenter.AddedHighlightBrush : _presenter.DeletedHighlightBrush;
var processingIdxStart = 0;
var processingIdxEnd = 0;
var nextHightlight = 0;
if (bg != null)
drawingContext.DrawRectangle(bg, null, new Rect(0, startY, width, endY - startY));
foreach (var tl in line.TextLines)
if (info.Highlights.Count > 0)
{
processingIdxEnd += tl.Length;
var highlightBG = info.Type == Models.TextDiffLineType.Added ? _presenter.AddedHighlightBrush : _presenter.DeletedHighlightBrush;
var processingIdxStart = 0;
var processingIdxEnd = 0;
var nextHighlight = 0;
var y = line.GetTextLineVisualYPosition(tl, VisualYPosition.LineTop) - textView.VerticalOffset;
var h = line.GetTextLineVisualYPosition(tl, VisualYPosition.LineBottom) - textView.VerticalOffset - y;
while (nextHightlight < info.Highlights.Count)
foreach (var tl in line.TextLines)
{
var highlight = info.Highlights[nextHightlight];
if (highlight.Start >= processingIdxEnd)
break;
processingIdxEnd += tl.Length;
var start = line.GetVisualColumn(highlight.Start < processingIdxStart ? processingIdxStart : highlight.Start);
var end = line.GetVisualColumn(highlight.End >= processingIdxEnd ? processingIdxEnd : highlight.End + 1);
var y = line.GetTextLineVisualYPosition(tl, VisualYPosition.LineTop) - textView.VerticalOffset;
var h = line.GetTextLineVisualYPosition(tl, VisualYPosition.LineBottom) - textView.VerticalOffset - y;
var x = line.GetTextLineVisualXPosition(tl, start) - textView.HorizontalOffset;
var w = line.GetTextLineVisualXPosition(tl, end) - textView.HorizontalOffset - x;
var rect = new Rect(x, y, w, h);
drawingContext.DrawRectangle(highlightBG, null, rect);
while (nextHighlight < info.Highlights.Count)
{
var highlight = info.Highlights[nextHighlight];
if (highlight.Start >= processingIdxEnd)
break;
if (highlight.End >= processingIdxEnd)
break;
var start = line.GetVisualColumn(highlight.Start < processingIdxStart ? processingIdxStart : highlight.Start);
var end = line.GetVisualColumn(highlight.End >= processingIdxEnd ? processingIdxEnd : highlight.End + 1);
nextHightlight++;
var x = line.GetTextLineVisualXPosition(tl, start) - textView.HorizontalOffset;
var w = line.GetTextLineVisualXPosition(tl, end) - textView.HorizontalOffset - x;
var rect = new Rect(x, y, w, h);
drawingContext.DrawRectangle(highlightBG, null, rect);
if (highlight.End >= processingIdxEnd)
break;
nextHighlight++;
}
processingIdxStart = processingIdxEnd;
}
processingIdxStart = processingIdxEnd;
}
}
if (changeBlock != null && changeBlock.IsInRange(index))
{
drawingContext.DrawRectangle(changeBlockBG, null, new Rect(0, startY, width, endY - startY));
if (index == changeBlock.StartLine)
drawingContext.DrawLine(changeBlockFG, new Point(0, startY), new Point(width, startY));
if (index == changeBlock.EndLine)
drawingContext.DrawLine(changeBlockFG, new Point(0, endY), new Point(width, endY));
}
}
}
@ -466,6 +483,15 @@ namespace SourceGit.Views
set => SetValue(SelectedChunkProperty, value);
}
public static readonly StyledProperty<int> CurrentChangeBlockIdxProperty =
AvaloniaProperty.Register<ThemedTextDiffPresenter, int>(nameof(CurrentChangeBlockIdx));
public int CurrentChangeBlockIdx
{
get => GetValue(CurrentChangeBlockIdxProperty);
set => SetValue(CurrentChangeBlockIdxProperty, value);
}
protected override Type StyleKeyOverride => typeof(TextEditor);
public ThemedTextDiffPresenter(TextArea area, TextDocument doc) : base(area, doc)
@ -498,6 +524,27 @@ namespace SourceGit.Views
{
}
public Models.TextDiffChangeBlock GetCurrentChangeBlock()
{
return GetChangeBlock(CurrentChangeBlockIdx);
}
public virtual Models.TextDiffChangeBlock GetChangeBlock(int changeBlockIdx)
{
return null;
}
public void JumpToChangeBlock(int changeBlockIdx)
{
var changeBlock = GetChangeBlock(changeBlockIdx);
if (changeBlock != null)
{
TextArea.Caret.Line = changeBlock.StartLine;
//TextArea.Caret.BringCaretToView(); // NOTE: Brings caret line (barely) into view.
ScrollToLine(changeBlock.StartLine); // NOTE: Brings specified line into center of view.
}
}
public override void Render(DrawingContext context)
{
base.Render(context);
@ -895,6 +942,16 @@ namespace SourceGit.Views
}
}
public override Models.TextDiffChangeBlock GetChangeBlock(int changeBlockIdx)
{
if (DataContext is Models.TextDiff diff)
{
if (changeBlockIdx >= 0 && changeBlockIdx < diff.ChangeBlocks.Count)
return diff.ChangeBlocks[changeBlockIdx];
}
return null;
}
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
@ -1109,6 +1166,16 @@ namespace SourceGit.Views
}
}
public override Models.TextDiffChangeBlock GetChangeBlock(int changeBlockIdx)
{
if (DataContext is ViewModels.TwoSideTextDiff diff)
{
if (changeBlockIdx >= 0 && changeBlockIdx < diff.ChangeBlocks.Count)
return diff.ChangeBlocks[changeBlockIdx];
}
return null;
}
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
@ -1241,6 +1308,15 @@ namespace SourceGit.Views
set => SetValue(EnableChunkSelectionProperty, value);
}
public static readonly StyledProperty<int> CurrentChangeBlockIdxProperty =
AvaloniaProperty.Register<TextDiffView, int>(nameof(CurrentChangeBlockIdx));
public int CurrentChangeBlockIdx
{
get => GetValue(CurrentChangeBlockIdxProperty);
set => SetValue(CurrentChangeBlockIdxProperty, value);
}
static TextDiffView()
{
UseSideBySideDiffProperty.Changed.AddClassHandler<TextDiffView>((v, _) =>
@ -1267,6 +1343,17 @@ namespace SourceGit.Views
v.Popup.Margin = new Thickness(0, top, right, 0);
v.Popup.IsVisible = true;
});
CurrentChangeBlockIdxProperty.Changed.AddClassHandler<TextDiffView>((v, e) =>
{
if (v.Editor.Presenter != null)
{
foreach (var p in v.Editor.Presenter.GetVisualDescendants().OfType<ThemedTextDiffPresenter>())
{
p.JumpToChangeBlock((int)e.NewValue);
}
}
});
}
public TextDiffView()
@ -1314,6 +1401,8 @@ namespace SourceGit.Views
IsUnstagedChange = diff.Option.IsUnstaged;
EnableChunkSelection = diff.Option.WorkingCopyChange != null;
diff.CurrentChangeBlockIdx = -1; // Unset current change block.
}
private void OnStageChunk(object _1, RoutedEventArgs _2)