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 }
///
/// One read-only diff viewer replacing DiffModal + WorktreeModal + PlanningDiff.
/// sources (dirty worktree / branch-vs-base / commit
/// range) load the whole diff via and present a folder tree;
/// 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.
///
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? ShowMergeModal { get; set; }
public Func? ResolveMergeVm { get; set; }
// ── Planning-source config ──────────────────────────────────────────────
private string? _planningTaskId;
private string _targetBranch = "";
// ── Left pane ───────────────────────────────────────────────────────────
public ObservableCollection FileTree { get; } = new();
public ObservableCollection 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 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();
}
}