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);
+ }
+}