Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 56 additions & 27 deletions src/WinRestoreKit.Tests/AdvancedHistoryViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SnapshotEventViewModel>());
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<SnapshotEventViewModel>());
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<SnapshotEventViewModel>());
});
}

private static void AssertVisible(AdvancedHistoryViewModel history, SnapshotEvent expected, string search)
Expand All @@ -68,7 +91,13 @@ private sealed class FakeCatalog : ISnapshotEventReader

internal FakeCatalog(params SnapshotEvent[] events) => this.events = events;

public IReadOnlyList<SnapshotEvent> Read() => events;
internal int ReadThreadId { get; private set; }

public IReadOnlyList<SnapshotEvent> Read()
{
ReadThreadId = Thread.CurrentThread.ManagedThreadId;
return events;
}
}

private sealed class FakePreparationService : ISnapshotPayloadPreparationService
Expand Down
44 changes: 42 additions & 2 deletions src/WinRestoreKit.Tests/ComparisonWorkspaceViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComparisonWorkspaceViewModel> LoadedWorkspace(
params TestModule[] modules)
=> await LoadedWorkspace(modules, (_, __) => { });
Expand Down Expand Up @@ -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);
Expand Down
68 changes: 66 additions & 2 deletions src/WinRestoreKit.Tests/WpfShellTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<AdvancedHistoryViewModel>(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<TimelineViewModel>(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<TimelineViewModel>(shell.CurrentWorkspace);
}
finally
{
RunCoordinator.SetRunning(false);
window.Close();
}
});
}

[Fact]
public void MainWindow_CloseIsCanceledUntilTheActiveRunFinishes()
{
WpfTestHost.Run(() =>
{
RunCoordinator.SetRunning(false);
ShellViewModel shell = ShellViewModel.ForTest(
_ => Task.FromResult<BackupRunCompletion>(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()
{
Expand Down
12 changes: 9 additions & 3 deletions src/WinRestoreKit.Wpf/Infrastructure/AsyncDelegateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,24 @@ internal sealed class AsyncDelegateCommand : ICommand
{
private readonly Func<Task> executeAsync;
private readonly Action<Exception> reportFailure;
private readonly Func<bool> canExecute;
private bool executing;

internal AsyncDelegateCommand(Func<Task> executeAsync, Action<Exception> reportFailure = null)
internal AsyncDelegateCommand(Func<Task> executeAsync, Action<Exception> reportFailure = null,
Func<bool> 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;
Expand All @@ -41,5 +44,8 @@ public async void Execute(object parameter)
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}

internal void RaiseCanExecuteChanged()
=> CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
29 changes: 26 additions & 3 deletions src/WinRestoreKit.Wpf/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -13,6 +14,7 @@
Background="{DynamicResource WindowBackgroundBrush}">
<Window.Resources>
<DataTemplate DataType="{x:Type timeline:TimelineViewModel}"><views:TimelineView /></DataTemplate>
<DataTemplate DataType="{x:Type history:AdvancedHistoryViewModel}"><views:AdvancedHistoryView /></DataTemplate>
<DataTemplate DataType="{x:Type vm:ComparisonWorkspaceViewModel}"><views:ComparisonWorkspaceView /></DataTemplate>
<DataTemplate DataType="{x:Type vm:ConfirmViewModel}"><views:ConfirmView /></DataTemplate>
<DataTemplate DataType="{x:Type vm:BackupWorkspaceViewModel}"><views:BackupWorkspaceView /></DataTemplate>
Expand All @@ -24,7 +26,7 @@
<DockPanel>
<!--
Compact command bar, not a navigation chrome: wordmark, the stage the user is in, and the
three always-available commands. Everything else belongs to the workspace below it.
primary workspace commands. Everything else belongs to the workspace below it.
-->
<Border DockPanel.Dock="Top"
Background="{DynamicResource SurfaceBrush}"
Expand All @@ -38,6 +40,8 @@
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>

<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
Expand All @@ -60,24 +64,43 @@
</StackPanel>

<Button Grid.Column="1"
Content="Timeline"
Command="{Binding ShowTimelineCommand}"
Style="{DynamicResource SubtleButtonStyle}"
VerticalAlignment="Center"
AutomationProperties.AutomationId="NavTimeline"
AutomationProperties.Name="Timeline" />
<Button Grid.Column="2"
Content="History"
Command="{Binding ShowAdvancedHistoryCommand}"
Style="{DynamicResource SubtleButtonStyle}"
Margin="4,0,0,0"
VerticalAlignment="Center"
AutomationProperties.AutomationId="NavAdvancedHistory"
AutomationProperties.Name="Advanced history" />
<Button Grid.Column="3"
Content="Create snapshot"
Command="{Binding CreateSnapshotCommand}"
Style="{DynamicResource PrimaryButtonStyle}"
Margin="8,0,0,0"
VerticalAlignment="Center"
AutomationProperties.AutomationId="NavCreateSnapshot"
AutomationProperties.Name="Create snapshot" />
<Button Grid.Column="2"
<Button Grid.Column="4"
Content="Settings"
Command="{Binding ShowSettingsCommand}"
Style="{DynamicResource SubtleButtonStyle}"
Margin="8,0,0,0"
VerticalAlignment="Center"
AutomationProperties.AutomationId="NavSettings"
AutomationProperties.Name="Settings" />
<Button Grid.Column="3"
<Button Grid.Column="5"
Content="About"
Command="{Binding ShowAboutCommand}"
Style="{DynamicResource SubtleButtonStyle}"
Margin="4,0,0,0"
VerticalAlignment="Center"
AutomationProperties.AutomationId="NavAbout"
AutomationProperties.Name="About" />
</Grid>
</Border>
Expand Down
Loading
Loading