From d5eec75bea145e3c9e7db737f50e4078228cabaf Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Fri, 19 Jun 2026 10:50:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(merge):=20additive=20conflict=20accept=20?= =?UTF-8?q?=E2=80=94=20stack=20ours/theirs=20in=20click=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-side replace (and the short-lived accept-both button) with additive accepts: each result conflict region starts EMPTY (thin marker bar), and the gutter controls append a side in click order — > adds ours, < adds theirs (first pick on top, next below), x clears. Controls stay visible after the first pick so both sides can be stacked; empty/unresolved regions render a marker so they stay visible. en/de keys updated; Ui 128 + Localization 16 green. --- src/ClaudeDo.Localization/locales/de.json | 6 +- src/ClaudeDo.Localization/locales/en.json | 6 +- src/ClaudeDo.Ui/CLAUDE.md | 2 +- .../Conflicts/ConflictResolverView.axaml.cs | 66 ++++++++++++------- 4 files changed, 51 insertions(+), 29 deletions(-) diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 0b198bf..cfd818e 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -406,9 +406,9 @@ "binaryHint": "Binärdateien können hier nicht zusammengeführt werden — brich ab und löse sie in deinem Editor:", "prevConflict": "Vorheriger Konflikt (Umschalt+F8)", "nextConflict": "Nächster Konflikt (F8)", - "acceptOurs": "Ours ins Ergebnis übernehmen", - "acceptTheirs": "Theirs ins Ergebnis übernehmen", - "acceptBoth": "Beide übernehmen (ours, dann theirs)", + "acceptOurs": "Ours hinzufügen (Reihenfolge = Stapel)", + "acceptTheirs": "Theirs hinzufügen (Reihenfolge = Stapel)", + "clearConflict": "Konflikt zurücksetzen (neu beginnen)", "continue": "Lösen & fortfahren", "abort": "Merge abbrechen" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 1077fb7..6a86a0c 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -406,9 +406,9 @@ "binaryHint": "Binary files can't be merged here — abort and resolve them in your editor:", "prevConflict": "Previous conflict (Shift+F8)", "nextConflict": "Next conflict (F8)", - "acceptOurs": "Accept ours into result", - "acceptTheirs": "Accept theirs into result", - "acceptBoth": "Accept both (ours, then theirs)", + "acceptOurs": "Add ours (stacks in click order)", + "acceptTheirs": "Add theirs (stacks in click order)", + "clearConflict": "Clear this conflict (start over)", "continue": "Resolve & continue", "abort": "Abort merge" }, diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index 45fb81f..ad45181 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -41,7 +41,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle - **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`. - **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), `ConflictResolverViewModel` (in-app **Rider-style 3-pane 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`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file 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`) shows the whole file in three **AvaloniaEdit** panes — Ours (read-only) | editable Result | Theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block), between-pane gutters host inline accept `›`/`‹` controls positioned per conflict, and the three panes share a proportional synced vertical scroll. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). +- **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolverViewModel` (in-app **Rider-style 3-pane 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`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file 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`) shows the whole file in three **AvaloniaEdit** panes — Ours (read-only) | editable Result | Theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar) and the between-pane gutter controls **append** sides in click order — `›` adds ours, `‹` adds theirs (first pick on top, the next below), `✕` clears — so a conflict can take ours, theirs, both, or neither; the three panes share a proportional synced vertical scroll. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). ## Services diff --git a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs index 0664196..79359bf 100644 --- a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs @@ -126,7 +126,8 @@ public partial class ConflictResolverView : Window var (oursText, oursSpans) = BuildSide(file, b => b.Ours); var (theirsText, theirsSpans) = BuildSide(file, b => b.Theirs); - var (resultText, resultSpans) = BuildSide(file, b => b.Resolution ?? b.Ours); + // Unresolved conflicts start EMPTY — the user builds the result by appending sides. + var (resultText, resultSpans) = BuildSide(file, b => b.Resolution ?? ""); _oursSpans = oursSpans; _theirsSpans = theirsSpans; @@ -214,21 +215,32 @@ public partial class ConflictResolverView : Window // ── Accept a side into the result ──────────────────────────────────────── - private void AcceptOurs(MergeConflictBlock block) => AcceptInto(block, block.Ours); - private void AcceptTheirs(MergeConflictBlock block) => AcceptInto(block, block.Theirs); - private void AcceptBoth(MergeConflictBlock block) => AcceptInto(block, block.Ours + block.Theirs); + private void AppendOurs(MergeConflictBlock block) => AppendSide(block, block.Ours); + private void AppendTheirs(MergeConflictBlock block) => AppendSide(block, block.Theirs); - private void AcceptInto(MergeConflictBlock block, string text) + // Accept APPENDS a side to the result region in click order (first pick on top, the + // next below), so a conflict can take ours, theirs, or both — and stay editable. + private void AppendSide(MergeConflictBlock block, string text) { var region = _resultRegions.FirstOrDefault(r => ReferenceEquals(r.Block, block)); if (region.Block is null) return; _applyingAccept = true; - try - { - ResultEditor.Document.Replace(region.Start.Offset, region.End.Offset - region.Start.Offset, text); - } + try { ResultEditor.Document.Insert(region.End.Offset, text); } finally { _applyingAccept = false; } - block.Resolution = text; + block.Resolution = ResultEditor.Document.GetText(region.Start.Offset, Math.Max(0, region.End.Offset - region.Start.Offset)); + InvalidateRenderers(); + PositionGutters(); + } + + // Reset a conflict back to empty/unresolved (start over). + private void ClearRegion(MergeConflictBlock block) + { + var region = _resultRegions.FirstOrDefault(r => ReferenceEquals(r.Block, block)); + if (region.Block is null) return; + _applyingAccept = true; + try { ResultEditor.Document.Replace(region.Start.Offset, region.End.Offset - region.Start.Offset, ""); } + finally { _applyingAccept = false; } + block.Resolution = null; InvalidateRenderers(); PositionGutters(); } @@ -263,7 +275,7 @@ public partial class ConflictResolverView : Window var doc = ResultEditor.Document; foreach (var (block, start, end) in _resultRegions) { - if (block.IsResolved) continue; + // Controls stay visible even once resolved, so you can append the other side too. var len = end.Offset - start.Offset; ISegment probe = len > 0 ? new Seg(start.Offset, len) @@ -276,16 +288,16 @@ public partial class ConflictResolverView : Window if (tv.TranslatePoint(new Point(0, y), LeftGutter) is { } pl && pl.Y > -24 && pl.Y < LeftGutter.Bounds.Height + 24) { - AddAcceptButton(LeftGutter, pl.Y, "›", () => AcceptOurs(capturedBlock), + AddAcceptButton(LeftGutter, pl.Y, "›", () => AppendOurs(capturedBlock), Tr("conflictResolver.acceptOurs")); - // "Accept both" sits just under the ours chevron: ours text then theirs text. - AddAcceptButton(LeftGutter, pl.Y + 21, "⊕", () => AcceptBoth(capturedBlock), - Tr("conflictResolver.acceptBoth")); + // ✕ resets the conflict to empty so you can start the stack over. + AddAcceptButton(LeftGutter, pl.Y + 21, "✕", () => ClearRegion(capturedBlock), + Tr("conflictResolver.clearConflict")); } if (tv.TranslatePoint(new Point(0, y), RightGutter) is { } pr && pr.Y > -24 && pr.Y < RightGutter.Bounds.Height + 24) - AddAcceptButton(RightGutter, pr.Y, "‹", () => AcceptTheirs(capturedBlock), + AddAcceptButton(RightGutter, pr.Y, "‹", () => AppendTheirs(capturedBlock), Tr("conflictResolver.acceptTheirs")); } } @@ -388,12 +400,22 @@ public partial class ConflictResolverView : Window if (!textView.VisualLinesValid) return; foreach (var (offset, length, resolved) in _spans()) { - ISegment seg = new Seg(offset, Math.Max(length, 0)); - var builder = new BackgroundGeometryBuilder { AlignToWholePixels = true, CornerRadius = 2 }; - builder.AddSegment(textView, seg); - var geo = builder.CreateGeometry(); - if (geo is not null) - drawingContext.DrawGeometry(resolved ? _resolved : _conflict, null, geo); + var brush = resolved ? _resolved : _conflict; + if (length > 0) + { + var builder = new BackgroundGeometryBuilder { AlignToWholePixels = true, CornerRadius = 2 }; + builder.AddSegment(textView, new Seg(offset, length)); + var geo = builder.CreateGeometry(); + if (geo is not null) drawingContext.DrawGeometry(brush, null, geo); + } + else + { + // Empty region (nothing accepted yet): a thin marker bar marks the spot. + var at = offset < textView.Document.TextLength ? offset : Math.Max(0, offset - 1); + var rects = BackgroundGeometryBuilder.GetRectsForSegment(textView, new Seg(at, 1)).ToList(); + if (rects.Count > 0) + drawingContext.FillRectangle(brush, new Rect(0, rects[0].Top, textView.Bounds.Width, 3)); + } } } }