feat(merge): resolve conflicts straight from the merge modal
The merge modal previews the selected target and offers 'Resolve conflicts' next to the stash retry when it predicts a conflict. A dirty target is autostashed. The resolver now aborts when closed natively (Alt+F4), so the merge and its autostash can never be left pending.
This commit is contained in:
@@ -539,7 +539,8 @@
|
||||
"removeWorktree": "Worktree nach dem Mergen entfernen",
|
||||
"commitMessage": "Commit-Nachricht",
|
||||
"conflictedFiles": "Konfliktdateien:",
|
||||
"stashAndMerge": "Änderungen stashen und mergen"
|
||||
"stashAndMerge": "Änderungen stashen und mergen",
|
||||
"resolveConflicts": "Konflikte lösen"
|
||||
},
|
||||
"diff": {
|
||||
"title": "DIFF",
|
||||
|
||||
@@ -539,7 +539,8 @@
|
||||
"removeWorktree": "Remove worktree after merge",
|
||||
"commitMessage": "Commit message",
|
||||
"conflictedFiles": "Conflicted files:",
|
||||
"stashAndMerge": "Stash changes and merge"
|
||||
"stashAndMerge": "Stash changes and merge",
|
||||
"resolveConflicts": "Resolve conflicts"
|
||||
},
|
||||
"diff": {
|
||||
"title": "DIFF",
|
||||
|
||||
@@ -122,6 +122,9 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
public string TaskId => _taskId;
|
||||
public Action? CloseRequested { get; set; }
|
||||
|
||||
/// <summary>True once the merge was continued or aborted; a window closed before that must abort, or the repo stays mid-merge with an autostash pending.</summary>
|
||||
public bool IsSettled { get; private set; }
|
||||
|
||||
/// <summary>Raised when the current conflict changes so the view can reload its editors.</summary>
|
||||
public event Action? CurrentChanged;
|
||||
|
||||
@@ -277,13 +280,17 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
// 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);
|
||||
IsSettled = true;
|
||||
CloseRequested?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _worker.ContinueConflictMergeAsync(_taskId);
|
||||
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
|
||||
{
|
||||
IsSettled = true;
|
||||
CloseRequested?.Invoke();
|
||||
}
|
||||
else
|
||||
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
|
||||
}
|
||||
@@ -297,6 +304,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
private async Task AbortAsync()
|
||||
{
|
||||
IsSettled = true;
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
|
||||
@@ -16,11 +16,11 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
|
||||
public ObservableCollection<string> Branches { get; } = new();
|
||||
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private string? _selectedBranch;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand), nameof(ResolveConflictsCommand))] private string? _selectedBranch;
|
||||
[ObservableProperty] private bool _removeWorktree = true;
|
||||
[ObservableProperty] private string _commitMessage = "";
|
||||
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _isBusy;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand), nameof(ResolveConflictsCommand))] private bool _isBusy;
|
||||
|
||||
/// What the pending merge is doing right now, fed by the worker's MergeProgress broadcast.
|
||||
/// A merge whose list has a verify command can occupy this call for minutes — without this the
|
||||
@@ -37,6 +37,11 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _showStashRetry;
|
||||
[ObservableProperty] private IReadOnlyList<string> _conflictFiles = Array.Empty<string>();
|
||||
|
||||
/// <summary>The merge preview for the selected branch predicts a conflict — offers the resolver up front.</summary>
|
||||
[ObservableProperty] private bool _previewHasConflict;
|
||||
|
||||
private bool _targetDirty;
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
/// True once a merge has succeeded — lets the caller (e.g. the diff window)
|
||||
@@ -79,6 +84,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
|
||||
// Known dirty before anyone clicks Merge — offer the stash retry right away instead
|
||||
// of making the user hit the "dirty_target" refusal first.
|
||||
_targetDirty = targets.TargetDirty;
|
||||
if (targets.TargetDirty)
|
||||
{
|
||||
WarningMessage = Loc.T("vm.merge.targetDirty");
|
||||
@@ -92,6 +98,29 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
partial void OnSelectedBranchChanged(string? value) => _ = RefreshPreviewAsync(value);
|
||||
|
||||
private async Task RefreshPreviewAsync(string? branch)
|
||||
{
|
||||
PreviewHasConflict = false;
|
||||
if (string.IsNullOrWhiteSpace(branch) || string.IsNullOrEmpty(TaskId)) return;
|
||||
try
|
||||
{
|
||||
var dto = await _worker.PreviewMergeAsync(TaskId, branch);
|
||||
if (SelectedBranch == branch) PreviewHasConflict = dto?.ConflictFiles.Count > 0;
|
||||
}
|
||||
catch { /* preview is a hint only; Merge still reports the real conflict */ }
|
||||
}
|
||||
|
||||
/// <summary>Hands the predicted conflict straight to the resolver; stashes a dirty target, as the warning above announces.</summary>
|
||||
[RelayCommand(CanExecute = nameof(CanSubmit))]
|
||||
private async Task ResolveConflictsAsync()
|
||||
{
|
||||
RoutedToResolver = true;
|
||||
CloseAction?.Invoke();
|
||||
await _merge.ResolveConflictAsync(TaskId, SelectedBranch!, _targetDirty);
|
||||
}
|
||||
|
||||
private bool CanSubmit() =>
|
||||
!IsBusy && !HasConflict && !string.IsNullOrWhiteSpace(SelectedBranch);
|
||||
|
||||
|
||||
@@ -42,6 +42,13 @@ public partial class ConflictResolverView : Window
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>Alt+F4 bypasses the Abort-bound close button; abort here so the merge and its autostash never stay pending.</summary>
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
base.OnClosed(e);
|
||||
if (_vm is { IsSettled: false }) _vm.AbortCommand.Execute(null);
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
|
||||
@@ -74,10 +74,16 @@
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding WarningMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
|
||||
<Button Classes="btn" HorizontalAlignment="Left"
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="btn"
|
||||
Content="{loc:Tr modals.merge.stashAndMerge}"
|
||||
Command="{Binding StashAndMergeCommand}"
|
||||
IsVisible="{Binding ShowStashRetry}" />
|
||||
<Button Classes="btn"
|
||||
Content="{loc:Tr modals.merge.resolveConflicts}"
|
||||
Command="{Binding ResolveConflictsCommand}"
|
||||
IsVisible="{Binding PreviewHasConflict}" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="danger-box"
|
||||
IsVisible="{Binding HasConflict}">
|
||||
|
||||
Reference in New Issue
Block a user