diff --git a/CHANGELOG.md b/CHANGELOG.md index 93e2b42..0328e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ version numbers, because that is what those releases were called. ## [Unreleased] +### Fixed: WPF lifecycle-safe navigation + +- Added explicit Timeline and Advanced history destinations to the command bar so every read-only + snapshot workspace is reachable and users can always leave Compare. +- Cancelled and cleared an active comparison before user-driven navigation, including when the same + snapshot is opened again later. +- Disabled workspace navigation while backup or restore work is running, and prevented the final WPF + window from closing until the active run finishes or is cancelled. +- Moved Advanced history catalog reads off the UI thread so a slow disk or large snapshot collection + does not freeze the window. + ### Added — WPF Timeline + Compare shell (side-by-side with WinForms) A new WPF application (`WinRestoreKit.Wpf`) ships alongside the existing WinForms app, implementing the diff --git a/src/WinRestoreKit.Tests/AdvancedHistoryViewModelTests.cs b/src/WinRestoreKit.Tests/AdvancedHistoryViewModelTests.cs index 0643580..49fb4bf 100644 --- a/src/WinRestoreKit.Tests/AdvancedHistoryViewModelTests.cs +++ b/src/WinRestoreKit.Tests/AdvancedHistoryViewModelTests.cs @@ -18,37 +18,60 @@ public sealed class AdvancedHistoryViewModelTests [Fact] public async Task SearchText_FiltersTheSharedProjectionByDisplayMachinePathAndStatus() { - SnapshotEvent verified = NewEvent(SnapshotEventKind.Verified, @"C:\history\alpha", "ALPHA-PC"); - SnapshotEvent partial = NewEvent(SnapshotEventKind.Partial, @"C:\history\bravo", "BRAVO-PC"); - SnapshotEvent failed = NewEvent(SnapshotEventKind.Failed, @"C:\history\charlie", "CHARLIE-PC", "disk full"); - FakeCatalog catalog = new FakeCatalog(verified, partial, failed); - AdvancedHistoryViewModel history = new AdvancedHistoryViewModel(catalog); - - await history.RefreshAsync(); - - AssertVisible(history, verified, "alpha"); - AssertVisible(history, partial, "BRAVO-PC"); - AssertVisible(history, partial, "history\\bravo"); - AssertVisible(history, partial, "partial snapshot"); + await WpfTestHost.RunAsync(async () => + { + SnapshotEvent verified = NewEvent(SnapshotEventKind.Verified, @"C:\history\alpha", "ALPHA-PC"); + SnapshotEvent partial = NewEvent(SnapshotEventKind.Partial, @"C:\history\bravo", "BRAVO-PC"); + SnapshotEvent failed = NewEvent(SnapshotEventKind.Failed, @"C:\history\charlie", "CHARLIE-PC", "disk full"); + FakeCatalog catalog = new FakeCatalog(verified, partial, failed); + AdvancedHistoryViewModel history = new AdvancedHistoryViewModel(catalog); + + await history.RefreshAsync(); + + AssertVisible(history, verified, "alpha"); + AssertVisible(history, partial, "BRAVO-PC"); + AssertVisible(history, partial, "history\\bravo"); + AssertVisible(history, partial, "partial snapshot"); + }); } [Fact] public async Task RefreshAsync_ReusesTheSameStableStatusProjectionAsTimeline() { - SnapshotEvent failed = NewEvent(SnapshotEventKind.Failed, @"C:\history\failed", "TEST-PC", "disk full"); - FakeCatalog catalog = new FakeCatalog(failed); - TimelineViewModel timeline = new TimelineViewModel( - catalog, new FakePreparationService(), new RecordingNavigator()); - AdvancedHistoryViewModel history = new AdvancedHistoryViewModel(catalog); - - await timeline.RefreshAsync(); - await history.RefreshAsync(); - - SnapshotEventViewModel timelineEvent = Assert.Single(timeline.Events); - SnapshotEventViewModel historyEvent = Assert.Single(history.Events.Cast()); - Assert.Same(timelineEvent.Status, historyEvent.Status); - Assert.Equal("Backup failed", historyEvent.Status.Label); - Assert.Equal("disk full", historyEvent.DiagnosticReason); + await WpfTestHost.RunAsync(async () => + { + SnapshotEvent failed = NewEvent(SnapshotEventKind.Failed, @"C:\history\failed", "TEST-PC", "disk full"); + FakeCatalog catalog = new FakeCatalog(failed); + TimelineViewModel timeline = new TimelineViewModel( + catalog, new FakePreparationService(), new RecordingNavigator()); + AdvancedHistoryViewModel history = new AdvancedHistoryViewModel(catalog); + + await timeline.RefreshAsync(); + await history.RefreshAsync(); + + SnapshotEventViewModel timelineEvent = Assert.Single(timeline.Events); + SnapshotEventViewModel historyEvent = Assert.Single(history.Events.Cast()); + Assert.Same(timelineEvent.Status, historyEvent.Status); + Assert.Equal("Backup failed", historyEvent.Status.Label); + Assert.Equal("disk full", historyEvent.DiagnosticReason); + }); + } + + [Fact] + public async Task RefreshAsync_ReadsTheCatalogAwayFromTheUiThread() + { + await WpfTestHost.RunAsync(async () => + { + int uiThreadId = Thread.CurrentThread.ManagedThreadId; + FakeCatalog catalog = new FakeCatalog( + NewEvent(SnapshotEventKind.Verified, @"C:\history\alpha", "ALPHA-PC")); + AdvancedHistoryViewModel history = new AdvancedHistoryViewModel(catalog); + + await history.RefreshAsync(); + + Assert.NotEqual(uiThreadId, catalog.ReadThreadId); + Assert.Single(history.Events.Cast()); + }); } private static void AssertVisible(AdvancedHistoryViewModel history, SnapshotEvent expected, string search) @@ -68,7 +91,13 @@ private sealed class FakeCatalog : ISnapshotEventReader internal FakeCatalog(params SnapshotEvent[] events) => this.events = events; - public IReadOnlyList Read() => events; + internal int ReadThreadId { get; private set; } + + public IReadOnlyList Read() + { + ReadThreadId = Thread.CurrentThread.ManagedThreadId; + return events; + } } private sealed class FakePreparationService : ISnapshotPayloadPreparationService diff --git a/src/WinRestoreKit.Tests/ComparisonWorkspaceViewModelTests.cs b/src/WinRestoreKit.Tests/ComparisonWorkspaceViewModelTests.cs index fef311d..01cfc50 100644 --- a/src/WinRestoreKit.Tests/ComparisonWorkspaceViewModelTests.cs +++ b/src/WinRestoreKit.Tests/ComparisonWorkspaceViewModelTests.cs @@ -117,6 +117,43 @@ await WpfTestHost.RunAsync(async () => }); } + [Fact] + public async Task Navigator_LeavingCompareClearsStateAndAllowsTheSameSnapshotToOpenAgain() + { + await WpfTestHost.RunAsync(async () => + { + Window owner = new Window(); + ShellViewModel shell = TestShell(); + CompareWorkflowNavigator navigator = new CompareWorkflowNavigator( + shell, owner, new TestDiscardDialog(true)); + string path = Path.Combine(Path.GetTempPath(), "WinRestoreKit.Tests", Guid.NewGuid().ToString("N")); + + Directory.CreateDirectory(path); + navigator.OpenCompare(PreparedAt(path, "same snapshot")); + await navigator.PendingTransition; + ComparisonWorkspaceViewModel firstWorkspace = navigator.CurrentWorkspace; + firstWorkspace.RestoreSet.Add(new ModuleComparison( + firstWorkspace.Rows[0].Registration.Module, + ComparisonState.Unavailable, true, "Artifact captured.", "Comparison unavailable.")); + + await navigator.LeaveCompareAsync(); + + Assert.Null(navigator.CurrentWorkspace); + Assert.False(firstWorkspace.RestoreSet.HasItems); + + Directory.CreateDirectory(path); + navigator.OpenCompare(PreparedAt(path, "same snapshot")); + await navigator.PendingTransition; + + Assert.NotNull(navigator.CurrentWorkspace); + Assert.NotSame(firstWorkspace, navigator.CurrentWorkspace); + Assert.Equal(Path.GetFullPath(path), navigator.CurrentWorkspace.Snapshot.CanonicalPath); + + await navigator.LeaveCompareAsync(); + owner.Close(); + }); + } + private static async Task LoadedWorkspace( params TestModule[] modules) => await LoadedWorkspace(modules, (_, __) => { }); @@ -146,10 +183,13 @@ private static SnapshotPayloadPreparation Prepared(string name) { string path = Path.Combine(Path.GetTempPath(), "WinRestoreKit.Tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); - SnapshotEvent snapshot = SnapshotFor(path, name); - return new SnapshotPayloadPreparation(snapshot, new BackupPayload.ReadScope(path, path), null); + return PreparedAt(path, name); } + private static SnapshotPayloadPreparation PreparedAt(string path, string name) + => new SnapshotPayloadPreparation( + SnapshotFor(path, name), new BackupPayload.ReadScope(path, path), null); + private static SnapshotEvent SnapshotFor(string path, string name) => new SnapshotEvent(SnapshotEventKind.Verified, DateTime.UtcNow, name, path, string.Empty, string.Empty, 0, true, null); diff --git a/src/WinRestoreKit.Tests/WpfShellTests.cs b/src/WinRestoreKit.Tests/WpfShellTests.cs index 26ec570..cb28427 100644 --- a/src/WinRestoreKit.Tests/WpfShellTests.cs +++ b/src/WinRestoreKit.Tests/WpfShellTests.cs @@ -6,6 +6,7 @@ using WinRestoreKit.Wpf; using WinRestoreKit.Wpf.Services; using WinRestoreKit.Wpf.ViewModels; +using WinRestoreKit.Wpf.ViewModels.History; using WinRestoreKit.Wpf.ViewModels.Timeline; using Xunit; @@ -28,22 +29,85 @@ public void Shell_ComposesTheRealTimelineWorkflow() } [Fact] - public void Shell_SettingsAndAboutCommandsReturnToTheRealTimeline() + public void Shell_PrimaryNavigationCommandsReachEveryWorkspaceAndReturnToTimeline() { WpfTestHost.Run(() => { ShellViewModel shell = CreateShell(); MainWindow window = new MainWindow(shell); + shell.ShowAdvancedHistoryCommand.Execute(null); + Assert.Equal("Advanced history", shell.WorkflowLabel); + Assert.IsType(shell.CurrentWorkspace); shell.ShowSettingsCommand.Execute(null); Assert.Equal("Settings", shell.WorkflowLabel); shell.ShowAboutCommand.Execute(null); Assert.Equal("About", shell.WorkflowLabel); - shell.ShowTimeline(); + shell.ShowTimelineCommand.Execute(null); Assert.IsType(shell.CurrentWorkspace); window.Close(); }); } + [Fact] + public void Shell_PrimaryNavigationCommandsCannotHideAnActiveRun() + { + WpfTestHost.Run(() => + { + RunCoordinator.SetRunning(false); + ShellViewModel shell = CreateShell(); + MainWindow window = new MainWindow(shell); + try + { + RunCoordinator.SetRunning(true); + + Assert.False(shell.CreateSnapshotCommand.CanExecute(null)); + Assert.False(shell.ShowTimelineCommand.CanExecute(null)); + Assert.False(shell.ShowAdvancedHistoryCommand.CanExecute(null)); + Assert.False(shell.ShowSettingsCommand.CanExecute(null)); + Assert.False(shell.ShowAboutCommand.CanExecute(null)); + + shell.ShowSettingsCommand.Execute(null); + Assert.Equal("Timeline", shell.WorkflowLabel); + Assert.IsType(shell.CurrentWorkspace); + } + finally + { + RunCoordinator.SetRunning(false); + window.Close(); + } + }); + } + + [Fact] + public void MainWindow_CloseIsCanceledUntilTheActiveRunFinishes() + { + WpfTestHost.Run(() => + { + RunCoordinator.SetRunning(false); + ShellViewModel shell = ShellViewModel.ForTest( + _ => Task.FromResult(null), + new SnapshotEventCatalog(), + () => Task.CompletedTask); + MainWindow window = new MainWindow(shell); + window.Show(); + try + { + RunCoordinator.SetRunning(true); + window.Close(); + + Assert.True(window.IsVisible); + Assert.Equal("Run in progress", shell.WorkflowLabel); + } + finally + { + RunCoordinator.SetRunning(false); + window.Close(); + } + + Assert.False(window.IsVisible); + }); + } + [Fact] public void Shell_ShowAbout_RendersReadOnlyVersionBinding() { diff --git a/src/WinRestoreKit.Wpf/Infrastructure/AsyncDelegateCommand.cs b/src/WinRestoreKit.Wpf/Infrastructure/AsyncDelegateCommand.cs index 50a5757..61c24dd 100644 --- a/src/WinRestoreKit.Wpf/Infrastructure/AsyncDelegateCommand.cs +++ b/src/WinRestoreKit.Wpf/Infrastructure/AsyncDelegateCommand.cs @@ -8,21 +8,24 @@ internal sealed class AsyncDelegateCommand : ICommand { private readonly Func executeAsync; private readonly Action reportFailure; + private readonly Func canExecute; private bool executing; - internal AsyncDelegateCommand(Func executeAsync, Action reportFailure = null) + internal AsyncDelegateCommand(Func executeAsync, Action reportFailure = null, + Func canExecute = null) { this.executeAsync = executeAsync ?? throw new ArgumentNullException(nameof(executeAsync)); this.reportFailure = reportFailure; + this.canExecute = canExecute; } public event EventHandler CanExecuteChanged; - public bool CanExecute(object parameter) => !executing; + public bool CanExecute(object parameter) => !executing && (canExecute?.Invoke() ?? true); public async void Execute(object parameter) { - if (executing) + if (!CanExecute(parameter)) return; executing = true; @@ -41,5 +44,8 @@ public async void Execute(object parameter) CanExecuteChanged?.Invoke(this, EventArgs.Empty); } } + + internal void RaiseCanExecuteChanged() + => CanExecuteChanged?.Invoke(this, EventArgs.Empty); } } diff --git a/src/WinRestoreKit.Wpf/MainWindow.xaml b/src/WinRestoreKit.Wpf/MainWindow.xaml index aa028c4..9fd3d7e 100644 --- a/src/WinRestoreKit.Wpf/MainWindow.xaml +++ b/src/WinRestoreKit.Wpf/MainWindow.xaml @@ -3,6 +3,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:views="clr-namespace:WinRestoreKit.Wpf.Views" xmlns:vm="clr-namespace:WinRestoreKit.Wpf.ViewModels" + xmlns:history="clr-namespace:WinRestoreKit.Wpf.ViewModels.History" xmlns:timeline="clr-namespace:WinRestoreKit.Wpf.ViewModels.Timeline" Title="WinRestoreKit" MinWidth="1024" MinHeight="640" UseLayoutRounding="True" @@ -13,6 +14,7 @@ Background="{DynamicResource WindowBackgroundBrush}"> + @@ -24,7 +26,7 @@ + + @@ -60,24 +64,43 @@