feat(diff): add persisted side-by-side and wrap toggles to the diff viewer

This commit is contained in:
mika kuns
2026-08-07 09:42:43 +02:00
parent 14e0cffa25
commit cf80fe3cd6
5 changed files with 64 additions and 15 deletions
+3 -1
View File
@@ -357,7 +357,9 @@
"merge": "Mergen…", "merge": "Mergen…",
"filesHeader": "Dateien", "filesHeader": "Dateien",
"binary": "Binärdatei — kein Text-Diff", "binary": "Binärdatei — kein Text-Diff",
"empty": "Kein Inhalt" "empty": "Kein Inhalt",
"splitView": "Nebeneinander",
"wrapLines": "Zeilenumbruch"
}, },
"worktreesOverview": { "worktreesOverview": {
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
+3 -1
View File
@@ -357,7 +357,9 @@
"merge": "Merge…", "merge": "Merge…",
"filesHeader": "Files", "filesHeader": "Files",
"binary": "Binary file — no text diff", "binary": "Binary file — no text diff",
"empty": "No content" "empty": "No content",
"splitView": "Side by side",
"wrapLines": "Wrap lines"
}, },
"worktreesOverview": { "worktreesOverview": {
"refresh": "Refresh", "refresh": "Refresh",
@@ -1,7 +1,10 @@
using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data.Git; using ClaudeDo.Data.Git;
using ClaudeDo.Ui;
using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
@@ -21,6 +24,7 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
{ {
private readonly GitService _git; private readonly GitService _git;
private readonly IWorkerClient _worker; private readonly IWorkerClient _worker;
private readonly AppSettings _settings;
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsPlanning))] [NotifyPropertyChangedFor(nameof(IsPlanning))]
@@ -56,6 +60,31 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
[ObservableProperty] private string _displayedDiff = ""; [ObservableProperty] private string _displayedDiff = "";
[ObservableProperty] private string? _statusMessage; [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 ──────────────────────────────────────────── // ── Planning combined toggle ────────────────────────────────────────────
[ObservableProperty] private bool _isCombinedMode; [ObservableProperty] private bool _isCombinedMode;
[ObservableProperty] private string? _combinedWarning; [ObservableProperty] private string? _combinedWarning;
@@ -63,10 +92,13 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
public Action? CloseAction { get; set; } public Action? CloseAction { get; set; }
public DiffViewerViewModel(GitService git, IWorkerClient worker) public DiffViewerViewModel(GitService git, IWorkerClient worker, AppSettings settings)
{ {
_git = git; _git = git;
_worker = worker; _worker = worker;
_settings = settings;
_isSplitView = string.Equals(settings.DiffViewMode, "split", StringComparison.OrdinalIgnoreCase);
_wrapLines = settings.DiffWrapLines;
} }
[RelayCommand] [RelayCommand]
@@ -30,6 +30,12 @@
<DockPanel> <DockPanel>
<!-- View toolbar: layout + wrap, both persisted -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="16,8,16,0">
<ToggleButton Content="{loc:Tr modals.diff.splitView}" IsChecked="{Binding IsSplitView}"/>
<ToggleButton Content="{loc:Tr modals.diff.wrapLines}" IsChecked="{Binding WrapLines}"/>
</StackPanel>
<!-- Planning toolbar: combined-mode toggle + warning/loading --> <!-- Planning toolbar: combined-mode toggle + warning/loading -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="16,8,16,0" <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="16,8,16,0"
IsVisible="{Binding IsPlanning}"> IsVisible="{Binding IsPlanning}">
@@ -149,10 +155,10 @@
Foreground="{DynamicResource TextMuteBrush}" Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding SelectedFile.IsEmptyContent}" IsVisible="{Binding SelectedFile.IsEmptyContent}"
HorizontalAlignment="Center" VerticalAlignment="Center"/> HorizontalAlignment="Center" VerticalAlignment="Center"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" <ctl:DiffTextView IsVisible="{Binding SelectedFile.HasLines}"
IsVisible="{Binding SelectedFile.HasLines}"> File="{Binding SelectedFile}"
<ctl:DiffLinesView Lines="{Binding SelectedFile.Lines}"/> IsSplit="{Binding IsSplitView}"
</ScrollViewer> WrapLines="{Binding WrapLines}"/>
</Grid> </Grid>
</DockPanel> </DockPanel>
@@ -17,6 +17,13 @@ public class DiffViewerViewModelTests
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en"); 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 private sealed class FakePlanningWorker : StubWorkerClient
{ {
public IReadOnlyList<SubtaskDiffDto> AggregateResult { get; set; } = Array.Empty<SubtaskDiffDto>(); public IReadOnlyList<SubtaskDiffDto> AggregateResult { get; set; } = Array.Empty<SubtaskDiffDto>();
@@ -36,7 +43,7 @@ public class DiffViewerViewModelTests
[Fact] [Fact]
public async Task CommitRange_NullHeadCommit_ShowsUnavailable() 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); vm.ConfigureCommitRange("/some/repo", "abc123", null);
await vm.LoadAsync(); await vm.LoadAsync();
@@ -49,7 +56,7 @@ public class DiffViewerViewModelTests
[Fact] [Fact]
public async Task CommitRange_NullBaseRef_ShowsUnavailable() 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"); vm.ConfigureCommitRange("/some/repo", null, "def456");
await vm.LoadAsync(); await vm.LoadAsync();
@@ -104,7 +111,7 @@ public class DiffViewerViewModelTests
new SubtaskDiffDto("s2", "Second", "branch-2", "base2", "head2", "+2 -1", "diff2"), 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"); vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync(); await vm.LoadAsync();
@@ -124,7 +131,7 @@ public class DiffViewerViewModelTests
new SubtaskDiffDto("s2", "Second", "b2", "base2", "head2", null, "DIFF-B"), 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"); vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync(); await vm.LoadAsync();
@@ -141,7 +148,7 @@ public class DiffViewerViewModelTests
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") }, AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedResult = new CombinedDiffResultDto(true, "integration-branch", "COMBINED-DIFF", null, null), 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"); vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync(); await vm.LoadAsync();
@@ -162,7 +169,7 @@ public class DiffViewerViewModelTests
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") }, 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" }), 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"); vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync(); await vm.LoadAsync();
@@ -184,7 +191,7 @@ public class DiffViewerViewModelTests
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") }, AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedResult = null, CombinedResult = null,
}; };
var vm = new DiffViewerViewModel(null!, fake); var vm = new DiffViewerViewModel(null!, fake, TestSettings());
vm.ConfigurePlanning("plan-1", "main"); vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync(); await vm.LoadAsync();
@@ -205,7 +212,7 @@ public class DiffViewerViewModelTests
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") }, AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedException = "planning task not found", CombinedException = "planning task not found",
}; };
var vm = new DiffViewerViewModel(null!, fake); var vm = new DiffViewerViewModel(null!, fake, TestSettings());
vm.ConfigurePlanning("plan-1", "main"); vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync(); await vm.LoadAsync();