From 87689e141f2b2caf571cb9ae8babc81ec457f010 Mon Sep 17 00:00:00 2001 From: goran-w Date: Fri, 29 Nov 2024 08:57:21 +0100 Subject: [PATCH] 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(). --- src/Commands/Diff.cs | 3 + src/Models/DiffResult.cs | 60 +++++++++++- src/ViewModels/DiffContext.cs | 34 +++++++ src/ViewModels/TwoSideTextDiff.cs | 33 +++++++ src/Views/DiffView.axaml | 3 +- src/Views/TextDiffView.axaml | 3 + src/Views/TextDiffView.axaml.cs | 147 ++++++++++++++++++++++++------ 7 files changed, 252 insertions(+), 31 deletions(-) diff --git a/src/Commands/Diff.cs b/src/Commands/Diff.cs index da971e58..f1bef7b7 100644 --- a/src/Commands/Diff.cs +++ b/src/Commands/Diff.cs @@ -51,6 +51,9 @@ namespace SourceGit.Commands _result.TextDiff.MaxLineNumber = Math.Max(_newLine, _oldLine); } + if (_result.TextDiff != null) + _result.TextDiff.ProcessChangeBlocks(); + return _result; } diff --git a/src/Models/DiffResult.cs b/src/Models/DiffResult.cs index e0ae82e0..afe22ad4 100644 --- a/src/Models/DiffResult.cs +++ b/src/Models/DiffResult.cs @@ -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 Lines { get; set; } = new List(); 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 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(); } diff --git a/src/ViewModels/DiffContext.cs b/src/ViewModels/DiffContext.cs index 87b1a6de..51418baf 100644 --- a/src/ViewModels/DiffContext.cs +++ b/src/ViewModels/DiffContext.cs @@ -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; diff --git a/src/ViewModels/TwoSideTextDiff.cs b/src/ViewModels/TwoSideTextDiff.cs index 3fb1e63b..493174e0 100644 --- a/src/ViewModels/TwoSideTextDiff.cs +++ b/src/ViewModels/TwoSideTextDiff.cs @@ -45,10 +45,43 @@ namespace SourceGit.ViewModels FillEmptyLines(); + ProcessChangeBlocks(); + if (previous != null && previous.File == File) _syncScrollOffset = previous._syncScrollOffset; } + public List 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); diff --git a/src/Views/DiffView.axaml b/src/Views/DiffView.axaml index e0627ad8..360ac8fc 100644 --- a/src/Views/DiffView.axaml +++ b/src/Views/DiffView.axaml @@ -241,7 +241,8 @@ + UseFullTextDiff="{Binding Source={x:Static vm:Preference.Instance}, Path=UseFullTextDiff, Mode=OneWay}" + CurrentChangeBlockIdx="{Binding CurrentChangeBlockIdx, Mode=OneWay}"/> diff --git a/src/Views/TextDiffView.axaml b/src/Views/TextDiffView.axaml index d9d6dde3..7c823b49 100644 --- a/src/Views/TextDiffView.axaml +++ b/src/Views/TextDiffView.axaml @@ -30,6 +30,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}"/> @@ -61,6 +62,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}"/> @@ -82,6 +84,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}"/> diff --git a/src/Views/TextDiffView.axaml.cs b/src/Views/TextDiffView.axaml.cs index e2a3a289..f6818722 100644 --- a/src/Views/TextDiffView.axaml.cs +++ b/src/Views/TextDiffView.axaml.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Text; using Avalonia; @@ -254,6 +255,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) @@ -266,51 +271,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 nextHighlight = 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 (nextHighlight < info.Highlights.Count) + foreach (var tl in line.TextLines) { - var highlight = info.Highlights[nextHighlight]; - 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); - 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); } + public static readonly StyledProperty CurrentChangeBlockIdxProperty = + AvaloniaProperty.Register(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) @@ -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) { 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) { 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) { base.OnLoaded(e); @@ -1480,6 +1547,15 @@ namespace SourceGit.Views set => SetValue(EnableChunkSelectionProperty, value); } + public static readonly StyledProperty CurrentChangeBlockIdxProperty = + AvaloniaProperty.Register(nameof(CurrentChangeBlockIdx)); + + public int CurrentChangeBlockIdx + { + get => GetValue(CurrentChangeBlockIdxProperty); + set => SetValue(CurrentChangeBlockIdxProperty, value); + } + static TextDiffView() { UseSideBySideDiffProperty.Changed.AddClassHandler((v, _) => @@ -1506,6 +1582,17 @@ namespace SourceGit.Views v.Popup.Margin = new Thickness(0, top, right, 0); v.Popup.IsVisible = true; }); + + CurrentChangeBlockIdxProperty.Changed.AddClassHandler((v, e) => + { + if (v.Editor.Presenter != null) + { + foreach (var p in v.Editor.Presenter.GetVisualDescendants().OfType()) + { + p.JumpToChangeBlock((int)e.NewValue); + } + } + }); } public TextDiffView() @@ -1553,6 +1640,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)