fix(merge): harden 3-pane editor + document the new conflict resolver

Review follow-ups: coalesce gutter re-layout posts (avoid dispatcher flooding when
visual lines aren't ready), drop the zero-length deletable segment (undo hygiene),
and clear stale scroll-sync hooks on DataContext swap. Update Ui/CLAUDE.md to the
3-pane editor and log visual-verification items (incl. empty-side + alignment edges)
in docs/open.md.
This commit is contained in:
Mika Kuns
2026-06-19 10:21:32 +02:00
parent c4d1acc75b
commit 869dd25a23
3 changed files with 22 additions and 7 deletions

View File

@@ -15,6 +15,7 @@ Kein Code-Aufwand, nur Durchspielen mit explizit notiertem Pass-Kriterium. Der G
- **Feature-Walkthroughs:** Planning-Session-Flow (Draft→Finalize→Chain), Prime/Daily-Prep-Trigger, Weekly-Report-Generierung, Self-Update (Banner → Update → „up to date").
- **UI-Sichtprüfung (neu, 2026-06-09):** Diff-Viewer (Dateiliste, Added/Deleted/Renamed/Binary-Erkennung, Commit-Range-Diff nach Merge) und das „children need attention"-Band auf dem Session-Tab des Parents.
- **UI-Sichtprüfung (neu, 2026-06-10, nach Refactoring-Merges):** Detail-Insel komplett durchklicken (Output/Git/Session-Tabs, Merge-Sektion, Agent-Settings-Overrides, Prep-Panel) — `DetailsIslandViewModel` wurde in Sektions-VMs aufgeteilt, Bindings angepasst. Außerdem: DiffModal-Fehler-State „Diff nicht mehr verfügbar" (Commit-Range ohne aufgezeichnete Commits) und der In-App-Konflikt-Resolver (Hub-Methoden umbenannt).
- **UI-Sichtprüfung (neu, 2026-06-19, Rider-Style 3-Pane Merge-Editor):** Echten Konflikt auslösen (Single-Task-Approve mit Konflikt **und** Planning-Unit-Merge) und prüfen: drei Panes (Ours read-only | Result editierbar | Theirs read-only), Konfliktblöcke rot / aufgelöst grün in allen Panes, Inline-Accept ``/`` in den Zwischen-Guttern landen die jeweilige Seite im Result, nur Konfliktregionen im Result editierbar (Stable read-only), synchrones vertikales Scrollen, File-Switcher bei mehreren Dateien, `M conflicts · K resolved`-Readout, Continue erst bei allen Konflikten gelöst, Binär-Guard. **Bekannte Kanten:** (1) Konflikt mit leerer Ours-Seite → Result-Region ist null-lang (Gutter via 1-Zeichen-Probe positioniert, Accept funktioniert; nur Hand-Tippen in die leere Region ist fummelig). (2) Gutter-Y nutzt `TranslatePoint` vom Result-`TextView` — bei sehr hohen Fenstern / großen Scrollständen die Ausrichtung gegenprüfen. (3) Blöcke richten sich nur über Stable-Text aus; nach einem Konflikt mit unterschiedlicher Zeilenzahl je Seite driften nachfolgende Blöcke vertikal (aligned/virtual-space Scroll ist bewusst zurückgestellt).
- **Worker-Autostart am Gerät:** Logoff/Logon-Autostart, Update-Pfad, Uninstall entfernt die Startup-`.lnk`.
## Offene Code-Punkte

View File

@@ -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 **3-way 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`, flattens conflicts for one-at-a-time navigation, per-block Accept Ours/Base/Theirs/Both + editable result, composes each file and writes 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`) renders a 3-column Base|Ours|Theirs + result using **AvaloniaEdit** with TextMate syntax highlighting by file extension (theme `StyleInclude` in `App.axaml`); editors are synced in code-behind since AvaloniaEdit's `Text` isn't cleanly bindable.
- **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`).
## Services

View File

@@ -38,6 +38,7 @@ public partial class ConflictResolverView : Window
private bool _rebuilding;
private bool _applyingAccept;
private bool _syncing;
private bool _gutterPending;
public ConflictResolverView()
{
@@ -53,6 +54,12 @@ public partial class ConflictResolverView : Window
_vm.ActiveFileChanged -= Rebuild;
_vm.CurrentChanged -= ScrollToCurrent;
}
// The editors persist across a DataContext swap, so drop stale scroll-sync hooks first.
foreach (var sv in _scrollViewers)
if (sv is not null) sv.ScrollChanged -= OnPaneScroll;
_scrollViewers = Array.Empty<ScrollViewer?>();
_wired = false;
_vm = DataContext as ConflictResolverViewModel;
if (_vm is null) return;
@@ -150,7 +157,7 @@ public partial class ConflictResolverView : Window
_wired = true;
Dispatcher.UIThread.Post(HookScrollSync, DispatcherPriority.Loaded);
}
Dispatcher.UIThread.Post(PositionGutters, DispatcherPriority.Loaded);
QueueGutters();
}
private static (string Text, List<(int Offset, int Length, MergeConflictBlock Block)> Spans) BuildSide(
@@ -185,7 +192,7 @@ public partial class ConflictResolverView : Window
if (e.PropertyName is nameof(MergeConflictBlock.IsResolved) or nameof(MergeConflictBlock.Resolution))
{
InvalidateRenderers();
Dispatcher.UIThread.Post(PositionGutters, DispatcherPriority.Background);
QueueGutters();
}
}
@@ -202,7 +209,7 @@ public partial class ConflictResolverView : Window
break;
}
}
Dispatcher.UIThread.Post(PositionGutters, DispatcherPriority.Background);
QueueGutters();
}
// ── Accept a side into the result ────────────────────────────────────────
@@ -233,6 +240,14 @@ public partial class ConflictResolverView : Window
RightGutter.Children.Clear();
}
// Coalesce gutter re-layouts so repeated change/scroll events can't flood the dispatcher.
private void QueueGutters()
{
if (_gutterPending) return;
_gutterPending = true;
Dispatcher.UIThread.Post(() => { _gutterPending = false; PositionGutters(); }, DispatcherPriority.Background);
}
private void PositionGutters()
{
ClearGutters();
@@ -240,7 +255,7 @@ public partial class ConflictResolverView : Window
var tv = ResultEditor.TextArea.TextView;
if (!tv.VisualLinesValid)
{
Dispatcher.UIThread.Post(PositionGutters, DispatcherPriority.Background);
QueueGutters();
return;
}
@@ -314,7 +329,7 @@ public partial class ConflictResolverView : Window
if (region.Block is null) return;
var line = ResultEditor.Document.GetLineByOffset(region.Start.Offset).LineNumber;
ResultEditor.ScrollToLine(line);
Dispatcher.UIThread.Post(PositionGutters, DispatcherPriority.Background);
QueueGutters();
}
private void InvalidateRenderers()
@@ -392,7 +407,6 @@ public partial class ConflictResolverView : Window
var s = Math.Max(segment.Offset, start);
var e = Math.Min(segment.EndOffset, end);
if (e > s) yield return new Seg(s, e - s);
else if (e == s && segment.Length == 0 && s >= start && s <= end) yield return new Seg(s, 0);
}
}
}