diff --git a/OpenNest.Data/NestDetailsSession.cs b/OpenNest.Data/NestDetailsSession.cs
index 27aed5f..f87f8ae 100644
--- a/OpenNest.Data/NestDetailsSession.cs
+++ b/OpenNest.Data/NestDetailsSession.cs
@@ -4,11 +4,20 @@ namespace OpenNest.Data;
/// Loads the details of the nest highlighted in the Database-mode nest browser. Moving
/// the highlight starts a new load and cancels the previous one; a superseded load's
/// result or failure is discarded, so details never belong to a nest that is no longer
-/// highlighted. Call from one thread (the UI thread).
+/// highlighted. Also holds a small LRU cache of recently shown or ed
+/// nests, so browsing back onto one, or onto one a caller warmed ahead of time, resolves
+/// without a server round trip. Call from one thread (the UI thread).
///
public sealed class NestDetailsSession : IDisposable
{
+ // Bounds memory from cached plate/drawing geometry; generous enough for a caller's
+ // prefetch window plus some scrollback before the oldest entries are evicted.
+ private const int CacheCapacity = 25;
+
private readonly Func> _load;
+ private readonly Dictionary _cache = new();
+ private readonly List _cacheOrder = new();
+ private readonly Dictionary _prefetches = new();
private CancellationTokenSource? _pending;
private int _generation;
private bool _disposed;
@@ -49,7 +58,18 @@ public sealed class NestDetailsSession : IDisposable
try
{
- var details = await _load(id, cancellation.Token);
+ NestDetails details;
+ if (_cache.TryGetValue(id, out var cached))
+ {
+ Touch(id);
+ details = cached;
+ }
+ else
+ {
+ details = await _load(id, cancellation.Token);
+ Remember(id, details);
+ }
+
if (generation != _generation)
return false;
@@ -70,7 +90,63 @@ public sealed class NestDetailsSession : IDisposable
}
}
- /// Forgets the current details and discards any load in flight (no nest highlighted).
+ ///
+ /// Starts loading one nest's details in the background so a later
+ /// for it resolves from the cache instead of the server. Skipped when the nest is already
+ /// cached, already prefetching, or is the loaded/loading nest. Failures are swallowed here;
+ /// an on-demand load surfaces the error if the nest is actually selected later.
+ ///
+ public void Prefetch(Guid id)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (id == NestId || _cache.ContainsKey(id) || _prefetches.ContainsKey(id))
+ return;
+
+ var cancellation = new CancellationTokenSource();
+ _prefetches[id] = cancellation;
+ _ = PrefetchCoreAsync(id, cancellation);
+ }
+
+ private async Task PrefetchCoreAsync(Guid id, CancellationTokenSource cancellation)
+ {
+ try
+ {
+ var details = await _load(id, cancellation.Token);
+ if (!cancellation.IsCancellationRequested)
+ Remember(id, details);
+ }
+ catch
+ {
+ // Silent: this prefetch only mattered if the nest is later selected, and
+ // LoadAsync's own on-demand fetch will surface the error then.
+ }
+ finally
+ {
+ if (ReferenceEquals(_prefetches.GetValueOrDefault(id), cancellation))
+ _prefetches.Remove(id);
+ cancellation.Dispose();
+ }
+ }
+
+ private void Remember(Guid id, NestDetails details)
+ {
+ _cache[id] = details;
+ Touch(id);
+ while (_cacheOrder.Count > CacheCapacity)
+ {
+ var oldest = _cacheOrder[0];
+ _cacheOrder.RemoveAt(0);
+ _cache.Remove(oldest);
+ }
+ }
+
+ private void Touch(Guid id)
+ {
+ _cacheOrder.Remove(id);
+ _cacheOrder.Add(id);
+ }
+
+ /// Forgets the current details and discards any load or prefetch in flight.
public void Clear()
{
if (_disposed)
@@ -83,6 +159,8 @@ public sealed class NestDetailsSession : IDisposable
Details = null;
IsLoading = false;
pending?.Cancel();
+ foreach (var prefetch in _prefetches.Values)
+ prefetch.Cancel();
}
private CancellationTokenSource Supersede()
diff --git a/OpenNest.Tests/Data/NestDetailsSessionTests.cs b/OpenNest.Tests/Data/NestDetailsSessionTests.cs
index 4c6c51c..935edb7 100644
--- a/OpenNest.Tests/Data/NestDetailsSessionTests.cs
+++ b/OpenNest.Tests/Data/NestDetailsSessionTests.cs
@@ -147,6 +147,74 @@ public class NestDetailsSessionTests
await Assert.ThrowsAsync(() => session.LoadAsync(Guid.NewGuid()));
}
+ [Fact]
+ public async Task Prefetch_ThenLoad_ResolvesFromTheCacheWithoutASecondRequest()
+ {
+ var requested = new List();
+ var details = new NestDetails();
+ using var session = new NestDetailsSession((id, _) =>
+ {
+ requested.Add(id);
+ return Task.FromResult(details);
+ });
+ var nestId = Guid.NewGuid();
+
+ session.Prefetch(nestId);
+ Assert.Equal(new[] { nestId }, requested);
+
+ Assert.True(await session.LoadAsync(nestId));
+ Assert.Same(details, session.Details);
+ Assert.Equal(new[] { nestId }, requested);
+ }
+
+ [Fact]
+ public async Task Prefetch_SkipsWhenAlreadyTheCurrentNest_OrAlreadyCached()
+ {
+ var requested = new List();
+ using var session = new NestDetailsSession((id, _) =>
+ {
+ requested.Add(id);
+ return Task.FromResult(new NestDetails());
+ });
+ var nestId = Guid.NewGuid();
+
+ await session.LoadAsync(nestId);
+ Assert.Equal(new[] { nestId }, requested);
+
+ // Already the current nest.
+ session.Prefetch(nestId);
+ Assert.Equal(new[] { nestId }, requested);
+
+ // No longer current, but its earlier load is still cached.
+ session.Clear();
+ session.Prefetch(nestId);
+ Assert.Equal(new[] { nestId }, requested);
+ }
+
+ [Fact]
+ public async Task Clear_CancelsAnInFlightPrefetch_SoItsResultIsNeverCached()
+ {
+ var loads = new ControlledLoads();
+ using var session = new NestDetailsSession(loads.Load);
+ var nestId = Guid.NewGuid();
+
+ session.Prefetch(nestId);
+ Assert.False(loads.Tokens[0].IsCancellationRequested);
+
+ session.Clear();
+ Assert.True(loads.Tokens[0].IsCancellationRequested);
+ loads.Results[0].SetResult(new NestDetails());
+
+ // Whether or not the cancelled prefetch's continuation has run yet, it must not have
+ // populated the cache, so loading the same nest issues a fresh request.
+ var fresh = new NestDetails();
+ var load = session.LoadAsync(nestId);
+ Assert.Equal(2, loads.Results.Count);
+ loads.Results[1].SetResult(fresh);
+ Assert.True(await load);
+ Assert.Same(fresh, session.Details);
+ }
+
/// Each load waits for its own result, which the test completes explicitly.
private sealed class ControlledLoads
{
diff --git a/OpenNest/Forms/SavedNestsForm.cs b/OpenNest/Forms/SavedNestsForm.cs
index 9b08684..a58d7da 100644
--- a/OpenNest/Forms/SavedNestsForm.cs
+++ b/OpenNest/Forms/SavedNestsForm.cs
@@ -22,6 +22,11 @@ namespace OpenNest.Forms;
///
public sealed class SavedNestsForm : Form
{
+ // Rows this far before/after the highlight are warmed in the background once the
+ // highlighted nest's own details are showing, so arrowing through the page mostly
+ // hits the NestDetailsSession cache instead of downloading each nest on demand.
+ private const int PrefetchWindow = 5;
+
private const string IdColumn = "Id";
private const string PlateSizeColumn = "PlateSize";
private const string AreaColumn = "Area";
@@ -571,6 +576,7 @@ public sealed class SavedNestsForm : Form
if (details.Details != null)
{
PopulateDetails(details.Details);
+ PrefetchNearby(id.Value);
return;
}
@@ -590,7 +596,10 @@ public sealed class SavedNestsForm : Form
try
{
if (await details.LoadAsync(id) && !IsDisposed)
+ {
PopulateDetails(details.Details);
+ PrefetchNearby(id);
+ }
}
catch (Exception ex)
{
@@ -599,6 +608,41 @@ public sealed class SavedNestsForm : Form
}
}
+ ///
+ /// Warms the cache for the rows around the highlighted one (
+ /// on each side) so arrowing to a nearby nest resolves from 's
+ /// cache instead of a fresh download. Only the current page's rows are candidates.
+ ///
+ private void PrefetchNearby(Guid id)
+ {
+ var index = -1;
+ for (var i = 0; i < nestGrid.Rows.Count; i++)
+ {
+ if (nestGrid.Rows[i].Cells[IdColumn].Value is Guid rowId && rowId == id)
+ {
+ index = i;
+ break;
+ }
+ }
+
+ if (index < 0)
+ return;
+
+ for (var offset = 1; offset <= PrefetchWindow; offset++)
+ {
+ PrefetchRow(index - offset);
+ PrefetchRow(index + offset);
+ }
+ }
+
+ private void PrefetchRow(int index)
+ {
+ if (index < 0 || index >= nestGrid.Rows.Count)
+ return;
+ if (nestGrid.Rows[index].Cells[IdColumn].Value is Guid id)
+ details.Prefetch(id);
+ }
+
/// Downloads one nest's archive and reads its plates and drawings off the UI thread.
private async Task LoadDetailsAsync(Guid id, CancellationToken cancellationToken)
{