From cf80fe3cd61e3aab2bb5bddb14b51cd9787d276f Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 09:42:43 +0200 Subject: [PATCH] feat(diff): add persisted side-by-side and wrap toggles to the diff viewer --- src/ClaudeDo.Localization/locales/de.json | 4 ++- src/ClaudeDo.Localization/locales/en.json | 4 ++- .../ViewModels/Modals/DiffViewerViewModel.cs | 34 ++++++++++++++++++- .../Views/Modals/DiffViewerView.axaml | 14 +++++--- .../ViewModels/DiffViewerViewModelTests.cs | 23 ++++++++----- 5 files changed, 64 insertions(+), 15 deletions(-) diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 8921d76e..9fe0e41f 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -357,7 +357,9 @@ "merge": "Mergen…", "filesHeader": "Dateien", "binary": "Binärdatei — kein Text-Diff", - "empty": "Kein Inhalt" + "empty": "Kein Inhalt", + "splitView": "Nebeneinander", + "wrapLines": "Zeilenumbruch" }, "worktreesOverview": { "refresh": "Aktualisieren", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 372d2d9a..328d7333 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -357,7 +357,9 @@ "merge": "Merge…", "filesHeader": "Files", "binary": "Binary file — no text diff", - "empty": "No content" + "empty": "No content", + "splitView": "Side by side", + "wrapLines": "Wrap lines" }, "worktreesOverview": { "refresh": "Refresh", diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs index 7dda17d8..455e1be4 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs @@ -1,7 +1,10 @@ +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; @@ -21,6 +24,7 @@ public sealed partial class DiffViewerViewModel : ViewModelBase { private readonly GitService _git; private readonly IWorkerClient _worker; + private readonly AppSettings _settings; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsPlanning))] @@ -56,6 +60,31 @@ public sealed partial class DiffViewerViewModel : ViewModelBase [ObservableProperty] private string _displayedDiff = ""; [ObservableProperty] private string? _statusMessage; + // ── View toggles (persisted to ui.config.json) ────────────────────────── + [ObservableProperty] private bool _isSplitView; + [ObservableProperty] private bool _wrapLines; + + 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; @@ -63,10 +92,13 @@ public sealed partial class DiffViewerViewModel : ViewModelBase public Action? CloseAction { get; set; } - public DiffViewerViewModel(GitService git, IWorkerClient worker) + 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] diff --git a/src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml b/src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml index 990ac855..5d66da0c 100644 --- a/src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml @@ -30,6 +30,12 @@ + + + + + + @@ -149,10 +155,10 @@ Foreground="{DynamicResource TextMuteBrush}" IsVisible="{Binding SelectedFile.IsEmptyContent}" HorizontalAlignment="Center" VerticalAlignment="Center"/> - - - + diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DiffViewerViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DiffViewerViewModelTests.cs index c38275d1..5c27c6b5 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/DiffViewerViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DiffViewerViewModelTests.cs @@ -17,6 +17,13 @@ public class DiffViewerViewModelTests LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en"); } + // A throwaway config path — the view toggles call Save(), and a test must never + // overwrite the developer's real ~/.todo-app/ui.config.json. + private static AppSettings TestSettings() => new() + { + ConfigPath = Path.Combine(Path.GetTempPath(), $"claudedo-uicfg-{Guid.NewGuid():N}.json"), + }; + private sealed class FakePlanningWorker : StubWorkerClient { public IReadOnlyList AggregateResult { get; set; } = Array.Empty(); @@ -36,7 +43,7 @@ public class DiffViewerViewModelTests [Fact] public async Task CommitRange_NullHeadCommit_ShowsUnavailable() { - var vm = new DiffViewerViewModel(null!, new FakePlanningWorker()); + var vm = new DiffViewerViewModel(null!, new FakePlanningWorker(), TestSettings()); vm.ConfigureCommitRange("/some/repo", "abc123", null); await vm.LoadAsync(); @@ -49,7 +56,7 @@ public class DiffViewerViewModelTests [Fact] public async Task CommitRange_NullBaseRef_ShowsUnavailable() { - var vm = new DiffViewerViewModel(null!, new FakePlanningWorker()); + var vm = new DiffViewerViewModel(null!, new FakePlanningWorker(), TestSettings()); vm.ConfigureCommitRange("/some/repo", null, "def456"); await vm.LoadAsync(); @@ -104,7 +111,7 @@ public class DiffViewerViewModelTests new SubtaskDiffDto("s2", "Second", "branch-2", "base2", "head2", "+2 -1", "diff2"), } }; - var vm = new DiffViewerViewModel(null!, fake); + var vm = new DiffViewerViewModel(null!, fake, TestSettings()); vm.ConfigurePlanning("plan-1", "main"); await vm.LoadAsync(); @@ -124,7 +131,7 @@ public class DiffViewerViewModelTests new SubtaskDiffDto("s2", "Second", "b2", "base2", "head2", null, "DIFF-B"), } }; - var vm = new DiffViewerViewModel(null!, fake); + var vm = new DiffViewerViewModel(null!, fake, TestSettings()); vm.ConfigurePlanning("plan-1", "main"); await vm.LoadAsync(); @@ -141,7 +148,7 @@ public class DiffViewerViewModelTests AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") }, CombinedResult = new CombinedDiffResultDto(true, "integration-branch", "COMBINED-DIFF", null, null), }; - var vm = new DiffViewerViewModel(null!, fake); + var vm = new DiffViewerViewModel(null!, fake, TestSettings()); vm.ConfigurePlanning("plan-1", "main"); await vm.LoadAsync(); @@ -162,7 +169,7 @@ public class DiffViewerViewModelTests AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") }, CombinedResult = new CombinedDiffResultDto(false, null, null, "subtask-42", new[] { "a.cs", "b.cs" }), }; - var vm = new DiffViewerViewModel(null!, fake); + var vm = new DiffViewerViewModel(null!, fake, TestSettings()); vm.ConfigurePlanning("plan-1", "main"); await vm.LoadAsync(); @@ -184,7 +191,7 @@ public class DiffViewerViewModelTests AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") }, CombinedResult = null, }; - var vm = new DiffViewerViewModel(null!, fake); + var vm = new DiffViewerViewModel(null!, fake, TestSettings()); vm.ConfigurePlanning("plan-1", "main"); await vm.LoadAsync(); @@ -205,7 +212,7 @@ public class DiffViewerViewModelTests AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") }, CombinedException = "planning task not found", }; - var vm = new DiffViewerViewModel(null!, fake); + var vm = new DiffViewerViewModel(null!, fake, TestSettings()); vm.ConfigurePlanning("plan-1", "main"); await vm.LoadAsync();