From 3202ec16186a3aff6935a30d6a7f1c71559adf50 Mon Sep 17 00:00:00 2001 From: Jon Wiswall Date: Fri, 22 May 2026 22:01:09 +0100 Subject: [PATCH 1/4] Add recent session history to SizeBench Persist recent single-binary and diff sessions, surface them on startup and in File > Recent, and constrain the landing page so long histories scroll correctly. Add GUI tests for persistence, deduping, and the 10-entry cap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MainWindowViewModelTests.cs | 185 +++++- .../RecentSessionStoreTests.cs | 108 ++++ src/SizeBench.GUI/MainWindow.xaml | 578 ++++++++++-------- src/SizeBench.GUI/MainWindowViewModel.cs | 114 +++- src/SizeBench.GUI/Properties/AssemblyInfo.cs | 57 +- src/SizeBench.GUI/RecentSession.cs | 209 +++++++ src/SizeBench.GUI/RecentSessionLauncher.cs | 26 + src/SizeBench.GUI/RecentSessionStore.cs | 186 ++++++ src/SizeBench.GUI/WindsorInstaller.cs | 156 ++--- 9 files changed, 1259 insertions(+), 360 deletions(-) create mode 100644 src/SizeBench.GUI.Tests/RecentSessionStoreTests.cs create mode 100644 src/SizeBench.GUI/RecentSession.cs create mode 100644 src/SizeBench.GUI/RecentSessionLauncher.cs create mode 100644 src/SizeBench.GUI/RecentSessionStore.cs diff --git a/src/SizeBench.GUI.Tests/MainWindowViewModelTests.cs b/src/SizeBench.GUI.Tests/MainWindowViewModelTests.cs index 6684ba7..cfe1f89 100644 --- a/src/SizeBench.GUI.Tests/MainWindowViewModelTests.cs +++ b/src/SizeBench.GUI.Tests/MainWindowViewModelTests.cs @@ -1,4 +1,5 @@ -using Castle.Windsor; +using System.IO; +using Castle.Windsor; using SizeBench.AnalysisEngine; using SizeBench.Logging; using SizeBench.TestInfrastructure; @@ -13,12 +14,19 @@ public sealed class MainWindowViewModelTests : IDisposable public IWindsorContainer WindsorContainer = new WindsorContainer(); public Mock MockSession = new Mock(); public Mock MockSessionFactory = new Mock(); + private Mock MockRecentSessionStore = new Mock(); + private Mock MockRecentSessionLauncher = new Mock(); [TestInitialize] public void TestInitialize() { this.MockSession = new Mock(); this.MockSessionFactory = new Mock(); + this.MockRecentSessionStore = new Mock(); + this.MockRecentSessionLauncher = new Mock(); + this.MockRecentSessionStore.Setup(store => store.GetRecentSessions()).Returns(Array.Empty()); + this.MockRecentSessionStore.Setup(store => store.RecordSession(It.IsAny())) + .Returns(recentSession => recentSession); this.WindsorContainer = new WindsorContainer(); } @@ -32,7 +40,9 @@ public void DeeplinkWithOnlyBinaryPathDoesNothing() using var appLogger = new TestNoOpApplicationLogger(); var vm = new MainWindowViewModel(container, appLogger, - this.MockSessionFactory.Object); + this.MockSessionFactory.Object, + this.MockRecentSessionStore.Object, + this.MockRecentSessionLauncher.Object); this.MockSessionFactory.Verify(sf => sf.CreateSession(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); } @@ -46,7 +56,9 @@ public void DeeplinkWithOnlyPDBPathDoesNothing() using var appLogger = new TestNoOpApplicationLogger(); var vm = new MainWindowViewModel(container, appLogger, - this.MockSessionFactory.Object); + this.MockSessionFactory.Object, + this.MockRecentSessionStore.Object, + this.MockRecentSessionLauncher.Object); this.MockSessionFactory.Verify(sf => sf.CreateSession(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); } @@ -63,9 +75,16 @@ public async Task DeeplinkWithBothPathsOpensSession() using var appLogger = new TestNoOpApplicationLogger(); var vm = new MainWindowViewModel(container, appLogger, - this.MockSessionFactory.Object); + this.MockSessionFactory.Object, + this.MockRecentSessionStore.Object, + this.MockRecentSessionLauncher.Object); await vm.TryResolveDeeplink(new Uri($"sizebench://2.0/Test?BinaryPath={Uri.EscapeDataString(expectedBinaryPath)}&PDBPath={Uri.EscapeDataString(expectedPDBPath)}")); this.MockSessionFactory.Verify(sf => sf.CreateSession(expectedBinaryPath, expectedPDBPath, It.IsAny(), It.IsAny()), Times.Exactly(1)); + this.MockRecentSessionStore.Verify(store => store.RecordSession(It.Is(recentSession => + recentSession.Kind == RecentSessionKind.SingleBinary && + recentSession.BinaryPath == expectedBinaryPath && + recentSession.PDBPath == expectedPDBPath)), + Times.Exactly(1)); } [TestMethod] @@ -83,7 +102,9 @@ public async Task DeeplinkWithBothPathsAndInAppPageGoesDeep() using var appLogger = new TestNoOpApplicationLogger(); var vm = new MainWindowViewModel(container, appLogger, - this.MockSessionFactory.Object); + this.MockSessionFactory.Object, + this.MockRecentSessionStore.Object, + this.MockRecentSessionLauncher.Object); var deeplinkResolutionTask = vm.TryResolveDeeplink(new Uri($"sizebench://2.0/{Uri.EscapeDataString(expectedInAppPage)}?BinaryPath={Uri.EscapeDataString(expectedBinaryPath)}&PDBPath={Uri.EscapeDataString(expectedPDBPath)}")); Assert.AreEqual(0, vm.OpenTabs.Count); @@ -110,7 +131,9 @@ public void DeeplinkToDiffWithOnlyBeforeAndAfterBinaryPathsDoesNothing() using var appLogger = new TestNoOpApplicationLogger(); var vm = new MainWindowViewModel(container, appLogger, - this.MockSessionFactory.Object); + this.MockSessionFactory.Object, + this.MockRecentSessionStore.Object, + this.MockRecentSessionLauncher.Object); this.MockSessionFactory.Verify(sf => sf.CreateSession(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); this.MockSessionFactory.Verify(sf => sf.CreateDiffSession(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); } @@ -125,7 +148,9 @@ public void DeeplinkToDiffWithOneBinaryPathAndBeforeAndAfterPDBPathsDoesNothing( using var appLogger = new TestNoOpApplicationLogger(); var vm = new MainWindowViewModel(container, appLogger, - this.MockSessionFactory.Object); + this.MockSessionFactory.Object, + this.MockRecentSessionStore.Object, + this.MockRecentSessionLauncher.Object); this.MockSessionFactory.Verify(sf => sf.CreateSession(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); this.MockSessionFactory.Verify(sf => sf.CreateDiffSession(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); } @@ -149,13 +174,22 @@ public async Task DeeplinkToDiffWithAllFourPathsOpensDiffSession() using var appLogger = new TestNoOpApplicationLogger(); var vm = new MainWindowViewModel(container, appLogger, - this.MockSessionFactory.Object); + this.MockSessionFactory.Object, + this.MockRecentSessionStore.Object, + this.MockRecentSessionLauncher.Object); await vm.TryResolveDeeplink(new Uri($"sizebench://2.0/Test?BeforeBinaryPath={Uri.EscapeDataString(expectedBeforeBinaryPath)}" + $"&BeforePDBPath={Uri.EscapeDataString(expectedBeforePDBPath)}" + $"&AfterBinaryPath={Uri.EscapeDataString(expectedAfterBinaryPath)}" + $"&AfterPDBPath={Uri.EscapeDataString(expectedAfterPDBPath)}")); this.MockSessionFactory.Verify(sf => sf.CreateSession(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); this.MockSessionFactory.Verify(sf => sf.CreateDiffSession(expectedBeforeBinaryPath, expectedBeforePDBPath, expectedAfterBinaryPath, expectedAfterPDBPath, It.IsAny()), Times.Exactly(1)); + this.MockRecentSessionStore.Verify(store => store.RecordSession(It.Is(recentSession => + recentSession.Kind == RecentSessionKind.BinaryDiff && + recentSession.BeforeBinaryPath == expectedBeforeBinaryPath && + recentSession.BeforePdbPath == expectedBeforePDBPath && + recentSession.AfterBinaryPath == expectedAfterBinaryPath && + recentSession.AfterPdbPath == expectedAfterPDBPath)), + Times.Exactly(1)); } [TestMethod] @@ -179,7 +213,9 @@ public async Task DeeplinkToDiffWithAllFourPathsAndInAppPageGoesDeep() using var appLogger = new TestNoOpApplicationLogger(); var vm = new MainWindowViewModel(container, appLogger, - this.MockSessionFactory.Object); + this.MockSessionFactory.Object, + this.MockRecentSessionStore.Object, + this.MockRecentSessionLauncher.Object); var deeplinkResolutionTask = vm.TryResolveDeeplink(new Uri($"sizebench://2.0/{Uri.EscapeDataString(expectedInAppPage)}?" + $"BeforeBinaryPath={Uri.EscapeDataString(expectedBeforeBinaryPath)}" + @@ -202,5 +238,136 @@ public async Task DeeplinkToDiffWithAllFourPathsAndInAppPageGoesDeep() Assert.AreEqual(new Uri(expectedInAppPage, UriKind.Relative), vm.SelectedTab!.CurrentPage); } + [TestMethod] + public void ConstructorLoadsRecentSessionsFromStore() + { + var expectedRecentSessions = new[] + { + RecentSession.CreateSingle(@"c:\dev\foo.dll", @"c:\dev\foo.pdb", new SessionOptions()), + RecentSession.CreateDiff(@"c:\dev\before.dll", @"c:\dev\before.pdb", @"c:\dev\after.dll", @"c:\dev\after.pdb") + }; + + this.MockRecentSessionStore.Setup(store => store.GetRecentSessions()).Returns(expectedRecentSessions); + + using var container = new WindsorContainer(); + using var appLogger = new TestNoOpApplicationLogger(); + var vm = new MainWindowViewModel(container, + appLogger, + this.MockSessionFactory.Object, + this.MockRecentSessionStore.Object, + this.MockRecentSessionLauncher.Object); + + CollectionAssert.AreEqual(expectedRecentSessions, vm.RecentSessions.ToArray()); + Assert.IsTrue(vm.HasRecentSessions); + } + + [TestMethod] + public async Task OpenRecentSingleBinarySessionCreatesTab() + { + var tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDirectory); + + try + { + var binaryPath = Path.Combine(tempDirectory, "foo.dll"); + var pdbPath = Path.Combine(tempDirectory, "foo.pdb"); + await File.WriteAllTextAsync(binaryPath, String.Empty); + await File.WriteAllTextAsync(pdbPath, String.Empty); + + var recentSession = RecentSession.CreateSingle(binaryPath, + pdbPath, + new SessionOptions() { SymbolSourcesSupported = SymbolSourcesSupported.Code }); + + this.MockSessionFactory.Setup(sf => sf.CreateSession(binaryPath, pdbPath, It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new Mock().Object)); + + using var container = new WindsorContainer(); + using var appLogger = new TestNoOpApplicationLogger(); + var vm = new MainWindowViewModel(container, + appLogger, + this.MockSessionFactory.Object, + this.MockRecentSessionStore.Object, + this.MockRecentSessionLauncher.Object); + + await vm.OpenRecentSession(recentSession); + + this.MockSessionFactory.Verify(sf => sf.CreateSession(binaryPath, + pdbPath, + It.Is(options => options.SymbolSourcesSupported == SymbolSourcesSupported.Code), + It.IsAny()), + Times.Exactly(1)); + Assert.AreEqual(1, vm.OpenTabs.Count); + Assert.IsInstanceOfType(vm.OpenTabs[0], typeof(SingleBinaryTab)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task DeeplinkWithBothPathsAndSymbolSourcesSupportedUsesProvidedSessionOptions() + { + var expectedBinaryPath = @"c:\dev\blah.dll"; + var expectedPDBPath = @"c:\dev\other\blah.pdb"; + + this.MockSessionFactory.Setup(sf => sf.CreateSession(expectedBinaryPath, expectedPDBPath, It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new Mock().Object)); + + using var container = new WindsorContainer(); + using var appLogger = new TestNoOpApplicationLogger(); + var vm = new MainWindowViewModel(container, + appLogger, + this.MockSessionFactory.Object, + this.MockRecentSessionStore.Object, + this.MockRecentSessionLauncher.Object); + + await vm.TryResolveDeeplink(new Uri($"sizebench://2.0/Test?BinaryPath={Uri.EscapeDataString(expectedBinaryPath)}&PDBPath={Uri.EscapeDataString(expectedPDBPath)}&SymbolSourcesSupported={(int)SymbolSourcesSupported.Code}")); + + this.MockSessionFactory.Verify(sf => sf.CreateSession(expectedBinaryPath, + expectedPDBPath, + It.Is(options => options.SymbolSourcesSupported == SymbolSourcesSupported.Code), + It.IsAny()), + Times.Exactly(1)); + } + + [TestMethod] + public void OpenRecentSessionInNewWindowUsesLauncher() + { + var tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDirectory); + + try + { + var binaryPath = Path.Combine(tempDirectory, "foo.dll"); + var pdbPath = Path.Combine(tempDirectory, "foo.pdb"); + File.WriteAllText(binaryPath, String.Empty); + File.WriteAllText(pdbPath, String.Empty); + + var recentSession = RecentSession.CreateSingle(binaryPath, + pdbPath, + new SessionOptions() { SymbolSourcesSupported = SymbolSourcesSupported.Code }); + + using var container = new WindsorContainer(); + using var appLogger = new TestNoOpApplicationLogger(); + var vm = new MainWindowViewModel(container, + appLogger, + this.MockSessionFactory.Object, + this.MockRecentSessionStore.Object, + this.MockRecentSessionLauncher.Object); + + vm.OpenRecentSessionInNewWindowCommand.Execute(recentSession); + + this.MockRecentSessionLauncher.Verify(launcher => launcher.LaunchRecentSession(It.Is(session => session.Matches(recentSession))), + Times.Exactly(1)); + this.MockSessionFactory.Verify(sf => sf.CreateSession(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + this.MockSessionFactory.Verify(sf => sf.CreateDiffSession(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + public void Dispose() => this.WindsorContainer.Dispose(); } diff --git a/src/SizeBench.GUI.Tests/RecentSessionStoreTests.cs b/src/SizeBench.GUI.Tests/RecentSessionStoreTests.cs new file mode 100644 index 0000000..fe181ad --- /dev/null +++ b/src/SizeBench.GUI.Tests/RecentSessionStoreTests.cs @@ -0,0 +1,108 @@ +using System.IO; +using SizeBench.AnalysisEngine; +using SizeBench.TestInfrastructure; + +namespace SizeBench.GUI.Tests; + +[TestClass] +public sealed class RecentSessionStoreTests +{ + [TestMethod] + public void RecordSessionPersistsAndReloadsSingleAndDiffSessions() + { + var tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDirectory); + + try + { + var storagePath = Path.Combine(tempDirectory, "RecentSessions.json"); + using var logger = new TestNoOpApplicationLogger(); + var store = new RecentSessionStore(logger, storagePath); + + var singleSession = RecentSession.CreateSingle(@"c:\dev\foo.dll", + @"c:\dev\foo.pdb", + new SessionOptions() { SymbolSourcesSupported = SymbolSourcesSupported.Code | SymbolSourcesSupported.DataSymbols }); + var diffSession = RecentSession.CreateDiff(@"c:\dev\before.dll", + @"c:\dev\before.pdb", + @"c:\dev\after.dll", + @"c:\dev\after.pdb"); + + store.RecordSession(singleSession); + store.RecordSession(diffSession); + + var reloadedStore = new RecentSessionStore(logger, storagePath); + var recentSessions = reloadedStore.GetRecentSessions(); + + Assert.AreEqual(2, recentSessions.Count); + Assert.AreEqual(RecentSessionKind.BinaryDiff, recentSessions[0].Kind); + Assert.AreEqual(@"c:\dev\before.dll", recentSessions[0].BeforeBinaryPath); + Assert.AreEqual(RecentSessionKind.SingleBinary, recentSessions[1].Kind); + Assert.AreEqual(SymbolSourcesSupported.Code | SymbolSourcesSupported.DataSymbols, recentSessions[1].SymbolSourcesSupported); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public void RecordSessionDedupesAndKeepsNewestCopy() + { + var tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDirectory); + + try + { + var storagePath = Path.Combine(tempDirectory, "RecentSessions.json"); + using var logger = new TestNoOpApplicationLogger(); + var store = new RecentSessionStore(logger, storagePath); + + var originalSession = RecentSession.CreateSingle(@"c:\dev\foo.dll", @"c:\dev\foo.pdb", new SessionOptions(), DateTimeOffset.UtcNow.AddDays(-2)); + var newerSession = RecentSession.CreateSingle(@"c:\dev\foo.dll", @"c:\dev\foo.pdb", new SessionOptions(), DateTimeOffset.UtcNow.AddDays(-1)); + + store.RecordSession(originalSession); + store.RecordSession(newerSession); + + var recentSessions = store.GetRecentSessions(); + + Assert.AreEqual(1, recentSessions.Count); + Assert.AreEqual(@"c:\dev\foo.dll", recentSessions[0].BinaryPath); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public void RecordSessionCapsHistoryAtTenEntries() + { + var tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDirectory); + + try + { + var storagePath = Path.Combine(tempDirectory, "RecentSessions.json"); + using var logger = new TestNoOpApplicationLogger(); + var store = new RecentSessionStore(logger, storagePath); + + for (var i = 0; i < RecentSessionStore.MaximumStoredSessions + 2; i++) + { + store.RecordSession(RecentSession.CreateSingle($@"c:\dev\foo{i}.dll", + $@"c:\dev\foo{i}.pdb", + new SessionOptions(), + DateTimeOffset.UtcNow.AddMinutes(-i))); + } + + var recentSessions = store.GetRecentSessions(); + + Assert.AreEqual(RecentSessionStore.MaximumStoredSessions, recentSessions.Count); + Assert.AreEqual(@"c:\dev\foo11.dll", recentSessions[0].BinaryPath); + Assert.AreEqual(@"c:\dev\foo2.dll", recentSessions[^1].BinaryPath); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } +} diff --git a/src/SizeBench.GUI/MainWindow.xaml b/src/SizeBench.GUI/MainWindow.xaml index 5de0dcf..5060310 100644 --- a/src/SizeBench.GUI/MainWindow.xaml +++ b/src/SizeBench.GUI/MainWindow.xaml @@ -1,246 +1,332 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Examine a binary - - - This is the place to start. Begin exploring a binary to learn what is consuming space, and where there is wasted space. - - - - - Start a diff - - - This is where to ask 'Did I make my product better?' by seeing how much you improved things between two versions of the - same binary. First select the 'before' then the 'after'. - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + +