diff --git a/docs/superpowers/plans/2026-06-19-unify-diff-viewer.md b/docs/superpowers/plans/2026-06-19-unify-diff-viewer.md new file mode 100644 index 0000000..73e1b3f --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-unify-diff-viewer.md @@ -0,0 +1,111 @@ +# Phase 5 — DiffViewer (A1 + B2) + +Date: 2026-06-23 +Umbrella: `docs/superpowers/plans/2026-06-19-feature-unification-plan.md` +Design: `docs/superpowers/specs/2026-06-19-feature-unification-design.md` (A1, B2) + +## Goal + +One diff component replaces the three parallel read-only diff windows: +`DiffModalViewModel`/View, `WorktreeModalViewModel`/View, `PlanningDiffViewModel`/View. +**Merge editor (`ConflictResolverViewModel`) is untouched** — per the design's hard +decision; the viewer only *opens* it on conflict via the existing Merge flow. + +All three are already master-detail: **left nav pane + right `DiffLinesView`**. They +differ only in left-pane content, chrome, and data source — so they collapse into one +shell with a source mode. + +## Decisions (Mika, 2026-06-23) + +- **File nav = file-tree** (folder-grouped), not a flat list. Port `WorktreeModal`'s tree + + the Avalonia-12 `TreeView.SelectionChanged` workaround. Carry per-file status + +adds/ + −dels into the tree rows (from the parsed `DiffFileViewModel`). +- Planning keeps its **subtask-list + combined-mode toggle**; the branch source keeps its + **Merge** button. + +## Target + +### Shared types → `ViewModels/Modals/DiffModels.cs` (new, same namespace) + +Move out of the to-be-deleted VMs so `UnifiedDiffParser`/`DiffLinesView` keep compiling: +`DiffLineKind`, `DiffFileStatus`, `DiffLineViewModel`, `DiffFileViewModel` (from +`DiffModalViewModel.cs`), `SubtaskDiffRow` (from `PlanningDiffViewModel.cs`). Add new +`DiffTreeNodeViewModel` (dir/file node; file leaves hold their `DiffFileViewModel`). + +### `DiffViewerViewModel` (`ViewModels/Modals/DiffViewerViewModel.cs`, new) + +ctor `(GitService git, IWorkerClient worker)`. A `DiffViewerMode { Files, Planning }`. + +- **File sources** (replaces DiffModal + WorktreeModal): config props `WorktreePath`, + `BaseRef`, `HeadCommit`, `FromCommitRange`, `TaskId`, `TaskTitle` + `ShowMergeModal`/ + `ResolveMergeVm` delegates. `LoadAsync` pulls the whole diff via GitService + (`GetCommitRangeDiffAsync` | `GetBranchDiffAsync` | `GetDiffAsync`), parses with + `UnifiedDiffParser.Parse`, builds `FileTree`. `SelectedNode` (leaf) → `SelectedFile` + (header + binary/empty placeholders + `Lines`). Commit-range null-guard → "no longer + available" (preserve DiffModal behavior). `MergeCommand` (CanMerge = TaskId + + delegates) opens the MergeModal, closes on merged/routed (verbatim from DiffModal). +- **Planning source** (replaces PlanningDiff): config `PlanningTaskId`, `TargetBranch`. + `LoadAsync` pulls `GetPlanningAggregateAsync` → `Subtasks`; `SelectedSubtask` → + `DisplayedDiff`; `IsCombinedMode` toggle → `BuildPlanningIntegrationBranchAsync` + (success → combined diff; conflict → `CombinedWarning` with subtask + file count; + null → hub-error warning). `DisplayedDiff` → flattened `DiffLines` (right pane). +- Shared: `StatusMessage`, `CloseAction`, `CloseCommand`. + +### `DiffViewerView` (`Views/Modals/DiffViewerView.axaml` + `.cs`, new) + +`ModalShell`-based window. Left pane: `TreeView` (Files mode) or subtask `ListBox` +(Planning mode), toggled by mode. Right pane: the DiffModal file pane (header + binary/ +empty/no-changes placeholders + `DiffLinesView Lines="SelectedFile.Lines"`) in Files mode, +or `DiffLinesView Lines="DiffLines"` in Planning mode. Toolbar: combined toggle + warning ++ loading (Planning). Footer: Merge button (Files mode, CanMerge). Code-behind: `CloseAction`, +the `TreeView.SelectionChanged` → `SelectedNode` workaround, dir-row tap-to-expand. + +### Re-point the 3 doors → one viewer + +- **`MergeSectionViewModel`**: `OpenDiffAsync` builds a Files-mode `DiffViewerViewModel` + (+ ShowMergeModal/ResolveMergeVm) and calls a single `ShowDiffViewer` delegate; + `ReviewCombinedDiffAsync` builds a Planning-mode one and calls the *same* delegate. + Replaces `ShowDiffModal` + `ShowPlanningDiffModal` with one `Func + ShowDiffViewer`; keeps `ShowMergeModal`. (Resolve the VM via `_services`.) +- **`DetailsIslandView.axaml.cs`**: replace the two `ShowDiffModal`/`ShowPlanningDiffModal` + wirings (→ `DiffModalView`/`PlanningDiffView`) with one `ShowDiffViewer` (→ `DiffViewerView`). + Keep `ShowMergeModal`. +- **`WorktreesOverviewModalViewModel`**: `ShowDiff` builds a Files-mode viewer (worktree path + + base). Change `_diffVmFactory` from `Func` to + `Func`; `ShowDiffAction` stays `Action`. +- **`WindowDialogService.cs`**: `ShowDiffAction` → `new DiffViewerView` + `LoadAsync` + show. +- **`Program.cs`**: register `DiffViewerViewModel` (transient) + `Func`; + drop the `WorktreeModalViewModel` registration. + +### Delete + +`DiffModalViewModel.cs`, `WorktreeModalViewModel.cs`, `PlanningDiffViewModel.cs`, +`DiffModalView.axaml(.cs)`, `WorktreeModalView.axaml(.cs)`, `PlanningDiffView.axaml(.cs)`. + +### Localization + +Reuse existing keys in the merged view (`modals.diff.*` for the file pane, `planning.diff.*` +for the planning toolbar). Prune clearly-orphaned `modals.worktree.*` if trivial; keep en/de +parity. + +## Tests + +Replace `DiffModalViewModelTests` + `PlanningDiffViewModelTests` with +`DiffViewerViewModelTests` preserving the behaviors: commit-range null-guard → unavailable; +planning init populates + selects first; subtask select → DisplayedDiff; combined toggle +success/conflict/null. `WorktreesOverviewBatchMergeTests` compiles unchanged (`() => null!` +satisfies the new Func type). `UnifiedDiffParserTests` unchanged. + +## Acceptance + +- `dotnet build -c Release` clean (App); `Ui.Tests` + `Localization.Tests` green. +- One viewer reached from all 3 doors; old VMs/views deleted; merge editor untouched. +- Visual gap flagged: Details "Open Diff" (dirty + post-merge commit-range), Worktrees- + Overview "Show Diff" (tree), Details "Review Combined Diff" (subtasks + combined toggle), + and the Merge button still opens the merge form / resolver on conflict. + +## Commit + +`refactor(diff): single DiffViewer replaces DiffModal + WorktreeModal + PlanningDiff`. +Stage by path (exclude concurrent peers' files). Then Phase 3 (WorktreeActions) follows as +its own slice, reusing this viewer. diff --git a/src/ClaudeDo.App/CLAUDE.md b/src/ClaudeDo.App/CLAUDE.md index b18566a..fe906eb 100644 --- a/src/ClaudeDo.App/CLAUDE.md +++ b/src/ClaudeDo.App/CLAUDE.md @@ -20,7 +20,7 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the ## DI Registration Pattern - **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `UpdateCheckService`, `IPrimeScheduleApi`/`WorkerPrimeScheduleApi`, `INotesApi`/`WorkerNotesApi`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext) -- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WeeklyReportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func` factories for on-demand dialog creation; `ConflictResolverViewModel` via a `Func` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`) +- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WeeklyReportModalViewModel`, `DiffViewerViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func` factories for on-demand dialog creation (`Func` for the diff viewer); `ConflictResolverViewModel` via a `Func` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`) ## Notes diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index c4a4e61..822ffd8 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -121,8 +121,8 @@ sealed class Program sc.AddSingleton(sp => sp.GetRequiredService()); // ViewModels - sc.AddTransient(); - sc.AddTransient>(sp => () => sp.GetRequiredService()); + sc.AddTransient(); + sc.AddTransient>(sp => () => sp.GetRequiredService()); sc.AddTransient(); sc.AddTransient>(sp => () => sp.GetRequiredService()); sc.AddSingleton(); diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index db873dc..de69634 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -288,9 +288,6 @@ "binary": "Binärdatei — kein Text-Diff", "empty": "Kein Inhalt" }, - "worktree": { - "title": "Worktree" - }, "worktreesOverview": { "refresh": "Aktualisieren", "cleanupFinished": "Abgeschlossene aufräumen", @@ -413,8 +410,6 @@ "abort": "Diesen Merge abbrechen" }, "diff": { - "windowTitle": "Planung — Kombiniertes Diff", - "modalTitle": "PLANUNG — KOMBINIERTES DIFF", "previewCombined": "Kombinierte Vorschau", "loading": "Wird geladen…" } diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index f1043aa..bdf966a 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -288,9 +288,6 @@ "binary": "Binary file — no text diff", "empty": "No content" }, - "worktree": { - "title": "Worktree" - }, "worktreesOverview": { "refresh": "Refresh", "cleanupFinished": "Cleanup finished", @@ -413,8 +410,6 @@ "abort": "Abort this merge" }, "diff": { - "windowTitle": "Planning — Combined diff", - "modalTitle": "PLANNING — COMBINED DIFF", "previewCombined": "Preview combined", "loading": "Loading…" } diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index a579b17..e26085e 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -20,10 +20,9 @@ ViewModels/ Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem, NotesEditor, MergePreviewPresenter Agent/ — AgentConfigEditorViewModel (scope-parameterized: List | Task) - Modals/ — About, Diff, ListSettings, Merge, RepoImport, Settings (+ Settings/ tab VMs), - UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree, - WorktreesOverview, UnifiedDiffParser - Planning/ — PlanningDiffViewModel + Modals/ — About, DiffViewer (+ DiffModels), ListSettings, Merge, RepoImport, + Settings (+ Settings/ tab VMs), UnfinishedPlanning, WeeklyReport, + WorkerConnection, WorktreesOverview, UnifiedDiffParser Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock) Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar, DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView @@ -37,12 +36,12 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle - **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay via `OpenLogVisualizerCommand`; `FlashFooterError` surfaces UI-action failures + the worker's Serilog Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help, LogVisualizer) plus `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`. - **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`. - **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RunInteractivelyAsync`, `RefineTask`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell. -- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` — live worktree or commit range after merge —, `ReviewCombinedDiffCommand` → `PlanningDiffViewModel`), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file. +- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel` and call `ShowDiffViewer`), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file. - **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints). - **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, delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync()`), `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`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`). -- **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. 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`). +- **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). `DiffModels.cs` holds shared types: `DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`, `DiffTreeNodeViewModel`, `DiffTree`. `DiffViewerViewModel` is a single unified read-only diff viewer with two modes: **Files** (dirty worktree / branch-vs-base / commit-range — loads via GitService, shows a folder file-tree on the left + per-file diff pane on the right, Merge button for live branch source) and **Planning** (per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat diff right, combined integration-branch toggle). The Merge button opens the merge form, which routes to `ConflictResolverViewModel` on conflict. `DiffLinesView` renders per-file diff content with binary/empty placeholders. +- **Conflicts** — `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/ViewModels/Islands/MergeSectionViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs index 3895f5a..7510a77 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs @@ -45,9 +45,8 @@ public sealed partial class MergeSectionViewModel : ViewModelBase public bool ShowMergeSection => _worktreePath != null || _isPlanningParent || _hasChildOutcomes; - public Func? ShowDiffModal { get; set; } + public Func? ShowDiffViewer { get; set; } public Func? ShowMergeModal { get; set; } - public Func? ShowPlanningDiffModal { get; set; } public MergeSectionViewModel(IWorkerClient worker, IServiceProvider services) { @@ -125,10 +124,11 @@ public sealed partial class MergeSectionViewModel : ViewModelBase [RelayCommand(CanExecute = nameof(CanReviewDiff))] private async System.Threading.Tasks.Task ReviewCombinedDiffAsync() { - if (TaskId is null || ShowPlanningDiffModal is null) return; - var vm = new ViewModels.Planning.PlanningDiffViewModel(_worker, TaskId, SelectedMergeTarget ?? "main"); - await vm.InitializeAsync(); - await ShowPlanningDiffModal(vm); + if (TaskId is null || ShowDiffViewer is null) return; + var vm = _services.GetRequiredService(); + vm.ConfigurePlanning(TaskId, SelectedMergeTarget ?? "main"); + await vm.LoadAsync(); + await ShowDiffViewer(vm); } private bool CanReviewDiff() => (_isPlanningParent && _subtaskCount > 0) || _hasChildOutcomes; @@ -136,43 +136,28 @@ public sealed partial class MergeSectionViewModel : ViewModelBase [RelayCommand(CanExecute = nameof(CanOpenDiff))] private async System.Threading.Tasks.Task OpenDiffAsync() { - if (ShowDiffModal is null) return; - var git = _services.GetRequiredService(); + if (ShowDiffViewer is null) return; var hasLiveWorktree = _worktreePath != null && _worktreeStateLabel == "Active" && System.IO.Directory.Exists(_worktreePath); - DiffModalViewModel diffVm; + var vm = _services.GetRequiredService(); if (hasLiveWorktree) { - diffVm = new DiffModalViewModel(git) - { - WorktreePath = _worktreePath!, - BaseRef = _worktreeBaseCommit, - TaskId = TaskId, - TaskTitle = TaskTitle ?? "", - ShowMergeModal = ShowMergeModal, - ResolveMergeVm = () => _services.GetRequiredService(), - }; + vm.ConfigureWorktree(_worktreePath!, _worktreeBaseCommit, TaskId, TaskTitle ?? ""); + vm.ShowMergeModal = ShowMergeModal; + vm.ResolveMergeVm = () => _services.GetRequiredService(); } else if (CanDiffMergedRange) { - diffVm = new DiffModalViewModel(git) - { - WorktreePath = _listWorkingDir!, - BaseRef = _worktreeBaseCommit, - HeadCommit = _worktreeHeadCommit, - FromCommitRange = true, - TaskId = TaskId, - TaskTitle = TaskTitle ?? "", - }; + vm.ConfigureCommitRange(_listWorkingDir!, _worktreeBaseCommit, _worktreeHeadCommit, TaskId, TaskTitle ?? ""); } else return; - await diffVm.LoadAsync(); - await ShowDiffModal(diffVm); + await vm.LoadAsync(); + await ShowDiffViewer(vm); } private bool CanDiffMergedRange => diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index c5b1d38..7a5d70d 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -10,7 +10,6 @@ using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Modals; -using ClaudeDo.Ui.ViewModels.Planning; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Ui.ViewModels; diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs deleted file mode 100644 index 2ffe596..0000000 --- a/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System.Collections.ObjectModel; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using ClaudeDo.Data.Git; -using ClaudeDo.Ui.Localization; - -namespace ClaudeDo.Ui.ViewModels.Modals; - -public enum DiffLineKind { Add, Del, Ctx, File } - -public enum DiffFileStatus { Modified, Added, Deleted, Renamed } - -public sealed class DiffLineViewModel -{ - public required DiffLineKind Kind { get; init; } - public int? OldNo { get; init; } - public int? NewNo { get; init; } - public required string Text { get; init; } - public string ClassName => Kind switch - { - DiffLineKind.Add => "add", - DiffLineKind.Del => "del", - DiffLineKind.File => "file", - _ => "ctx", - }; - - public string Sign => Kind switch - { - DiffLineKind.Add => "+", - DiffLineKind.Del => "-", - _ => " ", - }; -} - -public sealed class DiffFileViewModel -{ - public required string Path { get; set; } - public string? OldPath { get; set; } - public DiffFileStatus Status { get; set; } = DiffFileStatus.Modified; - public bool IsBinary { get; set; } - public int Additions { get; set; } - public int Deletions { get; set; } - public ObservableCollection Lines { get; } = new(); - - /// Single-letter badge for the file's change kind (A/M/D/R). - public string StatusCode => Status switch - { - DiffFileStatus.Added => "A", - DiffFileStatus.Deleted => "D", - DiffFileStatus.Renamed => "R", - _ => "M", - }; - - public bool HasLines => Lines.Count > 0; - - /// A text file that produced no diff hunks (e.g. a newly added empty file). - public bool IsEmptyContent => !IsBinary && Lines.Count == 0; -} - -public sealed partial class DiffModalViewModel : ViewModelBase -{ - private readonly GitService _git; - - public required string WorktreePath { get; init; } - public string? BaseRef { get; init; } - /// When set together with , the diff is computed as - /// BaseRef..HeadCommit inside (used as the repo - /// dir) — lets a merged task's diff be viewed after its worktree is gone. - public string? HeadCommit { get; init; } - public bool FromCommitRange { get; init; } - public string? TaskId { get; init; } - public string TaskTitle { get; init; } = ""; - public Func? ShowMergeModal { get; set; } - public Func? ResolveMergeVm { get; set; } - - public ObservableCollection Files { get; } = new(); - - [ObservableProperty] private DiffFileViewModel? _selectedFile; - [ObservableProperty] private string? _statusMessage; - - // Injected action to close the owning Window - public Action? CloseAction { get; set; } - - public DiffModalViewModel(GitService git) - { - _git = git; - } - - [RelayCommand] - private void Close() => CloseAction?.Invoke(); - - private bool CanMerge() => - !string.IsNullOrEmpty(TaskId) - && ShowMergeModal is not null - && ResolveMergeVm is not null; - - [RelayCommand(CanExecute = nameof(CanMerge))] - private async Task MergeAsync() - { - if (TaskId is null || ShowMergeModal is null || ResolveMergeVm is null) return; - var vm = ResolveMergeVm(); - await vm.InitializeAsync(TaskId, TaskTitle); - await ShowMergeModal(vm); - // 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) - { - Files.Clear(); - StatusMessage = null; - - if (FromCommitRange && (BaseRef is null || HeadCommit is null)) - { - StatusMessage = Loc.T("vm.diff.unavailable"); - return; - } - - string raw; - try - { - raw = FromCommitRange && BaseRef is not null && HeadCommit is not null - ? await _git.GetCommitRangeDiffAsync(WorktreePath, BaseRef, HeadCommit, ct) - : BaseRef is not null - ? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct) - : await _git.GetDiffAsync(WorktreePath, ct); - } - catch (Exception ex) - { - StatusMessage = Loc.T("vm.diff.loadFailed", ex.Message); - return; - } - - if (string.IsNullOrWhiteSpace(raw)) - { - StatusMessage = Loc.T("vm.diff.noChanges"); - return; - } - - foreach (var file in UnifiedDiffParser.Parse(raw)) - Files.Add(file); - - SelectedFile = Files.Count > 0 ? Files[0] : null; - if (Files.Count == 0) StatusMessage = Loc.T("vm.diff.noChanges"); - } -} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/DiffModels.cs b/src/ClaudeDo.Ui/ViewModels/Modals/DiffModels.cs new file mode 100644 index 0000000..17a7d1c --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/DiffModels.cs @@ -0,0 +1,131 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace ClaudeDo.Ui.ViewModels.Modals; + +// Shared diff models used by UnifiedDiffParser, DiffLinesView and DiffViewerViewModel. + +public enum DiffLineKind { Add, Del, Ctx, File } + +public enum DiffFileStatus { Modified, Added, Deleted, Renamed } + +public sealed class DiffLineViewModel +{ + public required DiffLineKind Kind { get; init; } + public int? OldNo { get; init; } + public int? NewNo { get; init; } + public required string Text { get; init; } + public string ClassName => Kind switch + { + DiffLineKind.Add => "add", + DiffLineKind.Del => "del", + DiffLineKind.File => "file", + _ => "ctx", + }; + + public string Sign => Kind switch + { + DiffLineKind.Add => "+", + DiffLineKind.Del => "-", + _ => " ", + }; +} + +public sealed class DiffFileViewModel +{ + public required string Path { get; set; } + public string? OldPath { get; set; } + public DiffFileStatus Status { get; set; } = DiffFileStatus.Modified; + public bool IsBinary { get; set; } + public int Additions { get; set; } + public int Deletions { get; set; } + public ObservableCollection Lines { get; } = new(); + + /// Single-letter badge for the file's change kind (A/M/D/R). + public string StatusCode => Status switch + { + DiffFileStatus.Added => "A", + DiffFileStatus.Deleted => "D", + DiffFileStatus.Renamed => "R", + _ => "M", + }; + + public bool HasLines => Lines.Count > 0; + + /// A text file that produced no diff hunks (e.g. a newly added empty file). + public bool IsEmptyContent => !IsBinary && Lines.Count == 0; +} + +/// One row in the planning subtask list (left pane in Planning mode). +public sealed record SubtaskDiffRow(string Id, string Title, string? DiffStat, string UnifiedDiff); + +/// Folder/file node for the file-tree nav (left pane in Files mode). File leaves carry +/// their parsed so selection swaps the right pane with no +/// further git calls. +public sealed partial class DiffTreeNodeViewModel : ViewModelBase +{ + public required string Name { get; init; } + public bool IsDirectory { get; init; } + public string RelativePath { get; init; } = ""; + public DiffFileViewModel? File { get; init; } + public ObservableCollection Children { get; } = new(); + [ObservableProperty] private bool _isExpanded = true; + + public string? StatusCode => File?.StatusCode; + public bool ShowStats => File is { IsBinary: false }; + public int Additions => File?.Additions ?? 0; + public int Deletions => File?.Deletions ?? 0; +} + +/// Builds a folder-grouped tree from a flat list of parsed diff files. +public static class DiffTree +{ + public static List Build(IEnumerable files) + { + var roots = new List(); + var dirs = new Dictionary(StringComparer.Ordinal); + + foreach (var file in files) + { + var path = file.Path.Replace('\\', '/'); + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) continue; + + DiffTreeNodeViewModel? parent = null; + var accumulated = ""; + for (var i = 0; i < segments.Length - 1; i++) + { + accumulated = accumulated.Length == 0 ? segments[i] : accumulated + "/" + segments[i]; + if (!dirs.TryGetValue(accumulated, out var dir)) + { + dir = new DiffTreeNodeViewModel { Name = segments[i], IsDirectory = true, RelativePath = accumulated }; + dirs[accumulated] = dir; + if (parent is null) roots.Add(dir); else parent.Children.Add(dir); + } + parent = dir; + } + + var leaf = new DiffTreeNodeViewModel + { + Name = segments[^1], + IsDirectory = false, + RelativePath = path, + File = file, + }; + if (parent is null) roots.Add(leaf); else parent.Children.Add(leaf); + } + + return roots; + } + + public static DiffTreeNodeViewModel? FirstLeaf(IEnumerable nodes) + { + foreach (var n in nodes) + { + if (!n.IsDirectory) return n; + var nested = FirstLeaf(n.Children); + if (nested is not null) return nested; + } + return null; + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs new file mode 100644 index 0000000..ca26597 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs @@ -0,0 +1,243 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ClaudeDo.Data.Git; +using ClaudeDo.Ui.Localization; +using ClaudeDo.Ui.Services; + +namespace ClaudeDo.Ui.ViewModels.Modals; + +public enum DiffViewerMode { Files, Planning } + +/// +/// One read-only diff viewer replacing DiffModal + WorktreeModal + PlanningDiff. +/// sources (dirty worktree / branch-vs-base / commit +/// range) load the whole diff via and present a folder tree; +/// loads per-subtask diffs from the worker with a +/// combined integration-branch toggle. The Merge button (branch source) opens the merge +/// form, which routes to the 3-pane resolver on conflict — the resolver itself is untouched. +/// +public sealed partial class DiffViewerViewModel : ViewModelBase +{ + private readonly GitService _git; + private readonly IWorkerClient _worker; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsPlanning))] + [NotifyPropertyChangedFor(nameof(ShowMerge))] + [NotifyCanExecuteChangedFor(nameof(MergeCommand))] + private DiffViewerMode _mode = DiffViewerMode.Files; + + public bool IsPlanning => Mode == DiffViewerMode.Planning; + + // ── File-source config ────────────────────────────────────────────────── + public string? WorktreePath { get; set; } + public string? BaseRef { get; set; } + public string? HeadCommit { get; set; } + public bool FromCommitRange { get; set; } + public string? TaskId { get; set; } + public string TaskTitle { get; set; } = ""; + public Func? ShowMergeModal { get; set; } + public Func? ResolveMergeVm { get; set; } + + // ── Planning-source config ────────────────────────────────────────────── + private string? _planningTaskId; + private string _targetBranch = ""; + + // ── Left pane ─────────────────────────────────────────────────────────── + public ObservableCollection FileTree { get; } = new(); + public ObservableCollection Subtasks { get; } = new(); + [ObservableProperty] private DiffTreeNodeViewModel? _selectedNode; + [ObservableProperty] private SubtaskDiffRow? _selectedSubtask; + + // ── Right pane ────────────────────────────────────────────────────────── + [ObservableProperty] private DiffFileViewModel? _selectedFile; // Files mode + public ObservableCollection DiffLines { get; } = new(); // Planning mode + [ObservableProperty] private string _displayedDiff = ""; + [ObservableProperty] private string? _statusMessage; + + // ── Planning combined toggle ──────────────────────────────────────────── + [ObservableProperty] private bool _isCombinedMode; + [ObservableProperty] private string? _combinedWarning; + [ObservableProperty] private bool _isLoadingCombined; + + public Action? CloseAction { get; set; } + + public DiffViewerViewModel(GitService git, IWorkerClient worker) + { + _git = git; + _worker = worker; + } + + [RelayCommand] + private void Close() => CloseAction?.Invoke(); + + // ── Configuration (called by the doors) ───────────────────────────────── + + public void ConfigureWorktree(string worktreePath, string? baseRef, string? taskId = null, string taskTitle = "") + { + Mode = DiffViewerMode.Files; + WorktreePath = worktreePath; + BaseRef = string.IsNullOrEmpty(baseRef) ? null : baseRef; + TaskId = taskId; + TaskTitle = taskTitle; + } + + public void ConfigureCommitRange(string repoDir, string? baseRef, string? headCommit, + string? taskId = null, string taskTitle = "") + { + Mode = DiffViewerMode.Files; + WorktreePath = repoDir; + BaseRef = baseRef; + HeadCommit = headCommit; + FromCommitRange = true; + TaskId = taskId; + TaskTitle = taskTitle; + } + + public void ConfigurePlanning(string planningTaskId, string targetBranch) + { + Mode = DiffViewerMode.Planning; + _planningTaskId = planningTaskId; + _targetBranch = targetBranch; + } + + // ── Load ──────────────────────────────────────────────────────────────── + + public Task LoadAsync(CancellationToken ct = default) => + Mode == DiffViewerMode.Planning ? LoadPlanningAsync() : LoadFilesAsync(ct); + + private async Task LoadFilesAsync(CancellationToken ct) + { + FileTree.Clear(); + SelectedNode = null; + SelectedFile = null; + StatusMessage = null; + + if ((FromCommitRange && (BaseRef is null || HeadCommit is null)) || WorktreePath is null) + { + StatusMessage = Loc.T("vm.diff.unavailable"); + return; + } + + string raw; + try + { + raw = FromCommitRange && BaseRef is not null && HeadCommit is not null + ? await _git.GetCommitRangeDiffAsync(WorktreePath, BaseRef, HeadCommit, ct) + : BaseRef is not null + ? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct) + : await _git.GetDiffAsync(WorktreePath, ct); + } + catch (Exception ex) + { + StatusMessage = Loc.T("vm.diff.loadFailed", ex.Message); + return; + } + + if (string.IsNullOrWhiteSpace(raw)) + { + StatusMessage = Loc.T("vm.diff.noChanges"); + return; + } + + var files = UnifiedDiffParser.Parse(raw).ToList(); + foreach (var node in DiffTree.Build(files)) + FileTree.Add(node); + + SelectedNode = DiffTree.FirstLeaf(FileTree); + if (files.Count == 0) StatusMessage = Loc.T("vm.diff.noChanges"); + } + + partial void OnSelectedNodeChanged(DiffTreeNodeViewModel? value) + { + if (value is { IsDirectory: false, File: { } f }) + SelectedFile = f; + } + + private async Task LoadPlanningAsync() + { + if (_planningTaskId is null) return; + var items = await _worker.GetPlanningAggregateAsync(_planningTaskId); + Subtasks.Clear(); + foreach (var i in items) + Subtasks.Add(new SubtaskDiffRow(i.SubtaskId, i.Title, i.DiffStat, i.UnifiedDiff)); + SelectedSubtask = Subtasks.FirstOrDefault(); + } + + partial void OnSelectedSubtaskChanged(SubtaskDiffRow? value) + { + if (!IsCombinedMode) + DisplayedDiff = value?.UnifiedDiff ?? ""; + } + + [RelayCommand] + private async Task ToggleCombinedAsync() + { + if (IsCombinedMode) + { + IsLoadingCombined = true; + try + { + var result = await _worker.BuildPlanningIntegrationBranchAsync(_planningTaskId!, _targetBranch); + if (result is null) + { + DisplayedDiff = ""; + CombinedWarning = Loc.T("vm.planningDiff.hubError"); + } + else if (result.Success) + { + DisplayedDiff = result.UnifiedDiff ?? ""; + CombinedWarning = null; + } + else + { + var files = result.ConflictedFiles?.Count ?? 0; + CombinedWarning = Loc.T("vm.planningDiff.conflict", result.FirstConflictSubtaskId ?? "", files); + DisplayedDiff = ""; + } + } + finally + { + IsLoadingCombined = false; + } + } + else + { + DisplayedDiff = SelectedSubtask?.UnifiedDiff ?? ""; + CombinedWarning = null; + } + } + + partial void OnIsCombinedModeChanged(bool value) => ToggleCombinedCommand.Execute(null); + + partial void OnDisplayedDiffChanged(string value) + { + DiffLines.Clear(); + foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(value))) + DiffLines.Add(line); + } + + // ── Merge (Files mode, branch source) ─────────────────────────────────── + + /// Whether the Merge button is offered — only a live branch source with a task and the + /// merge delegates wired (set before the view binds, so a plain computed read suffices). + public bool ShowMerge => + Mode == DiffViewerMode.Files + && !string.IsNullOrEmpty(TaskId) + && ShowMergeModal is not null + && ResolveMergeVm is not null; + + private bool CanMerge() => ShowMerge; + + [RelayCommand(CanExecute = nameof(CanMerge))] + private async Task MergeAsync() + { + if (TaskId is null || ShowMergeModal is null || ResolveMergeVm is null) return; + var vm = ResolveMergeVm(); + await vm.InitializeAsync(TaskId, TaskTitle); + await ShowMergeModal(vm); + // The diff is stale once the worktree merged away or a conflict opened the editor. + if (vm.Merged || vm.RoutedToResolver) CloseAction?.Invoke(); + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs deleted file mode 100644 index eaccea7..0000000 --- a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System.Collections.ObjectModel; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using ClaudeDo.Data.Git; - -namespace ClaudeDo.Ui.ViewModels.Modals; - -public sealed partial class WorktreeNodeViewModel : ViewModelBase -{ - public required string Name { get; init; } - public string? Status { get; init; } - public bool IsDirectory { get; init; } - public string RelativePath { get; init; } = ""; - public ObservableCollection Children { get; } = new(); - [ObservableProperty] private bool _isExpanded = true; -} - -public sealed partial class WorktreeModalViewModel : ViewModelBase -{ - private readonly GitService _git; - - public ObservableCollection Root { get; } = new(); - public ObservableCollection SelectedFileDiffLines { get; } = new(); - - [ObservableProperty] private string _worktreePath = ""; - [ObservableProperty] private string? _baseCommit; - [ObservableProperty] private WorktreeNodeViewModel? _selectedNode; - - // Set by the view (same pattern as DiffModalViewModel.CloseAction) - public Action? CloseAction { get; set; } - - public WorktreeModalViewModel(GitService git) - { - _git = git; - } - - partial void OnSelectedNodeChanged(WorktreeNodeViewModel? value) - { - _ = LoadFileDiffAsync(value); - } - - private async Task LoadFileDiffAsync(WorktreeNodeViewModel? node) - { - SelectedFileDiffLines.Clear(); - - if (node is null || node.IsDirectory || string.IsNullOrEmpty(node.RelativePath)) - return; - - string diff; - try - { - diff = await _git.GetFileDiffAsync(WorktreePath, BaseCommit, node.RelativePath); - } - catch - { - return; - } - - foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(diff))) - SelectedFileDiffLines.Add(line); - } - - [RelayCommand] - private void Close() => CloseAction?.Invoke(); - - public async Task LoadAsync(CancellationToken ct = default) - { - Root.Clear(); - - string stdout; - bool committedMode = !string.IsNullOrEmpty(BaseCommit); - try - { - stdout = committedMode - ? await _git.GetCommittedFilesAsync(WorktreePath, BaseCommit!, ct) - : await _git.GetStatusPorcelainAsync(WorktreePath, ct); - } - catch { return; } - - if (string.IsNullOrWhiteSpace(stdout)) return; - - var dirs = new Dictionary(StringComparer.Ordinal); - - foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)) - { - string? path; - string? status; - - if (committedMode) - { - // diff --name-status format: \t - var tab = line.IndexOf('\t'); - if (tab < 0) continue; - var statusChar = line[0]; - status = statusChar != ' ' ? statusChar.ToString() : null; - path = line[(tab + 1)..].Trim().Replace('\\', '/'); - } - else - { - // porcelain format: XYpath - if (line.Length < 4) continue; - var xy = line[..2]; - var statusChar = xy[0] != ' ' ? xy[0] : xy[1]; - status = statusChar != ' ' ? statusChar.ToString() : null; - path = line[3..].Trim().Replace('\\', '/'); - } - - var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0) continue; - - WorktreeNodeViewModel? parent = null; - var accumulated = ""; - for (var i = 0; i < segments.Length - 1; i++) - { - accumulated = accumulated.Length == 0 ? segments[i] : accumulated + "/" + segments[i]; - if (!dirs.TryGetValue(accumulated, out var dir)) - { - dir = new WorktreeNodeViewModel { Name = segments[i], IsDirectory = true }; - dirs[accumulated] = dir; - if (parent == null) Root.Add(dir); - else parent.Children.Add(dir); - } - parent = dir; - } - - var leaf = new WorktreeNodeViewModel - { - Name = segments[^1], - Status = status, - IsDirectory = false, - RelativePath = path - }; - if (parent == null) Root.Add(leaf); - else parent.Children.Add(leaf); - } - - SelectedNode = FindFirstLeaf(Root); - } - - private static WorktreeNodeViewModel? FindFirstLeaf(IEnumerable nodes) - { - foreach (var n in nodes) - { - if (!n.IsDirectory) return n; - var nested = FindFirstLeaf(n.Children); - if (nested is not null) return nested; - } - return null; - } -} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs index ca3d110..59aaef2 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs @@ -61,7 +61,7 @@ public sealed partial class WorktreesGroupViewModel : ViewModelBase public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase { private readonly IWorkerClient _worker; - private readonly Func _diffVmFactory; + private readonly Func _diffVmFactory; private readonly IMergeCoordinator _merge; [ObservableProperty] private string? _listIdFilter; @@ -81,13 +81,13 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase public ObservableCollection ConflictRows { get; } = new(); public Action? CloseAction { get; set; } - public Action? ShowDiffAction { get; set; } + public Action? ShowDiffAction { get; set; } public Action? JumpToTaskAction { get; set; } public Func>? ConfirmAction { get; set; } public Func? ResolveMergeVm { get; set; } public Func? ShowMergeAction { get; set; } - public WorktreesOverviewModalViewModel(IWorkerClient worker, Func diffVmFactory, IMergeCoordinator merge) + public WorktreesOverviewModalViewModel(IWorkerClient worker, Func diffVmFactory, IMergeCoordinator merge) { _worker = worker; _diffVmFactory = diffVmFactory; @@ -177,8 +177,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase { if (row is null) return; var diffVm = _diffVmFactory(); - diffVm.WorktreePath = row.Path; - diffVm.BaseCommit = string.IsNullOrEmpty(row.BaseCommit) ? null : row.BaseCommit; + diffVm.ConfigureWorktree(row.Path, row.BaseCommit); ShowDiffAction?.Invoke(diffVm); } diff --git a/src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs deleted file mode 100644 index 9d43382..0000000 --- a/src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Collections.ObjectModel; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using ClaudeDo.Ui.Localization; -using ClaudeDo.Ui.Services; -using ClaudeDo.Ui.ViewModels.Modals; - -namespace ClaudeDo.Ui.ViewModels.Planning; - -public sealed partial class PlanningDiffViewModel : ObservableObject -{ - private readonly IWorkerClient _worker; - private readonly string _planningTaskId; - private readonly string _targetBranch; - - public ObservableCollection Subtasks { get; } = new(); - public ObservableCollection DiffLines { get; } = new(); - - [ObservableProperty] private SubtaskDiffRow? _selectedSubtask; - [ObservableProperty] private string _displayedDiff = ""; - [ObservableProperty] private bool _isCombinedMode; - [ObservableProperty] private string? _combinedWarning; - [ObservableProperty] private bool _isLoadingCombined; - - public Action? CloseAction { get; set; } - - public PlanningDiffViewModel(IWorkerClient worker, string planningTaskId, string targetBranch) - { - _worker = worker; - _planningTaskId = planningTaskId; - _targetBranch = targetBranch; - } - - [RelayCommand] - private void Close() => CloseAction?.Invoke(); - - public async Task InitializeAsync() - { - var items = await _worker.GetPlanningAggregateAsync(_planningTaskId); - Subtasks.Clear(); - foreach (var i in items) - Subtasks.Add(new SubtaskDiffRow(i.SubtaskId, i.Title, i.DiffStat, i.UnifiedDiff)); - SelectedSubtask = Subtasks.FirstOrDefault(); - } - - partial void OnSelectedSubtaskChanged(SubtaskDiffRow? value) - { - if (!IsCombinedMode) - DisplayedDiff = value?.UnifiedDiff ?? ""; - } - - [RelayCommand] - private async Task ToggleCombinedAsync() - { - if (IsCombinedMode) - { - IsLoadingCombined = true; - try - { - var result = await _worker.BuildPlanningIntegrationBranchAsync(_planningTaskId, _targetBranch); - - if (result is null) - { - DisplayedDiff = ""; - CombinedWarning = Loc.T("vm.planningDiff.hubError"); - } - else if (result.Success) - { - DisplayedDiff = result.UnifiedDiff ?? ""; - CombinedWarning = null; - } - else - { - var files = result.ConflictedFiles?.Count ?? 0; - CombinedWarning = Loc.T("vm.planningDiff.conflict", result.FirstConflictSubtaskId ?? "", files); - DisplayedDiff = ""; - } - } - finally - { - IsLoadingCombined = false; - } - } - else - { - DisplayedDiff = SelectedSubtask?.UnifiedDiff ?? ""; - CombinedWarning = null; - } - } - - partial void OnIsCombinedModeChanged(bool value) => ToggleCombinedCommand.Execute(null); - - partial void OnDisplayedDiffChanged(string value) - { - DiffLines.Clear(); - foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(value))) - DiffLines.Add(line); - } -} - -public sealed record SubtaskDiffRow(string Id, string Title, string? DiffStat, string UnifiedDiff); diff --git a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs index 15307f0..decd3fa 100644 --- a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs @@ -8,7 +8,6 @@ using Avalonia.Platform.Storage; using Avalonia.Reactive; using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.Views.Modals; -using ClaudeDo.Ui.Views.Planning; namespace ClaudeDo.Ui.Views.Islands; @@ -129,11 +128,11 @@ public partial class DetailsIslandView : UserControl vm.PropertyChanged += OnViewModelPropertyChanged; ApplyResizeStateForCurrentTask(); - vm.Merge.ShowDiffModal = async (diffVm) => + vm.Merge.ShowDiffViewer = async (diffVm) => { var owner = TopLevel.GetTopLevel(this) as Window; if (owner == null) return; - var modal = new DiffModalView { DataContext = diffVm }; + var modal = new DiffViewerView { DataContext = diffVm }; await modal.ShowDialog(owner); }; @@ -145,14 +144,6 @@ public partial class DetailsIslandView : UserControl await modal.ShowDialog(owner); }; - vm.Merge.ShowPlanningDiffModal = async (planningDiffVm) => - { - var owner = TopLevel.GetTopLevel(this) as Window; - if (owner == null) return; - var modal = new PlanningDiffView { DataContext = planningDiffVm }; - await modal.ShowDialog(owner); - }; - vm.ConfirmAsync = ShowConfirmAsync; vm.ShowErrorAsync = ShowErrorDialogAsync; } diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs b/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs index 8536ee8..d96f06a 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs @@ -8,7 +8,6 @@ using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.Views.Modals; -using ClaudeDo.Ui.Views.Planning; namespace ClaudeDo.Ui.Views; diff --git a/src/ClaudeDo.Ui/Views/Modals/DiffModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/DiffModalView.axaml deleted file mode 100644 index 7a7cb7f..0000000 --- a/src/ClaudeDo.Ui/Views/Modals/DiffModalView.axaml +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - -