diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 0463bf1..ab74563 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -108,7 +108,8 @@ sealed class Program sc.AddSingleton(sp => new DetailsIslandViewModel( sp.GetRequiredService>(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp)); sc.AddSingleton(); return sc.BuildServiceProvider(); diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index ecd7fdb..8a11ce7 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -27,6 +27,14 @@ public sealed class GitService throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}"); } + public async Task GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default) + { + var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain"], ct); + if (exitCode != 0) + throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}"); + return stdout; + } + public async Task HasChangesAsync(string worktreePath, CancellationToken ct = default) { var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct); @@ -50,6 +58,21 @@ public sealed class GitService throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}"); } + public async Task GetDiffAsync(string worktreePath, CancellationToken ct = default) + { + var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, + ["diff", "HEAD"], ct); + if (exitCode != 0) + throw new InvalidOperationException($"git diff HEAD failed (exit {exitCode}): {stderr}"); + // If nothing staged vs HEAD, try the index (untracked is never in diff) + if (string.IsNullOrWhiteSpace(stdout)) + { + var (e2, s2, _) = await RunGitAsync(worktreePath, ["diff", "--cached"], ct); + if (e2 == 0) return s2; + } + return stdout; + } + public async Task DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default) { var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 53ca6ce..646db50 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -4,7 +4,9 @@ using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data; using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Modals; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; namespace ClaudeDo.Ui.ViewModels.Islands; @@ -12,6 +14,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase { private readonly IDbContextFactory _dbFactory; private readonly WorkerClient _worker; + private readonly IServiceProvider _services; // Current task row (set by IslandsShellViewModel via Bind) [ObservableProperty] private TaskRowViewModel? _task; @@ -35,10 +38,17 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase // The task ID we are currently subscribed to for live log messages private string? _subscribedTaskId; - public DetailsIslandViewModel(IDbContextFactory dbFactory, WorkerClient worker) + // Set by the view so OpenDiffCommand can show the modal as a dialog + public Func? ShowDiffModal { get; set; } + + // Set by the view so OpenWorktreeCommand can show the modal as a dialog + public Func? ShowWorktreeModal { get; set; } + + public DetailsIslandViewModel(IDbContextFactory dbFactory, WorkerClient worker, IServiceProvider services) { _dbFactory = dbFactory; _worker = worker; + _services = services; // Subscribe once; filter by current task id inside the handler _worker.TaskMessageEvent += OnTaskMessage; @@ -99,6 +109,38 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed }); } + [RelayCommand(CanExecute = nameof(CanOpenDiff))] + private async System.Threading.Tasks.Task OpenDiffAsync() + { + if (WorktreePath == null || ShowDiffModal == null) return; + var diffVm = new DiffModalViewModel(_services.GetRequiredService()) + { + WorktreePath = WorktreePath, + }; + await diffVm.LoadAsync(); + await ShowDiffModal(diffVm); + } + + private bool CanOpenDiff() => WorktreePath != null; + + [RelayCommand(CanExecute = nameof(CanOpenWorktree))] + private async System.Threading.Tasks.Task OpenWorktreeAsync() + { + if (WorktreePath == null || ShowWorktreeModal == null) return; + var vm = _services.GetRequiredService(); + vm.WorktreePath = WorktreePath; + await vm.LoadAsync(); + await ShowWorktreeModal(vm); + } + + private bool CanOpenWorktree() => WorktreePath != null; + + partial void OnWorktreePathChanged(string? value) + { + OpenDiffCommand.NotifyCanExecuteChanged(); + OpenWorktreeCommand.NotifyCanExecuteChanged(); + } + [RelayCommand] private async System.Threading.Tasks.Task SendPromptAsync() { diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs new file mode 100644 index 0000000..09641ae --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs @@ -0,0 +1,154 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ClaudeDo.Data.Git; + +namespace ClaudeDo.Ui.ViewModels.Modals; + +public enum DiffLineKind { Add, Del, Ctx } + +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", + _ => "ctx", + }; + + public string Sign => Kind switch + { + DiffLineKind.Add => "+", + DiffLineKind.Del => "-", + _ => " ", + }; +} + +public sealed class DiffFileViewModel +{ + public required string Path { get; init; } + public int Additions { get; init; } + public int Deletions { get; init; } + public ObservableCollection Lines { get; } = new(); +} + +public sealed partial class DiffModalViewModel : ViewModelBase +{ + private readonly GitService _git; + + public required string WorktreePath { get; init; } + + public ObservableCollection Files { get; } = new(); + + [ObservableProperty] private DiffFileViewModel? _selectedFile; + + // Injected action to close the owning Window + public Action? CloseAction { get; set; } + + public DiffModalViewModel(GitService git) + { + _git = git; + } + + [RelayCommand] + private void Close() => CloseAction?.Invoke(); + + public async Task LoadAsync(CancellationToken ct = default) + { + Files.Clear(); + + string raw; + try { raw = await _git.GetDiffAsync(WorktreePath, ct); } + catch { return; } + + if (string.IsNullOrWhiteSpace(raw)) return; + + // Parse unified diff — state machine over lines + DiffFileViewModel? current = null; + int oldLine = 0, newLine = 0; + + foreach (var line in raw.Split('\n')) + { + if (line.StartsWith("diff --git ", StringComparison.Ordinal)) + { + // e.g. "diff --git a/src/Foo.cs b/src/Foo.cs" + var parts = line.Split(' '); + var path = parts.Length >= 4 ? parts[3][2..] : line; + current = new DiffFileViewModel { Path = path }; + Files.Add(current); + oldLine = 0; newLine = 0; + continue; + } + + if (current == null) continue; + + if (line.StartsWith("@@ ", StringComparison.Ordinal)) + { + // e.g. "@@ -10,7 +10,9 @@" + ParseHunkHeader(line, out oldLine, out newLine); + continue; + } + + // Skip diff metadata lines + if (line.StartsWith("--- ", StringComparison.Ordinal) || + line.StartsWith("+++ ", StringComparison.Ordinal) || + line.StartsWith("index ", StringComparison.Ordinal) || + line.StartsWith("new file", StringComparison.Ordinal) || + line.StartsWith("deleted file", StringComparison.Ordinal) || + line.StartsWith("Binary ", StringComparison.Ordinal)) + continue; + + if (line.StartsWith('+')) + { + current.Lines.Add(new DiffLineViewModel + { + Kind = DiffLineKind.Add, + NewNo = newLine++, + Text = line.Length > 1 ? line[1..] : "", + }); + // Count additions on the file VM + } + else if (line.StartsWith('-')) + { + current.Lines.Add(new DiffLineViewModel + { + Kind = DiffLineKind.Del, + OldNo = oldLine++, + Text = line.Length > 1 ? line[1..] : "", + }); + } + else if (line.StartsWith(' ')) + { + current.Lines.Add(new DiffLineViewModel + { + Kind = DiffLineKind.Ctx, + OldNo = oldLine++, + NewNo = newLine++, + Text = line.Length > 1 ? line[1..] : "", + }); + } + } + + SelectedFile = Files.Count > 0 ? Files[0] : null; + } + + private static void ParseHunkHeader(string header, out int oldStart, out int newStart) + { + oldStart = 1; newStart = 1; + // Format: @@ -, +, @@ + var at = header.IndexOf("@@", 3, StringComparison.Ordinal); + var inner = at > 0 ? header[3..at].Trim() : header; + var segs = inner.Split(' '); + foreach (var seg in segs) + { + if (seg.StartsWith('-') && int.TryParse(seg[1..].Split(',')[0], out var o)) + oldStart = o; + else if (seg.StartsWith('+') && int.TryParse(seg[1..].Split(',')[0], out var n)) + newStart = n; + } + } +} diff --git a/src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml b/src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml index 36cfa04..91c03e6 100644 --- a/src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml @@ -29,8 +29,8 @@ IsVisible="{Binding BranchLine, Converter={x:Static ObjectConverters.IsNotNull}}"/> -