feature: add a minimap to merge conflict editor

Signed-off-by: leo <longshuang@msn.cn>
This commit is contained in:
leo
2026-01-28 15:39:37 +08:00
parent 7c2053a81d
commit bc2d13a3b8
4 changed files with 167 additions and 26 deletions

View File

@@ -33,5 +33,8 @@ namespace SourceGit.Converters
public static readonly FuncValueConverter<int, IBrush> ToBookmarkBrush =
new FuncValueConverter<int, IBrush>(v => Models.Bookmarks.Get(v) ?? App.Current?.FindResource("Brush.FG1") as IBrush);
public static readonly FuncValueConverter<int, string> ToUnsolvedDesc =
new FuncValueConverter<int, string>(v => v == 0 ? App.Text("MergeConflictEditor.AllResolved") : App.Text("MergeConflictEditor.ConflictsRemaining", v));
}
}

View File

@@ -46,14 +46,10 @@ namespace SourceGit.ViewModels
private set => SetProperty(ref _diffMaxLineNumber, value);
}
public string StatusText
public int UnsolvedCount
{
get
{
if (_unresolvedConflictCount > 0)
return App.Text("MergeConflictEditor.ConflictsRemaining", _unresolvedConflictCount);
return App.Text("MergeConflictEditor.AllResolved");
}
get => _unsolvedCount;
private set => SetProperty(ref _unsolvedCount, value);
}
public Vector ScrollOffset
@@ -68,9 +64,10 @@ namespace SourceGit.ViewModels
set => SetProperty(ref _selectedChunk, value);
}
public IReadOnlyList<Models.ConflictRegion> ConflictRegions => _conflictRegions;
public bool HasUnresolvedConflicts => _unresolvedConflictCount > 0;
public bool HasUnsavedChanges => _unresolvedConflictCount < _conflictRegions.Count;
public IReadOnlyList<Models.ConflictRegion> ConflictRegions
{
get => _conflictRegions;
}
public MergeConflictEditor(Repository repo, string filePath)
{
@@ -126,7 +123,7 @@ namespace SourceGit.ViewModels
if (_conflictRegions.Count == 0)
return true;
if (_unresolvedConflictCount > 0)
if (_unsolvedCount > 0)
{
Error = "Cannot save: there are still unresolved conflicts.";
return false;
@@ -460,23 +457,21 @@ namespace SourceGit.ViewModels
SelectedChunk = null;
ResultDiffLines = resultLines;
var unresolved = new List<int>();
var unsolved = new List<int>();
for (var i = 0; i < _conflictRegions.Count; i++)
{
var r = _conflictRegions[i];
if (!r.IsResolved)
unresolved.Add(i);
unsolved.Add(i);
}
_unresolvedConflictCount = unresolved.Count;
OnPropertyChanged(nameof(StatusText));
OnPropertyChanged(nameof(HasUnresolvedConflicts));
UnsolvedCount = unsolved.Count;
}
private readonly Repository _repo;
private readonly string _filePath;
private string _originalContent = string.Empty;
private int _unresolvedConflictCount = 0;
private int _unsolvedCount = 0;
private int _diffMaxLineNumber = 0;
private List<Models.TextDiffLine> _oursDiffLines = [];
private List<Models.TextDiffLine> _theirsDiffLines = [];

View File

@@ -5,6 +5,7 @@
xmlns:m="using:SourceGit.Models"
xmlns:vm="using:SourceGit.ViewModels"
xmlns:v="using:SourceGit.Views"
xmlns:c="using:SourceGit.Converters"
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="800"
x:Class="SourceGit.Views.MergeConflictEditor"
x:DataType="vm:MergeConflictEditor"
@@ -51,7 +52,7 @@
ToolTip.Tip="{DynamicResource Text.MergeConflictEditor.PrevConflict}"
Click="OnGotoPrevConflict"
HotKey="{OnPlatform Ctrl+Up, macOS=⌘+Up}"
IsEnabled="{Binding HasUnresolvedConflicts, Mode=OneWay}">
IsEnabled="{Binding UnsolvedCount, Converter={x:Static c:IntConverters.IsGreaterThanZero}, Mode=OneWay}">
<Path Width="12" Height="12" Margin="0,6,0,0" Data="{StaticResource Icons.Up}"/>
</Button>
<Button Classes="icon_button"
@@ -59,12 +60,13 @@
ToolTip.Tip="{DynamicResource Text.MergeConflictEditor.NextConflict}"
Click="OnGotoNextConflict"
HotKey="{OnPlatform Ctrl+Down, macOS=⌘+Down}"
IsEnabled="{Binding HasUnresolvedConflicts, Mode=OneWay}">
IsEnabled="{Binding UnsolvedCount, Converter={x:Static c:IntConverters.IsGreaterThanZero}, Mode=OneWay}">
<Path Width="12" Height="12" Margin="0,6,0,0" Data="{StaticResource Icons.Down}"/>
</Button>
<!-- Info Bar -->
<TextBlock Text="{Binding StatusText}" VerticalAlignment="Center"/>
<TextBlock Text="{Binding UnsolvedCount, Converter={x:Static c:IntConverters.ToUnsolvedDesc}, Mode=OneWay}"
VerticalAlignment="Center"/>
<Rectangle Width="1" Fill="{DynamicResource Brush.Border2}" Margin="4,6"/>
<!-- Save -->
@@ -78,9 +80,9 @@
</Border>
<!-- Main Content -->
<Grid Grid.Row="2" RowDefinitions="*,*">
<Grid Grid.Row="2" RowDefinitions="*,*" ColumnDefinitions="*,20">
<!-- Mine and Theirs Panels (Side-by-Side) -->
<Grid Grid.Row="0" ColumnDefinitions="*,*" Margin="4,4,4,2">
<Grid Grid.Row="0" Grid.Column="0" ColumnDefinitions="*,*" Margin="4,4,4,2">
<!-- Mine (Ours) Panel -->
<Border Grid.Column="0" Margin="0,0,2,0">
<Grid RowDefinitions="Auto,*" Background="{DynamicResource Brush.Contents}">
@@ -163,7 +165,7 @@
</Grid>
<!-- Result Panel -->
<Border Grid.Row="1" Margin="4,2,4,4">
<Border Grid.Row="1" Grid.Column="0" Margin="4,2,4,4">
<Grid RowDefinitions="Auto,*" Background="{DynamicResource Brush.Contents}">
<Border Grid.Row="0" Padding="8,4" Background="{DynamicResource Brush.ToolBar}" BorderThickness="1,1,1,0" BorderBrush="{DynamicResource Brush.Border2}">
<TextBlock Text="{DynamicResource Text.MergeConflictEditor.Result}" FontWeight="Bold"/>
@@ -247,6 +249,14 @@
</Grid>
</Grid>
</Border>
<!-- Minimap -->
<Border Grid.Row="0" Grid.RowSpan="2" Grid.Column="1"
Margin="0,0,4,4"
BorderThickness="1,0,1,1" BorderBrush="{DynamicResource Brush.Border2}">
<v:MergeConflictMinimap DisplayRange="{Binding #ResultPresenter.DisplayRange, Mode=OneWay}"
UnsolvedCount="{Binding UnsolvedCount, Mode=OneWay}"/>
</Border>
</Grid>
<!-- Error -->

View File

@@ -12,6 +12,7 @@ using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvaloniaEdit;
using AvaloniaEdit.Document;
@@ -105,6 +106,15 @@ namespace SourceGit.Views
set => SetValue(SelectedChunkProperty, value);
}
public static readonly StyledProperty<ViewModels.TextDiffDisplayRange> DisplayRangeProperty =
AvaloniaProperty.Register<MergeDiffPresenter, ViewModels.TextDiffDisplayRange>(nameof(DisplayRange));
public ViewModels.TextDiffDisplayRange DisplayRange
{
get => GetValue(DisplayRangeProperty);
set => SetValue(DisplayRangeProperty, value);
}
protected override Type StyleKeyOverride => typeof(TextEditor);
public MergeDiffPresenter() : base(new TextArea(), new TextDocument())
@@ -147,7 +157,10 @@ namespace SourceGit.Views
TextArea.TextView.PointerEntered += OnTextViewPointerChanged;
TextArea.TextView.PointerMoved += OnTextViewPointerChanged;
TextArea.TextView.PointerWheelChanged += OnTextViewPointerWheelChanged;
TextArea.TextView.VisualLinesChanged += OnTextViewVisualLinesChanged;
TextArea.TextView.LineTransformers.Add(new MergeDiffIndicatorTransformer(this));
OnTextViewVisualLinesChanged(null, null);
}
protected override void OnUnloaded(RoutedEventArgs e)
@@ -156,6 +169,7 @@ namespace SourceGit.Views
TextArea.TextView.PointerEntered -= OnTextViewPointerChanged;
TextArea.TextView.PointerMoved -= OnTextViewPointerChanged;
TextArea.TextView.PointerWheelChanged -= OnTextViewPointerWheelChanged;
TextArea.TextView.VisualLinesChanged -= OnTextViewVisualLinesChanged;
if (_textMate != null)
{
@@ -253,6 +267,34 @@ namespace SourceGit.Views
Dispatcher.UIThread.Post(() => UpdateSelectedChunkPosition(vm, y));
}
private void OnTextViewVisualLinesChanged(object sender, EventArgs e)
{
if (!TextArea.TextView.VisualLinesValid)
{
SetCurrentValue(DisplayRangeProperty, null);
return;
}
var lines = DiffLines;
var start = int.MaxValue;
var count = 0;
foreach (var line in TextArea.TextView.VisualLines)
{
if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted)
continue;
var index = line.FirstDocumentLine.LineNumber - 1;
if (index >= lines.Count)
continue;
count++;
if (start > index)
start = index;
}
SetCurrentValue(DisplayRangeProperty, new ViewModels.TextDiffDisplayRange(start, start + count));
}
private void OnTextViewScrollChanged(object sender, ScrollChangedEventArgs e)
{
if (_scrollViewer == null || DataContext is not ViewModels.MergeConflictEditor vm)
@@ -525,6 +567,97 @@ namespace SourceGit.Views
private readonly MergeDiffPresenter _presenter;
}
public class MergeConflictMinimap : Control
{
public static readonly StyledProperty<ViewModels.TextDiffDisplayRange> DisplayRangeProperty =
AvaloniaProperty.Register<MergeConflictMinimap, ViewModels.TextDiffDisplayRange>(nameof(DisplayRange));
public ViewModels.TextDiffDisplayRange DisplayRange
{
get => GetValue(DisplayRangeProperty);
set => SetValue(DisplayRangeProperty, value);
}
public static readonly StyledProperty<int> UnsolvedCountProperty =
AvaloniaProperty.Register<MergeConflictMinimap, int>(nameof(UnsolvedCount));
public int UnsolvedCount
{
get => GetValue(UnsolvedCountProperty);
set => SetValue(UnsolvedCountProperty, value);
}
public override void Render(DrawingContext context)
{
context.DrawRectangle(Brushes.Transparent, null, new Rect(0, 0, Bounds.Width, Bounds.Height));
if (DataContext is not ViewModels.MergeConflictEditor vm)
return;
var total = vm.OursDiffLines.Count;
var unitHeight = Bounds.Height / (total * 1.0);
var conflicts = vm.ConflictRegions;
var blockBGs = new SolidColorBrush[] { new SolidColorBrush(Colors.Red, 0.6), new SolidColorBrush(Colors.Green, 0.6) };
foreach (var c in conflicts)
{
var topY = c.StartLineInOriginal * unitHeight;
var bottomY = (c.EndLineInOriginal + 1) * unitHeight;
var bg = blockBGs[c.IsResolved ? 1 : 0];
context.DrawRectangle(bg, null, new Rect(0, topY, Bounds.Width, bottomY - topY));
}
var range = DisplayRange;
if (range == null || range.End == 0)
return;
var startY = range.Start * unitHeight;
var endY = range.End * unitHeight;
var color = (Color)this.FindResource("SystemAccentColor");
var brush = new SolidColorBrush(color, 0.2);
var pen = new Pen(color.ToUInt32());
var rect = new Rect(0, startY, Bounds.Width, endY - startY);
context.DrawRectangle(brush, null, rect);
context.DrawLine(pen, rect.TopLeft, rect.TopRight);
context.DrawLine(pen, rect.BottomLeft, rect.BottomRight);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == DisplayRangeProperty ||
change.Property == UnsolvedCountProperty ||
change.Property.Name.Equals(nameof(ActualThemeVariant), StringComparison.Ordinal))
InvalidateVisual();
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
if (DataContext is not ViewModels.MergeConflictEditor vm)
return;
var total = vm.OursDiffLines.Count;
var range = DisplayRange;
if (range == null || range.End == 0)
return;
var unitHeight = Bounds.Height / (total * 1.0);
var startY = range.Start * unitHeight;
var endY = range.End * unitHeight;
var pressedY = e.GetPosition(this).Y;
if (pressedY >= startY && pressedY <= endY)
return;
var line = Math.Max(1, Math.Min(total, (int)Math.Ceiling(pressedY / unitHeight)));
var editor = this.FindAncestorOfType<MergeConflictEditor>();
if (editor != null)
editor.OursPresenter.ScrollToLine(line);
}
}
public partial class MergeConflictEditor : ChromelessWindow
{
public MergeConflictEditor()
@@ -547,7 +680,7 @@ namespace SourceGit.Views
if (DataContext is not ViewModels.MergeConflictEditor vm)
return;
if (_forceClose || !vm.HasUnsavedChanges)
if (_forceClose || vm.UnsolvedCount < vm.ConflictRegions.Count)
{
vm.PropertyChanged -= OnViewModelPropertyChanged;
return;
@@ -580,7 +713,7 @@ namespace SourceGit.Views
private void OnGotoPrevConflict(object sender, RoutedEventArgs e)
{
if (IsLoaded && DataContext is ViewModels.MergeConflictEditor vm && vm.HasUnresolvedConflicts)
if (IsLoaded && DataContext is ViewModels.MergeConflictEditor vm && vm.UnsolvedCount > 0)
{
var view = OursPresenter.TextArea?.TextView;
var lines = vm.OursDiffLines;
@@ -623,7 +756,7 @@ namespace SourceGit.Views
private void OnGotoNextConflict(object sender, RoutedEventArgs e)
{
if (IsLoaded && DataContext is ViewModels.MergeConflictEditor vm && vm.HasUnresolvedConflicts)
if (IsLoaded && DataContext is ViewModels.MergeConflictEditor vm && vm.UnsolvedCount > 0)
{
var view = OursPresenter.TextArea?.TextView;
var lines = vm.OursDiffLines;