using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Text; using Avalonia; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Threading; using Avalonia.VisualTree; using AvaloniaEdit; using AvaloniaEdit.Document; using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; using AvaloniaEdit.TextMate; using AvaloniaEdit.Utils; namespace SourceGit.Views { public class TextDiffViewChunk { public double Y { get; set; } = 0.0; public double Height { get; set; } = 0.0; public int StartIdx { get; set; } = 0; public int EndIdx { get; set; } = 0; public bool Combined { get; set; } = true; public bool IsOldSide { get; set; } = false; public bool ShouldReplace(TextDiffViewChunk old) { if (old == null) return true; return Math.Abs(Y - old.Y) > 0.001 || Math.Abs(Height - old.Height) > 0.001 || StartIdx != old.StartIdx || EndIdx != old.EndIdx || Combined != old.Combined || IsOldSide != old.IsOldSide; } } public class ThemedTextDiffPresenter : TextEditor { public class VerticalSeperatorMargin : AbstractMargin { public override void Render(DrawingContext context) { var presenter = this.FindAncestorOfType(); if (presenter != null) { var pen = new Pen(presenter.LineBrush); context.DrawLine(pen, new Point(0, 0), new Point(0, Bounds.Height)); } } protected override Size MeasureOverride(Size availableSize) { return new Size(1, 0); } } public class LineNumberMargin : AbstractMargin { public LineNumberMargin(bool usePresenter, bool isOld) { _usePresenter = usePresenter; _isOld = isOld; Margin = new Thickness(8, 0); ClipToBounds = true; } public override void Render(DrawingContext context) { var presenter = this.FindAncestorOfType(); if (presenter == null) return; var isOld = _isOld; if (_usePresenter) isOld = presenter.IsOld; var lines = presenter.GetLines(); var view = TextView; if (view != null && view.VisualLinesValid) { var typeface = view.CreateTypeface(); foreach (var line in view.VisualLines) { if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) continue; var index = line.FirstDocumentLine.LineNumber; if (index > lines.Count) break; var info = lines[index - 1]; var lineNumber = isOld ? info.OldLine : info.NewLine; if (string.IsNullOrEmpty(lineNumber)) continue; var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineMiddle) - view.VerticalOffset; var txt = new FormattedText( lineNumber, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, presenter.FontSize, presenter.Foreground); context.DrawText(txt, new Point(Bounds.Width - txt.Width, y - txt.Height * 0.5)); } } } protected override Size MeasureOverride(Size availableSize) { var presenter = this.FindAncestorOfType(); if (presenter == null) return new Size(32, 0); var maxLineNumber = presenter.GetMaxLineNumber(); var typeface = TextView.CreateTypeface(); var test = new FormattedText( $"{maxLineNumber}", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, presenter.FontSize, Brushes.White); return new Size(test.Width, 0); } protected override void OnDataContextChanged(EventArgs e) { base.OnDataContextChanged(e); InvalidateMeasure(); } private bool _usePresenter = false; private bool _isOld = false; } public class LineModifyTypeMargin : AbstractMargin { public LineModifyTypeMargin() { Margin = new Thickness(1, 0); ClipToBounds = true; } public override void Render(DrawingContext context) { var presenter = this.FindAncestorOfType(); if (presenter == null) return; var lines = presenter.GetLines(); var view = TextView; if (view != null && view.VisualLinesValid) { var typeface = view.CreateTypeface(); foreach (var line in view.VisualLines) { if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) continue; var index = line.FirstDocumentLine.LineNumber; if (index > lines.Count) break; var info = lines[index - 1]; var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineMiddle) - view.VerticalOffset; var indicator = null as FormattedText; if (info.Type == Models.TextDiffLineType.Added) { indicator = new FormattedText( "+", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, presenter.FontSize, Brushes.Green); } else if (info.Type == Models.TextDiffLineType.Deleted) { indicator = new FormattedText( "-", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, presenter.FontSize, Brushes.Red); } if (indicator != null) context.DrawText(indicator, new Point(0, y - indicator.Height * 0.5)); } } } protected override Size MeasureOverride(Size availableSize) { var presenter = this.FindAncestorOfType(); if (presenter == null) return new Size(0, 0); var maxLineNumber = presenter.GetMaxLineNumber(); var typeface = TextView.CreateTypeface(); var test = new FormattedText( $"-", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, presenter.FontSize, Brushes.White); return new Size(test.Width, 0); } protected override void OnDataContextChanged(EventArgs e) { base.OnDataContextChanged(e); InvalidateMeasure(); } } public class LineBackgroundRenderer : IBackgroundRenderer { public KnownLayer Layer => KnownLayer.Background; public LineBackgroundRenderer(ThemedTextDiffPresenter presenter) { _presenter = presenter; } public void Draw(TextView textView, DrawingContext drawingContext) { if (_presenter.Document == null || !textView.VisualLinesValid) return; var lines = _presenter.GetLines(); var width = textView.Bounds.Width; foreach (var line in textView.VisualLines) { if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) continue; var index = line.FirstDocumentLine.LineNumber; if (index > lines.Count) 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 highlightBG = info.Type == Models.TextDiffLineType.Added ? _presenter.AddedHighlightBrush : _presenter.DeletedHighlightBrush; var processingIdxStart = 0; var processingIdxEnd = 0; var nextHightlight = 0; foreach (var tl in line.TextLines) { processingIdxEnd += tl.Length; var y = line.GetTextLineVisualYPosition(tl, VisualYPosition.LineTop) - textView.VerticalOffset; var h = line.GetTextLineVisualYPosition(tl, VisualYPosition.LineBottom) - textView.VerticalOffset - y; while (nextHightlight < info.Highlights.Count) { var highlight = info.Highlights[nextHightlight]; if (highlight.Start >= processingIdxEnd) break; var start = line.GetVisualColumn(highlight.Start < processingIdxStart ? processingIdxStart : highlight.Start); var end = line.GetVisualColumn(highlight.End >= processingIdxEnd ? processingIdxEnd : highlight.End + 1); 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; nextHightlight++; } processingIdxStart = processingIdxEnd; } } } } private IBrush GetBrushByLineType(Models.TextDiffLineType type) { switch (type) { case Models.TextDiffLineType.None: return _presenter.EmptyContentBackground; case Models.TextDiffLineType.Added: return _presenter.AddedContentBackground; case Models.TextDiffLineType.Deleted: return _presenter.DeletedContentBackground; default: return null; } } private ThemedTextDiffPresenter _presenter = null; } public class LineStyleTransformer : DocumentColorizingTransformer { public LineStyleTransformer(ThemedTextDiffPresenter presenter) { _presenter = presenter; } protected override void ColorizeLine(DocumentLine line) { var lines = _presenter.GetLines(); var idx = line.LineNumber; if (idx > lines.Count) return; var info = lines[idx - 1]; if (info.Type == Models.TextDiffLineType.Indicator) { ChangeLinePart(line.Offset, line.EndOffset, v => { v.TextRunProperties.SetForegroundBrush(_presenter.IndicatorForeground); v.TextRunProperties.SetTypeface(new Typeface(_presenter.FontFamily, FontStyle.Italic)); }); } } private readonly ThemedTextDiffPresenter _presenter; } public static readonly StyledProperty FileNameProperty = AvaloniaProperty.Register(nameof(FileName), string.Empty); public string FileName { get => GetValue(FileNameProperty); set => SetValue(FileNameProperty, value); } public static readonly StyledProperty IsOldProperty = AvaloniaProperty.Register(nameof(IsOld)); public bool IsOld { get => GetValue(IsOldProperty); set => SetValue(IsOldProperty, value); } public static readonly StyledProperty LineBrushProperty = AvaloniaProperty.Register(nameof(LineBrush), new SolidColorBrush(Colors.DarkGray)); public IBrush LineBrush { get => GetValue(LineBrushProperty); set => SetValue(LineBrushProperty, value); } public static readonly StyledProperty EmptyContentBackgroundProperty = AvaloniaProperty.Register(nameof(EmptyContentBackground), new SolidColorBrush(Color.FromArgb(60, 0, 0, 0))); public IBrush EmptyContentBackground { get => GetValue(EmptyContentBackgroundProperty); set => SetValue(EmptyContentBackgroundProperty, value); } public static readonly StyledProperty AddedContentBackgroundProperty = AvaloniaProperty.Register(nameof(AddedContentBackground), new SolidColorBrush(Color.FromArgb(60, 0, 255, 0))); public IBrush AddedContentBackground { get => GetValue(AddedContentBackgroundProperty); set => SetValue(AddedContentBackgroundProperty, value); } public static readonly StyledProperty DeletedContentBackgroundProperty = AvaloniaProperty.Register(nameof(DeletedContentBackground), new SolidColorBrush(Color.FromArgb(60, 255, 0, 0))); public IBrush DeletedContentBackground { get => GetValue(DeletedContentBackgroundProperty); set => SetValue(DeletedContentBackgroundProperty, value); } public static readonly StyledProperty AddedHighlightBrushProperty = AvaloniaProperty.Register(nameof(AddedHighlightBrush), new SolidColorBrush(Color.FromArgb(90, 0, 255, 0))); public IBrush AddedHighlightBrush { get => GetValue(AddedHighlightBrushProperty); set => SetValue(AddedHighlightBrushProperty, value); } public static readonly StyledProperty DeletedHighlightBrushProperty = AvaloniaProperty.Register(nameof(DeletedHighlightBrush), new SolidColorBrush(Color.FromArgb(80, 255, 0, 0))); public IBrush DeletedHighlightBrush { get => GetValue(DeletedHighlightBrushProperty); set => SetValue(DeletedHighlightBrushProperty, value); } public static readonly StyledProperty IndicatorForegroundProperty = AvaloniaProperty.Register(nameof(IndicatorForeground), Brushes.Gray); public IBrush IndicatorForeground { get => GetValue(IndicatorForegroundProperty); set => SetValue(IndicatorForegroundProperty, value); } public static readonly StyledProperty UseSyntaxHighlightingProperty = AvaloniaProperty.Register(nameof(UseSyntaxHighlighting)); public bool UseSyntaxHighlighting { get => GetValue(UseSyntaxHighlightingProperty); set => SetValue(UseSyntaxHighlightingProperty, value); } public static readonly StyledProperty ShowHiddenSymbolsProperty = AvaloniaProperty.Register(nameof(ShowHiddenSymbols)); public bool ShowHiddenSymbols { get => GetValue(ShowHiddenSymbolsProperty); set => SetValue(ShowHiddenSymbolsProperty, value); } public static readonly StyledProperty EnableChunkSelectionProperty = AvaloniaProperty.Register(nameof(EnableChunkSelection)); public bool EnableChunkSelection { get => GetValue(EnableChunkSelectionProperty); set => SetValue(EnableChunkSelectionProperty, value); } public static readonly StyledProperty SelectedChunkProperty = AvaloniaProperty.Register(nameof(SelectedChunk)); public TextDiffViewChunk SelectedChunk { get => GetValue(SelectedChunkProperty); set => SetValue(SelectedChunkProperty, value); } protected override Type StyleKeyOverride => typeof(TextEditor); public ThemedTextDiffPresenter(TextArea area, TextDocument doc) : base(area, doc) { IsReadOnly = true; ShowLineNumbers = false; BorderThickness = new Thickness(0); _lineStyleTransformer = new LineStyleTransformer(this); TextArea.TextView.Margin = new Thickness(2, 0); TextArea.TextView.Options.EnableHyperlinks = false; TextArea.TextView.Options.EnableEmailHyperlinks = false; TextArea.TextView.BackgroundRenderers.Add(new LineBackgroundRenderer(this)); TextArea.TextView.LineTransformers.Add(_lineStyleTransformer); } public virtual List GetLines() { return []; } public virtual int GetMaxLineNumber() { return 0; } public virtual void UpdateSelectedChunk(double y) { } public void GotoPrevChange() { var view = TextArea.TextView; var lines = GetLines(); var firstLineIdx = lines.Count; foreach (var line in view.VisualLines) { if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) continue; var index = line.FirstDocumentLine.LineNumber - 1; if (index >= lines.Count) continue; if (firstLineIdx > index) firstLineIdx = index; } var firstLineType = lines[firstLineIdx].Type; var isChangeFirstLine = firstLineType != Models.TextDiffLineType.Normal && firstLineType != Models.TextDiffLineType.Indicator; if (isChangeFirstLine) { for (var i = firstLineIdx - 1; i >= 0; i--) { var prevType = lines[i].Type; if (prevType == Models.TextDiffLineType.Normal || prevType == Models.TextDiffLineType.Indicator) { ScrollToLine(i + 2); return; } } } else { var prevChangeEnd = -1; for (var i = firstLineIdx - 1; i >= 0; i--) { var prevType = lines[i].Type; if (prevType == Models.TextDiffLineType.None || prevType == Models.TextDiffLineType.Added || prevType == Models.TextDiffLineType.Deleted) { prevChangeEnd = i; break; } } if (prevChangeEnd <= 0) return; for (var i = prevChangeEnd - 1; i >= 0; i--) { var prevType = lines[i].Type; if (prevType == Models.TextDiffLineType.Normal || prevType == Models.TextDiffLineType.Indicator) { ScrollToLine(i + 2); return; } } } } public void GotoNextChange() { var view = TextArea.TextView; var lines = GetLines(); var lastLineIdx = -1; foreach (var line in view.VisualLines) { if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) continue; var index = line.FirstDocumentLine.LineNumber - 1; if (index >= lines.Count) continue; if (lastLineIdx < index) lastLineIdx = index; } var lastLineType = lines[lastLineIdx].Type; var findNormalLine = lastLineType == Models.TextDiffLineType.Normal || lastLineType == Models.TextDiffLineType.Indicator; for (var idx = lastLineIdx + 1; idx < lines.Count; idx++) { var nextType = lines[idx].Type; if (nextType == Models.TextDiffLineType.None || nextType == Models.TextDiffLineType.Added || nextType == Models.TextDiffLineType.Deleted) { if (findNormalLine) { ScrollToLine(idx + 1); return; } } else if (!findNormalLine) { findNormalLine = true; } } } public override void Render(DrawingContext context) { base.Render(context); var chunk = SelectedChunk; if (chunk == null || (!chunk.Combined && chunk.IsOldSide != IsOld)) return; var color = (Color)this.FindResource("SystemAccentColor")!; var brush = new SolidColorBrush(color, 0.1); var pen = new Pen(color.ToUInt32()); var rect = new Rect(0, chunk.Y, Bounds.Width, chunk.Height); context.DrawRectangle(brush, null, rect); context.DrawLine(pen, rect.TopLeft, rect.TopRight); context.DrawLine(pen, rect.BottomLeft, rect.BottomRight); } protected override void OnLoaded(RoutedEventArgs e) { base.OnLoaded(e); TextArea.TextView.ContextRequested += OnTextViewContextRequested; TextArea.TextView.PointerEntered += OnTextViewPointerChanged; TextArea.TextView.PointerMoved += OnTextViewPointerChanged; TextArea.TextView.PointerWheelChanged += OnTextViewPointerWheelChanged; UpdateTextMate(); } protected override void OnUnloaded(RoutedEventArgs e) { base.OnUnloaded(e); TextArea.TextView.ContextRequested -= OnTextViewContextRequested; TextArea.TextView.PointerEntered -= OnTextViewPointerChanged; TextArea.TextView.PointerMoved -= OnTextViewPointerChanged; TextArea.TextView.PointerWheelChanged -= OnTextViewPointerWheelChanged; if (_textMate != null) { _textMate.Dispose(); _textMate = null; } } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if (change.Property == UseSyntaxHighlightingProperty) { UpdateTextMate(); } else if (change.Property == ShowHiddenSymbolsProperty) { var val = change.NewValue is true; Options.ShowTabs = val; Options.ShowSpaces = val; } else if (change.Property == FileNameProperty) { Models.TextMateHelper.SetGrammarByFileName(_textMate, FileName); } else if (change.Property.Name == "ActualThemeVariant" && change.NewValue != null) { Models.TextMateHelper.SetThemeByApp(_textMate); } else if (change.Property == SelectedChunkProperty) { InvalidateVisual(); } } private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e) { var selection = TextArea.Selection; if (selection.IsEmpty) return; var copy = new MenuItem(); copy.Header = App.Text("Copy"); copy.Icon = App.CreateMenuIcon("Icons.Copy"); copy.Click += (_, ev) => { App.CopyText(SelectedText); ev.Handled = true; }; var menu = new ContextMenu(); menu.Items.Add(copy); menu.Open(TextArea.TextView); e.Handled = true; } private void OnTextViewPointerChanged(object sender, PointerEventArgs e) { if (EnableChunkSelection && sender is TextView view) { var selection = TextArea.Selection; if (selection == null || selection.IsEmpty) { if (_lastSelectStart != _lastSelectEnd) { _lastSelectStart = TextLocation.Empty; _lastSelectEnd = TextLocation.Empty; } var chunk = SelectedChunk; if (chunk != null) { var rect = new Rect(0, chunk.Y, Bounds.Width, chunk.Height); if (rect.Contains(e.GetPosition(this))) return; } UpdateSelectedChunk(e.GetPosition(view).Y + view.VerticalOffset); return; } var start = selection.StartPosition.Location; var end = selection.EndPosition.Location; if (_lastSelectStart != start || _lastSelectEnd != end) { _lastSelectStart = start; _lastSelectEnd = end; UpdateSelectedChunk(e.GetPosition(view).Y + view.VerticalOffset); return; } if (SelectedChunk == null) UpdateSelectedChunk(e.GetPosition(view).Y + view.VerticalOffset); } } private void OnTextViewPointerWheelChanged(object sender, PointerWheelEventArgs e) { if (EnableChunkSelection && sender is TextView view) { var y = e.GetPosition(view).Y + view.VerticalOffset; Dispatcher.UIThread.Post(() => UpdateSelectedChunk(y)); } } protected void TrySetChunk(TextDiffViewChunk chunk) { var old = SelectedChunk; if (chunk == null) { if (old != null) SetCurrentValue(SelectedChunkProperty, null); return; } if (chunk.ShouldReplace(old)) SetCurrentValue(SelectedChunkProperty, chunk); } protected (int, int) FindRangeByIndex(List lines, int lineIdx) { var startIdx = -1; var endIdx = -1; var normalLineCount = 0; var modifiedLineCount = 0; for (int i = lineIdx; i >= 0; i--) { var line = lines[i]; if (line.Type == Models.TextDiffLineType.Indicator) { startIdx = i; break; } if (line.Type == Models.TextDiffLineType.Normal) { normalLineCount++; if (normalLineCount >= 2) { startIdx = i; break; } } else { normalLineCount = 0; modifiedLineCount++; } } normalLineCount = lines[lineIdx].Type == Models.TextDiffLineType.Normal ? 1 : 0; for (int i = lineIdx + 1; i < lines.Count; i++) { var line = lines[i]; if (line.Type == Models.TextDiffLineType.Indicator) { endIdx = i; break; } if (line.Type == Models.TextDiffLineType.Normal) { normalLineCount++; if (normalLineCount >= 2) { endIdx = i; break; } } else { normalLineCount = 0; modifiedLineCount++; } } if (endIdx == -1) endIdx = lines.Count - 1; return modifiedLineCount > 0 ? (startIdx, endIdx) : (-1, -1); } private void UpdateTextMate() { if (UseSyntaxHighlighting) { if (_textMate == null) { TextArea.TextView.LineTransformers.Remove(_lineStyleTransformer); _textMate = Models.TextMateHelper.CreateForEditor(this); TextArea.TextView.LineTransformers.Add(_lineStyleTransformer); Models.TextMateHelper.SetGrammarByFileName(_textMate, FileName); } } else { if (_textMate != null) { _textMate.Dispose(); _textMate = null; GC.Collect(); TextArea.TextView.Redraw(); } } } private TextMate.Installation _textMate = null; private TextLocation _lastSelectStart = TextLocation.Empty; private TextLocation _lastSelectEnd = TextLocation.Empty; private LineStyleTransformer _lineStyleTransformer = null; } public class CombinedTextDiffPresenter : ThemedTextDiffPresenter { public CombinedTextDiffPresenter() : base(new TextArea(), new TextDocument()) { TextArea.LeftMargins.Add(new LineNumberMargin(false, true)); TextArea.LeftMargins.Add(new VerticalSeperatorMargin()); TextArea.LeftMargins.Add(new LineNumberMargin(false, false)); TextArea.LeftMargins.Add(new VerticalSeperatorMargin()); TextArea.LeftMargins.Add(new LineModifyTypeMargin()); } public override List GetLines() { if (DataContext is Models.TextDiff diff) return diff.Lines; return []; } public override int GetMaxLineNumber() { if (DataContext is Models.TextDiff diff) return diff.MaxLineNumber; return 0; } public override void UpdateSelectedChunk(double y) { var diff = DataContext as Models.TextDiff; if (diff == null) return; var view = TextArea.TextView; var selection = TextArea.Selection; if (!selection.IsEmpty) { var startIdx = Math.Min(selection.StartPosition.Line - 1, diff.Lines.Count - 1); var endIdx = Math.Min(selection.EndPosition.Line - 1, diff.Lines.Count - 1); if (startIdx > endIdx) (startIdx, endIdx) = (endIdx, startIdx); var hasChanges = false; for (var i = startIdx; i <= endIdx; i++) { var line = diff.Lines[i]; if (line.Type == Models.TextDiffLineType.Added || line.Type == Models.TextDiffLineType.Deleted) { hasChanges = true; break; } } if (!hasChanges) { TrySetChunk(null); return; } var firstLineIdx = view.VisualLines[0].FirstDocumentLine.LineNumber - 1; var lastLineIdx = view.VisualLines[^1].FirstDocumentLine.LineNumber - 1; if (endIdx < firstLineIdx || startIdx > lastLineIdx) { TrySetChunk(null); return; } var startLine = view.GetVisualLine(startIdx + 1); var endLine = view.GetVisualLine(endIdx + 1); var rectStartY = startLine != null ? startLine.GetTextLineVisualYPosition(startLine.TextLines[0], VisualYPosition.TextTop) - view.VerticalOffset : 0; var rectEndY = endLine != null ? endLine.GetTextLineVisualYPosition(endLine.TextLines[^1], VisualYPosition.TextBottom) - view.VerticalOffset : view.Bounds.Height; TrySetChunk(new TextDiffViewChunk() { Y = rectStartY, Height = rectEndY - rectStartY, StartIdx = startIdx, EndIdx = endIdx, Combined = true, IsOldSide = false, }); } else { var lineIdx = -1; foreach (var line in view.VisualLines) { if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) continue; var index = line.FirstDocumentLine.LineNumber; if (index > diff.Lines.Count) break; var endY = line.GetTextLineVisualYPosition(line.TextLines[^1], VisualYPosition.TextBottom); if (endY > y) { lineIdx = index - 1; break; } } if (lineIdx == -1) { TrySetChunk(null); return; } var (startIdx, endIdx) = FindRangeByIndex(diff.Lines, lineIdx); if (startIdx == -1) { TrySetChunk(null); return; } var startLine = view.GetVisualLine(startIdx + 1); var endLine = view.GetVisualLine(endIdx + 1); var rectStartY = startLine != null ? startLine.GetTextLineVisualYPosition(startLine.TextLines[0], VisualYPosition.TextTop) - view.VerticalOffset : 0; var rectEndY = endLine != null ? endLine.GetTextLineVisualYPosition(endLine.TextLines[^1], VisualYPosition.TextBottom) - view.VerticalOffset : view.Bounds.Height; TrySetChunk(new TextDiffViewChunk() { Y = rectStartY, Height = rectEndY - rectStartY, StartIdx = startIdx, EndIdx = endIdx, Combined = true, IsOldSide = false, }); } } protected override void OnLoaded(RoutedEventArgs e) { base.OnLoaded(e); var scroller = this.FindDescendantOfType(); if (scroller != null) { scroller.Bind(ScrollViewer.OffsetProperty, new Binding("ScrollOffset", BindingMode.TwoWay)); scroller.GotFocus += OnTextViewScrollGotFocus; } } protected override void OnUnloaded(RoutedEventArgs e) { var scroller = this.FindDescendantOfType(); if (scroller != null) scroller.GotFocus -= OnTextViewScrollGotFocus; base.OnUnloaded(e); } protected override void OnDataContextChanged(EventArgs e) { base.OnDataContextChanged(e); var textDiff = DataContext as Models.TextDiff; if (textDiff != null) { var builder = new StringBuilder(); foreach (var line in textDiff.Lines) { if (line.Content.Length > 10000) { builder.Append(line.Content.Substring(0, 1000)); builder.Append($"...({line.Content.Length - 1000} character trimmed)"); builder.AppendLine(); } else { builder.AppendLine(line.Content); } } Text = builder.ToString(); } else { Text = string.Empty; } GC.Collect(); } private void OnTextViewScrollGotFocus(object sender, GotFocusEventArgs e) { if (EnableChunkSelection && sender is ScrollViewer viewer) { var area = viewer.FindDescendantOfType