feat(merge): in-app 3-way merge editor (chunk 2b)

Replace the whole-file conflict resolver with a real 3-way merge editor
built on the line-level hunk pipeline.

- ConflictModels: MergeFile/MergeFileSegment/MergeConflictBlock with
  Compose() that reassembles stable text + chosen resolutions
- ConflictResolverViewModel (same seam contract): loads conflict
  documents, flattens conflicts for one-at-a-time navigation, per-block
  Accept Ours/Base/Theirs/Both + editable result, binary files block continue
- ConflictResolverView: 3-column Base|Ours|Theirs + editable result via
  AvaloniaEdit with TextMate syntax highlighting by file extension;
  editors synced in code-behind
- add Avalonia.AvaloniaEdit + AvaloniaEdit.TextMate + TextMateSharp.Grammars;
  AvaloniaEdit theme StyleInclude in App.axaml
- rewrite ConflictResolverViewModel tests (load/gating/compose/nav/binary/abort)
This commit is contained in:
Mika Kuns
2026-06-18 16:46:43 +02:00
parent e779e13654
commit 92767c646e
9 changed files with 416 additions and 106 deletions

View File

@@ -1,19 +1,102 @@
using System;
using System.ComponentModel;
using System.IO;
using Avalonia.Controls;
using AvaloniaEdit;
using AvaloniaEdit.TextMate;
using ClaudeDo.Ui.ViewModels.Conflicts;
using TextMateSharp.Grammars;
namespace ClaudeDo.Ui.Views.Conflicts;
public partial class ConflictResolverView : Window
{
private ConflictResolverViewModel? _vm;
private RegistryOptions? _registry;
private TextMate.Installation? _baseTm, _oursTm, _theirsTm, _resultTm;
private MergeConflictBlock? _hooked;
private bool _reloading;
public ConflictResolverView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(System.EventArgs e)
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is ConflictResolverViewModel vm)
vm.CloseRequested = Close;
if (_vm is not null) _vm.CurrentChanged -= ReloadEditors;
_vm = DataContext as ConflictResolverViewModel;
if (_vm is null) return;
_vm.CloseRequested = Close;
EnsureTextMate();
_vm.CurrentChanged += ReloadEditors;
ResultEditor.TextChanged += OnResultEditorChanged;
ReloadEditors();
}
private void EnsureTextMate()
{
if (_registry is not null) return;
_registry = new RegistryOptions(ThemeName.DarkPlus);
_baseTm = BaseEditor.InstallTextMate(_registry);
_oursTm = OursEditor.InstallTextMate(_registry);
_theirsTm = TheirsEditor.InstallTextMate(_registry);
_resultTm = ResultEditor.InstallTextMate(_registry);
}
private void ReloadEditors()
{
if (_vm is null) return;
_reloading = true;
try
{
if (_hooked is not null) _hooked.PropertyChanged -= OnCurrentResolutionChanged;
_hooked = _vm.Current;
if (_hooked is not null) _hooked.PropertyChanged += OnCurrentResolutionChanged;
BaseEditor.Text = _vm.Current?.Base ?? "";
OursEditor.Text = _vm.Current?.Ours ?? "";
TheirsEditor.Text = _vm.Current?.Theirs ?? "";
ResultEditor.Text = _vm.Current?.Resolution ?? "";
ApplyGrammar(_vm.CurrentPath);
}
finally { _reloading = false; }
}
// User edits in the result editor flow back to the current conflict's resolution.
private void OnResultEditorChanged(object? sender, EventArgs e)
{
if (_reloading || _vm?.Current is null) return;
_vm.Current.Resolution = ResultEditor.Text;
}
// Accept-buttons set Resolution on the VM; mirror that into the result editor.
private void OnCurrentResolutionChanged(object? sender, PropertyChangedEventArgs e)
{
if (_reloading || e.PropertyName != nameof(MergeConflictBlock.Resolution)) return;
var resolved = _vm?.Current?.Resolution ?? "";
if (ResultEditor.Text == resolved) return;
_reloading = true;
try { ResultEditor.Text = resolved; }
finally { _reloading = false; }
}
private void ApplyGrammar(string? path)
{
if (_registry is null || string.IsNullOrEmpty(path)) return;
var ext = Path.GetExtension(path);
if (string.IsNullOrEmpty(ext)) return;
var language = _registry.GetLanguageByExtension(ext);
if (language is null) return;
var scope = _registry.GetScopeByLanguageId(language.Id);
_baseTm?.SetGrammar(scope);
_oursTm?.SetGrammar(scope);
_theirsTm?.SetGrammar(scope);
_resultTm?.SetGrammar(scope);
}
}