TryInvokeAsync catches every exception and returns null, which is fine
for read-only calls but hid real HubException reasons behind a generic
"offline" message for the 7 mutating call sites (RestoreDefaultAgents,
UpsertPrimeSchedule, AddDailyNote, CleanupFinishedWorktrees,
ResetAllWorktrees, ForceRemoveWorktree, BuildPlanningIntegrationBranch)
— the same bug class fixed for ApproveReview in e1807fd. Each of the 22
TryInvokeAsync call sites was audited; the 15 read-only ones are left
unchanged (empty/offline is the right display). For the 7 switched to
a direct hub invoke, every caller was checked and, where it had no
catch, one was added so the exception surfaces (StatusMessage,
ShowErrorAsync/CombinedWarning) instead of crashing.
249 lines
9.7 KiB
C#
249 lines
9.7 KiB
C#
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 = "";
|
|
}
|
|
}
|
|
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);
|
|
|
|
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();
|
|
}
|
|
}
|