refactor(diff): single DiffViewer replaces DiffModal + WorktreeModal + PlanningDiff

This commit is contained in:
Mika Kuns
2026-06-23 09:30:37 +02:00
parent 4022bd7197
commit 167d2fec6a
28 changed files with 923 additions and 1120 deletions

View File

@@ -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<DiffViewerViewModel,Task>
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<WorktreeModalViewModel>` to
`Func<DiffViewerViewModel>`; `ShowDiffAction` stays `Action<DiffViewerViewModel>`.
- **`WindowDialogService.cs`**: `ShowDiffAction``new DiffViewerView` + `LoadAsync` + show.
- **`Program.cs`**: register `DiffViewerViewModel` (transient) + `Func<DiffViewerViewModel>`;
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.

View File

@@ -20,7 +20,7 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
## DI Registration Pattern ## 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) - **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<T>` factories for on-demand dialog creation; `ConflictResolverViewModel` via a `Func<string, ConflictResolverViewModel>` 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<T>` factories for on-demand dialog creation (`Func<DiffViewerViewModel>` for the diff viewer); `ConflictResolverViewModel` via a `Func<string, ConflictResolverViewModel>` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`)
## Notes ## Notes

View File

@@ -121,8 +121,8 @@ sealed class Program
sc.AddSingleton<IMergeCoordinator>(sp => sp.GetRequiredService<MergeCoordinator>()); sc.AddSingleton<IMergeCoordinator>(sp => sp.GetRequiredService<MergeCoordinator>());
// ViewModels // ViewModels
sc.AddTransient<WorktreeModalViewModel>(); sc.AddTransient<DiffViewerViewModel>();
sc.AddTransient<Func<WorktreeModalViewModel>>(sp => () => sp.GetRequiredService<WorktreeModalViewModel>()); sc.AddTransient<Func<DiffViewerViewModel>>(sp => () => sp.GetRequiredService<DiffViewerViewModel>());
sc.AddTransient<WorktreesOverviewModalViewModel>(); sc.AddTransient<WorktreesOverviewModalViewModel>();
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>()); sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>(); sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();

View File

@@ -288,9 +288,6 @@
"binary": "Binärdatei — kein Text-Diff", "binary": "Binärdatei — kein Text-Diff",
"empty": "Kein Inhalt" "empty": "Kein Inhalt"
}, },
"worktree": {
"title": "Worktree"
},
"worktreesOverview": { "worktreesOverview": {
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
"cleanupFinished": "Abgeschlossene aufräumen", "cleanupFinished": "Abgeschlossene aufräumen",
@@ -413,8 +410,6 @@
"abort": "Diesen Merge abbrechen" "abort": "Diesen Merge abbrechen"
}, },
"diff": { "diff": {
"windowTitle": "Planung — Kombiniertes Diff",
"modalTitle": "PLANUNG — KOMBINIERTES DIFF",
"previewCombined": "Kombinierte Vorschau", "previewCombined": "Kombinierte Vorschau",
"loading": "Wird geladen…" "loading": "Wird geladen…"
} }

View File

@@ -288,9 +288,6 @@
"binary": "Binary file — no text diff", "binary": "Binary file — no text diff",
"empty": "No content" "empty": "No content"
}, },
"worktree": {
"title": "Worktree"
},
"worktreesOverview": { "worktreesOverview": {
"refresh": "Refresh", "refresh": "Refresh",
"cleanupFinished": "Cleanup finished", "cleanupFinished": "Cleanup finished",
@@ -413,8 +410,6 @@
"abort": "Abort this merge" "abort": "Abort this merge"
}, },
"diff": { "diff": {
"windowTitle": "Planning — Combined diff",
"modalTitle": "PLANNING — COMBINED DIFF",
"previewCombined": "Preview combined", "previewCombined": "Preview combined",
"loading": "Loading…" "loading": "Loading…"
} }

View File

@@ -20,10 +20,9 @@ ViewModels/
Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem, Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem,
NotesEditor, MergePreviewPresenter NotesEditor, MergePreviewPresenter
Agent/ — AgentConfigEditorViewModel (scope-parameterized: List | Task) Agent/ — AgentConfigEditorViewModel (scope-parameterized: List | Task)
Modals/ — About, Diff, ListSettings, Merge, RepoImport, Settings (+ Settings/ tab VMs), Modals/ — About, DiffViewer (+ DiffModels), ListSettings, Merge, RepoImport,
UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree, Settings (+ Settings/ tab VMs), UnfinishedPlanning, WeeklyReport,
WorktreesOverview, UnifiedDiffParser WorkerConnection, WorktreesOverview, UnifiedDiffParser
Planning/ — PlanningDiffViewModel
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock) Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar, Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView 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`. - **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`. - **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. - **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<AttachmentRowViewModel>`), `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<AttachmentRowViewModel>`), `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). - **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`. - **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`). - **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`. - **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.
- **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`). - **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 ## Services

View File

@@ -45,9 +45,8 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
public bool ShowMergeSection => public bool ShowMergeSection =>
_worktreePath != null || _isPlanningParent || _hasChildOutcomes; _worktreePath != null || _isPlanningParent || _hasChildOutcomes;
public Func<DiffModalViewModel, System.Threading.Tasks.Task>? ShowDiffModal { get; set; } public Func<DiffViewerViewModel, System.Threading.Tasks.Task>? ShowDiffViewer { get; set; }
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; } public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
public Func<ViewModels.Planning.PlanningDiffViewModel, System.Threading.Tasks.Task>? ShowPlanningDiffModal { get; set; }
public MergeSectionViewModel(IWorkerClient worker, IServiceProvider services) public MergeSectionViewModel(IWorkerClient worker, IServiceProvider services)
{ {
@@ -125,10 +124,11 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanReviewDiff))] [RelayCommand(CanExecute = nameof(CanReviewDiff))]
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync() private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
{ {
if (TaskId is null || ShowPlanningDiffModal is null) return; if (TaskId is null || ShowDiffViewer is null) return;
var vm = new ViewModels.Planning.PlanningDiffViewModel(_worker, TaskId, SelectedMergeTarget ?? "main"); var vm = _services.GetRequiredService<DiffViewerViewModel>();
await vm.InitializeAsync(); vm.ConfigurePlanning(TaskId, SelectedMergeTarget ?? "main");
await ShowPlanningDiffModal(vm); await vm.LoadAsync();
await ShowDiffViewer(vm);
} }
private bool CanReviewDiff() => (_isPlanningParent && _subtaskCount > 0) || _hasChildOutcomes; private bool CanReviewDiff() => (_isPlanningParent && _subtaskCount > 0) || _hasChildOutcomes;
@@ -136,43 +136,28 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanOpenDiff))] [RelayCommand(CanExecute = nameof(CanOpenDiff))]
private async System.Threading.Tasks.Task OpenDiffAsync() private async System.Threading.Tasks.Task OpenDiffAsync()
{ {
if (ShowDiffModal is null) return; if (ShowDiffViewer is null) return;
var git = _services.GetRequiredService<ClaudeDo.Data.Git.GitService>();
var hasLiveWorktree = var hasLiveWorktree =
_worktreePath != null _worktreePath != null
&& _worktreeStateLabel == "Active" && _worktreeStateLabel == "Active"
&& System.IO.Directory.Exists(_worktreePath); && System.IO.Directory.Exists(_worktreePath);
DiffModalViewModel diffVm; var vm = _services.GetRequiredService<DiffViewerViewModel>();
if (hasLiveWorktree) if (hasLiveWorktree)
{ {
diffVm = new DiffModalViewModel(git) vm.ConfigureWorktree(_worktreePath!, _worktreeBaseCommit, TaskId, TaskTitle ?? "");
{ vm.ShowMergeModal = ShowMergeModal;
WorktreePath = _worktreePath!, vm.ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>();
BaseRef = _worktreeBaseCommit,
TaskId = TaskId,
TaskTitle = TaskTitle ?? "",
ShowMergeModal = ShowMergeModal,
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
};
} }
else if (CanDiffMergedRange) else if (CanDiffMergedRange)
{ {
diffVm = new DiffModalViewModel(git) vm.ConfigureCommitRange(_listWorkingDir!, _worktreeBaseCommit, _worktreeHeadCommit, TaskId, TaskTitle ?? "");
{
WorktreePath = _listWorkingDir!,
BaseRef = _worktreeBaseCommit,
HeadCommit = _worktreeHeadCommit,
FromCommitRange = true,
TaskId = TaskId,
TaskTitle = TaskTitle ?? "",
};
} }
else return; else return;
await diffVm.LoadAsync(); await vm.LoadAsync();
await ShowDiffModal(diffVm); await ShowDiffViewer(vm);
} }
private bool CanDiffMergedRange => private bool CanDiffMergedRange =>

View File

@@ -10,7 +10,6 @@ using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.ViewModels.Planning;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels; namespace ClaudeDo.Ui.ViewModels;

View File

@@ -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<DiffLineViewModel> 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 <see cref="FromCommitRange"/>, the diff is computed as
/// <c>BaseRef..HeadCommit</c> inside <see cref="WorktreePath"/> (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<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
public ObservableCollection<DiffFileViewModel> 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");
}
}

View File

@@ -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<DiffLineViewModel> 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 <see cref="DiffFileViewModel"/> 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<DiffTreeNodeViewModel> 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<DiffTreeNodeViewModel> Build(IEnumerable<DiffFileViewModel> files)
{
var roots = new List<DiffTreeNodeViewModel>();
var dirs = new Dictionary<string, DiffTreeNodeViewModel>(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<DiffTreeNodeViewModel> 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;
}
}

View File

@@ -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 }
/// <summary>
/// One read-only diff viewer replacing DiffModal + WorktreeModal + PlanningDiff.
/// <see cref="DiffViewerMode.Files"/> sources (dirty worktree / branch-vs-base / commit
/// range) load the whole diff via <see cref="GitService"/> and present a folder tree;
/// <see cref="DiffViewerMode.Planning"/> 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.
/// </summary>
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<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
// ── Planning-source config ──────────────────────────────────────────────
private string? _planningTaskId;
private string _targetBranch = "";
// ── Left pane ───────────────────────────────────────────────────────────
public ObservableCollection<DiffTreeNodeViewModel> FileTree { get; } = new();
public ObservableCollection<SubtaskDiffRow> Subtasks { get; } = new();
[ObservableProperty] private DiffTreeNodeViewModel? _selectedNode;
[ObservableProperty] private SubtaskDiffRow? _selectedSubtask;
// ── Right pane ──────────────────────────────────────────────────────────
[ObservableProperty] private DiffFileViewModel? _selectedFile; // Files mode
public ObservableCollection<DiffLineViewModel> 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();
}
}

View File

@@ -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<WorktreeNodeViewModel> Children { get; } = new();
[ObservableProperty] private bool _isExpanded = true;
}
public sealed partial class WorktreeModalViewModel : ViewModelBase
{
private readonly GitService _git;
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
public ObservableCollection<DiffLineViewModel> 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<string, WorktreeNodeViewModel>(StringComparer.Ordinal);
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
string? path;
string? status;
if (committedMode)
{
// diff --name-status format: <status>\t<path>
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: XY<space>path
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<WorktreeNodeViewModel> 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;
}
}

View File

@@ -61,7 +61,7 @@ public sealed partial class WorktreesGroupViewModel : ViewModelBase
public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
{ {
private readonly IWorkerClient _worker; private readonly IWorkerClient _worker;
private readonly Func<WorktreeModalViewModel> _diffVmFactory; private readonly Func<DiffViewerViewModel> _diffVmFactory;
private readonly IMergeCoordinator _merge; private readonly IMergeCoordinator _merge;
[ObservableProperty] private string? _listIdFilter; [ObservableProperty] private string? _listIdFilter;
@@ -81,13 +81,13 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new(); public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
public Action? CloseAction { get; set; } public Action? CloseAction { get; set; }
public Action<WorktreeModalViewModel>? ShowDiffAction { get; set; } public Action<DiffViewerViewModel>? ShowDiffAction { get; set; }
public Action<string, string>? JumpToTaskAction { get; set; } public Action<string, string>? JumpToTaskAction { get; set; }
public Func<string, Task<bool>>? ConfirmAction { get; set; } public Func<string, Task<bool>>? ConfirmAction { get; set; }
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; } public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
public Func<MergeModalViewModel, Task>? ShowMergeAction { get; set; } public Func<MergeModalViewModel, Task>? ShowMergeAction { get; set; }
public WorktreesOverviewModalViewModel(IWorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory, IMergeCoordinator merge) public WorktreesOverviewModalViewModel(IWorkerClient worker, Func<DiffViewerViewModel> diffVmFactory, IMergeCoordinator merge)
{ {
_worker = worker; _worker = worker;
_diffVmFactory = diffVmFactory; _diffVmFactory = diffVmFactory;
@@ -177,8 +177,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
{ {
if (row is null) return; if (row is null) return;
var diffVm = _diffVmFactory(); var diffVm = _diffVmFactory();
diffVm.WorktreePath = row.Path; diffVm.ConfigureWorktree(row.Path, row.BaseCommit);
diffVm.BaseCommit = string.IsNullOrEmpty(row.BaseCommit) ? null : row.BaseCommit;
ShowDiffAction?.Invoke(diffVm); ShowDiffAction?.Invoke(diffVm);
} }

View File

@@ -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<SubtaskDiffRow> Subtasks { get; } = new();
public ObservableCollection<DiffLineViewModel> 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);

View File

@@ -8,7 +8,6 @@ using Avalonia.Platform.Storage;
using Avalonia.Reactive; using Avalonia.Reactive;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.Views.Modals; using ClaudeDo.Ui.Views.Modals;
using ClaudeDo.Ui.Views.Planning;
namespace ClaudeDo.Ui.Views.Islands; namespace ClaudeDo.Ui.Views.Islands;
@@ -129,11 +128,11 @@ public partial class DetailsIslandView : UserControl
vm.PropertyChanged += OnViewModelPropertyChanged; vm.PropertyChanged += OnViewModelPropertyChanged;
ApplyResizeStateForCurrentTask(); ApplyResizeStateForCurrentTask();
vm.Merge.ShowDiffModal = async (diffVm) => vm.Merge.ShowDiffViewer = async (diffVm) =>
{ {
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return; if (owner == null) return;
var modal = new DiffModalView { DataContext = diffVm }; var modal = new DiffViewerView { DataContext = diffVm };
await modal.ShowDialog(owner); await modal.ShowDialog(owner);
}; };
@@ -145,14 +144,6 @@ public partial class DetailsIslandView : UserControl
await modal.ShowDialog(owner); 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.ConfirmAsync = ShowConfirmAsync;
vm.ShowErrorAsync = ShowErrorDialogAsync; vm.ShowErrorAsync = ShowErrorDialogAsync;
} }

View File

@@ -8,7 +8,6 @@ using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.Views.Modals; using ClaudeDo.Ui.Views.Modals;
using ClaudeDo.Ui.Views.Planning;
namespace ClaudeDo.Ui.Views; namespace ClaudeDo.Ui.Views;

View File

@@ -1,125 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Modals.DiffModalView"
x:DataType="vm:DiffModalViewModel"
Title="{loc:Tr modals.diff.windowTitle}"
Width="1200" Height="800" MinWidth="700" MinHeight="450"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
<ctl:ModalShell Title="{loc:Tr modals.diff.title}" CloseCommand="{Binding CloseCommand}">
<ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Classes="btn" Content="{loc:Tr modals.diff.merge}" Command="{Binding MergeCommand}"/>
</StackPanel>
</ctl:ModalShell.Footer>
<!-- Body: two islands — file list | diff content -->
<Grid ColumnDefinitions="280,12,*" Margin="16">
<!-- Files island -->
<Border Grid.Column="0" Classes="island">
<DockPanel>
<Border DockPanel.Dock="Top" Classes="island-header">
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.diff.filesHeader}"/>
</Border>
<ListBox ItemsSource="{Binding Files}"
SelectedItem="{Binding SelectedFile, Mode=TwoWay}"
Background="Transparent"
BorderThickness="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:DiffFileViewModel">
<Border Padding="10,8" Background="Transparent">
<StackPanel Spacing="4">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Tag="{Binding StatusCode}"
CornerRadius="3" Padding="4,0" Margin="0,0,8,0"
VerticalAlignment="Center">
<TextBlock Text="{Binding StatusCode}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeEyebrow}"
Foreground="{DynamicResource TextBrush}"/>
</Border>
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding Path}"
VerticalAlignment="Center"
TextTrimming="PrefixCharacterEllipsis"/>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="6"
IsVisible="{Binding !IsBinary}">
<Border Classes="chip" Padding="5,2">
<TextBlock Foreground="{DynamicResource MossBrightBrush}"
Text="{Binding Additions, StringFormat='+{0}'}"/>
</Border>
<Border Classes="chip" Padding="5,2">
<TextBlock Foreground="{DynamicResource BloodBrush}"
Text="{Binding Deletions, StringFormat='{0}'}"/>
</Border>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<!-- Diff content island -->
<Border Grid.Column="2" Classes="island">
<DockPanel>
<Border DockPanel.Dock="Top" Classes="island-header"
IsVisible="{Binding SelectedFile, Converter={x:Static ObjectConverters.IsNotNull}}">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Tag="{Binding SelectedFile.StatusCode}"
CornerRadius="3" Padding="4,0" Margin="0,0,8,0"
VerticalAlignment="Center">
<TextBlock Text="{Binding SelectedFile.StatusCode}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeEyebrow}"
Foreground="{DynamicResource TextBrush}"/>
</Border>
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding SelectedFile.Path}"
VerticalAlignment="Center"
TextTrimming="PrefixCharacterEllipsis"/>
</Grid>
</Border>
<Grid Background="{DynamicResource VoidBrush}">
<!-- Load / no-changes message -->
<TextBlock Classes="body" Text="{Binding StatusMessage}"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Binary file -->
<TextBlock Classes="body" Text="{loc:Tr modals.diff.binary}"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding SelectedFile.IsBinary}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Empty / no-content file -->
<TextBlock Classes="body" Text="{loc:Tr modals.diff.empty}"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding SelectedFile.IsEmptyContent}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Diff content -->
<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
IsVisible="{Binding SelectedFile.HasLines}">
<ctl:DiffLinesView Lines="{Binding SelectedFile.Lines}"/>
</ScrollViewer>
</Grid>
</DockPanel>
</Border>
</Grid>
</ctl:ModalShell>
</Window>

View File

@@ -1,45 +0,0 @@
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Styling;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class DiffModalView : Window
{
public DiffModalView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is DiffModalViewModel vm)
vm.CloseAction = Close;
}
protected override async void OnOpened(EventArgs e)
{
base.OnOpened(e);
Opacity = 0;
RenderTransform = new ScaleTransform(0.98, 0.98);
var anim = new Avalonia.Animation.Animation
{
Duration = TimeSpan.FromMilliseconds(180),
Easing = new CubicEaseOut(),
FillMode = FillMode.Forward,
Children =
{
new KeyFrame { Cue = new Cue(0), Setters = { new Setter(OpacityProperty, 0d) } },
new KeyFrame { Cue = new Cue(1), Setters = { new Setter(OpacityProperty, 1d) } },
}
};
await anim.RunAsync(this);
Opacity = 1;
RenderTransform = new ScaleTransform(1.0, 1.0);
}
}

View File

@@ -0,0 +1,171 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Modals.DiffViewerView"
x:DataType="vm:DiffViewerViewModel"
Title="{loc:Tr modals.diff.windowTitle}"
Width="1200" Height="800" MinWidth="700" MinHeight="450"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
<ctl:ModalShell Title="{loc:Tr modals.diff.title}" CloseCommand="{Binding CloseCommand}">
<ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Classes="btn" Content="{loc:Tr modals.diff.merge}"
Command="{Binding MergeCommand}"
IsVisible="{Binding ShowMerge}"/>
</StackPanel>
</ctl:ModalShell.Footer>
<DockPanel>
<!-- Planning toolbar: combined-mode toggle + warning/loading -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="16,8,16,0"
IsVisible="{Binding IsPlanning}">
<ToggleButton Content="{loc:Tr planning.diff.previewCombined}" IsChecked="{Binding IsCombinedMode}"/>
<TextBlock Text="{Binding CombinedWarning}"
Foreground="{DynamicResource BloodBrush}"
VerticalAlignment="Center"
IsVisible="{Binding CombinedWarning, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<TextBlock Text="{loc:Tr planning.diff.loading}"
VerticalAlignment="Center"
IsVisible="{Binding IsLoadingCombined}"/>
</StackPanel>
<!-- Body: nav | splitter | diff -->
<Grid ColumnDefinitions="280,4,*" Margin="16">
<!-- Left nav: file tree (Files) OR subtask list (Planning) -->
<Border Grid.Column="0" Classes="island">
<DockPanel>
<Border DockPanel.Dock="Top" Classes="island-header" IsVisible="{Binding !IsPlanning}">
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.diff.filesHeader}"/>
</Border>
<Panel>
<!-- Files: folder tree -->
<TreeView x:Name="FileTree"
ItemsSource="{Binding FileTree}"
SelectedItem="{Binding SelectedNode, Mode=TwoWay}"
Background="Transparent"
BorderThickness="0"
IsVisible="{Binding !IsPlanning}">
<TreeView.Styles>
<Style Selector="TreeViewItem" x:DataType="vm:DiffTreeNodeViewModel">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}"/>
</Style>
</TreeView.Styles>
<TreeView.ItemTemplate>
<TreeDataTemplate DataType="vm:DiffTreeNodeViewModel" ItemsSource="{Binding Children}">
<Border Background="Transparent" Tapped="OnNodeTapped" Cursor="Hand" Padding="0,2">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Tag="{Binding StatusCode}" CornerRadius="3" Padding="4,0" Margin="0,0,6,0"
VerticalAlignment="Center"
IsVisible="{Binding StatusCode, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding StatusCode}"
FontFamily="{DynamicResource MonoFont}" FontSize="{StaticResource FontSizeEyebrow}"
Foreground="{DynamicResource TextBrush}"/>
</Border>
<TextBlock Grid.Column="1" Classes="meta" Text="{Binding Name}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center"
IsVisible="{Binding ShowStats}">
<TextBlock Foreground="{DynamicResource MossBrightBrush}" FontSize="{StaticResource FontSizeEyebrow}"
Text="{Binding Additions, StringFormat='+{0}'}"/>
<TextBlock Foreground="{DynamicResource BloodBrush}" FontSize="{StaticResource FontSizeEyebrow}"
Text="{Binding Deletions, StringFormat='{0}'}"/>
</StackPanel>
</Grid>
</Border>
</TreeDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
<!-- Planning: subtask list -->
<ListBox ItemsSource="{Binding Subtasks}"
SelectedItem="{Binding SelectedSubtask}"
IsEnabled="{Binding !IsCombinedMode}"
IsVisible="{Binding IsPlanning}"
Background="Transparent"
BorderThickness="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SubtaskDiffRow">
<Border Padding="10,8" Background="Transparent">
<StackPanel Spacing="2">
<TextBlock Classes="title" Text="{Binding Title}" TextTrimming="CharacterEllipsis"/>
<TextBlock Classes="meta" Text="{Binding DiffStat}"/>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Panel>
</DockPanel>
</Border>
<GridSplitter Grid.Column="1" ResizeDirection="Columns" Background="{DynamicResource LineBrush}"/>
<!-- Right: diff content (Files per-file pane OR Planning flat diff) -->
<Border Grid.Column="2" Classes="island">
<Panel>
<!-- Files mode: per-file pane with header + placeholders -->
<DockPanel IsVisible="{Binding !IsPlanning}">
<Border DockPanel.Dock="Top" Classes="island-header"
IsVisible="{Binding SelectedFile, Converter={x:Static ObjectConverters.IsNotNull}}">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Tag="{Binding SelectedFile.StatusCode}"
CornerRadius="3" Padding="4,0" Margin="0,0,8,0" VerticalAlignment="Center">
<TextBlock Text="{Binding SelectedFile.StatusCode}"
FontFamily="{DynamicResource MonoFont}" FontSize="{StaticResource FontSizeEyebrow}"
Foreground="{DynamicResource TextBrush}"/>
</Border>
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding SelectedFile.Path}"
VerticalAlignment="Center" TextTrimming="PrefixCharacterEllipsis"/>
</Grid>
</Border>
<Grid Background="{DynamicResource VoidBrush}">
<TextBlock Classes="body" Text="{Binding StatusMessage}"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Classes="body" Text="{loc:Tr modals.diff.binary}"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding SelectedFile.IsBinary}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Classes="body" Text="{loc:Tr modals.diff.empty}"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding SelectedFile.IsEmptyContent}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
IsVisible="{Binding SelectedFile.HasLines}">
<ctl:DiffLinesView Lines="{Binding SelectedFile.Lines}"/>
</ScrollViewer>
</Grid>
</DockPanel>
<!-- Planning mode: flat aggregate/combined diff -->
<Grid Background="{DynamicResource VoidBrush}" IsVisible="{Binding IsPlanning}">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
<ctl:DiffLinesView Lines="{Binding DiffLines}"/>
</ScrollViewer>
</Grid>
</Panel>
</Border>
</Grid>
</DockPanel>
</ctl:ModalShell>
</Window>

View File

@@ -0,0 +1,41 @@
using Avalonia.Controls;
using Avalonia.Input;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class DiffViewerView : Window
{
public DiffViewerView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is DiffViewerViewModel vm)
vm.CloseAction = Close;
// SelectedItem TwoWay binding can miss on Avalonia 12 TreeView — back it
// up with SelectionChanged.
var tree = this.FindControl<TreeView>("FileTree");
if (tree is not null)
tree.SelectionChanged += OnFileTreeSelectionChanged;
}
private void OnFileTreeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (DataContext is DiffViewerViewModel vm && sender is TreeView tree)
vm.SelectedNode = tree.SelectedItem as DiffTreeNodeViewModel;
}
private void OnNodeTapped(object? sender, TappedEventArgs e)
{
if (sender is not Control c) return;
if (c.DataContext is not DiffTreeNodeViewModel node) return;
if (!node.IsDirectory) return;
node.IsExpanded = !node.IsExpanded;
e.Handled = true;
}
}

View File

@@ -1,96 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
xmlns:converters="using:ClaudeDo.Ui.Converters"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Modals.WorktreeModalView"
x:DataType="vm:WorktreeModalViewModel"
Title="{loc:Tr modals.worktree.title}"
Width="1100" Height="720"
MinWidth="640" MinHeight="400"
WindowStartupLocation="CenterOwner"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
Background="Transparent"
CanResize="True"
TransparencyLevelHint="AcrylicBlur">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
<Border Classes="island" Margin="12">
<DockPanel>
<!-- Title strip -->
<Border DockPanel.Dock="Top" Height="36"
Background="Transparent"
PointerPressed="OnTitleBarPressed"
PointerMoved="OnTitleBarMoved"
PointerReleased="OnTitleBarReleased">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Grid.Column="0" Text="{loc:Tr modals.worktree.title}" VerticalAlignment="Center"
FontFamily="{DynamicResource MonoFont}" FontSize="{StaticResource FontSizeBody}"
Foreground="{DynamicResource TextMuteBrush}"/>
<Button Grid.Column="1" Classes="icon-btn" Content="✕"
Command="{Binding CloseCommand}" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Path strip -->
<Border DockPanel.Dock="Top" Padding="14,0,14,8">
<TextBlock Classes="path-mono" Text="{Binding WorktreePath}"/>
</Border>
<!-- Split: file tree | splitter | diff pane -->
<Grid ColumnDefinitions="260,4,*">
<!-- Left: file tree -->
<TreeView x:Name="FileTree"
Grid.Column="0"
ItemsSource="{Binding Root}"
SelectedItem="{Binding SelectedNode, Mode=TwoWay}"
Background="Transparent"
Margin="8,0,4,8">
<TreeView.Styles>
<Style Selector="TreeViewItem" x:DataType="vm:WorktreeNodeViewModel">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}"/>
</Style>
</TreeView.Styles>
<TreeView.ItemTemplate>
<TreeDataTemplate DataType="vm:WorktreeNodeViewModel"
ItemsSource="{Binding Children}">
<Border Background="Transparent" Tapped="OnNodeTapped" Cursor="Hand">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="meta" Text="{Binding Name}"/>
<Border Tag="{Binding Status}" CornerRadius="3" Padding="4,0"
VerticalAlignment="Center"
IsVisible="{Binding Status, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding Status}"
FontFamily="{DynamicResource MonoFont}" FontSize="{StaticResource FontSizeEyebrow}"
Foreground="{DynamicResource TextBrush}"/>
</Border>
</StackPanel>
</Border>
</TreeDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
<!-- Splitter -->
<GridSplitter Grid.Column="1" ResizeDirection="Columns" Background="{DynamicResource LineBrush}"/>
<!-- Right: diff content -->
<ScrollViewer Grid.Column="2" Padding="8"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Margin="4,0,8,8">
<ctl:DiffLinesView Lines="{Binding SelectedFileDiffLines}"/>
</ScrollViewer>
</Grid>
</DockPanel>
</Border>
</Window>

View File

@@ -1,96 +0,0 @@
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class WorktreeModalView : Window
{
public WorktreeModalView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is WorktreeModalViewModel vm)
vm.CloseAction = Close;
// Wire TreeView selection — SelectedItem TwoWay binding may not fire
// reliably in Avalonia 12 for TreeView; use SelectionChanged as backup.
var tree = this.FindControl<TreeView>("FileTree");
if (tree is not null)
tree.SelectionChanged += OnFileTreeSelectionChanged;
}
private void OnFileTreeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (DataContext is WorktreeModalViewModel vm && sender is TreeView tree)
vm.SelectedNode = tree.SelectedItem as WorktreeNodeViewModel;
}
protected override async void OnOpened(EventArgs e)
{
base.OnOpened(e);
Opacity = 0;
RenderTransform = new ScaleTransform(0.98, 0.98);
var anim = new Avalonia.Animation.Animation
{
Duration = TimeSpan.FromMilliseconds(180),
Easing = new CubicEaseOut(),
FillMode = FillMode.Forward,
Children =
{
new KeyFrame { Cue = new Cue(0), Setters = { new Setter(OpacityProperty, 0d) } },
new KeyFrame { Cue = new Cue(1), Setters = { new Setter(OpacityProperty, 1d) } },
}
};
await anim.RunAsync(this);
Opacity = 1;
RenderTransform = new ScaleTransform(1.0, 1.0);
}
private void OnNodeTapped(object? sender, Avalonia.Input.TappedEventArgs e)
{
if (sender is not Control c) return;
if (c.DataContext is not WorktreeNodeViewModel node) return;
if (!node.IsDirectory) return;
node.IsExpanded = !node.IsExpanded;
e.Handled = true;
}
private PixelPoint _dragStartScreen;
private PixelPoint _dragStartPos;
private bool _dragging;
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
_dragStartScreen = this.PointToScreen(e.GetPosition(this));
_dragStartPos = Position;
_dragging = true;
e.Pointer.Capture(sender as IInputElement);
}
private void OnTitleBarMoved(object? sender, PointerEventArgs e)
{
if (!_dragging || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
var cur = this.PointToScreen(e.GetPosition(this));
Position = new PixelPoint(
_dragStartPos.X + (cur.X - _dragStartScreen.X),
_dragStartPos.Y + (cur.Y - _dragStartScreen.Y));
}
private void OnTitleBarReleased(object? sender, PointerReleasedEventArgs e)
{
_dragging = false;
e.Pointer.Capture(null);
}
}

View File

@@ -1,77 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Planning"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Planning.PlanningDiffView"
x:DataType="vm:PlanningDiffViewModel"
Title="{loc:Tr planning.diff.windowTitle}"
Width="1100" Height="700" MinWidth="700" MinHeight="450"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
<ctl:ModalShell Title="{loc:Tr planning.diff.modalTitle}" CloseCommand="{Binding CloseCommand}">
<!-- Toolbar row -->
<DockPanel>
<StackPanel DockPanel.Dock="Top"
Orientation="Horizontal"
Spacing="8"
Margin="8,6">
<ToggleButton Content="{loc:Tr planning.diff.previewCombined}" IsChecked="{Binding IsCombinedMode}"/>
<TextBlock Text="{Binding CombinedWarning}"
Foreground="{DynamicResource BloodBrush}"
VerticalAlignment="Center"
IsVisible="{Binding CombinedWarning, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<TextBlock Text="{loc:Tr planning.diff.loading}"
VerticalAlignment="Center"
IsVisible="{Binding IsLoadingCombined}"/>
</StackPanel>
<!-- Two-pane body -->
<Grid ColumnDefinitions="240,*">
<!-- Subtask list (left pane) -->
<Border Grid.Column="0"
Classes="sidebar-pane">
<ListBox ItemsSource="{Binding Subtasks}"
SelectedItem="{Binding SelectedSubtask}"
IsEnabled="{Binding !IsCombinedMode}"
Background="Transparent"
BorderThickness="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SubtaskDiffRow">
<Border Padding="10,8" Background="Transparent">
<StackPanel Spacing="2">
<TextBlock Classes="title" Text="{Binding Title}"
TextTrimming="CharacterEllipsis"/>
<TextBlock Classes="meta" Text="{Binding DiffStat}"/>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Diff content (right pane) -->
<Grid Grid.Column="1" Background="{DynamicResource VoidBrush}">
<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<ctl:DiffLinesView Lines="{Binding DiffLines}"/>
</ScrollViewer>
</Grid>
</Grid>
</DockPanel>
</ctl:ModalShell>
</Window>

View File

@@ -1,19 +0,0 @@
using Avalonia.Controls;
using ClaudeDo.Ui.ViewModels.Planning;
namespace ClaudeDo.Ui.Views.Planning;
public partial class PlanningDiffView : Window
{
public PlanningDiffView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is PlanningDiffViewModel vm)
vm.CloseAction = Close;
}
}

View File

@@ -73,7 +73,7 @@ public sealed class WindowDialogService : IDialogService
}; };
vm.ShowDiffAction = diffVm => vm.ShowDiffAction = diffVm =>
{ {
var diffDlg = new WorktreeModalView { DataContext = diffVm }; var diffDlg = new DiffViewerView { DataContext = diffVm };
diffVm.CloseAction = () => diffDlg.Close(); diffVm.CloseAction = () => diffDlg.Close();
_ = diffVm.LoadAsync(); _ = diffVm.LoadAsync();
_ = diffDlg.ShowDialog(_owner); _ = diffDlg.ShowDialog(_owner);

View File

@@ -1,54 +0,0 @@
using System.IO;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class DiffModalViewModelTests
{
public DiffModalViewModelTests()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
dir = Path.GetDirectoryName(dir);
Loc.Current = new Localizer(
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
}
[Fact]
public async Task LoadAsync_CommitRange_NullHeadCommit_ShowsUnavailableState()
{
var vm = new DiffModalViewModel(null!)
{
WorktreePath = "/some/repo",
BaseRef = "abc123",
HeadCommit = null,
FromCommitRange = true,
};
await vm.LoadAsync();
Assert.Empty(vm.Files);
Assert.NotNull(vm.StatusMessage);
Assert.Contains("no longer available", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task LoadAsync_CommitRange_NullBaseRef_ShowsUnavailableState()
{
var vm = new DiffModalViewModel(null!)
{
WorktreePath = "/some/repo",
BaseRef = null,
HeadCommit = "def456",
FromCommitRange = true,
};
await vm.LoadAsync();
Assert.Empty(vm.Files);
Assert.NotNull(vm.StatusMessage);
Assert.Contains("no longer available", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,196 @@
using System.IO;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class DiffViewerViewModelTests
{
public DiffViewerViewModelTests()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
dir = Path.GetDirectoryName(dir);
Loc.Current = new Localizer(
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
}
private sealed class FakePlanningWorker : StubWorkerClient
{
public IReadOnlyList<SubtaskDiffDto> AggregateResult { get; set; } = Array.Empty<SubtaskDiffDto>();
public CombinedDiffResultDto? CombinedResult { get; set; }
public override Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) =>
Task.FromResult(AggregateResult);
public override Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) =>
Task.FromResult(CombinedResult);
}
// ── Files mode: commit-range guards (ported from DiffModal) ──
[Fact]
public async Task CommitRange_NullHeadCommit_ShowsUnavailable()
{
var vm = new DiffViewerViewModel(null!, new FakePlanningWorker());
vm.ConfigureCommitRange("/some/repo", "abc123", null);
await vm.LoadAsync();
Assert.Empty(vm.FileTree);
Assert.NotNull(vm.StatusMessage);
Assert.Contains("no longer available", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task CommitRange_NullBaseRef_ShowsUnavailable()
{
var vm = new DiffViewerViewModel(null!, new FakePlanningWorker());
vm.ConfigureCommitRange("/some/repo", null, "def456");
await vm.LoadAsync();
Assert.Empty(vm.FileTree);
Assert.NotNull(vm.StatusMessage);
Assert.Contains("no longer available", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
// ── File tree building ──
[Fact]
public void DiffTree_Build_GroupsByFolder_AndCarriesFileLeaves()
{
var files = new List<DiffFileViewModel>
{
new() { Path = "src/a/x.cs", Additions = 3, Deletions = 1 },
new() { Path = "src/a/y.cs" },
new() { Path = "readme.md", Status = DiffFileStatus.Added },
};
var roots = DiffTree.Build(files);
Assert.Equal(2, roots.Count); // "src" dir + "readme.md" leaf
var src = roots.First(n => n is { IsDirectory: true, Name: "src" });
var a = Assert.Single(src.Children);
Assert.True(a is { IsDirectory: true, Name: "a" });
Assert.Equal(2, a.Children.Count);
var x = a.Children.First(n => n.Name == "x.cs");
Assert.False(x.IsDirectory);
Assert.NotNull(x.File);
Assert.Equal(3, x.Additions);
var readme = roots.First(n => n is { IsDirectory: false, Name: "readme.md" });
Assert.Equal("A", readme.StatusCode);
// First leaf walks depth-first in insertion order → src/a/x.cs.
Assert.Equal(x, DiffTree.FirstLeaf(roots));
}
// ── Planning mode (ported from PlanningDiff) ──
[Fact]
public async Task Planning_Load_PopulatesSubtasks_SelectsFirst()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "branch-1", "base1", "head1", "+1 -0", "diff1"),
new SubtaskDiffDto("s2", "Second", "branch-2", "base2", "head2", "+2 -1", "diff2"),
}
};
var vm = new DiffViewerViewModel(null!, fake);
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
Assert.Equal(2, vm.Subtasks.Count);
Assert.Equal(vm.Subtasks[0], vm.SelectedSubtask);
}
[Fact]
public async Task Planning_SelectSubtask_SetsDisplayedDiff()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
new SubtaskDiffDto("s2", "Second", "b2", "base2", "head2", null, "DIFF-B"),
}
};
var vm = new DiffViewerViewModel(null!, fake);
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
vm.SelectedSubtask = vm.Subtasks[1];
Assert.Equal("DIFF-B", vm.DisplayedDiff);
}
[Fact]
public async Task Planning_ToggleCombined_Success_DisplaysUnifiedDiff()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedResult = new CombinedDiffResultDto(true, "integration-branch", "COMBINED-DIFF", null, null),
};
var vm = new DiffViewerViewModel(null!, fake);
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
vm.IsCombinedMode = true;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined) await Task.Delay(10);
Assert.Equal("COMBINED-DIFF", vm.DisplayedDiff);
Assert.Null(vm.CombinedWarning);
}
[Fact]
public async Task Planning_ToggleCombined_Conflict_ShowsWarning()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedResult = new CombinedDiffResultDto(false, null, null, "subtask-42", new[] { "a.cs", "b.cs" }),
};
var vm = new DiffViewerViewModel(null!, fake);
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
vm.IsCombinedMode = true;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined) await Task.Delay(10);
Assert.NotNull(vm.CombinedWarning);
Assert.Contains("subtask-42", vm.CombinedWarning);
Assert.Contains("2 files", vm.CombinedWarning);
}
[Fact]
public async Task Planning_ToggleCombined_HubReturnsNull_ShowsError()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedResult = null,
};
var vm = new DiffViewerViewModel(null!, fake);
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
vm.IsCombinedMode = true;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined) await Task.Delay(10);
Assert.NotNull(vm.CombinedWarning);
Assert.NotEmpty(vm.CombinedWarning!);
}
}

View File

@@ -1,143 +0,0 @@
using System.IO;
using ClaudeDo.Data.Models;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Planning;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class PlanningDiffViewModelTests
{
public PlanningDiffViewModelTests()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
dir = Path.GetDirectoryName(dir);
Loc.Current = new Localizer(
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
}
private sealed class FakePlanningWorker : StubWorkerClient
{
public IReadOnlyList<SubtaskDiffDto> AggregateResult { get; set; } = Array.Empty<SubtaskDiffDto>();
public CombinedDiffResultDto? CombinedResult { get; set; }
public override Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) =>
Task.FromResult(AggregateResult);
public override Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) =>
Task.FromResult(CombinedResult);
}
[Fact]
public async Task InitializeAsync_PopulatesSubtasks()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "branch-1", "base1", "head1", "+1 -0", "diff1"),
new SubtaskDiffDto("s2", "Second", "branch-2", "base2", "head2", "+2 -1", "diff2"),
}
};
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
await vm.InitializeAsync();
Assert.Equal(2, vm.Subtasks.Count);
Assert.Equal(vm.Subtasks[0], vm.SelectedSubtask);
}
[Fact]
public async Task SelectingSubtask_InGroupedMode_SetsDisplayedDiff()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
new SubtaskDiffDto("s2", "Second", "b2", "base2", "head2", null, "DIFF-B"),
}
};
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
await vm.InitializeAsync();
vm.SelectedSubtask = vm.Subtasks[1];
Assert.Equal("DIFF-B", vm.DisplayedDiff);
}
[Fact]
public async Task ToggleCombined_Success_DisplaysUnifiedDiff()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
},
CombinedResult = new CombinedDiffResultDto(true, "integration-branch", "COMBINED-DIFF", null, null),
};
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
await vm.InitializeAsync();
vm.IsCombinedMode = true;
// Wait for the async toggle command to complete
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined)
await Task.Delay(10);
Assert.Equal("COMBINED-DIFF", vm.DisplayedDiff);
Assert.Null(vm.CombinedWarning);
}
[Fact]
public async Task ToggleCombined_Conflict_ShowsWarning()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
},
CombinedResult = new CombinedDiffResultDto(false, null, null, "subtask-42", new[] { "a.cs", "b.cs" }),
};
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
await vm.InitializeAsync();
vm.IsCombinedMode = true;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined)
await Task.Delay(10);
Assert.NotNull(vm.CombinedWarning);
Assert.Contains("subtask-42", vm.CombinedWarning);
Assert.Contains("2 files", vm.CombinedWarning);
}
[Fact]
public async Task ToggleCombined_HubReturnsNull_ShowsError()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
},
CombinedResult = null,
};
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
await vm.InitializeAsync();
vm.IsCombinedMode = true;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined)
await Task.Delay(10);
Assert.NotNull(vm.CombinedWarning);
Assert.NotEmpty(vm.CombinedWarning!);
}
}