get_task_diff gained an optional paths param (git pathspec, both stat and full-diff modes) so a large multi-file task can be narrowed to the files actually in question instead of shipping the whole diff. preview_merge_set now also reports Subsets: pairs where one task's changed files are a proper subset of another's in the same set, the strongest available post-hoc signal that a task may be redundant with another.
287 lines
11 KiB
C#
287 lines
11 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; }
|
|
|
|
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);
|
|
|
|
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);
|
|
}
|
|
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)
|
|
{
|
|
PlanningFiles.Clear();
|
|
foreach (var file in UnifiedDiffParser.Parse(value))
|
|
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();
|
|
}
|
|
}
|