From 29a294b7f3bc0e0d6e940c3ed78600a2c1a8ed7c Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Fri, 19 Jun 2026 11:31:34 +0200 Subject: [PATCH] feat(merge): diff Merge opens the 3-pane editor + conflict overview ruler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Merge button in the Diff window now hands a conflicting merge to the in-app 3-pane editor (MergeModal routes 'conflict' through RequestConflictResolution, the same seam Approve uses) instead of dead-ending on a conflict message. - Add a conflict overview ruler right of the Result pane: a proportional map of every conflict in the file, recolored by resolved state, click a tick to jump — so conflicts are findable in long files without scrolling. - New MergeResolvedEdgeBrush token + conflictMap en/de key. Ui 128 + Loc 16 green. --- src/ClaudeDo.Localization/locales/de.json | 1 + src/ClaudeDo.Localization/locales/en.json | 1 + src/ClaudeDo.Ui/CLAUDE.md | 2 +- src/ClaudeDo.Ui/Design/Tokens.axaml | 3 +- .../Islands/MergeSectionViewModel.cs | 1 + .../ViewModels/Modals/DiffModalViewModel.cs | 6 ++- .../ViewModels/Modals/MergeModalViewModel.cs | 25 +++++++++-- .../Conflicts/ConflictResolverView.axaml | 3 ++ .../Conflicts/ConflictResolverView.axaml.cs | 45 ++++++++++++++++++- 9 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 62a5176..307e630 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -406,6 +406,7 @@ "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)", + "conflictMap": "Konflikte in dieser Datei — Marker anklicken zum Springen", "acceptOurs": "Main hinzufügen", "acceptTheirs": "Incoming hinzufügen", "removeOurs": "Main entfernen", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 0edc90d..430b947 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -406,6 +406,7 @@ "binaryHint": "Binary files can't be merged here — abort and resolve them in your editor:", "prevConflict": "Previous conflict (Shift+F8)", "nextConflict": "Next conflict (F8)", + "conflictMap": "Conflicts in this file — click a marker to jump", "acceptOurs": "Add main", "acceptTheirs": "Add incoming", "removeOurs": "Remove main", diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index 7058a90..55a51bc 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 — MAIN/ours (read-only) | editable Result | INCOMING/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); the between-pane gutter controls **toggle** each side in/out of the result — `›`/`‹` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, 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 — MAIN/ours (read-only) | editable Result | INCOMING/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); the between-pane gutter controls **toggle** each side in/out of the result — `›`/`‹` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`). ## Services diff --git a/src/ClaudeDo.Ui/Design/Tokens.axaml b/src/ClaudeDo.Ui/Design/Tokens.axaml index d291080..95e965d 100644 --- a/src/ClaudeDo.Ui/Design/Tokens.axaml +++ b/src/ClaudeDo.Ui/Design/Tokens.axaml @@ -104,8 +104,9 @@ - + + diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs index a3f6819..568f871 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs @@ -156,6 +156,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase TaskTitle = TaskTitle ?? "", ShowMergeModal = ShowMergeModal, ResolveMergeVm = () => _services.GetRequiredService(), + RequestConflictResolution = RequestConflictResolution, }; } else if (CanDiffMergedRange) diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs index 82050a3..b7a3f9d 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs @@ -72,6 +72,7 @@ public sealed partial class DiffModalViewModel : ViewModelBase public string TaskTitle { get; init; } = ""; public Func? ShowMergeModal { get; set; } public Func? ResolveMergeVm { get; set; } + public Func? RequestConflictResolution { get; set; } public ObservableCollection Files { get; } = new(); @@ -99,10 +100,11 @@ public sealed partial class DiffModalViewModel : ViewModelBase { if (TaskId is null || ShowMergeModal is null || ResolveMergeVm is null) return; var vm = ResolveMergeVm(); + vm.RequestConflictResolution = RequestConflictResolution; await vm.InitializeAsync(TaskId, TaskTitle); await ShowMergeModal(vm); - // The diff is stale once the worktree has been merged away — close it too. - if (vm.Merged) CloseAction?.Invoke(); + // The diff is stale once the worktree merged away or a conflict opened the editor. + if (vm.Merged || vm.RoutedToResolver) CloseAction?.Invoke(); } public async Task LoadAsync(CancellationToken ct = default) diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs index 79d7236..c9aa681 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs @@ -28,10 +28,17 @@ public sealed partial class MergeModalViewModel : ViewModelBase public Action? CloseAction { get; set; } + /// Set by the caller to hand a conflicting merge off to the in-app 3-pane editor + /// instead of dead-ending on the conflict message. + public Func? RequestConflictResolution { get; set; } + /// True once a merge has succeeded — lets the caller (e.g. the diff window) /// close itself after this modal closes. public bool Merged { get; private set; } + /// True once a conflict has been handed off to the resolver — also a cue to close the diff window. + public bool RoutedToResolver { get; private set; } + public MergeModalViewModel(IWorkerClient worker) { _worker = worker; @@ -96,9 +103,21 @@ public sealed partial class MergeModalViewModel : ViewModelBase }); break; case "conflict": - HasConflict = true; - ConflictFiles = result.ConflictFiles; - ErrorMessage = Loc.T("vm.merge.conflict"); + // Hand off to the in-app 3-pane merge editor when wired (MergeTask aborted + // cleanly, so the resolver re-starts the merge leaving conflicts in the tree). + if (RequestConflictResolution is not null) + { + var branch = SelectedBranch!; + RoutedToResolver = true; + CloseAction?.Invoke(); + await RequestConflictResolution(TaskId, branch); + } + else + { + HasConflict = true; + ConflictFiles = result.ConflictFiles; + ErrorMessage = Loc.T("vm.merge.conflict"); + } break; case "blocked": ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? ""); diff --git a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml index 6cc5302..157bd47 100644 --- a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml +++ b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml @@ -149,6 +149,9 @@ + diff --git a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs index 5f380bd..6bda13a 100644 --- a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Shapes; using Avalonia.Media; using Avalonia.Threading; using Avalonia.VisualTree; @@ -251,6 +252,7 @@ public partial class ConflictResolverView : Window private void PositionGutters() { ClearGutters(); + PopulateConflictMap(); if (_vm?.ActiveFile is null) return; var tv = ResultEditor.TextArea.TextView; if (!tv.VisualLinesValid) @@ -298,6 +300,47 @@ public partial class ConflictResolverView : Window canvas.Children.Add(b); } + // ── Conflict overview ruler (right of the result pane) ─────────────────── + + // A proportional map of every conflict in the active file so they're findable in + // long files without scrolling; ticks recolor by resolved state and jump on click. + private void PopulateConflictMap() + { + ConflictMap.Children.Clear(); + if (_vm?.ActiveFile is null || _resultRegions.Count == 0) return; + var h = ConflictMap.Bounds.Height; + if (h <= 1) return; + var doc = ResultEditor.Document; + var totalLines = Math.Max(1, doc.LineCount); + var unresolved = BrushRes("MergeConflictEdgeBrush", Color.Parse("#80C87060")); + var resolved = BrushRes("MergeResolvedEdgeBrush", Color.Parse("#806FA86B")); + + foreach (var region in _resultRegions) + { + var line = doc.GetLineByOffset(region.Start.Offset).LineNumber; + var y = (line - 1) / (double)totalLines * h; + var tick = new Rectangle + { + Width = 9, + Height = 4, + Fill = region.Block.IsResolved ? resolved : unresolved, + Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand), + }; + Canvas.SetLeft(tick, 2); + Canvas.SetTop(tick, Math.Min(h - 4, Math.Max(0, y))); + var r = region; + tick.PointerPressed += (_, _) => JumpToRegion(r); + ConflictMap.Children.Add(tick); + } + } + + private void JumpToRegion(ResultRegion region) + { + var line = ResultEditor.Document.GetLineByOffset(region.Start.Offset).LineNumber; + ResultEditor.ScrollToLine(line); + QueueGutters(); + } + private static string Tr(string key) => ClaudeDo.Ui.Localization.Loc.T(key); // ── Synced vertical scroll across the three panes ───────────────────────── @@ -345,7 +388,7 @@ public partial class ConflictResolverView : Window private void ApplyGrammar(string? path) { if (_registry is null || string.IsNullOrEmpty(path)) return; - var ext = Path.GetExtension(path); + var ext = System.IO.Path.GetExtension(path); if (string.IsNullOrEmpty(ext)) return; var language = _registry.GetLanguageByExtension(ext); if (language is null) return;