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.
301 lines
11 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|