Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs
T
Mika Kuns f6994f32d5 fix(ui): show ParseOp indicator on the planning-mode diff parse too
ParseOp only covered LoadFilesAsync; OnDisplayedDiffChanged's parse (driven
by ToggleCombinedAsync and OnSelectedSubtaskChanged) had no visible
feedback even though the offload was already correct. Both call sites now
go through one ParseOffUiThreadAsync helper (Begin on the UI thread before
Task.Run, End via `using` regardless of a stale generation), so the
already-visible toolbar indicator covers the planning path without
duplicating the begin/offload/end ceremony.
2026-08-12 13:41:05 +02:00

324 lines
13 KiB
C#

using System;
using System.Collections.ObjectModel;
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data.Git;
using ClaudeDo.Ui;
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;
private readonly AppSettings _settings;
[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
// Planning mode: one entry per file so each gets its own editor and grammar.
public ObservableCollection<DiffFileViewModel> PlanningFiles { get; } = new();
[ObservableProperty] private string _displayedDiff = "";
[ObservableProperty] private string? _statusMessage;
// ── View toggles (persisted to ui.config.json) ──────────────────────────
[ObservableProperty] private bool _isSplitView;
[ObservableProperty] private bool _wrapLines;
// The layout picker is a segmented switch, so each segment sets its own mode rather than
// flipping a shared flag — clicking the already-active segment must be a no-op.
[RelayCommand] private void ShowUnified() => IsSplitView = false;
[RelayCommand] private void ShowSplit() => IsSplitView = true;
partial void OnIsSplitViewChanged(bool value)
{
_settings.DiffViewMode = value ? "split" : "unified";
PersistViewPreferences();
}
partial void OnWrapLinesChanged(bool value)
{
_settings.DiffWrapLines = value;
PersistViewPreferences();
}
/// A failed preference write must never take the diff viewer down with it; the toggle
/// still works for this session, it just won't survive a restart.
private void PersistViewPreferences()
{
try { _settings.Save(); }
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
// ── Planning combined toggle ────────────────────────────────────────────
[ObservableProperty] private bool _isCombinedMode;
[ObservableProperty] private string? _combinedWarning;
[ObservableProperty] private bool _isLoadingCombined;
public Action? CloseAction { get; set; }
// Parsing a large diff (UnifiedDiffParser.Parse + DiffTree.Build, both pure CPU work) is
// offloaded to a threadpool thread so it never blocks the UI dispatcher.
public OperationStatus ParseOp { get; } = new();
// Guards the fire-and-forget planning parse below against a stale write: a second
// DisplayedDiff change while the first parse is still running must not clobber PlanningFiles.
private int _planningFilesGeneration;
public DiffViewerViewModel(GitService git, IWorkerClient worker, AppSettings settings)
{
_git = git;
_worker = worker;
_settings = settings;
_isSplitView = string.Equals(settings.DiffViewMode, "split", StringComparison.OrdinalIgnoreCase);
_wrapLines = settings.DiffWrapLines;
}
[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);
// Shared by both parse call sites (LoadFilesAsync, ApplyParsedPlanningFilesAsync) so the
// ParseOp begin/offload/end ceremony isn't duplicated. Begin runs synchronously on the UI
// thread (the caller hasn't awaited anything yet), and the `using` guarantees End runs when
// the offloaded parse finishes — even if the caller's own result turns out to be stale.
private async Task<T> ParseOffUiThreadAsync<T>(Func<T> parse, CancellationToken ct = default)
{
using (ParseOp.Begin(Loc.T("ops.diff.parsing")))
return await Task.Run(parse, 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: ct)
: BaseRef is not null
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct: ct)
: await _git.GetDiffAsync(WorktreePath, ct: 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, tree) = await ParseOffUiThreadAsync(() =>
{
var parsed = UnifiedDiffParser.Parse(raw);
return (parsed, DiffTree.Build(parsed));
}, ct);
foreach (var node in tree)
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 = "";
}
}
catch (Exception ex)
{
DisplayedDiff = "";
CombinedWarning = Loc.T("vm.planningDiff.buildFailed", ex.Message);
}
finally
{
IsLoadingCombined = false;
}
}
else
{
DisplayedDiff = SelectedSubtask?.UnifiedDiff ?? "";
CombinedWarning = null;
}
}
partial void OnIsCombinedModeChanged(bool value) => ToggleCombinedCommand.Execute(null);
// DisplayedDiff is set from four places (OnSelectedSubtaskChanged plus three branches of
// ToggleCombinedAsync), and OnSelectedSubtaskChanged is itself a synchronous, non-awaitable
// callback — funneling the offload through this single change handler covers all of them
// without touching every call site. Fire-and-forget is safe because the generation counter
// below discards a parse whose result is no longer the current DisplayedDiff.
partial void OnDisplayedDiffChanged(string value)
{
var generation = ++_planningFilesGeneration;
_ = ApplyParsedPlanningFilesAsync(value, generation);
}
private async Task ApplyParsedPlanningFilesAsync(string diff, int generation)
{
var files = await ParseOffUiThreadAsync(() => UnifiedDiffParser.Parse(diff));
if (generation != _planningFilesGeneration) return;
PlanningFiles.Clear();
foreach (var file in files)
PlanningFiles.Add(file);
}
// ── 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();
}
}