From 56ba05ccb7fa077e631beb8a1e79abccc8299526 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Wed, 20 May 2026 22:41:58 -0500 Subject: [PATCH] feat: restore open tabs and active tab across sessions --- src/SharpFM/App.axaml.cs | 10 + src/SharpFM/MainWindow.axaml.cs | 11 ++ src/SharpFM/Models/SessionState.cs | 34 ++++ src/SharpFM/Services/SessionStateService.cs | 74 ++++++++ src/SharpFM/ViewModels/MainWindowViewModel.cs | 50 +++++ .../Services/SessionStateServiceTests.cs | 128 +++++++++++++ .../ViewModels/SessionRestoreTests.cs | 179 ++++++++++++++++++ 7 files changed, 486 insertions(+) create mode 100644 src/SharpFM/Models/SessionState.cs create mode 100644 src/SharpFM/Services/SessionStateService.cs create mode 100644 tests/SharpFM.Tests/Services/SessionStateServiceTests.cs create mode 100644 tests/SharpFM.Tests/ViewModels/SessionRestoreTests.cs diff --git a/src/SharpFM/App.axaml.cs b/src/SharpFM/App.axaml.cs index 2c7efb2..01ad74a 100644 --- a/src/SharpFM/App.axaml.cs +++ b/src/SharpFM/App.axaml.cs @@ -66,9 +66,19 @@ public override void OnFrameworkInitializationCompleted() repos.AddRange(pluginHost.Repositories); viewModel.AvailableRepositories = repos; + // Session restore: the VM ctor has already loaded the repository, + // so FileMakerClips is populated. Restore the previously open tabs + // before any UI interaction; misses (deleted/renamed clips) are + // silently skipped. + var sessionService = new SessionStateService(logger); + viewModel.RestoreSessionState(sessionService.Load()); + // Give the window access to plugin services for the manager dialog if (desktop.MainWindow is MainWindow mainWindow) + { mainWindow.SetPluginServices(pluginService, pluginUIHost, pluginConfigService); + mainWindow.SetSessionService(sessionService); + } desktop.MainWindow.DataContext = viewModel; diff --git a/src/SharpFM/MainWindow.axaml.cs b/src/SharpFM/MainWindow.axaml.cs index 4973758..961fe6a 100644 --- a/src/SharpFM/MainWindow.axaml.cs +++ b/src/SharpFM/MainWindow.axaml.cs @@ -24,6 +24,7 @@ public partial class MainWindow : Window private PluginService? _pluginService; private PluginUIHost? _pluginHost; private PluginConfigService? _pluginConfigService; + private SessionStateService? _sessionService; public MainWindow() { @@ -49,8 +50,18 @@ public MainWindow() // Wire up plugin UI when DataContext is set DataContextChanged += OnDataContextChanged; + + // Persist open tabs on close so the next launch can restore them. + Closing += (_, _) => + { + if (_sessionService is not null && DataContext is MainWindowViewModel vm) + _sessionService.Save(vm.CaptureSessionState()); + }; } + public void SetSessionService(SessionStateService sessionService) => + _sessionService = sessionService; + public void SetPluginServices(PluginService pluginService, PluginUIHost pluginHost, PluginConfigService pluginConfigService) { _pluginService = pluginService; diff --git a/src/SharpFM/Models/SessionState.cs b/src/SharpFM/Models/SessionState.cs new file mode 100644 index 0000000..985c652 --- /dev/null +++ b/src/SharpFM/Models/SessionState.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace SharpFM.Models; + +/// +/// Persisted UI session — the set of clips the user had open across the editor +/// tab strip and which one was active. Restored on launch after the clip +/// repository has finished loading. +/// +/// +/// Tabs in their visual order. References that don't resolve against the +/// current clip catalog on restore are silently skipped — they were deleted +/// or renamed between sessions. +/// +/// +/// The previously active tab, or null if no tab was active. Skipped +/// silently on restore if it doesn't resolve. +/// +public sealed record SessionState( + IReadOnlyList OpenTabs, + TabRef? ActiveTab) +{ + /// Shared empty state — used when no session file exists yet. + public static SessionState Empty { get; } = new([], null); +} + +/// +/// Stable enough handle for a clip across sessions: its folder path plus its +/// name. Mirrors ClipData's identity. Renames or deletions invalidate +/// the reference — by design, those tabs simply don't restore. +/// +public sealed record TabRef( + IReadOnlyList FolderPath, + string Name); diff --git a/src/SharpFM/Services/SessionStateService.cs b/src/SharpFM/Services/SessionStateService.cs new file mode 100644 index 0000000..9e5c0d9 --- /dev/null +++ b/src/SharpFM/Services/SessionStateService.cs @@ -0,0 +1,74 @@ +using System; +using System.IO; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using SharpFM.Models; + +namespace SharpFM.Services; + +/// +/// Persists the UI session (open tabs + active tab) as a single JSON file at +/// %LocalAppData%/SharpFM/session.json. Read on launch, written on +/// window close. Malformed or missing files yield ; +/// write failures are logged and swallowed so a failing disk can't crash app exit. +/// +public class SessionStateService +{ + private readonly ILogger _logger; + + public string FilePath { get; } + + public SessionStateService(ILogger logger) + : this(logger, DefaultPath()) + { + } + + public SessionStateService(ILogger logger, string filePath) + { + _logger = logger; + FilePath = filePath; + } + + private static string DefaultPath() => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "SharpFM", "session.json"); + + public SessionState Load() + { + if (!File.Exists(FilePath)) return SessionState.Empty; + + try + { + var text = File.ReadAllText(FilePath); + var state = JsonSerializer.Deserialize(text); + if (state is null) return SessionState.Empty; + // System.Text.Json doesn't enforce non-nullable record properties, + // so a hand-edited or partial file can produce a SessionState with + // a null OpenTabs list. Coerce here so the restore path can trust + // its iteration target. + return state.OpenTabs is null + ? state with { OpenTabs = [] } + : state; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read session state from {Path}; ignoring.", FilePath); + return SessionState.Empty; + } + } + + public void Save(SessionState state) + { + try + { + var dir = Path.GetDirectoryName(FilePath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + var text = JsonSerializer.Serialize(state, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(FilePath, text); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to write session state to {Path}.", FilePath); + } + } +} diff --git a/src/SharpFM/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs index cadb567..141b2ef 100644 --- a/src/SharpFM/ViewModels/MainWindowViewModel.cs +++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs @@ -195,6 +195,56 @@ private void Populate(IReadOnlyList clips, IReadOnlyList f } } + /// + /// Snapshot the current tab strip and active tab as a serializable + /// . Each tab is identified by its clip's + /// (FolderPath, Name) tuple — the same pair used by the repository + /// on disk. + /// + public SessionState CaptureSessionState() + { + var refs = OpenTabs.Tabs + .Select(t => new TabRef(t.Clip.FolderPath, t.Clip.Clip.Name)) + .ToList(); + var active = OpenTabs.ActiveTab is { } a + ? new TabRef(a.Clip.FolderPath, a.Clip.Clip.Name) + : null; + return new SessionState(refs, active); + } + + /// + /// Reopen every tab in that still resolves + /// against . Refs that don't match any + /// loaded clip — because the clip was deleted or renamed between + /// sessions — are silently skipped. Restored tabs are permanent (not + /// preview). If the saved active tab survives, it becomes the active + /// tab; otherwise the last successfully restored tab stays active as + /// the natural side-effect of . + /// + public void RestoreSessionState(SessionState state) + { + foreach (var tabRef in state.OpenTabs) + { + var clip = FindClip(tabRef); + if (clip is not null) OpenTabs.OpenAsPermanent(clip); + } + + if (state.ActiveTab is { } activeRef) + { + var clip = FindClip(activeRef); + if (clip is not null) + { + var existing = OpenTabs.Tabs.FirstOrDefault(t => ReferenceEquals(t.Clip, clip)); + if (existing is not null) OpenTabs.ActiveTab = existing; + } + } + } + + private ClipViewModel? FindClip(TabRef tabRef) => + FileMakerClips.FirstOrDefault(c => + c.Clip.Name == tabRef.Name && + c.FolderPath.SequenceEqual(tabRef.FolderPath)); + public async Task OpenFolderPicker() { try diff --git a/tests/SharpFM.Tests/Services/SessionStateServiceTests.cs b/tests/SharpFM.Tests/Services/SessionStateServiceTests.cs new file mode 100644 index 0000000..3634507 --- /dev/null +++ b/tests/SharpFM.Tests/Services/SessionStateServiceTests.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Extensions.Logging.Abstractions; +using SharpFM.Models; +using SharpFM.Services; +using Xunit; + +namespace SharpFM.Tests.Services; + +public class SessionStateServiceTests : IDisposable +{ + private readonly string _dir; + private readonly SessionStateService _svc; + + public SessionStateServiceTests() + { + _dir = Path.Combine(Path.GetTempPath(), $"sharpfm-session-{Guid.NewGuid()}"); + _svc = new SessionStateService(NullLogger.Instance, Path.Combine(_dir, "session.json")); + } + + public void Dispose() + { + if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true); + } + + [Fact] + public void Load_NoFile_ReturnsEmptyState() + { + var state = _svc.Load(); + + Assert.Empty(state.OpenTabs); + Assert.Null(state.ActiveTab); + } + + [Fact] + public void RoundTrip_PreservesOpenTabsAndActive() + { + var state = new SessionState( + OpenTabs: + [ + new TabRef(["Marketing"], "Welcome"), + new TabRef([], "Notes"), + new TabRef(["Marketing", "Drafts"], "Launch"), + ], + ActiveTab: new TabRef([], "Notes")); + + _svc.Save(state); + var loaded = _svc.Load(); + + Assert.Equal(3, loaded.OpenTabs.Count); + Assert.Equal(["Marketing"], loaded.OpenTabs[0].FolderPath); + Assert.Equal("Welcome", loaded.OpenTabs[0].Name); + Assert.Equal("Notes", loaded.ActiveTab?.Name); + } + + [Fact] + public void RoundTrip_NullActiveTab_Preserved() + { + var state = new SessionState([new TabRef([], "Only")], ActiveTab: null); + + _svc.Save(state); + var loaded = _svc.Load(); + + Assert.Single(loaded.OpenTabs); + Assert.Null(loaded.ActiveTab); + } + + [Fact] + public void Save_CreatesParentDirectory_WhenAbsent() + { + // _dir doesn't exist yet because we haven't saved anything + Assert.False(Directory.Exists(_dir)); + + _svc.Save(new SessionState([new TabRef([], "X")], null)); + + Assert.True(Directory.Exists(_dir)); + } + + [Fact] + public void Load_MalformedJson_ReturnsEmptyState() + { + Directory.CreateDirectory(_dir); + File.WriteAllText(Path.Combine(_dir, "session.json"), "not json"); + + var state = _svc.Load(); + + Assert.Empty(state.OpenTabs); + Assert.Null(state.ActiveTab); + } + + [Fact] + public void Load_PartialJsonMissingOpenTabs_YieldsEmptyOpenTabs() + { + // System.Text.Json fills missing record fields with default (null for + // reference types), so a hand-edited file like `{}` would otherwise + // produce a SessionState with a null OpenTabs and break the restore + // path's iteration. Load coerces. + Directory.CreateDirectory(_dir); + File.WriteAllText(Path.Combine(_dir, "session.json"), "{}"); + + var state = _svc.Load(); + + Assert.NotNull(state.OpenTabs); + Assert.Empty(state.OpenTabs); + Assert.Null(state.ActiveTab); + } + + [Fact] + public void Save_IOFailure_DoesNotThrow() + { + // Point the service at a path whose parent cannot be created (a file + // standing where a directory needs to exist). Save must swallow the + // resulting IOException — failure to persist must not crash app exit. + var blocker = Path.Combine(Path.GetTempPath(), $"sharpfm-blocker-{Guid.NewGuid()}"); + File.WriteAllText(blocker, ""); + try + { + var bad = new SessionStateService(NullLogger.Instance, Path.Combine(blocker, "session.json")); + var ex = Record.Exception(() => bad.Save(new SessionState([], null))); + Assert.Null(ex); + } + finally + { + if (File.Exists(blocker)) File.Delete(blocker); + } + } +} diff --git a/tests/SharpFM.Tests/ViewModels/SessionRestoreTests.cs b/tests/SharpFM.Tests/ViewModels/SessionRestoreTests.cs new file mode 100644 index 0000000..0498c73 --- /dev/null +++ b/tests/SharpFM.Tests/ViewModels/SessionRestoreTests.cs @@ -0,0 +1,179 @@ +using System.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using SharpFM.Model; +using SharpFM.Models; +using SharpFM.ViewModels; +using Xunit; + +namespace SharpFM.Tests.ViewModels; + +/// +/// Capture/Restore round-trip behaviour on +/// for the session-restore feature. The clip repository for these tests is +/// empty (the default %LocalAppData%/SharpFM/Clips path returns no +/// clips in CI); each test seeds +/// directly with the (folder, name) tuples it needs. +/// +public class SessionRestoreTests +{ + private static MainWindowViewModel CreateVm() => + new( + NullLoggerFactory.Instance.CreateLogger(), + new MockClipboardService(), + new MockFolderService()); + + private static ClipViewModel SeedClip(MainWindowViewModel vm, string name, params string[] folder) + { + var clip = new ClipViewModel(Clip.FromXml(name, "Mac-XMSS", "")) + { + FolderPath = folder, + }; + vm.FileMakerClips.Add(clip); + return clip; + } + + [Fact] + public void CaptureSessionState_NoTabsOpen_ReturnsEmpty() + { + var vm = CreateVm(); + + var state = vm.CaptureSessionState(); + + Assert.Empty(state.OpenTabs); + Assert.Null(state.ActiveTab); + } + + [Fact] + public void CaptureSessionState_RecordsTabsInOrder_AndActiveTab() + { + var vm = CreateVm(); + var a = SeedClip(vm, "Alpha"); + var b = SeedClip(vm, "Beta", "Drafts"); + var c = SeedClip(vm, "Gamma", "Drafts", "Old"); + + vm.OpenTabs.OpenAsPermanent(a); + vm.OpenTabs.OpenAsPermanent(b); + vm.OpenTabs.OpenAsPermanent(c); + vm.OpenTabs.ActiveTab = vm.OpenTabs.Tabs[1]; + + var state = vm.CaptureSessionState(); + + Assert.Equal(3, state.OpenTabs.Count); + Assert.Equal("Alpha", state.OpenTabs[0].Name); + Assert.Empty(state.OpenTabs[0].FolderPath); + Assert.Equal("Beta", state.OpenTabs[1].Name); + Assert.Equal(["Drafts"], state.OpenTabs[1].FolderPath); + Assert.Equal(["Drafts", "Old"], state.OpenTabs[2].FolderPath); + Assert.Equal("Beta", state.ActiveTab?.Name); + } + + [Fact] + public void RestoreSessionState_ResolvesByFolderAndName_OpensAndSetsActive() + { + var vm = CreateVm(); + SeedClip(vm, "Alpha"); + SeedClip(vm, "Beta", "Drafts"); + + var state = new SessionState( + OpenTabs: + [ + new TabRef([], "Alpha"), + new TabRef(["Drafts"], "Beta"), + ], + ActiveTab: new TabRef(["Drafts"], "Beta")); + + vm.RestoreSessionState(state); + + Assert.Equal(2, vm.OpenTabs.Tabs.Count); + Assert.Equal("Alpha", vm.OpenTabs.Tabs[0].Clip.Clip.Name); + Assert.Equal("Beta", vm.OpenTabs.Tabs[1].Clip.Clip.Name); + Assert.Equal("Beta", vm.OpenTabs.ActiveTab?.Clip.Clip.Name); + } + + [Fact] + public void RestoreSessionState_RestoredTabsAreNotPreview() + { + var vm = CreateVm(); + SeedClip(vm, "Alpha"); + + vm.RestoreSessionState(new SessionState([new TabRef([], "Alpha")], null)); + + Assert.False(vm.OpenTabs.Tabs[0].IsPreview); + } + + [Fact] + public void RestoreSessionState_SkipsMissingClipsSilently() + { + var vm = CreateVm(); + SeedClip(vm, "Alpha"); + + var state = new SessionState( + OpenTabs: + [ + new TabRef([], "Alpha"), + new TabRef([], "Gone"), + new TabRef(["Wrong"], "Alpha"), + ], + ActiveTab: null); + + vm.RestoreSessionState(state); + + Assert.Single(vm.OpenTabs.Tabs); + Assert.Equal("Alpha", vm.OpenTabs.Tabs[0].Clip.Clip.Name); + } + + [Fact] + public void RestoreSessionState_AllClipsMissing_LeavesEditorEmpty() + { + var vm = CreateVm(); + + var state = new SessionState( + OpenTabs: [new TabRef([], "Gone"), new TabRef([], "AlsoGone")], + ActiveTab: new TabRef([], "Gone")); + + vm.RestoreSessionState(state); + + Assert.Empty(vm.OpenTabs.Tabs); + Assert.Null(vm.OpenTabs.ActiveTab); + } + + [Fact] + public void RestoreSessionState_MissingActive_LeavesLastRestoredActive() + { + var vm = CreateVm(); + SeedClip(vm, "Alpha"); + SeedClip(vm, "Beta"); + + var state = new SessionState( + OpenTabs: [new TabRef([], "Alpha"), new TabRef([], "Beta")], + ActiveTab: new TabRef([], "Gone")); + + vm.RestoreSessionState(state); + + Assert.Equal(2, vm.OpenTabs.Tabs.Count); + // OpenAsPermanent sets ActiveTab to whatever was just opened; with the + // saved active unresolved, we leave that natural last-opened state alone. + Assert.Equal("Beta", vm.OpenTabs.ActiveTab?.Clip.Clip.Name); + } + + [Fact] + public void Capture_then_Restore_RoundTripsThroughEmpty() + { + var vm1 = CreateVm(); + var a = SeedClip(vm1, "Alpha"); + var b = SeedClip(vm1, "Beta", "Folder"); + vm1.OpenTabs.OpenAsPermanent(a); + vm1.OpenTabs.OpenAsPermanent(b); + vm1.OpenTabs.ActiveTab = vm1.OpenTabs.Tabs[0]; + var captured = vm1.CaptureSessionState(); + + var vm2 = CreateVm(); + SeedClip(vm2, "Alpha"); + SeedClip(vm2, "Beta", "Folder"); + vm2.RestoreSessionState(captured); + + Assert.Equal(2, vm2.OpenTabs.Tabs.Count); + Assert.Equal("Alpha", vm2.OpenTabs.ActiveTab?.Clip.Clip.Name); + } +}