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
10 changes: 10 additions & 0 deletions src/SharpFM/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
11 changes: 11 additions & 0 deletions src/SharpFM/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public partial class MainWindow : Window
private PluginService? _pluginService;
private PluginUIHost? _pluginHost;
private PluginConfigService? _pluginConfigService;
private SessionStateService? _sessionService;

public MainWindow()
{
Expand All @@ -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;
Expand Down
34 changes: 34 additions & 0 deletions src/SharpFM/Models/SessionState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System.Collections.Generic;

namespace SharpFM.Models;

/// <summary>
/// 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.
/// </summary>
/// <param name="OpenTabs">
/// 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.
/// </param>
/// <param name="ActiveTab">
/// The previously active tab, or <c>null</c> if no tab was active. Skipped
/// silently on restore if it doesn't resolve.
/// </param>
public sealed record SessionState(
IReadOnlyList<TabRef> OpenTabs,
TabRef? ActiveTab)
{
/// <summary>Shared empty state — used when no session file exists yet.</summary>
public static SessionState Empty { get; } = new([], null);
}

/// <summary>
/// Stable enough handle for a clip across sessions: its folder path plus its
/// name. Mirrors <c>ClipData</c>'s identity. Renames or deletions invalidate
/// the reference — by design, those tabs simply don't restore.
/// </summary>
public sealed record TabRef(
IReadOnlyList<string> FolderPath,
string Name);
74 changes: 74 additions & 0 deletions src/SharpFM/Services/SessionStateService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System;
using System.IO;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using SharpFM.Models;

namespace SharpFM.Services;

/// <summary>
/// Persists the UI session (open tabs + active tab) as a single JSON file at
/// <c>%LocalAppData%/SharpFM/session.json</c>. Read on launch, written on
/// window close. Malformed or missing files yield <see cref="SessionState.Empty"/>;
/// write failures are logged and swallowed so a failing disk can't crash app exit.
/// </summary>
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<SessionState>(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);
}
}
}
50 changes: 50 additions & 0 deletions src/SharpFM/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,56 @@ private void Populate(IReadOnlyList<ClipData> clips, IReadOnlyList<FolderData> f
}
}

/// <summary>
/// Snapshot the current tab strip and active tab as a serializable
/// <see cref="SessionState"/>. Each tab is identified by its clip's
/// <c>(FolderPath, Name)</c> tuple — the same pair used by the repository
/// on disk.
/// </summary>
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);
}

/// <summary>
/// Reopen every tab in <paramref name="state"/> that still resolves
/// against <see cref="FileMakerClips"/>. 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 <see cref="OpenTabsViewModel.OpenAsPermanent"/>.
/// </summary>
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
Expand Down
128 changes: 128 additions & 0 deletions tests/SharpFM.Tests/Services/SessionStateServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading
Loading