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",
|
"removeWorktree": "Worktree nach dem Mergen entfernen",
|
||||||
"commitMessage": "Commit-Nachricht",
|
"commitMessage": "Commit-Nachricht",
|
||||||
"conflictedFiles": "Konfliktdateien:",
|
"conflictedFiles": "Konfliktdateien:",
|
||||||
"stashAndMerge": "Änderungen stashen und mergen"
|
"stashAndMerge": "Änderungen stashen und mergen",
|
||||||
|
"resolveConflicts": "Konflikte lösen"
|
||||||
},
|
},
|
||||||
"diff": {
|
"diff": {
|
||||||
"title": "DIFF",
|
"title": "DIFF",
|
||||||
|
|||||||
@@ -539,7 +539,8 @@
|
|||||||
"removeWorktree": "Remove worktree after merge",
|
"removeWorktree": "Remove worktree after merge",
|
||||||
"commitMessage": "Commit message",
|
"commitMessage": "Commit message",
|
||||||
"conflictedFiles": "Conflicted files:",
|
"conflictedFiles": "Conflicted files:",
|
||||||
"stashAndMerge": "Stash changes and merge"
|
"stashAndMerge": "Stash changes and merge",
|
||||||
|
"resolveConflicts": "Resolve conflicts"
|
||||||
},
|
},
|
||||||
"diff": {
|
"diff": {
|
||||||
"title": "DIFF",
|
"title": "DIFF",
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
public string TaskId => _taskId;
|
public string TaskId => _taskId;
|
||||||
public Action? CloseRequested { get; set; }
|
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>
|
/// <summary>Raised when the current conflict changes so the view can reload its editors.</summary>
|
||||||
public event Action? CurrentChanged;
|
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.
|
// 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.
|
// A later subtask conflict re-opens this editor via the PlanningMergeConflict broadcast.
|
||||||
await _worker.ContinuePlanningMergeAsync(_planningParentId);
|
await _worker.ContinuePlanningMergeAsync(_planningParentId);
|
||||||
|
IsSettled = true;
|
||||||
CloseRequested?.Invoke();
|
CloseRequested?.Invoke();
|
||||||
return;
|
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))
|
||||||
|
{
|
||||||
|
IsSettled = true;
|
||||||
CloseRequested?.Invoke();
|
CloseRequested?.Invoke();
|
||||||
|
}
|
||||||
else
|
else
|
||||||
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
|
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
|
||||||
}
|
}
|
||||||
@@ -297,6 +304,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task AbortAsync()
|
private async Task AbortAsync()
|
||||||
{
|
{
|
||||||
|
IsSettled = true;
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
public ObservableCollection<string> Branches { get; } = new();
|
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 bool _removeWorktree = true;
|
||||||
[ObservableProperty] private string _commitMessage = "";
|
[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.
|
/// 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
|
/// 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 bool _showStashRetry;
|
||||||
[ObservableProperty] private IReadOnlyList<string> _conflictFiles = Array.Empty<string>();
|
[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; }
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
/// True once a merge has succeeded — lets the caller (e.g. the diff window)
|
/// 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
|
// Known dirty before anyone clicks Merge — offer the stash retry right away instead
|
||||||
// of making the user hit the "dirty_target" refusal first.
|
// of making the user hit the "dirty_target" refusal first.
|
||||||
|
_targetDirty = targets.TargetDirty;
|
||||||
if (targets.TargetDirty)
|
if (targets.TargetDirty)
|
||||||
{
|
{
|
||||||
WarningMessage = Loc.T("vm.merge.targetDirty");
|
WarningMessage = Loc.T("vm.merge.targetDirty");
|
||||||
@@ -92,6 +98,29 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
|||||||
finally { IsBusy = false; }
|
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() =>
|
private bool CanSubmit() =>
|
||||||
!IsBusy && !HasConflict && !string.IsNullOrWhiteSpace(SelectedBranch);
|
!IsBusy && !HasConflict && !string.IsNullOrWhiteSpace(SelectedBranch);
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ public partial class ConflictResolverView : Window
|
|||||||
InitializeComponent();
|
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)
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
{
|
{
|
||||||
base.OnDataContextChanged(e);
|
base.OnDataContextChanged(e);
|
||||||
|
|||||||
@@ -74,10 +74,16 @@
|
|||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
IsVisible="{Binding WarningMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
IsVisible="{Binding WarningMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
|
|
||||||
<Button Classes="btn" HorizontalAlignment="Left"
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
Content="{loc:Tr modals.merge.stashAndMerge}"
|
<Button Classes="btn"
|
||||||
Command="{Binding StashAndMergeCommand}"
|
Content="{loc:Tr modals.merge.stashAndMerge}"
|
||||||
IsVisible="{Binding ShowStashRetry}" />
|
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"
|
<Border Classes="danger-box"
|
||||||
IsVisible="{Binding HasConflict}">
|
IsVisible="{Binding HasConflict}">
|
||||||
|
|||||||
Reference in New Issue
Block a user