refactor(diff): single DiffViewer replaces DiffModal + WorktreeModal + PlanningDiff

This commit is contained in:
Mika Kuns
2026-06-23 09:30:37 +02:00
parent 4022bd7197
commit 167d2fec6a
28 changed files with 923 additions and 1120 deletions

View File

@@ -45,9 +45,8 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
public bool ShowMergeSection =>
_worktreePath != null || _isPlanningParent || _hasChildOutcomes;
public Func<DiffModalViewModel, System.Threading.Tasks.Task>? ShowDiffModal { get; set; }
public Func<DiffViewerViewModel, System.Threading.Tasks.Task>? ShowDiffViewer { get; set; }
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
public Func<ViewModels.Planning.PlanningDiffViewModel, System.Threading.Tasks.Task>? ShowPlanningDiffModal { get; set; }
public MergeSectionViewModel(IWorkerClient worker, IServiceProvider services)
{
@@ -125,10 +124,11 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
{
if (TaskId is null || ShowPlanningDiffModal is null) return;
var vm = new ViewModels.Planning.PlanningDiffViewModel(_worker, TaskId, SelectedMergeTarget ?? "main");
await vm.InitializeAsync();
await ShowPlanningDiffModal(vm);
if (TaskId is null || ShowDiffViewer is null) return;
var vm = _services.GetRequiredService<DiffViewerViewModel>();
vm.ConfigurePlanning(TaskId, SelectedMergeTarget ?? "main");
await vm.LoadAsync();
await ShowDiffViewer(vm);
}
private bool CanReviewDiff() => (_isPlanningParent && _subtaskCount > 0) || _hasChildOutcomes;
@@ -136,43 +136,28 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
private async System.Threading.Tasks.Task OpenDiffAsync()
{
if (ShowDiffModal is null) return;
var git = _services.GetRequiredService<ClaudeDo.Data.Git.GitService>();
if (ShowDiffViewer is null) return;
var hasLiveWorktree =
_worktreePath != null
&& _worktreeStateLabel == "Active"
&& System.IO.Directory.Exists(_worktreePath);
DiffModalViewModel diffVm;
var vm = _services.GetRequiredService<DiffViewerViewModel>();
if (hasLiveWorktree)
{
diffVm = new DiffModalViewModel(git)
{
WorktreePath = _worktreePath!,
BaseRef = _worktreeBaseCommit,
TaskId = TaskId,
TaskTitle = TaskTitle ?? "",
ShowMergeModal = ShowMergeModal,
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
};
vm.ConfigureWorktree(_worktreePath!, _worktreeBaseCommit, TaskId, TaskTitle ?? "");
vm.ShowMergeModal = ShowMergeModal;
vm.ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>();
}
else if (CanDiffMergedRange)
{
diffVm = new DiffModalViewModel(git)
{
WorktreePath = _listWorkingDir!,
BaseRef = _worktreeBaseCommit,
HeadCommit = _worktreeHeadCommit,
FromCommitRange = true,
TaskId = TaskId,
TaskTitle = TaskTitle ?? "",
};
vm.ConfigureCommitRange(_listWorkingDir!, _worktreeBaseCommit, _worktreeHeadCommit, TaskId, TaskTitle ?? "");
}
else return;
await diffVm.LoadAsync();
await ShowDiffModal(diffVm);
await vm.LoadAsync();
await ShowDiffViewer(vm);
}
private bool CanDiffMergedRange =>

View File

@@ -10,7 +10,6 @@ using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.ViewModels.Planning;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels;

View File

@@ -1,146 +0,0 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data.Git;
using ClaudeDo.Ui.Localization;
namespace ClaudeDo.Ui.ViewModels.Modals;
public enum DiffLineKind { Add, Del, Ctx, File }
public enum DiffFileStatus { Modified, Added, Deleted, Renamed }
public sealed class DiffLineViewModel
{
public required DiffLineKind Kind { get; init; }
public int? OldNo { get; init; }
public int? NewNo { get; init; }
public required string Text { get; init; }
public string ClassName => Kind switch
{
DiffLineKind.Add => "add",
DiffLineKind.Del => "del",
DiffLineKind.File => "file",
_ => "ctx",
};
public string Sign => Kind switch
{
DiffLineKind.Add => "+",
DiffLineKind.Del => "-",
_ => " ",
};
}
public sealed class DiffFileViewModel
{
public required string Path { get; set; }
public string? OldPath { get; set; }
public DiffFileStatus Status { get; set; } = DiffFileStatus.Modified;
public bool IsBinary { get; set; }
public int Additions { get; set; }
public int Deletions { get; set; }
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
/// Single-letter badge for the file's change kind (A/M/D/R).
public string StatusCode => Status switch
{
DiffFileStatus.Added => "A",
DiffFileStatus.Deleted => "D",
DiffFileStatus.Renamed => "R",
_ => "M",
};
public bool HasLines => Lines.Count > 0;
/// A text file that produced no diff hunks (e.g. a newly added empty file).
public bool IsEmptyContent => !IsBinary && Lines.Count == 0;
}
public sealed partial class DiffModalViewModel : ViewModelBase
{
private readonly GitService _git;
public required string WorktreePath { get; init; }
public string? BaseRef { get; init; }
/// When set together with <see cref="FromCommitRange"/>, the diff is computed as
/// <c>BaseRef..HeadCommit</c> inside <see cref="WorktreePath"/> (used as the repo
/// dir) — lets a merged task's diff be viewed after its worktree is gone.
public string? HeadCommit { get; init; }
public bool FromCommitRange { get; init; }
public string? TaskId { get; init; }
public string TaskTitle { get; init; } = "";
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
public ObservableCollection<DiffFileViewModel> Files { get; } = new();
[ObservableProperty] private DiffFileViewModel? _selectedFile;
[ObservableProperty] private string? _statusMessage;
// Injected action to close the owning Window
public Action? CloseAction { get; set; }
public DiffModalViewModel(GitService git)
{
_git = git;
}
[RelayCommand]
private void Close() => CloseAction?.Invoke();
private bool CanMerge() =>
!string.IsNullOrEmpty(TaskId)
&& ShowMergeModal is not null
&& ResolveMergeVm is not null;
[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();
}
public async Task LoadAsync(CancellationToken ct = default)
{
Files.Clear();
StatusMessage = null;
if (FromCommitRange && (BaseRef is null || HeadCommit 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;
}
foreach (var file in UnifiedDiffParser.Parse(raw))
Files.Add(file);
SelectedFile = Files.Count > 0 ? Files[0] : null;
if (Files.Count == 0) StatusMessage = Loc.T("vm.diff.noChanges");
}
}

View File

@@ -0,0 +1,131 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Modals;
// Shared diff models used by UnifiedDiffParser, DiffLinesView and DiffViewerViewModel.
public enum DiffLineKind { Add, Del, Ctx, File }
public enum DiffFileStatus { Modified, Added, Deleted, Renamed }
public sealed class DiffLineViewModel
{
public required DiffLineKind Kind { get; init; }
public int? OldNo { get; init; }
public int? NewNo { get; init; }
public required string Text { get; init; }
public string ClassName => Kind switch
{
DiffLineKind.Add => "add",
DiffLineKind.Del => "del",
DiffLineKind.File => "file",
_ => "ctx",
};
public string Sign => Kind switch
{
DiffLineKind.Add => "+",
DiffLineKind.Del => "-",
_ => " ",
};
}
public sealed class DiffFileViewModel
{
public required string Path { get; set; }
public string? OldPath { get; set; }
public DiffFileStatus Status { get; set; } = DiffFileStatus.Modified;
public bool IsBinary { get; set; }
public int Additions { get; set; }
public int Deletions { get; set; }
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
/// Single-letter badge for the file's change kind (A/M/D/R).
public string StatusCode => Status switch
{
DiffFileStatus.Added => "A",
DiffFileStatus.Deleted => "D",
DiffFileStatus.Renamed => "R",
_ => "M",
};
public bool HasLines => Lines.Count > 0;
/// A text file that produced no diff hunks (e.g. a newly added empty file).
public bool IsEmptyContent => !IsBinary && Lines.Count == 0;
}
/// One row in the planning subtask list (left pane in Planning mode).
public sealed record SubtaskDiffRow(string Id, string Title, string? DiffStat, string UnifiedDiff);
/// Folder/file node for the file-tree nav (left pane in Files mode). File leaves carry
/// their parsed <see cref="DiffFileViewModel"/> so selection swaps the right pane with no
/// further git calls.
public sealed partial class DiffTreeNodeViewModel : ViewModelBase
{
public required string Name { get; init; }
public bool IsDirectory { get; init; }
public string RelativePath { get; init; } = "";
public DiffFileViewModel? File { get; init; }
public ObservableCollection<DiffTreeNodeViewModel> Children { get; } = new();
[ObservableProperty] private bool _isExpanded = true;
public string? StatusCode => File?.StatusCode;
public bool ShowStats => File is { IsBinary: false };
public int Additions => File?.Additions ?? 0;
public int Deletions => File?.Deletions ?? 0;
}
/// Builds a folder-grouped tree from a flat list of parsed diff files.
public static class DiffTree
{
public static List<DiffTreeNodeViewModel> Build(IEnumerable<DiffFileViewModel> files)
{
var roots = new List<DiffTreeNodeViewModel>();
var dirs = new Dictionary<string, DiffTreeNodeViewModel>(StringComparer.Ordinal);
foreach (var file in files)
{
var path = file.Path.Replace('\\', '/');
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0) continue;
DiffTreeNodeViewModel? parent = null;
var accumulated = "";
for (var i = 0; i < segments.Length - 1; i++)
{
accumulated = accumulated.Length == 0 ? segments[i] : accumulated + "/" + segments[i];
if (!dirs.TryGetValue(accumulated, out var dir))
{
dir = new DiffTreeNodeViewModel { Name = segments[i], IsDirectory = true, RelativePath = accumulated };
dirs[accumulated] = dir;
if (parent is null) roots.Add(dir); else parent.Children.Add(dir);
}
parent = dir;
}
var leaf = new DiffTreeNodeViewModel
{
Name = segments[^1],
IsDirectory = false,
RelativePath = path,
File = file,
};
if (parent is null) roots.Add(leaf); else parent.Children.Add(leaf);
}
return roots;
}
public static DiffTreeNodeViewModel? FirstLeaf(IEnumerable<DiffTreeNodeViewModel> nodes)
{
foreach (var n in nodes)
{
if (!n.IsDirectory) return n;
var nested = FirstLeaf(n.Children);
if (nested is not null) return nested;
}
return null;
}
}

View File

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

View File

@@ -1,150 +0,0 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data.Git;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class WorktreeNodeViewModel : ViewModelBase
{
public required string Name { get; init; }
public string? Status { get; init; }
public bool IsDirectory { get; init; }
public string RelativePath { get; init; } = "";
public ObservableCollection<WorktreeNodeViewModel> Children { get; } = new();
[ObservableProperty] private bool _isExpanded = true;
}
public sealed partial class WorktreeModalViewModel : ViewModelBase
{
private readonly GitService _git;
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
public ObservableCollection<DiffLineViewModel> SelectedFileDiffLines { get; } = new();
[ObservableProperty] private string _worktreePath = "";
[ObservableProperty] private string? _baseCommit;
[ObservableProperty] private WorktreeNodeViewModel? _selectedNode;
// Set by the view (same pattern as DiffModalViewModel.CloseAction)
public Action? CloseAction { get; set; }
public WorktreeModalViewModel(GitService git)
{
_git = git;
}
partial void OnSelectedNodeChanged(WorktreeNodeViewModel? value)
{
_ = LoadFileDiffAsync(value);
}
private async Task LoadFileDiffAsync(WorktreeNodeViewModel? node)
{
SelectedFileDiffLines.Clear();
if (node is null || node.IsDirectory || string.IsNullOrEmpty(node.RelativePath))
return;
string diff;
try
{
diff = await _git.GetFileDiffAsync(WorktreePath, BaseCommit, node.RelativePath);
}
catch
{
return;
}
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(diff)))
SelectedFileDiffLines.Add(line);
}
[RelayCommand]
private void Close() => CloseAction?.Invoke();
public async Task LoadAsync(CancellationToken ct = default)
{
Root.Clear();
string stdout;
bool committedMode = !string.IsNullOrEmpty(BaseCommit);
try
{
stdout = committedMode
? await _git.GetCommittedFilesAsync(WorktreePath, BaseCommit!, ct)
: await _git.GetStatusPorcelainAsync(WorktreePath, ct);
}
catch { return; }
if (string.IsNullOrWhiteSpace(stdout)) return;
var dirs = new Dictionary<string, WorktreeNodeViewModel>(StringComparer.Ordinal);
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
string? path;
string? status;
if (committedMode)
{
// diff --name-status format: <status>\t<path>
var tab = line.IndexOf('\t');
if (tab < 0) continue;
var statusChar = line[0];
status = statusChar != ' ' ? statusChar.ToString() : null;
path = line[(tab + 1)..].Trim().Replace('\\', '/');
}
else
{
// porcelain format: XY<space>path
if (line.Length < 4) continue;
var xy = line[..2];
var statusChar = xy[0] != ' ' ? xy[0] : xy[1];
status = statusChar != ' ' ? statusChar.ToString() : null;
path = line[3..].Trim().Replace('\\', '/');
}
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0) continue;
WorktreeNodeViewModel? parent = null;
var accumulated = "";
for (var i = 0; i < segments.Length - 1; i++)
{
accumulated = accumulated.Length == 0 ? segments[i] : accumulated + "/" + segments[i];
if (!dirs.TryGetValue(accumulated, out var dir))
{
dir = new WorktreeNodeViewModel { Name = segments[i], IsDirectory = true };
dirs[accumulated] = dir;
if (parent == null) Root.Add(dir);
else parent.Children.Add(dir);
}
parent = dir;
}
var leaf = new WorktreeNodeViewModel
{
Name = segments[^1],
Status = status,
IsDirectory = false,
RelativePath = path
};
if (parent == null) Root.Add(leaf);
else parent.Children.Add(leaf);
}
SelectedNode = FindFirstLeaf(Root);
}
private static WorktreeNodeViewModel? FindFirstLeaf(IEnumerable<WorktreeNodeViewModel> nodes)
{
foreach (var n in nodes)
{
if (!n.IsDirectory) return n;
var nested = FindFirstLeaf(n.Children);
if (nested is not null) return nested;
}
return null;
}
}

View File

@@ -61,7 +61,7 @@ public sealed partial class WorktreesGroupViewModel : ViewModelBase
public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
private readonly Func<WorktreeModalViewModel> _diffVmFactory;
private readonly Func<DiffViewerViewModel> _diffVmFactory;
private readonly IMergeCoordinator _merge;
[ObservableProperty] private string? _listIdFilter;
@@ -81,13 +81,13 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
public Action? CloseAction { get; set; }
public Action<WorktreeModalViewModel>? ShowDiffAction { get; set; }
public Action<DiffViewerViewModel>? ShowDiffAction { get; set; }
public Action<string, string>? JumpToTaskAction { get; set; }
public Func<string, Task<bool>>? ConfirmAction { get; set; }
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
public Func<MergeModalViewModel, Task>? ShowMergeAction { get; set; }
public WorktreesOverviewModalViewModel(IWorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory, IMergeCoordinator merge)
public WorktreesOverviewModalViewModel(IWorkerClient worker, Func<DiffViewerViewModel> diffVmFactory, IMergeCoordinator merge)
{
_worker = worker;
_diffVmFactory = diffVmFactory;
@@ -177,8 +177,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
{
if (row is null) return;
var diffVm = _diffVmFactory();
diffVm.WorktreePath = row.Path;
diffVm.BaseCommit = string.IsNullOrEmpty(row.BaseCommit) ? null : row.BaseCommit;
diffVm.ConfigureWorktree(row.Path, row.BaseCommit);
ShowDiffAction?.Invoke(diffVm);
}

View File

@@ -1,101 +0,0 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.ViewModels.Planning;
public sealed partial class PlanningDiffViewModel : ObservableObject
{
private readonly IWorkerClient _worker;
private readonly string _planningTaskId;
private readonly string _targetBranch;
public ObservableCollection<SubtaskDiffRow> Subtasks { get; } = new();
public ObservableCollection<DiffLineViewModel> DiffLines { get; } = new();
[ObservableProperty] private SubtaskDiffRow? _selectedSubtask;
[ObservableProperty] private string _displayedDiff = "";
[ObservableProperty] private bool _isCombinedMode;
[ObservableProperty] private string? _combinedWarning;
[ObservableProperty] private bool _isLoadingCombined;
public Action? CloseAction { get; set; }
public PlanningDiffViewModel(IWorkerClient worker, string planningTaskId, string targetBranch)
{
_worker = worker;
_planningTaskId = planningTaskId;
_targetBranch = targetBranch;
}
[RelayCommand]
private void Close() => CloseAction?.Invoke();
public async Task InitializeAsync()
{
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);
}
}
public sealed record SubtaskDiffRow(string Id, string Title, string? DiffStat, string UnifiedDiff);