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:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user