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(); } }