Skip to content

Commit 34fa043

Browse files
committed
feat: restore open tabs and active tab across sessions
1 parent 571f400 commit 34fa043

7 files changed

Lines changed: 486 additions & 0 deletions

File tree

src/SharpFM/App.axaml.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,19 @@ public override void OnFrameworkInitializationCompleted()
6666
repos.AddRange(pluginHost.Repositories);
6767
viewModel.AvailableRepositories = repos;
6868

69+
// Session restore: the VM ctor has already loaded the repository,
70+
// so FileMakerClips is populated. Restore the previously open tabs
71+
// before any UI interaction; misses (deleted/renamed clips) are
72+
// silently skipped.
73+
var sessionService = new SessionStateService(logger);
74+
viewModel.RestoreSessionState(sessionService.Load());
75+
6976
// Give the window access to plugin services for the manager dialog
7077
if (desktop.MainWindow is MainWindow mainWindow)
78+
{
7179
mainWindow.SetPluginServices(pluginService, pluginUIHost, pluginConfigService);
80+
mainWindow.SetSessionService(sessionService);
81+
}
7282

7383
desktop.MainWindow.DataContext = viewModel;
7484

src/SharpFM/MainWindow.axaml.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public partial class MainWindow : Window
2424
private PluginService? _pluginService;
2525
private PluginUIHost? _pluginHost;
2626
private PluginConfigService? _pluginConfigService;
27+
private SessionStateService? _sessionService;
2728

2829
public MainWindow()
2930
{
@@ -49,8 +50,18 @@ public MainWindow()
4950

5051
// Wire up plugin UI when DataContext is set
5152
DataContextChanged += OnDataContextChanged;
53+
54+
// Persist open tabs on close so the next launch can restore them.
55+
Closing += (_, _) =>
56+
{
57+
if (_sessionService is not null && DataContext is MainWindowViewModel vm)
58+
_sessionService.Save(vm.CaptureSessionState());
59+
};
5260
}
5361

62+
public void SetSessionService(SessionStateService sessionService) =>
63+
_sessionService = sessionService;
64+
5465
public void SetPluginServices(PluginService pluginService, PluginUIHost pluginHost, PluginConfigService pluginConfigService)
5566
{
5667
_pluginService = pluginService;

src/SharpFM/Models/SessionState.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using System.Collections.Generic;
2+
3+
namespace SharpFM.Models;
4+
5+
/// <summary>
6+
/// Persisted UI session — the set of clips the user had open across the editor
7+
/// tab strip and which one was active. Restored on launch after the clip
8+
/// repository has finished loading.
9+
/// </summary>
10+
/// <param name="OpenTabs">
11+
/// Tabs in their visual order. References that don't resolve against the
12+
/// current clip catalog on restore are silently skipped — they were deleted
13+
/// or renamed between sessions.
14+
/// </param>
15+
/// <param name="ActiveTab">
16+
/// The previously active tab, or <c>null</c> if no tab was active. Skipped
17+
/// silently on restore if it doesn't resolve.
18+
/// </param>
19+
public sealed record SessionState(
20+
IReadOnlyList<TabRef> OpenTabs,
21+
TabRef? ActiveTab)
22+
{
23+
/// <summary>Shared empty state — used when no session file exists yet.</summary>
24+
public static SessionState Empty { get; } = new([], null);
25+
}
26+
27+
/// <summary>
28+
/// Stable enough handle for a clip across sessions: its folder path plus its
29+
/// name. Mirrors <c>ClipData</c>'s identity. Renames or deletions invalidate
30+
/// the reference — by design, those tabs simply don't restore.
31+
/// </summary>
32+
public sealed record TabRef(
33+
IReadOnlyList<string> FolderPath,
34+
string Name);
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using System;
2+
using System.IO;
3+
using System.Text.Json;
4+
using Microsoft.Extensions.Logging;
5+
using SharpFM.Models;
6+
7+
namespace SharpFM.Services;
8+
9+
/// <summary>
10+
/// Persists the UI session (open tabs + active tab) as a single JSON file at
11+
/// <c>%LocalAppData%/SharpFM/session.json</c>. Read on launch, written on
12+
/// window close. Malformed or missing files yield <see cref="SessionState.Empty"/>;
13+
/// write failures are logged and swallowed so a failing disk can't crash app exit.
14+
/// </summary>
15+
public class SessionStateService
16+
{
17+
private readonly ILogger _logger;
18+
19+
public string FilePath { get; }
20+
21+
public SessionStateService(ILogger logger)
22+
: this(logger, DefaultPath())
23+
{
24+
}
25+
26+
public SessionStateService(ILogger logger, string filePath)
27+
{
28+
_logger = logger;
29+
FilePath = filePath;
30+
}
31+
32+
private static string DefaultPath() => Path.Combine(
33+
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
34+
"SharpFM", "session.json");
35+
36+
public SessionState Load()
37+
{
38+
if (!File.Exists(FilePath)) return SessionState.Empty;
39+
40+
try
41+
{
42+
var text = File.ReadAllText(FilePath);
43+
var state = JsonSerializer.Deserialize<SessionState>(text);
44+
if (state is null) return SessionState.Empty;
45+
// System.Text.Json doesn't enforce non-nullable record properties,
46+
// so a hand-edited or partial file can produce a SessionState with
47+
// a null OpenTabs list. Coerce here so the restore path can trust
48+
// its iteration target.
49+
return state.OpenTabs is null
50+
? state with { OpenTabs = [] }
51+
: state;
52+
}
53+
catch (Exception ex)
54+
{
55+
_logger.LogWarning(ex, "Failed to read session state from {Path}; ignoring.", FilePath);
56+
return SessionState.Empty;
57+
}
58+
}
59+
60+
public void Save(SessionState state)
61+
{
62+
try
63+
{
64+
var dir = Path.GetDirectoryName(FilePath);
65+
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
66+
var text = JsonSerializer.Serialize(state, new JsonSerializerOptions { WriteIndented = true });
67+
File.WriteAllText(FilePath, text);
68+
}
69+
catch (Exception ex)
70+
{
71+
_logger.LogWarning(ex, "Failed to write session state to {Path}.", FilePath);
72+
}
73+
}
74+
}

src/SharpFM/ViewModels/MainWindowViewModel.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,56 @@ private void Populate(IReadOnlyList<ClipData> clips, IReadOnlyList<FolderData> f
195195
}
196196
}
197197

198+
/// <summary>
199+
/// Snapshot the current tab strip and active tab as a serializable
200+
/// <see cref="SessionState"/>. Each tab is identified by its clip's
201+
/// <c>(FolderPath, Name)</c> tuple — the same pair used by the repository
202+
/// on disk.
203+
/// </summary>
204+
public SessionState CaptureSessionState()
205+
{
206+
var refs = OpenTabs.Tabs
207+
.Select(t => new TabRef(t.Clip.FolderPath, t.Clip.Clip.Name))
208+
.ToList();
209+
var active = OpenTabs.ActiveTab is { } a
210+
? new TabRef(a.Clip.FolderPath, a.Clip.Clip.Name)
211+
: null;
212+
return new SessionState(refs, active);
213+
}
214+
215+
/// <summary>
216+
/// Reopen every tab in <paramref name="state"/> that still resolves
217+
/// against <see cref="FileMakerClips"/>. Refs that don't match any
218+
/// loaded clip — because the clip was deleted or renamed between
219+
/// sessions — are silently skipped. Restored tabs are permanent (not
220+
/// preview). If the saved active tab survives, it becomes the active
221+
/// tab; otherwise the last successfully restored tab stays active as
222+
/// the natural side-effect of <see cref="OpenTabsViewModel.OpenAsPermanent"/>.
223+
/// </summary>
224+
public void RestoreSessionState(SessionState state)
225+
{
226+
foreach (var tabRef in state.OpenTabs)
227+
{
228+
var clip = FindClip(tabRef);
229+
if (clip is not null) OpenTabs.OpenAsPermanent(clip);
230+
}
231+
232+
if (state.ActiveTab is { } activeRef)
233+
{
234+
var clip = FindClip(activeRef);
235+
if (clip is not null)
236+
{
237+
var existing = OpenTabs.Tabs.FirstOrDefault(t => ReferenceEquals(t.Clip, clip));
238+
if (existing is not null) OpenTabs.ActiveTab = existing;
239+
}
240+
}
241+
}
242+
243+
private ClipViewModel? FindClip(TabRef tabRef) =>
244+
FileMakerClips.FirstOrDefault(c =>
245+
c.Clip.Name == tabRef.Name &&
246+
c.FolderPath.SequenceEqual(tabRef.FolderPath));
247+
198248
public async Task OpenFolderPicker()
199249
{
200250
try
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using Microsoft.Extensions.Logging.Abstractions;
5+
using SharpFM.Models;
6+
using SharpFM.Services;
7+
using Xunit;
8+
9+
namespace SharpFM.Tests.Services;
10+
11+
public class SessionStateServiceTests : IDisposable
12+
{
13+
private readonly string _dir;
14+
private readonly SessionStateService _svc;
15+
16+
public SessionStateServiceTests()
17+
{
18+
_dir = Path.Combine(Path.GetTempPath(), $"sharpfm-session-{Guid.NewGuid()}");
19+
_svc = new SessionStateService(NullLogger.Instance, Path.Combine(_dir, "session.json"));
20+
}
21+
22+
public void Dispose()
23+
{
24+
if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true);
25+
}
26+
27+
[Fact]
28+
public void Load_NoFile_ReturnsEmptyState()
29+
{
30+
var state = _svc.Load();
31+
32+
Assert.Empty(state.OpenTabs);
33+
Assert.Null(state.ActiveTab);
34+
}
35+
36+
[Fact]
37+
public void RoundTrip_PreservesOpenTabsAndActive()
38+
{
39+
var state = new SessionState(
40+
OpenTabs:
41+
[
42+
new TabRef(["Marketing"], "Welcome"),
43+
new TabRef([], "Notes"),
44+
new TabRef(["Marketing", "Drafts"], "Launch"),
45+
],
46+
ActiveTab: new TabRef([], "Notes"));
47+
48+
_svc.Save(state);
49+
var loaded = _svc.Load();
50+
51+
Assert.Equal(3, loaded.OpenTabs.Count);
52+
Assert.Equal(["Marketing"], loaded.OpenTabs[0].FolderPath);
53+
Assert.Equal("Welcome", loaded.OpenTabs[0].Name);
54+
Assert.Equal("Notes", loaded.ActiveTab?.Name);
55+
}
56+
57+
[Fact]
58+
public void RoundTrip_NullActiveTab_Preserved()
59+
{
60+
var state = new SessionState([new TabRef([], "Only")], ActiveTab: null);
61+
62+
_svc.Save(state);
63+
var loaded = _svc.Load();
64+
65+
Assert.Single(loaded.OpenTabs);
66+
Assert.Null(loaded.ActiveTab);
67+
}
68+
69+
[Fact]
70+
public void Save_CreatesParentDirectory_WhenAbsent()
71+
{
72+
// _dir doesn't exist yet because we haven't saved anything
73+
Assert.False(Directory.Exists(_dir));
74+
75+
_svc.Save(new SessionState([new TabRef([], "X")], null));
76+
77+
Assert.True(Directory.Exists(_dir));
78+
}
79+
80+
[Fact]
81+
public void Load_MalformedJson_ReturnsEmptyState()
82+
{
83+
Directory.CreateDirectory(_dir);
84+
File.WriteAllText(Path.Combine(_dir, "session.json"), "not json");
85+
86+
var state = _svc.Load();
87+
88+
Assert.Empty(state.OpenTabs);
89+
Assert.Null(state.ActiveTab);
90+
}
91+
92+
[Fact]
93+
public void Load_PartialJsonMissingOpenTabs_YieldsEmptyOpenTabs()
94+
{
95+
// System.Text.Json fills missing record fields with default (null for
96+
// reference types), so a hand-edited file like `{}` would otherwise
97+
// produce a SessionState with a null OpenTabs and break the restore
98+
// path's iteration. Load coerces.
99+
Directory.CreateDirectory(_dir);
100+
File.WriteAllText(Path.Combine(_dir, "session.json"), "{}");
101+
102+
var state = _svc.Load();
103+
104+
Assert.NotNull(state.OpenTabs);
105+
Assert.Empty(state.OpenTabs);
106+
Assert.Null(state.ActiveTab);
107+
}
108+
109+
[Fact]
110+
public void Save_IOFailure_DoesNotThrow()
111+
{
112+
// Point the service at a path whose parent cannot be created (a file
113+
// standing where a directory needs to exist). Save must swallow the
114+
// resulting IOException — failure to persist must not crash app exit.
115+
var blocker = Path.Combine(Path.GetTempPath(), $"sharpfm-blocker-{Guid.NewGuid()}");
116+
File.WriteAllText(blocker, "");
117+
try
118+
{
119+
var bad = new SessionStateService(NullLogger.Instance, Path.Combine(blocker, "session.json"));
120+
var ex = Record.Exception(() => bad.Save(new SessionState([], null)));
121+
Assert.Null(ex);
122+
}
123+
finally
124+
{
125+
if (File.Exists(blocker)) File.Delete(blocker);
126+
}
127+
}
128+
}

0 commit comments

Comments
 (0)