Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs
Mika Kuns 23a93ce0bb
All checks were successful
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 43s
fix(merge): unresolved conflicts compose to empty, not Ours (+ review nits)
Code-review follow-ups before push:
- MergeFile.ResultText/Compose() fell back to Ours for unresolved conflicts while
  the editor seeds them empty — align both on empty so the public model matches the
  pane and Continue can't silently auto-accept Ours.
- Bound the gutter re-layout retry (was an unbounded Background re-post when the
  editor isn't laid out, e.g. minimized).
- Pluralize the readout ('1 conflict' not '1 conflicts'). Tests updated. Ui 128 green.
2026-06-19 13:14:51 +02:00

301 lines
11 KiB
C#

using System;
using System.Collections.Generic;
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;
// 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.
private readonly List<(MergeFile File, MergeConflictBlock Block)> _flat = new();
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string? _error;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ContinueHint))]
private bool _canContinue;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasCurrent))]
private MergeConflictBlock? _current;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(PositionText))]
[NotifyPropertyChangedFor(nameof(CurrentPath))]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
[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));
// Keep the focused conflict inside the active file (e.g. when switched via the file picker).
if (value is not null && (Current is null || !value.Conflicts.Contains(Current)))
{
var idx = _flat.FindIndex(x => x.File == value);
if (idx >= 0) MoveTo(idx);
}
}
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
{
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} {(count == 1 ? "conflict" : "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);
public bool HasMultipleFiles => Files.Count > 1;
/// <summary>Cross-file progress shown in the editor: how many files still have unresolved
/// (or binary) conflicts, so you can see how many more need attention.</summary>
public string FilesSummary
{
get
{
var total = Files.Count;
if (total == 0) return "";
var unresolved = Files.Count(f => !f.AllResolved);
return unresolved == 0 ? $"All {total} files resolved" : $"{unresolved} of {total} files unresolved";
}
}
public string ContinueHint => HasBinaryFiles
? "Binary conflicts must be resolved externally — abort and resolve in your editor."
: "";
private bool InRange => CurrentIndex >= 0 && CurrentIndex < _flat.Count;
public string TaskId => _taskId;
public Action? CloseRequested { get; set; }
/// <summary>Raised when the current conflict changes so the view can reload its editors.</summary>
public event Action? CurrentChanged;
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
{
_worker = worker;
_taskId = taskId;
_conflictTaskId = taskId;
}
/// <summary>Starts the conflict merge and loads the conflicted files as line-level segments.
/// Returns true when there is something 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;
}
return await LoadDocumentsAsync();
}
catch (Exception ex)
{
Error = ex.Message;
return false;
}
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));
OnPropertyChanged(nameof(HasMultipleFiles));
OnPropertyChanged(nameof(FilesSummary));
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;
return block;
}
private void OnBlockChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(MergeConflictBlock.IsResolved) or nameof(MergeConflictBlock.Resolution))
{
RecomputeCanContinue();
OnPropertyChanged(nameof(ResolvedCount));
OnPropertyChanged(nameof(PositionText));
OnPropertyChanged(nameof(ActiveResultText));
OnPropertyChanged(nameof(FilesSummary));
}
}
private void RecomputeCanContinue() =>
CanContinue = Files.Count > 0 && Files.All(f => f.AllResolved);
private void MoveTo(int index)
{
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;
[RelayCommand(CanExecute = nameof(CanGoNext))]
private void Next() => MoveTo(CurrentIndex + 1);
[RelayCommand(CanExecute = nameof(CanGoPrevious))]
private void Previous() => MoveTo(CurrentIndex - 1);
[RelayCommand]
private async Task ContinueAsync()
{
if (!CanContinue) return;
IsBusy = true;
Error = null;
try
{
foreach (var file in Files.Where(f => !f.IsBinary))
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))
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
{
if (_planningParentId is not null)
await _worker.AbortPlanningMergeAsync(_planningParentId);
else
await _worker.AbortConflictMergeAsync(_taskId);
}
catch (Exception ex) { Error = ex.Message; }
finally
{
IsBusy = false;
CloseRequested?.Invoke();
}
}
}