From 214e53b7142a51ad3faed4f8b2370df9a8048262 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 2 Oct 2026 18:53:34 +0200 Subject: [PATCH] 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. --- src/ClaudeDo.Localization/locales/de.json | 3 +- src/ClaudeDo.Localization/locales/en.json | 3 +- .../Conflicts/ConflictResolverViewModel.cs | 8 +++++ .../ViewModels/Modals/MergeModalViewModel.cs | 33 +++++++++++++++++-- .../Conflicts/ConflictResolverView.axaml.cs | 7 ++++ .../Views/Modals/MergeModalView.axaml | 14 +++++--- 6 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 2bf19ba6..ddbfc14a 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -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", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 928398a3..36262cc9 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -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", diff --git a/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs index b17fbe5a..1226be2d 100644 --- a/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs @@ -122,6 +122,9 @@ public sealed partial class ConflictResolverViewModel : ObservableObject public string TaskId => _taskId; public Action? CloseRequested { get; set; } + /// 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. + public bool IsSettled { get; private set; } + /// Raised when the current conflict changes so the view can reload its editors. 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 { diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs index 5860d83c..f307f128 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs @@ -16,11 +16,11 @@ public sealed partial class MergeModalViewModel : ViewModelBase public ObservableCollection 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 _conflictFiles = Array.Empty(); + /// The merge preview for the selected branch predicts a conflict — offers the resolver up front. + [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 */ } + } + + /// Hands the predicted conflict straight to the resolver; stashes a dirty target, as the warning above announces. + [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); diff --git a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs index 47160d69..738d3e20 100644 --- a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs @@ -42,6 +42,13 @@ public partial class ConflictResolverView : Window InitializeComponent(); } + /// Alt+F4 bypasses the Abort-bound close button; abort here so the merge and its autostash never stay pending. + 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); diff --git a/src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml index 1cea2654..47c396b7 100644 --- a/src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml @@ -74,10 +74,16 @@ TextWrapping="Wrap" IsVisible="{Binding WarningMessage, Converter={x:Static ObjectConverters.IsNotNull}}" /> -