merge(layer-c): inline conflict resolver + worker conflict plumbing

This commit is contained in:
mika kuns
2026-06-05 11:09:02 +02:00
15 changed files with 1518 additions and 3 deletions

View File

@@ -0,0 +1,49 @@
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Conflicts;
public sealed partial class ConflictHunk : ObservableObject
{
public string Ours { get; }
public string Theirs { get; }
public string? Base { get; }
[ObservableProperty] private string? _resolution;
public bool IsResolved => Resolution is not null;
public ConflictHunk(string ours, string theirs, string? @base)
{
Ours = ours;
Theirs = theirs;
Base = @base;
}
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
[RelayCommand] private void EditManually() => Resolution ??= Ours;
}
public sealed class ConflictFile
{
public string Path { get; }
public IReadOnlyList<ConflictHunk> Hunks { get; }
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
{
Path = path;
Hunks = hunks;
}
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved);
/// <summary>Merged file content: concatenation of each hunk's resolution
/// (single whole-file hunk today; concatenation stays correct for multi-hunk later).</summary>
public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution));
}

View File

@@ -0,0 +1,116 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Conflicts;
public sealed partial class ConflictResolverViewModel : ObservableObject
{
private readonly IWorkerClient _worker;
private readonly string _taskId;
public ObservableCollection<ConflictFile> Files { get; } = new();
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string? _error;
[ObservableProperty] private bool _canContinue;
public string TaskId => _taskId;
public Action? CloseRequested { get; set; }
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
{
_worker = worker;
_taskId = taskId;
}
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
public async Task<bool> OpenAsync(string targetBranch)
{
IsBusy = true;
Error = null;
try
{
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
{
if (string.Equals(start.Status, "blocked", StringComparison.Ordinal))
Error = start.ErrorMessage;
return false;
}
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
Files.Clear();
foreach (var f in conflicts.Files)
{
var hunks = f.Hunks.Select(h =>
{
var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base);
hk.PropertyChanged += OnHunkChanged;
return hk;
}).ToList();
Files.Add(new ConflictFile(f.Path, hunks));
}
RecomputeCanContinue();
return Files.Count > 0;
}
catch (Exception ex)
{
Error = ex.Message;
return false;
}
finally { IsBusy = false; }
}
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
RecomputeCanContinue();
}
private void RecomputeCanContinue()
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
[RelayCommand]
private async Task ContinueAsync()
{
if (!CanContinue) return;
IsBusy = true;
Error = null;
try
{
foreach (var file in Files)
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
var result = await _worker.ContinueMergeAsync(_taskId);
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
CloseRequested?.Invoke();
else
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
}
catch (Exception ex)
{
Error = ex.Message;
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task AbortAsync()
{
IsBusy = true;
try { await _worker.AbortMergeAsync(_taskId); }
catch (Exception ex) { Error = ex.Message; }
finally
{
IsBusy = false;
CloseRequested?.Invoke();
}
}
}

View File

@@ -44,6 +44,20 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
// Set by MainWindow to open the conflict resolution dialog.
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
// Layer C seam: composition root sets the factory; MainWindow sets the dialog opener.
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
public Func<ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel, Task>? ShowConflictResolver { get; set; }
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
{
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
var vm = ConflictResolverFactory(taskId);
var hasConflicts = await vm.OpenAsync(targetBranch);
if (hasConflicts)
await ShowConflictResolver(vm);
}
// Set by MainWindow to open the About dialog.
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }