refactor(merge): single IMergeCoordinator replaces the 5 conflict seams

The RequestConflictResolution Func was declared on 5 VMs and hand-threaded shell->details->merge-section->diff->merge-modal. Replaced with a DI-singleton IMergeCoordinator (MergeCoordinator holder; shell wires its Handler at composition, breaking the shell<->island cycle). Invokers (MergeModal, DetailsIsland, WorktreesOverview) depend on the interface; the two pass-through VMs (DiffModal, MergeSection) drop the seam entirely. No behavior change; conflict-seam + batch tests rewired to assert via the coordinator.
This commit is contained in:
Mika Kuns
2026-06-26 16:11:48 +02:00
parent 3f9f047955
commit 5be4b5c5fb
15 changed files with 79 additions and 75 deletions
@@ -72,7 +72,6 @@ public sealed partial class DiffModalViewModel : ViewModelBase
public string TaskTitle { get; init; } = "";
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
public Func<string, string, Task>? RequestConflictResolution { get; set; }
public ObservableCollection<DiffFileViewModel> Files { get; } = new();
@@ -100,7 +99,6 @@ public sealed partial class DiffModalViewModel : ViewModelBase
{
if (TaskId is null || ShowMergeModal is null || ResolveMergeVm is null) return;
var vm = ResolveMergeVm();
vm.RequestConflictResolution = RequestConflictResolution;
await vm.InitializeAsync(TaskId, TaskTitle);
await ShowMergeModal(vm);
// The diff is stale once the worktree merged away or a conflict opened the editor.
@@ -9,6 +9,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class MergeModalViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
private readonly IMergeCoordinator _merge;
public string TaskId { get; set; } = "";
public string TaskTitle { get; set; } = "";
@@ -28,10 +29,6 @@ public sealed partial class MergeModalViewModel : ViewModelBase
public Action? CloseAction { get; set; }
/// Set by the caller to hand a conflicting merge off to the in-app 3-pane editor
/// instead of dead-ending on the conflict message.
public Func<string, string, Task>? RequestConflictResolution { get; set; }
/// True once a merge has succeeded — lets the caller (e.g. the diff window)
/// close itself after this modal closes.
public bool Merged { get; private set; }
@@ -39,9 +36,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
/// True once a conflict has been handed off to the resolver — also a cue to close the diff window.
public bool RoutedToResolver { get; private set; }
public MergeModalViewModel(IWorkerClient worker)
public MergeModalViewModel(IWorkerClient worker, IMergeCoordinator merge)
{
_worker = worker;
_merge = merge;
}
public async Task InitializeAsync(string taskId, string taskTitle)
@@ -103,21 +101,11 @@ public sealed partial class MergeModalViewModel : ViewModelBase
});
break;
case "conflict":
// Hand off to the in-app 3-pane merge editor when wired (MergeTask aborted
// cleanly, so the resolver re-starts the merge leaving conflicts in the tree).
if (RequestConflictResolution is not null)
{
var branch = SelectedBranch!;
RoutedToResolver = true;
CloseAction?.Invoke();
await RequestConflictResolution(TaskId, branch);
}
else
{
HasConflict = true;
ConflictFiles = result.ConflictFiles;
ErrorMessage = Loc.T("vm.merge.conflict");
}
// MergeTask aborted cleanly; hand the conflict to the in-app 3-pane editor,
// which re-starts the merge leaving conflicts in the tree.
RoutedToResolver = true;
CloseAction?.Invoke();
await _merge.ResolveConflictAsync(TaskId, SelectedBranch!);
break;
case "blocked":
ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? "");
@@ -62,6 +62,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
private readonly Func<WorktreeModalViewModel> _diffVmFactory;
private readonly IMergeCoordinator _merge;
[ObservableProperty] private string? _listIdFilter;
[ObservableProperty] private string _title = "Worktrees";
@@ -79,9 +80,6 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
public ObservableCollection<string> MergeTargets { get; } = new();
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
/// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch)
public Func<string, string, Task>? RequestConflictResolution { get; set; }
public Action? CloseAction { get; set; }
public Action<WorktreeModalViewModel>? ShowDiffAction { get; set; }
public Action<string, string>? JumpToTaskAction { get; set; }
@@ -89,10 +87,11 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
public Func<MergeModalViewModel, Task>? ShowMergeAction { get; set; }
public WorktreesOverviewModalViewModel(IWorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory)
public WorktreesOverviewModalViewModel(IWorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory, IMergeCoordinator merge)
{
_worker = worker;
_diffVmFactory = diffVmFactory;
_merge = merge;
}
public void SelectRow(WorktreeOverviewRowViewModel row)
@@ -328,7 +327,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
{
if (row is null) return;
RequestConflictResolution?.Invoke(row.TaskId, SelectedTarget ?? "");
_ = _merge.ResolveConflictAsync(row.TaskId, SelectedTarget ?? "");
}
[RelayCommand]