feat(merge): unify planning conflicts onto the resolver + 3-pane VM foundation

Route planning unit-merge conflicts through ConflictResolverViewModel
(OpenForPlanningAsync) and delete the old ConflictResolutionViewModel dialog.
Add active-file 3-pane reconstruction (MergeFile OursText/TheirsText/ResultText,
ActiveFile, SelectFileCommand, active-file readout) as the VM foundation for the
Rider-style editor. Seam preserved; Ui.Tests 128/128.
This commit is contained in:
Mika Kuns
2026-06-19 09:58:32 +02:00
parent 983c177c9a
commit 378a92c156
10 changed files with 288 additions and 337 deletions

View File

@@ -75,4 +75,16 @@ public sealed class MergeFile
/// <summary>Reassemble the file: stable text verbatim, each conflict replaced by its resolution.</summary>
public string Compose() => string.Concat(
Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? s.Conflict.Ours) : s.StableText));
/// <summary>Left pane document: stable regions verbatim, conflict regions show Ours text.</summary>
public string OursText => string.Concat(
Segments.Select(s => s.IsConflict ? s.Conflict!.Ours : s.StableText));
/// <summary>Right pane document: stable regions verbatim, conflict regions show Theirs text.</summary>
public string TheirsText => string.Concat(
Segments.Select(s => s.IsConflict ? s.Conflict!.Theirs : s.StableText));
/// <summary>Middle (result) pane document: stable regions verbatim, conflict regions show Resolution if set, else Ours.</summary>
public string ResultText => string.Concat(
Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? s.Conflict.Ours) : s.StableText));
}

View File

@@ -15,6 +15,13 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
private readonly IWorkerClient _worker;
private readonly string _taskId;
// The task whose conflicted working tree is read/written. For a single-task merge this is
// _taskId; for a planning unit-merge it's the subtask currently being merged.
private string _conflictTaskId;
// When set, this is a planning unit-merge: continue/abort drive the orchestrator on the parent.
private string? _planningParentId;
public ObservableCollection<MergeFile> Files { get; } = new();
// All text conflicts across all files, flattened for one-at-a-time navigation.
@@ -38,14 +45,39 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
[NotifyCanExecuteChangedFor(nameof(PreviousCommand))]
private int _currentIndex = -1;
[ObservableProperty] private MergeFile? _activeFile;
/// <summary>Raised when the active file changes so the view can rebuild its three documents.</summary>
public event Action? ActiveFileChanged;
partial void OnActiveFileChanged(MergeFile? value)
{
ActiveFileChanged?.Invoke();
OnPropertyChanged(nameof(ActiveOursText));
OnPropertyChanged(nameof(ActiveTheirsText));
OnPropertyChanged(nameof(ActiveResultText));
OnPropertyChanged(nameof(PositionText));
}
public string ActiveOursText => ActiveFile?.OursText ?? "";
public string ActiveTheirsText => ActiveFile?.TheirsText ?? "";
public string ActiveResultText => ActiveFile?.ResultText ?? "";
public bool HasCurrent => Current is not null;
public int TotalConflicts => _flat.Count;
public int ResolvedCount => _flat.Count(x => x.Block.IsResolved);
public string? CurrentPath => InRange ? _flat[CurrentIndex].File.Path : null;
public string PositionText => _flat.Count == 0
? "No text conflicts"
: $"Conflict {CurrentIndex + 1} of {_flat.Count} · {ResolvedCount} resolved";
public string PositionText
{
get
{
if (ActiveFile is null || ActiveFile.Conflicts.Count == 0) return "No text conflicts";
var count = ActiveFile.Conflicts.Count;
var resolved = ActiveFile.Conflicts.Count(c => c.IsResolved);
return $"{count} conflicts · {resolved} resolved";
}
}
public IReadOnlyList<string> BinaryFilePaths => Files.Where(f => f.IsBinary).Select(f => f.Path).ToList();
public bool HasBinaryFiles => Files.Any(f => f.IsBinary);
@@ -66,6 +98,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
{
_worker = worker;
_taskId = taskId;
_conflictTaskId = taskId;
}
/// <summary>Starts the conflict merge and loads the conflicted files as line-level segments.
@@ -83,26 +116,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
Error = start.ErrorMessage;
return false;
}
var docs = await _worker.GetMergeConflictDocumentsAsync(_taskId);
Files.Clear();
_flat.Clear();
foreach (var f in docs.Files)
{
var segments = f.Segments.Select(s => s.IsConflict
? MergeFileSegment.FromConflict(Hook(new MergeConflictBlock(s.Ours, s.Base, s.Theirs)))
: MergeFileSegment.Stable(s.Text)).ToList();
var file = new MergeFile(f.Path, f.IsBinary, segments);
Files.Add(file);
foreach (var c in file.Conflicts) _flat.Add((file, c));
}
OnPropertyChanged(nameof(TotalConflicts));
OnPropertyChanged(nameof(BinaryFilePaths));
OnPropertyChanged(nameof(HasBinaryFiles));
RecomputeCanContinue();
if (_flat.Count > 0) MoveTo(0);
return Files.Count > 0;
return await LoadDocumentsAsync();
}
catch (Exception ex)
{
@@ -112,6 +126,53 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
finally { IsBusy = false; }
}
/// <summary>Resolves a planning unit-merge conflict for <paramref name="subtaskId"/>. The merge is
/// already mid-conflict (driven by the orchestrator), so this only loads the conflicted files;
/// continue/abort hand back to the orchestrator on <paramref name="planningParentId"/>.</summary>
public async Task<bool> OpenForPlanningAsync(string planningParentId, string subtaskId)
{
_planningParentId = planningParentId;
_conflictTaskId = subtaskId;
IsBusy = true;
Error = null;
try
{
return await LoadDocumentsAsync();
}
catch (Exception ex)
{
Error = ex.Message;
return false;
}
finally { IsBusy = false; }
}
private async Task<bool> LoadDocumentsAsync()
{
var docs = await _worker.GetMergeConflictDocumentsAsync(_conflictTaskId);
Files.Clear();
_flat.Clear();
foreach (var f in docs.Files)
{
var segments = f.Segments.Select(s => s.IsConflict
? MergeFileSegment.FromConflict(Hook(new MergeConflictBlock(s.Ours, s.Base, s.Theirs)))
: MergeFileSegment.Stable(s.Text)).ToList();
var file = new MergeFile(f.Path, f.IsBinary, segments);
Files.Add(file);
foreach (var c in file.Conflicts) _flat.Add((file, c));
}
OnPropertyChanged(nameof(TotalConflicts));
OnPropertyChanged(nameof(BinaryFilePaths));
OnPropertyChanged(nameof(HasBinaryFiles));
RecomputeCanContinue();
if (_flat.Count > 0)
MoveTo(0); // also sets ActiveFile via MoveTo
else if (Files.Count > 0)
ActiveFile = Files[0];
return Files.Count > 0;
}
private MergeConflictBlock Hook(MergeConflictBlock block)
{
block.PropertyChanged += OnBlockChanged;
@@ -125,6 +186,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
RecomputeCanContinue();
OnPropertyChanged(nameof(ResolvedCount));
OnPropertyChanged(nameof(PositionText));
OnPropertyChanged(nameof(ActiveResultText));
}
}
@@ -135,10 +197,22 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
{
CurrentIndex = index;
Current = _flat[index].Block;
ActiveFile = _flat[index].File;
OnPropertyChanged(nameof(CurrentPath));
CurrentChanged?.Invoke();
}
[RelayCommand]
private void SelectFile(MergeFile file)
{
// Jump to the first conflict in this file (if any); otherwise just switch the active file.
var idx = _flat.FindIndex(x => x.File == file);
if (idx >= 0)
MoveTo(idx);
else
ActiveFile = file;
}
private bool CanGoNext() => CurrentIndex >= 0 && CurrentIndex < _flat.Count - 1;
private bool CanGoPrevious() => CurrentIndex > 0;
@@ -157,7 +231,16 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
try
{
foreach (var file in Files.Where(f => !f.IsBinary))
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.Compose());
await _worker.WriteConflictResolutionAsync(_conflictTaskId, file.Path, file.Compose());
if (_planningParentId is not null)
{
// Hand back to the orchestrator: it commits this subtask and drains the rest.
// A later subtask conflict re-opens this editor via the PlanningMergeConflict broadcast.
await _worker.ContinuePlanningMergeAsync(_planningParentId);
CloseRequested?.Invoke();
return;
}
var result = await _worker.ContinueConflictMergeAsync(_taskId);
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
@@ -176,7 +259,13 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
private async Task AbortAsync()
{
IsBusy = true;
try { await _worker.AbortConflictMergeAsync(_taskId); }
try
{
if (_planningParentId is not null)
await _worker.AbortPlanningMergeAsync(_planningParentId);
else
await _worker.AbortConflictMergeAsync(_taskId);
}
catch (Exception ex) { Error = ex.Message; }
finally
{

View File

@@ -41,9 +41,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
// 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; }
@@ -146,44 +143,17 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
{
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
_ = OpenConflictDialogAsync(planningTaskId, subtaskId, conflictedFiles);
// A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge.
_ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
}
private async Task OpenConflictDialogAsync(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId)
{
if (ShowConflictDialog == null || _dbFactory == null) return;
string subtaskTitle = subtaskId;
// The conflict lives in the list's working dir (the repo being merged into),
// not the subtask worktree. VS Code must open this folder to show the merge UI.
string repoDirectory = System.Environment.CurrentDirectory;
string targetBranch = Worker?.LastApproveTarget ?? "main";
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var entity = await ctx.Tasks
.Include(t => t.List)
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == subtaskId);
if (entity != null)
{
subtaskTitle = entity.Title;
if (entity.List?.WorkingDir is { } dir && !string.IsNullOrWhiteSpace(dir))
repoDirectory = dir;
}
}
catch { /* Non-fatal: fall back to subtaskId and cwd */ }
var vm = new ConflictResolutionViewModel(
Worker!,
planningTaskId,
subtaskTitle,
targetBranch,
conflictedFiles,
repoDirectory);
await ShowConflictDialog(vm);
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
var vm = ConflictResolverFactory(subtaskId);
var hasConflicts = await vm.OpenForPlanningAsync(planningTaskId, subtaskId);
if (hasConflicts)
await ShowConflictResolver(vm);
}
// For tests only — does NOT wire up events.

View File

@@ -1,90 +0,0 @@
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Planning;
public sealed partial class ConflictResolutionViewModel : ObservableObject
{
private readonly IWorkerClient _worker;
private readonly string _planningTaskId;
// The repository directory that is currently mid-merge (the list's working dir),
// NOT the subtask worktree. Opening this folder is what makes VS Code show its
// merge-conflict resolution UI.
private readonly string _repoDirectory;
public string SubtaskTitle { get; }
public string TargetBranch { get; }
public IReadOnlyList<string> ConflictedFiles { get; }
public string SubtaskLabel => Loc.T("vm.conflictResolution.subtaskPrefix", SubtaskTitle);
public string TargetLabel => Loc.T("vm.conflictResolution.targetPrefix", TargetBranch);
[ObservableProperty] private string? _vsCodeError;
[ObservableProperty] private string? _actionError;
public Action? CloseRequested { get; set; }
public ConflictResolutionViewModel(
IWorkerClient worker,
string planningTaskId,
string subtaskTitle,
string targetBranch,
IReadOnlyList<string> conflictedFiles,
string repoDirectory)
{
_worker = worker;
_planningTaskId = planningTaskId;
_repoDirectory = repoDirectory;
SubtaskTitle = subtaskTitle;
TargetBranch = targetBranch;
ConflictedFiles = conflictedFiles;
}
[RelayCommand]
private void OpenInVsCode()
{
try
{
// Open the folder that is mid-merge so VS Code shows the Source Control
// merge-conflict UI for every conflicted file. Opening individual files
// gives only a plain editor with no conflict resolution affordances.
Process.Start(new ProcessStartInfo
{
FileName = "code",
Arguments = $"\"{_repoDirectory}\"",
UseShellExecute = true,
});
VsCodeError = null;
}
catch (Exception ex)
{
VsCodeError = Loc.T("vm.conflictResolution.vsCodeError", ex.Message);
}
}
[RelayCommand]
private async Task ContinueAsync()
{
ActionError = null;
try
{
await _worker.ContinuePlanningMergeAsync(_planningTaskId);
CloseRequested?.Invoke();
}
catch (Exception ex) { ActionError = ex.Message; }
}
[RelayCommand]
private async Task AbortAsync()
{
ActionError = null;
try
{
await _worker.AbortPlanningMergeAsync(_planningTaskId);
CloseRequested?.Invoke();
}
catch (Exception ex) { ActionError = ex.Message; }
}
}