refactor(diff): single DiffViewer replaces DiffModal + WorktreeModal + PlanningDiff
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user