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:
@@ -22,7 +22,7 @@ ViewModels/
|
|||||||
Modals/ — About, Diff, ListSettings, Merge, RepoImport, Settings (+ Settings/ tab VMs),
|
Modals/ — About, Diff, ListSettings, Merge, RepoImport, Settings (+ Settings/ tab VMs),
|
||||||
UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree,
|
UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree,
|
||||||
WorktreesOverview, UnifiedDiffParser
|
WorktreesOverview, UnifiedDiffParser
|
||||||
Planning/ — PlanningDiffViewModel, ConflictResolutionViewModel
|
Planning/ — PlanningDiffViewModel
|
||||||
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
|
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
|
||||||
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
||||||
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView
|
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView
|
||||||
@@ -41,7 +41,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
|
|||||||
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
|
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
|
||||||
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, per-list Model/SystemPrompt/AgentPath/MaxTurns with inherited-badge + reset, delete list), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`.
|
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, per-list Model/SystemPrompt/AgentPath/MaxTurns with inherited-badge + reset, delete list), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`.
|
||||||
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModalViewModel` has two modes: live worktree (branch diff vs base, with a Merge action) and commit-range `base..head` (`FromCommitRange = true` — shows a merged task's diff after its worktree is gone; no merge action). The view renders a file list with binary/empty placeholders via `DiffLinesView`.
|
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModalViewModel` has two modes: live worktree (branch diff vs base, with a Merge action) and commit-range `base..head` (`FromCommitRange = true` — shows a merged task's diff after its worktree is gone; no merge action). The view renders a file list with binary/empty placeholders via `DiffLinesView`.
|
||||||
- **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolutionViewModel` (unit-merge conflict dialog: conflicted files, open in VS Code, continue/abort planning merge), `ConflictResolverViewModel` (in-app **3-way merge editor** for single-task merges: starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`, flattens conflicts for one-at-a-time navigation, per-block Accept Ours/Base/Theirs/Both + editable result, composes each file and writes via `WriteConflictResolution`, continue/abort). The view (`Views/Conflicts/ConflictResolverView`) renders a 3-column Base|Ours|Theirs + result using **AvaloniaEdit** with TextMate syntax highlighting by file extension (theme `StyleInclude` in `App.axaml`); editors are synced in code-behind since AvaloniaEdit's `Text` isn't cleanly bindable.
|
- **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolverViewModel` (in-app **3-way merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`, flattens conflicts for one-at-a-time navigation, per-block Accept Ours/Base/Theirs/Both + editable result, composes each file and writes via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) renders a 3-column Base|Ours|Theirs + result using **AvaloniaEdit** with TextMate syntax highlighting by file extension (theme `StyleInclude` in `App.axaml`); editors are synced in code-behind since AvaloniaEdit's `Text` isn't cleanly bindable.
|
||||||
|
|
||||||
## Services
|
## Services
|
||||||
|
|
||||||
|
|||||||
@@ -75,4 +75,16 @@ public sealed class MergeFile
|
|||||||
/// <summary>Reassemble the file: stable text verbatim, each conflict replaced by its resolution.</summary>
|
/// <summary>Reassemble the file: stable text verbatim, each conflict replaced by its resolution.</summary>
|
||||||
public string Compose() => string.Concat(
|
public string Compose() => string.Concat(
|
||||||
Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? s.Conflict.Ours) : s.StableText));
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
private readonly IWorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
private readonly string _taskId;
|
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();
|
public ObservableCollection<MergeFile> Files { get; } = new();
|
||||||
|
|
||||||
// All text conflicts across all files, flattened for one-at-a-time navigation.
|
// 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))]
|
[NotifyCanExecuteChangedFor(nameof(PreviousCommand))]
|
||||||
private int _currentIndex = -1;
|
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 bool HasCurrent => Current is not null;
|
||||||
public int TotalConflicts => _flat.Count;
|
public int TotalConflicts => _flat.Count;
|
||||||
public int ResolvedCount => _flat.Count(x => x.Block.IsResolved);
|
public int ResolvedCount => _flat.Count(x => x.Block.IsResolved);
|
||||||
public string? CurrentPath => InRange ? _flat[CurrentIndex].File.Path : null;
|
public string? CurrentPath => InRange ? _flat[CurrentIndex].File.Path : null;
|
||||||
|
|
||||||
public string PositionText => _flat.Count == 0
|
public string PositionText
|
||||||
? "No text conflicts"
|
{
|
||||||
: $"Conflict {CurrentIndex + 1} of {_flat.Count} · {ResolvedCount} resolved";
|
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 IReadOnlyList<string> BinaryFilePaths => Files.Where(f => f.IsBinary).Select(f => f.Path).ToList();
|
||||||
public bool HasBinaryFiles => Files.Any(f => f.IsBinary);
|
public bool HasBinaryFiles => Files.Any(f => f.IsBinary);
|
||||||
@@ -66,6 +98,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
_taskId = taskId;
|
_taskId = taskId;
|
||||||
|
_conflictTaskId = taskId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Starts the conflict merge and loads the conflicted files as line-level segments.
|
/// <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;
|
Error = start.ErrorMessage;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
return await LoadDocumentsAsync();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -112,6 +126,53 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
finally { IsBusy = 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));
|
||||||
|
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)
|
private MergeConflictBlock Hook(MergeConflictBlock block)
|
||||||
{
|
{
|
||||||
block.PropertyChanged += OnBlockChanged;
|
block.PropertyChanged += OnBlockChanged;
|
||||||
@@ -125,6 +186,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
RecomputeCanContinue();
|
RecomputeCanContinue();
|
||||||
OnPropertyChanged(nameof(ResolvedCount));
|
OnPropertyChanged(nameof(ResolvedCount));
|
||||||
OnPropertyChanged(nameof(PositionText));
|
OnPropertyChanged(nameof(PositionText));
|
||||||
|
OnPropertyChanged(nameof(ActiveResultText));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,10 +197,22 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
CurrentIndex = index;
|
CurrentIndex = index;
|
||||||
Current = _flat[index].Block;
|
Current = _flat[index].Block;
|
||||||
|
ActiveFile = _flat[index].File;
|
||||||
OnPropertyChanged(nameof(CurrentPath));
|
OnPropertyChanged(nameof(CurrentPath));
|
||||||
CurrentChanged?.Invoke();
|
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 CanGoNext() => CurrentIndex >= 0 && CurrentIndex < _flat.Count - 1;
|
||||||
private bool CanGoPrevious() => CurrentIndex > 0;
|
private bool CanGoPrevious() => CurrentIndex > 0;
|
||||||
|
|
||||||
@@ -157,7 +231,16 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var file in Files.Where(f => !f.IsBinary))
|
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);
|
var result = await _worker.ContinueConflictMergeAsync(_taskId);
|
||||||
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
|
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
|
||||||
@@ -176,7 +259,13 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
private async Task AbortAsync()
|
private async Task AbortAsync()
|
||||||
{
|
{
|
||||||
IsBusy = true;
|
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; }
|
catch (Exception ex) { Error = ex.Message; }
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
|
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.
|
// 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.
|
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
|
||||||
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
|
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)
|
private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
|
||||||
{
|
{
|
||||||
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
|
// 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;
|
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
|
||||||
|
var vm = ConflictResolverFactory(subtaskId);
|
||||||
string subtaskTitle = subtaskId;
|
var hasConflicts = await vm.OpenForPlanningAsync(planningTaskId, subtaskId);
|
||||||
// The conflict lives in the list's working dir (the repo being merged into),
|
if (hasConflicts)
|
||||||
// not the subtask worktree. VS Code must open this folder to show the merge UI.
|
await ShowConflictResolver(vm);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For tests only — does NOT wire up events.
|
// For tests only — does NOT wire up events.
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,11 +42,6 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
if (DataContext is IslandsShellViewModel vm)
|
if (DataContext is IslandsShellViewModel vm)
|
||||||
{
|
{
|
||||||
vm.ShowConflictDialog = async (conflictVm) =>
|
|
||||||
{
|
|
||||||
var modal = new ConflictResolutionView { DataContext = conflictVm };
|
|
||||||
await modal.ShowDialog(this);
|
|
||||||
};
|
|
||||||
vm.ShowAboutModal = async (aboutVm) =>
|
vm.ShowAboutModal = async (aboutVm) =>
|
||||||
{
|
{
|
||||||
var dlg = new AboutModalView { DataContext = aboutVm };
|
var dlg = new AboutModalView { DataContext = aboutVm };
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Planning"
|
|
||||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
|
||||||
x:DataType="vm:ConflictResolutionViewModel"
|
|
||||||
x:Class="ClaudeDo.Ui.Views.Planning.ConflictResolutionView"
|
|
||||||
Title="{loc:Tr planning.conflict.windowTitle}"
|
|
||||||
Width="560" SizeToContent="Height" MinWidth="460"
|
|
||||||
CanResize="True"
|
|
||||||
WindowDecorations="BorderOnly"
|
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
|
||||||
ExtendClientAreaTitleBarHeightHint="-1"
|
|
||||||
WindowStartupLocation="CenterOwner"
|
|
||||||
Background="{DynamicResource SurfaceBrush}">
|
|
||||||
|
|
||||||
<Window.KeyBindings>
|
|
||||||
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
|
||||||
</Window.KeyBindings>
|
|
||||||
|
|
||||||
<ctl:ModalShell Title="{loc:Tr planning.conflict.modalTitle}" CloseCommand="{Binding AbortCommand}">
|
|
||||||
<ctl:ModalShell.Footer>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8"
|
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Center">
|
|
||||||
<Button Classes="btn" Content="{loc:Tr planning.conflict.openInVsCode}" Command="{Binding OpenInVsCodeCommand}"/>
|
|
||||||
<Button Classes="btn" Content="{loc:Tr planning.conflict.resolved}" Command="{Binding ContinueCommand}"/>
|
|
||||||
<Button Classes="btn" Content="{loc:Tr planning.conflict.abort}" Command="{Binding AbortCommand}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</ctl:ModalShell.Footer>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<StackPanel Spacing="12" Margin="20,16" MinWidth="520">
|
|
||||||
<TextBlock Classes="heading"
|
|
||||||
Text="{Binding SubtaskLabel}"/>
|
|
||||||
<TextBlock Classes="body" Text="{Binding TargetLabel}"/>
|
|
||||||
<ItemsControl ItemsSource="{Binding ConflictedFiles}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<TextBlock Classes="path-mono" Text="{Binding}"/>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
<TextBlock Classes="meta" Text="{Binding VsCodeError}" Foreground="{DynamicResource BloodBrush}"
|
|
||||||
IsVisible="{Binding VsCodeError, Converter={x:Static ObjectConverters.IsNotNull}}"
|
|
||||||
TextWrapping="Wrap"/>
|
|
||||||
<TextBlock Classes="meta" Text="{Binding ActionError}" Foreground="{DynamicResource BloodBrush}"
|
|
||||||
IsVisible="{Binding ActionError, Converter={x:Static ObjectConverters.IsNotNull}}"
|
|
||||||
TextWrapping="Wrap"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
</ctl:ModalShell>
|
|
||||||
</Window>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using Avalonia.Controls;
|
|
||||||
using ClaudeDo.Ui.ViewModels.Planning;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Planning;
|
|
||||||
|
|
||||||
public partial class ConflictResolutionView : Window
|
|
||||||
{
|
|
||||||
public ConflictResolutionView()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnDataContextChanged(EventArgs e)
|
|
||||||
{
|
|
||||||
base.OnDataContextChanged(e);
|
|
||||||
if (DataContext is ConflictResolutionViewModel vm)
|
|
||||||
vm.CloseRequested = Close;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
using ClaudeDo.Data.Models;
|
|
||||||
using ClaudeDo.Ui.Services;
|
|
||||||
using ClaudeDo.Ui.ViewModels.Planning;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
|
||||||
|
|
||||||
public class ConflictResolutionViewModelTests
|
|
||||||
{
|
|
||||||
// ------------------------------------------------------------------ fake
|
|
||||||
private sealed class FakeWorker : StubWorkerClient
|
|
||||||
{
|
|
||||||
public string? ContinueCalledWith { get; private set; }
|
|
||||||
public string? AbortCalledWith { get; private set; }
|
|
||||||
public Exception? ContinueThrows { get; set; }
|
|
||||||
public Exception? AbortThrows { get; set; }
|
|
||||||
|
|
||||||
public override Task ContinuePlanningMergeAsync(string planningTaskId)
|
|
||||||
{
|
|
||||||
ContinueCalledWith = planningTaskId;
|
|
||||||
if (ContinueThrows is not null) throw ContinueThrows;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Task AbortPlanningMergeAsync(string planningTaskId)
|
|
||||||
{
|
|
||||||
AbortCalledWith = planningTaskId;
|
|
||||||
if (AbortThrows is not null) throw AbortThrows;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ConflictResolutionViewModel BuildVm(FakeWorker worker, string planningTaskId = "plan-1") =>
|
|
||||||
new ConflictResolutionViewModel(
|
|
||||||
worker,
|
|
||||||
planningTaskId,
|
|
||||||
subtaskTitle: "My subtask",
|
|
||||||
targetBranch: "main",
|
|
||||||
conflictedFiles: new[] { "src/Foo.cs", "src/Bar.cs" },
|
|
||||||
repoDirectory: "C:/repos/plan-1");
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ tests
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ContinueAsync_CallsHub_AndClosesOnSuccess()
|
|
||||||
{
|
|
||||||
var worker = new FakeWorker();
|
|
||||||
var vm = BuildVm(worker, "plan-42");
|
|
||||||
bool closeCalled = false;
|
|
||||||
vm.CloseRequested = () => closeCalled = true;
|
|
||||||
|
|
||||||
await vm.ContinueCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
Assert.Equal("plan-42", worker.ContinueCalledWith);
|
|
||||||
Assert.True(closeCalled);
|
|
||||||
Assert.Null(vm.ActionError);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ContinueAsync_HubThrows_ShowsActionErrorAndStaysOpen()
|
|
||||||
{
|
|
||||||
var worker = new FakeWorker { ContinueThrows = new InvalidOperationException("hub down") };
|
|
||||||
var vm = BuildVm(worker);
|
|
||||||
bool closeCalled = false;
|
|
||||||
vm.CloseRequested = () => closeCalled = true;
|
|
||||||
|
|
||||||
await vm.ContinueCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
Assert.False(closeCalled);
|
|
||||||
Assert.Equal("hub down", vm.ActionError);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AbortAsync_CallsHub_AndClosesOnSuccess()
|
|
||||||
{
|
|
||||||
var worker = new FakeWorker();
|
|
||||||
var vm = BuildVm(worker, "plan-99");
|
|
||||||
bool closeCalled = false;
|
|
||||||
vm.CloseRequested = () => closeCalled = true;
|
|
||||||
|
|
||||||
await vm.AbortCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
Assert.Equal("plan-99", worker.AbortCalledWith);
|
|
||||||
Assert.True(closeCalled);
|
|
||||||
Assert.Null(vm.ActionError);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AbortAsync_HubThrows_ShowsActionError()
|
|
||||||
{
|
|
||||||
var worker = new FakeWorker { AbortThrows = new InvalidOperationException("abort failed") };
|
|
||||||
var vm = BuildVm(worker);
|
|
||||||
bool closeCalled = false;
|
|
||||||
vm.CloseRequested = () => closeCalled = true;
|
|
||||||
|
|
||||||
await vm.AbortCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
Assert.False(closeCalled);
|
|
||||||
Assert.Equal("abort failed", vm.ActionError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenInVsCode is not unit-tested here because abstracting Process.Start
|
|
||||||
// would require an indirection layer that isn't part of the approved design.
|
|
||||||
// The error path is covered by the VsCodeError property being set on catch.
|
|
||||||
}
|
|
||||||
@@ -19,22 +19,39 @@ public class ConflictResolverViewModelTests
|
|||||||
|
|
||||||
private sealed class FakeWorker : StubWorkerClient
|
private sealed class FakeWorker : StubWorkerClient
|
||||||
{
|
{
|
||||||
|
public string? WrittenTaskId;
|
||||||
public string? WrittenPath;
|
public string? WrittenPath;
|
||||||
public string? WrittenContent;
|
public string? WrittenContent;
|
||||||
public bool Continued;
|
public bool Continued;
|
||||||
public bool Aborted;
|
public bool Aborted;
|
||||||
public string ContinueStatus = "merged";
|
public string ContinueStatus = "merged";
|
||||||
public IReadOnlyList<ConflictDocumentDto> Docs = new[] { OneConflictFile() };
|
public IReadOnlyList<ConflictDocumentDto> Docs = new[] { OneConflictFile() };
|
||||||
|
public bool StartCalled;
|
||||||
|
public string? ContinuedPlanningParent;
|
||||||
|
public string? AbortedPlanningParent;
|
||||||
|
|
||||||
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||||
=> Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
|
{
|
||||||
|
StartCalled = true;
|
||||||
|
return Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task ContinuePlanningMergeAsync(string planningTaskId)
|
||||||
|
{
|
||||||
|
ContinuedPlanningParent = planningTaskId; return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task AbortPlanningMergeAsync(string planningTaskId)
|
||||||
|
{
|
||||||
|
AbortedPlanningParent = planningTaskId; return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public override Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
|
public override Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
|
||||||
=> Task.FromResult(new MergeConflictDocumentsDto(taskId, Docs));
|
=> Task.FromResult(new MergeConflictDocumentsDto(taskId, Docs));
|
||||||
|
|
||||||
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||||
{
|
{
|
||||||
WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
|
WrittenTaskId = taskId; WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task<MergeResultDto> ContinueConflictMergeAsync(string taskId)
|
public override Task<MergeResultDto> ContinueConflictMergeAsync(string taskId)
|
||||||
@@ -160,4 +177,137 @@ public class ConflictResolverViewModelTests
|
|||||||
Assert.True(worker.Aborted);
|
Assert.True(worker.Aborted);
|
||||||
Assert.True(closed);
|
Assert.True(closed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenForPlanning_LoadsConflicts_WithoutStartingAMerge()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
var vm = new ConflictResolverViewModel(worker, "parent-1");
|
||||||
|
|
||||||
|
var hasConflicts = await vm.OpenForPlanningAsync("parent-1", "subtask-7");
|
||||||
|
|
||||||
|
Assert.True(hasConflicts);
|
||||||
|
Assert.False(worker.StartCalled); // the orchestrator already started the merge
|
||||||
|
Assert.Equal(1, vm.TotalConflicts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PlanningContinue_HandsBackToOrchestrator_AndCloses()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
var vm = new ConflictResolverViewModel(worker, "parent-1");
|
||||||
|
var closed = false;
|
||||||
|
vm.CloseRequested = () => closed = true;
|
||||||
|
|
||||||
|
await vm.OpenForPlanningAsync("parent-1", "subtask-7");
|
||||||
|
vm.Current!.AcceptOursCommand.Execute(null);
|
||||||
|
await vm.ContinueCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal("subtask-7", worker.WrittenTaskId); // resolution written against the subtask's working tree
|
||||||
|
Assert.Equal("parent-1", worker.ContinuedPlanningParent);
|
||||||
|
Assert.False(worker.Continued); // single-task continue must NOT be used in planning mode
|
||||||
|
Assert.True(closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PlanningAbort_AbortsThePlanningMerge()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
var vm = new ConflictResolverViewModel(worker, "parent-1");
|
||||||
|
|
||||||
|
await vm.OpenForPlanningAsync("parent-1", "subtask-7");
|
||||||
|
await vm.AbortCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal("parent-1", worker.AbortedPlanningParent);
|
||||||
|
Assert.False(worker.Aborted); // single-task abort must NOT be used in planning mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task 1 new tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OursTheirsResult_Reconstruction()
|
||||||
|
{
|
||||||
|
// stable "a\n" + conflict(ours "o\n", base null, theirs "t\n") + stable "z\n"
|
||||||
|
var segments = new[]
|
||||||
|
{
|
||||||
|
MergeFileSegment.Stable("a\n"),
|
||||||
|
MergeFileSegment.FromConflict(new MergeConflictBlock("o\n", null, "t\n")),
|
||||||
|
MergeFileSegment.Stable("z\n"),
|
||||||
|
};
|
||||||
|
var file = new MergeFile("f.cs", false, segments);
|
||||||
|
|
||||||
|
Assert.Equal("a\no\nz\n", file.OursText);
|
||||||
|
Assert.Equal("a\nt\nz\n", file.TheirsText);
|
||||||
|
Assert.Equal("a\no\nz\n", file.ResultText); // unresolved seeds Ours
|
||||||
|
|
||||||
|
// After resolving: ResultText reflects the resolution
|
||||||
|
file.Conflicts[0].Resolution = "r\n";
|
||||||
|
Assert.Equal("a\nr\nz\n", file.ResultText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ActiveFile_DefaultsToFirstFile_AfterOpen()
|
||||||
|
{
|
||||||
|
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
|
||||||
|
|
||||||
|
await vm.OpenAsync("main");
|
||||||
|
|
||||||
|
Assert.NotNull(vm.ActiveFile);
|
||||||
|
Assert.Equal("README.md", vm.ActiveFile!.Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SelectFile_PreservesResolution_AndUpdatesPassThroughs()
|
||||||
|
{
|
||||||
|
// Two-file setup
|
||||||
|
var worker = new FakeWorker
|
||||||
|
{
|
||||||
|
Docs = new[]
|
||||||
|
{
|
||||||
|
new ConflictDocumentDto("a.cs", false, new[]
|
||||||
|
{
|
||||||
|
new MergeSegmentDto(false, "a\n", "", null, ""),
|
||||||
|
new MergeSegmentDto(true, "", "ours-a\n", null, "theirs-a\n"),
|
||||||
|
new MergeSegmentDto(false, "z\n", "", null, ""),
|
||||||
|
}),
|
||||||
|
new ConflictDocumentDto("b.cs", false, new[]
|
||||||
|
{
|
||||||
|
new MergeSegmentDto(true, "", "ours-b\n", null, "theirs-b\n"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||||
|
await vm.OpenAsync("main");
|
||||||
|
|
||||||
|
// Resolve the block in file A
|
||||||
|
var fileA = vm.Files[0];
|
||||||
|
fileA.Conflicts[0].Resolution = "resolved-a\n";
|
||||||
|
|
||||||
|
// Switch to file B
|
||||||
|
vm.SelectFileCommand.Execute(vm.Files[1]);
|
||||||
|
Assert.Equal("b.cs", vm.ActiveFile!.Path);
|
||||||
|
Assert.Equal("ours-b\n", vm.ActiveResultText); // unresolved seeds Ours
|
||||||
|
|
||||||
|
// Switch back to file A
|
||||||
|
vm.SelectFileCommand.Execute(vm.Files[0]);
|
||||||
|
Assert.Equal("a.cs", vm.ActiveFile!.Path);
|
||||||
|
// Resolution survived the round-trip
|
||||||
|
Assert.Equal("a\nresolved-a\nz\n", vm.ActiveResultText);
|
||||||
|
Assert.Equal("a\nours-a\nz\n", vm.ActiveOursText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PositionText_ReadsActiveFileConflicts()
|
||||||
|
{
|
||||||
|
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
|
||||||
|
await vm.OpenAsync("main");
|
||||||
|
|
||||||
|
// 1 conflict, 0 resolved
|
||||||
|
Assert.Equal("1 conflicts · 0 resolved", vm.PositionText);
|
||||||
|
|
||||||
|
vm.Current!.AcceptOursCommand.Execute(null);
|
||||||
|
|
||||||
|
// 1 conflict, 1 resolved
|
||||||
|
Assert.Equal("1 conflicts · 1 resolved", vm.PositionText);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user