Implemented change-block navigation

* Modified behavior of the Prev/Next Change buttons in DiffView toolbar.
* Well-defined change-blocks are pre-calculated and can be navigated between.
* Current change-block is highlighted in the Diff panel(s).
* Prev/next at start/end of range (re-)scrolls to first/last change-block
(I.e when unset, or already at first/last change-block, or at the only one.)
* Current change-block is unset in RefreshContent().
This commit is contained in:
goran-w 2024-11-29 08:57:21 +01:00
parent 6c5e31f481
commit ad9cf996f5
7 changed files with 252 additions and 31 deletions

View file

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

View file

@ -2,6 +2,8 @@
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using CommunityToolkit.Mvvm.ComponentModel;
using Avalonia; using Avalonia;
using Avalonia.Media.Imaging; 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 string File { get; set; } = string.Empty;
public List<TextDiffLine> Lines { get; set; } = new List<TextDiffLine>(); public List<TextDiffLine> Lines { get; set; } = new List<TextDiffLine>();
public Vector ScrollOffset { get; set; } = Vector.Zero; public Vector ScrollOffset { get; set; } = Vector.Zero;
public int MaxLineNumber = 0; public int MaxLineNumber = 0;
public int CurrentChangeBlockIdx
{
get => _currentChangeBlockIdx;
set => SetProperty(ref _currentChangeBlockIdx, value);
}
public string Repo { get; set; } = null; public string Repo { get; set; } = null;
public DiffOption Option { 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) public TextDiffSelection MakeSelection(int startLine, int endLine, bool isCombined, bool isOldSide)
{ {
var rs = new TextDiffSelection(); var rs = new TextDiffSelection();
@ -626,6 +682,8 @@ namespace SourceGit.Models
return true; return true;
} }
private int _currentChangeBlockIdx = -1; // NOTE: Use -1 as "not set".
[GeneratedRegex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@")] [GeneratedRegex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@")]
private static partial Regex REG_INDICATOR(); private static partial Regex REG_INDICATOR();
} }

View file

@ -73,6 +73,40 @@ namespace SourceGit.ViewModels
LoadDiffContent(); 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() public void ToggleFullTextDiff()
{ {
Preference.Instance.UseFullTextDiff = !Preference.Instance.UseFullTextDiff; Preference.Instance.UseFullTextDiff = !Preference.Instance.UseFullTextDiff;

View file

@ -45,10 +45,43 @@ namespace SourceGit.ViewModels
FillEmptyLines(); FillEmptyLines();
ProcessChangeBlocks();
if (previous != null && previous.File == File) if (previous != null && previous.File == File)
_syncScrollOffset = previous._syncScrollOffset; _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) public void ConvertsToCombinedRange(Models.TextDiff combined, ref int startLine, ref int endLine, bool isOldSide)
{ {
endLine = Math.Min(endLine, combined.Lines.Count - 1); endLine = Math.Min(endLine, combined.Lines.Count - 1);

View file

@ -241,7 +241,8 @@
<DataTemplate DataType="m:TextDiff"> <DataTemplate DataType="m:TextDiff">
<v:TextDiffView <v:TextDiffView
UseSideBySideDiff="{Binding Source={x:Static vm:Preference.Instance}, Path=UseSideBySideDiff, Mode=OneWay}" 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> </DataTemplate>
<!-- Empty or only EOL changes --> <!-- Empty or only EOL changes -->

View file

@ -30,6 +30,7 @@
UseSyntaxHighlighting="{Binding Source={x:Static vm:Preference.Instance}, Path=UseSyntaxHighlighting}" UseSyntaxHighlighting="{Binding Source={x:Static vm:Preference.Instance}, Path=UseSyntaxHighlighting}"
WordWrap="{Binding Source={x:Static vm:Preference.Instance}, Path=EnableDiffViewWordWrap}" WordWrap="{Binding Source={x:Static vm:Preference.Instance}, Path=EnableDiffViewWordWrap}"
ShowHiddenSymbols="{Binding Source={x:Static vm:Preference.Instance}, Path=ShowHiddenSymbolsInDiffView}" ShowHiddenSymbols="{Binding Source={x:Static vm:Preference.Instance}, Path=ShowHiddenSymbolsInDiffView}"
CurrentChangeBlockIdx="{Binding #ThisControl.CurrentChangeBlockIdx}"
EnableChunkSelection="{Binding #ThisControl.EnableChunkSelection}" EnableChunkSelection="{Binding #ThisControl.EnableChunkSelection}"
SelectedChunk="{Binding #ThisControl.SelectedChunk, Mode=TwoWay}"/> SelectedChunk="{Binding #ThisControl.SelectedChunk, Mode=TwoWay}"/>
@ -61,6 +62,7 @@
UseSyntaxHighlighting="{Binding Source={x:Static vm:Preference.Instance}, Path=UseSyntaxHighlighting}" UseSyntaxHighlighting="{Binding Source={x:Static vm:Preference.Instance}, Path=UseSyntaxHighlighting}"
WordWrap="False" WordWrap="False"
ShowHiddenSymbols="{Binding Source={x:Static vm:Preference.Instance}, Path=ShowHiddenSymbolsInDiffView}" ShowHiddenSymbols="{Binding Source={x:Static vm:Preference.Instance}, Path=ShowHiddenSymbolsInDiffView}"
CurrentChangeBlockIdx="{Binding #ThisControl.CurrentChangeBlockIdx}"
EnableChunkSelection="{Binding #ThisControl.EnableChunkSelection}" EnableChunkSelection="{Binding #ThisControl.EnableChunkSelection}"
SelectedChunk="{Binding #ThisControl.SelectedChunk, Mode=TwoWay}"/> SelectedChunk="{Binding #ThisControl.SelectedChunk, Mode=TwoWay}"/>
@ -82,6 +84,7 @@
UseSyntaxHighlighting="{Binding Source={x:Static vm:Preference.Instance}, Path=UseSyntaxHighlighting}" UseSyntaxHighlighting="{Binding Source={x:Static vm:Preference.Instance}, Path=UseSyntaxHighlighting}"
WordWrap="False" WordWrap="False"
ShowHiddenSymbols="{Binding Source={x:Static vm:Preference.Instance}, Path=ShowHiddenSymbolsInDiffView}" ShowHiddenSymbols="{Binding Source={x:Static vm:Preference.Instance}, Path=ShowHiddenSymbolsInDiffView}"
CurrentChangeBlockIdx="{Binding #ThisControl.CurrentChangeBlockIdx}"
EnableChunkSelection="{Binding #ThisControl.EnableChunkSelection}" EnableChunkSelection="{Binding #ThisControl.EnableChunkSelection}"
SelectedChunk="{Binding #ThisControl.SelectedChunk, Mode=TwoWay}"/> SelectedChunk="{Binding #ThisControl.SelectedChunk, Mode=TwoWay}"/>

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq;
using System.Text; using System.Text;
using Avalonia; using Avalonia;
@ -254,6 +255,10 @@ namespace SourceGit.Views
if (_presenter.Document == null || !textView.VisualLinesValid) if (_presenter.Document == null || !textView.VisualLinesValid)
return; 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 lines = _presenter.GetLines();
var width = textView.Bounds.Width; var width = textView.Bounds.Width;
foreach (var line in textView.VisualLines) foreach (var line in textView.VisualLines)
@ -266,51 +271,63 @@ namespace SourceGit.Views
break; break;
var info = lines[index - 1]; 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 startY = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineTop) - textView.VerticalOffset;
var endY = line.GetTextLineVisualYPosition(line.TextLines[^1], VisualYPosition.LineBottom) - 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; if (bg != null)
var processingIdxStart = 0; drawingContext.DrawRectangle(bg, null, new Rect(0, startY, width, endY - startY));
var processingIdxEnd = 0;
var nextHighlight = 0;
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; foreach (var tl in line.TextLines)
var h = line.GetTextLineVisualYPosition(tl, VisualYPosition.LineBottom) - textView.VerticalOffset - y;
while (nextHighlight < info.Highlights.Count)
{ {
var highlight = info.Highlights[nextHighlight]; processingIdxEnd += tl.Length;
if (highlight.Start >= processingIdxEnd)
break;
var start = line.GetVisualColumn(highlight.Start < processingIdxStart ? processingIdxStart : highlight.Start); var y = line.GetTextLineVisualYPosition(tl, VisualYPosition.LineTop) - textView.VerticalOffset;
var end = line.GetVisualColumn(highlight.End >= processingIdxEnd ? processingIdxEnd : highlight.End + 1); var h = line.GetTextLineVisualYPosition(tl, VisualYPosition.LineBottom) - textView.VerticalOffset - y;
var x = line.GetTextLineVisualXPosition(tl, start) - textView.HorizontalOffset; while (nextHighlight < info.Highlights.Count)
var w = line.GetTextLineVisualXPosition(tl, end) - textView.HorizontalOffset - x; {
var rect = new Rect(x, y, w, h); var highlight = info.Highlights[nextHighlight];
drawingContext.DrawRectangle(highlightBG, null, rect); if (highlight.Start >= processingIdxEnd)
break;
if (highlight.End >= processingIdxEnd) var start = line.GetVisualColumn(highlight.Start < processingIdxStart ? processingIdxStart : highlight.Start);
break; var end = line.GetVisualColumn(highlight.End >= processingIdxEnd ? processingIdxEnd : highlight.End + 1);
nextHighlight++; 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));
}
} }
} }
@ -486,6 +503,15 @@ namespace SourceGit.Views
set => SetValue(DisplayRangeProperty, value); set => SetValue(DisplayRangeProperty, 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); protected override Type StyleKeyOverride => typeof(TextEditor);
public ThemedTextDiffPresenter(TextArea area, TextDocument doc) : base(area, doc) public ThemedTextDiffPresenter(TextArea area, TextDocument doc) : base(area, doc)
@ -590,6 +616,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) public override void Render(DrawingContext context)
{ {
base.Render(context); base.Render(context);
@ -1018,6 +1065,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) protected override void OnLoaded(RoutedEventArgs e)
{ {
base.OnLoaded(e); base.OnLoaded(e);
@ -1234,6 +1291,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) protected override void OnLoaded(RoutedEventArgs e)
{ {
base.OnLoaded(e); base.OnLoaded(e);
@ -1480,6 +1547,15 @@ namespace SourceGit.Views
set => SetValue(EnableChunkSelectionProperty, value); 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() static TextDiffView()
{ {
UseSideBySideDiffProperty.Changed.AddClassHandler<TextDiffView>((v, _) => UseSideBySideDiffProperty.Changed.AddClassHandler<TextDiffView>((v, _) =>
@ -1506,6 +1582,17 @@ namespace SourceGit.Views
v.Popup.Margin = new Thickness(0, top, right, 0); v.Popup.Margin = new Thickness(0, top, right, 0);
v.Popup.IsVisible = true; 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() public TextDiffView()
@ -1553,6 +1640,8 @@ namespace SourceGit.Views
IsUnstagedChange = diff.Option.IsUnstaged; IsUnstagedChange = diff.Option.IsUnstaged;
EnableChunkSelection = diff.Option.WorkingCopyChange != null; EnableChunkSelection = diff.Option.WorkingCopyChange != null;
diff.CurrentChangeBlockIdx = -1; // Unset current change block.
} }
private void OnStageChunk(object _1, RoutedEventArgs _2) private void OnStageChunk(object _1, RoutedEventArgs _2)