using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data.Git; namespace ClaudeDo.Ui.ViewModels.Modals; public enum WorktreeDiffLineKind { Header, Hunk, Added, Removed, Context } public sealed partial class WorktreeDiffLineViewModel : ViewModelBase { public required string Text { get; init; } public required WorktreeDiffLineKind Kind { get; init; } } 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 Children { get; } = new(); [ObservableProperty] private bool _isExpanded = true; } public sealed partial class WorktreeModalViewModel : ViewModelBase { private readonly GitService _git; public ObservableCollection Root { get; } = new(); public ObservableCollection 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 diff.Split('\n')) { var kind = line switch { _ when line.StartsWith("+++") || line.StartsWith("---") => WorktreeDiffLineKind.Header, _ when line.StartsWith("@@") => WorktreeDiffLineKind.Hunk, _ when line.StartsWith('+') => WorktreeDiffLineKind.Added, _ when line.StartsWith('-') => WorktreeDiffLineKind.Removed, _ when line.StartsWith("diff ") || line.StartsWith("index ") || line.StartsWith("\\ ") => WorktreeDiffLineKind.Header, _ => WorktreeDiffLineKind.Context, }; SelectedFileDiffLines.Add(new WorktreeDiffLineViewModel { Text = line, Kind = kind }); } } [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(StringComparer.Ordinal); foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)) { string? path; string? status; if (committedMode) { // diff --name-status format: \t 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: XYpath 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 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; } }