diff --git a/OpenNest.Data/DrawingLibraryBrowseSession.cs b/OpenNest.Data/DrawingLibraryBrowseSession.cs
new file mode 100644
index 00000000..160dba0c
--- /dev/null
+++ b/OpenNest.Data/DrawingLibraryBrowseSession.cs
@@ -0,0 +1,230 @@
+using System.Globalization;
+using OpenNest;
+
+namespace OpenNest.Data;
+
+///
+/// Browse state for Add from Drawing Library: the requested search, sort, page and
+/// nest filter, and the last page applied. By default only drawings with the nest's
+/// material, grade and thickness are listed; lifts that
+/// filter. Every change issues one bounded server query. A response that a later request
+/// superseded is discarded (and the earlier request is cancelled), so an older result can
+/// never replace a newer one. Call from one thread (the UI thread).
+///
+public sealed class DrawingLibraryBrowseSession : IDisposable
+{
+ private readonly IDrawingLibrary _library;
+ private CancellationTokenSource? _pending;
+ private int _generation;
+ private bool _disposed;
+
+ public DrawingLibraryBrowseSession(IDrawingLibrary library, Nest nest, int pageSize = DrawingLibraryQuery.DefaultLimit)
+ {
+ ArgumentNullException.ThrowIfNull(library);
+ ArgumentNullException.ThrowIfNull(nest);
+ if (pageSize < 1 || pageSize > DrawingLibraryQuery.MaxLimit)
+ throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {DrawingLibraryQuery.MaxLimit}.");
+
+ _library = library;
+ PageSize = pageSize;
+ MaterialName = (nest.Material?.Name ?? "").Trim();
+ MaterialGrade = (nest.Material?.Grade ?? "").Trim();
+ // A nest without a thickness has nothing to match; it filters by material only.
+ Thickness = double.IsFinite(nest.Thickness) && nest.Thickness > 0
+ ? DrawingLibraryRules.NormalizeThickness(nest.Thickness)
+ : null;
+ }
+
+ public int PageSize { get; }
+
+ /// The nest's material name, applied unless ; blank means any.
+ public string MaterialName { get; }
+
+ /// The nest's grade, applied unless ; blank means any.
+ public string MaterialGrade { get; }
+
+ /// The nest's thickness, applied unless ; null means any.
+ public double? Thickness { get; }
+
+ /// True when the nest filter is lifted and every active drawing is listed.
+ public bool ShowAll { get; private set; }
+
+ /// True when the latest request narrows by the nest's material, grade or thickness.
+ public bool IsFilteredToNest =>
+ !ShowAll && (MaterialName.Length > 0 || MaterialGrade.Length > 0 || Thickness is not null);
+
+ /// Trimmed search text of the latest request.
+ public string Search { get; private set; } = "";
+
+ public DrawingSortField Sort { get; private set; } = DrawingSortField.SavedAt;
+
+ public bool Descending { get; private set; } = true;
+
+ /// Offset of the latest request.
+ public int Offset { get; private set; }
+
+ /// The latest applied page; null before the first result and after a failed request.
+ public DrawingLibraryPage? Page { get; private set; }
+
+ /// True while the latest request is outstanding; superseded requests do not count.
+ public bool IsLoading { get; private set; }
+
+ public bool CanGoPrevious => Offset > 0;
+
+ public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
+
+ /// Operator-facing summary of , such as "Showing 101-200 of 1,234 drawings".
+ public string Summary
+ {
+ get
+ {
+ if (Page is not { } page)
+ return "";
+ if (page.Total == 0)
+ {
+ if (Search.Length > 0)
+ return "No drawings match the search.";
+ return IsFilteredToNest
+ ? "No library drawings match this nest's material and thickness. Choose Show all to see every drawing."
+ : "No drawings in the library.";
+ }
+ if (page.Items.Count == 0)
+ return $"No drawings on this page ({Count(page.Total)} in total).";
+
+ return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} drawings";
+ }
+ }
+
+ /// Applies new search text and returns to the first page.
+ public Task SetSearchAsync(string? search)
+ {
+ Search = (search ?? "").Trim();
+ Offset = 0;
+ return RunAsync();
+ }
+
+ /// Lifts or restores the nest filter and returns to the first page.
+ public Task SetShowAllAsync(bool showAll)
+ {
+ ShowAll = showAll;
+ Offset = 0;
+ return RunAsync();
+ }
+
+ /// Sorts by a column: a new column starts ascending, the same column reverses.
+ public Task SortByAsync(DrawingSortField field)
+ {
+ Descending = field == Sort && !Descending;
+ Sort = field;
+ Offset = 0;
+ return RunAsync();
+ }
+
+ public Task NextPageAsync()
+ {
+ if (!CanGoNext)
+ return Task.FromResult(false);
+
+ Offset = Page!.Offset + PageSize;
+ return RunAsync();
+ }
+
+ public Task PreviousPageAsync()
+ {
+ if (!CanGoPrevious)
+ return Task.FromResult(false);
+
+ Offset = System.Math.Max(0, Offset - PageSize);
+ return RunAsync();
+ }
+
+ /// Re-reads the current page; steps back to the last page if it is now past the end.
+ public Task RefreshAsync() => RunAsync();
+
+ ///
+ /// Sends the current request. Returns true when its page was applied, false when a later
+ /// request superseded it. Failures of the latest request clear and propagate;
+ /// failures of superseded requests are ignored.
+ ///
+ private async Task RunAsync()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ // Supersede before cancelling: a request that completes synchronously when cancelled
+ // must already see itself as superseded. Each source is released by its own request.
+ var previous = _pending;
+ using var cancellation = new CancellationTokenSource();
+ _pending = cancellation;
+ var generation = ++_generation;
+ IsLoading = true;
+
+ try
+ {
+ previous?.Cancel();
+ var page = await QueryAsync(cancellation.Token);
+ if (generation != _generation)
+ return false;
+
+ // An archive or another PC can leave the requested page past the end.
+ if (page.Items.Count == 0 && page.Offset > 0 && page.Total > 0)
+ {
+ Offset = (page.Total - 1) / PageSize * PageSize;
+ page = await QueryAsync(cancellation.Token);
+ if (generation != _generation)
+ return false;
+ }
+
+ Page = page;
+ return true;
+ }
+ catch (Exception) when (generation != _generation)
+ {
+ return false;
+ }
+ catch
+ {
+ Page = null;
+ throw;
+ }
+ finally
+ {
+ if (ReferenceEquals(_pending, cancellation))
+ _pending = null;
+ if (generation == _generation)
+ IsLoading = false;
+ }
+ }
+
+ private Task QueryAsync(CancellationToken cancellationToken) =>
+ _library.QueryAsync(
+ new DrawingLibraryQuery
+ {
+ Search = Search,
+ MaterialName = ShowAll ? "" : MaterialName,
+ MaterialGrade = ShowAll ? "" : MaterialGrade,
+ Thickness = ShowAll ? null : Thickness,
+ Sort = Sort,
+ Descending = Descending,
+ Offset = Offset,
+ Limit = PageSize,
+ },
+ cancellationToken);
+
+ private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
+
+ ///
+ /// Cancels any request in flight (which releases its own source when it ends);
+ /// the library client is not owned and stays open.
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ _generation++;
+ IsLoading = false;
+ var pending = _pending;
+ _pending = null;
+ pending?.Cancel();
+ }
+}
diff --git a/OpenNest.Data/IDrawingLibrary.cs b/OpenNest.Data/IDrawingLibrary.cs
new file mode 100644
index 00000000..033860a8
--- /dev/null
+++ b/OpenNest.Data/IDrawingLibrary.cs
@@ -0,0 +1,14 @@
+namespace OpenNest.Data;
+
+///
+/// The read side of the server drawing library that browsing and adding to a nest use.
+/// is the only production implementation.
+///
+public interface IDrawingLibrary
+{
+ /// One bounded page of drawings matching the query, filtered and ordered by the server.
+ Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default);
+
+ /// The exact package of one immutable revision, or null when the drawing or revision is unknown.
+ Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default);
+}
diff --git a/OpenNest.Data/RemoteDrawingLibrary.cs b/OpenNest.Data/RemoteDrawingLibrary.cs
index 0f0d5da1..8b1571e5 100644
--- a/OpenNest.Data/RemoteDrawingLibrary.cs
+++ b/OpenNest.Data/RemoteDrawingLibrary.cs
@@ -16,7 +16,7 @@ namespace OpenNest.Data;
/// the library raises ; this client never falls back
/// to another store. Other failures surface as .
///
-public sealed class RemoteDrawingLibrary : IDisposable
+public sealed class RemoteDrawingLibrary : IDrawingLibrary, IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
diff --git a/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs b/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs
new file mode 100644
index 00000000..825a94bb
--- /dev/null
+++ b/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs
@@ -0,0 +1,273 @@
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+public class DrawingLibraryBrowseSessionTests
+{
+ private static Nest SyntheticNest() => new("Synthetic job")
+ {
+ Thickness = 0.187504,
+ Material = new Material(" Synthetic Steel ", "Grade A"),
+ };
+
+ [Fact]
+ public async Task Refresh_DefaultsToTheNestsMaterialGradeAndThicknessNewestSavedFirst()
+ {
+ var library = new PagedLibrary(25);
+ using var session = new DrawingLibraryBrowseSession(library, SyntheticNest(), pageSize: 10);
+
+ Assert.True(await session.RefreshAsync());
+
+ var query = Assert.Single(library.Queries);
+ Assert.Equal(("Synthetic Steel", "Grade A", (double?)0.1875), (query.MaterialName, query.MaterialGrade, query.Thickness));
+ Assert.Equal(("", (CutType?)null, false), (query.Search, query.CutType, query.IncludeArchived));
+ Assert.Equal((DrawingSortField.SavedAt, true, 0, 10), (query.Sort, query.Descending, query.Offset, query.Limit));
+ Assert.True(session.IsFilteredToNest);
+ Assert.Equal("Showing 1-10 of 25 drawings", session.Summary);
+ }
+
+ [Fact]
+ public async Task ShowAll_LiftsTheNestFilterAndReturnsToTheFirstPage()
+ {
+ var library = new PagedLibrary(25);
+ using var session = new DrawingLibraryBrowseSession(library, SyntheticNest(), pageSize: 10);
+ await session.RefreshAsync();
+ await session.NextPageAsync();
+
+ await session.SetShowAllAsync(true);
+
+ var all = library.Queries[^1];
+ Assert.Equal(("", "", (double?)null, 0), (all.MaterialName, all.MaterialGrade, all.Thickness, all.Offset));
+ Assert.False(session.IsFilteredToNest);
+
+ await session.SetShowAllAsync(false);
+
+ var filtered = library.Queries[^1];
+ Assert.Equal(("Synthetic Steel", "Grade A", (double?)0.1875), (filtered.MaterialName, filtered.MaterialGrade, filtered.Thickness));
+ }
+
+ [Fact]
+ public async Task NestWithoutMaterialOrThickness_IsNotFiltered()
+ {
+ var library = new PagedLibrary(3);
+ using var session = new DrawingLibraryBrowseSession(library, new Nest("Blank job") { Material = null! });
+
+ await session.RefreshAsync();
+
+ var query = Assert.Single(library.Queries);
+ Assert.Equal(("", "", (double?)null), (query.MaterialName, query.MaterialGrade, query.Thickness));
+ Assert.False(session.IsFilteredToNest);
+ }
+
+ [Fact]
+ public async Task SearchSortAndPaging_FollowTheNestBrowseRules()
+ {
+ var library = new PagedLibrary(25);
+ using var session = new DrawingLibraryBrowseSession(library, SyntheticNest(), pageSize: 10);
+ await session.RefreshAsync();
+ Assert.False(session.CanGoPrevious);
+
+ Assert.True(await session.NextPageAsync());
+ Assert.True(await session.NextPageAsync());
+ Assert.Equal("Showing 21-25 of 25 drawings", session.Summary);
+ Assert.False(session.CanGoNext);
+ var sent = library.Queries.Count;
+ Assert.False(await session.NextPageAsync());
+ Assert.Equal(sent, library.Queries.Count);
+ Assert.True(await session.PreviousPageAsync());
+ Assert.Equal(10, library.Queries[^1].Offset);
+
+ await session.SetSearchAsync(" bracket ");
+ Assert.Equal(("bracket", 0), (library.Queries[^1].Search, library.Queries[^1].Offset));
+
+ await session.SortByAsync(DrawingSortField.Name);
+ Assert.Equal((DrawingSortField.Name, false), (library.Queries[^1].Sort, library.Queries[^1].Descending));
+ await session.SortByAsync(DrawingSortField.Name);
+ Assert.Equal((DrawingSortField.Name, true), (library.Queries[^1].Sort, library.Queries[^1].Descending));
+ }
+
+ [Theory]
+ [InlineData(false, "", "No library drawings match this nest's material and thickness. Choose Show all to see every drawing.")]
+ [InlineData(true, "", "No drawings in the library.")]
+ [InlineData(false, "no such text", "No drawings match the search.")]
+ public async Task Summary_ExplainsAnEmptyResult(bool showAll, string search, string expected)
+ {
+ using var session = new DrawingLibraryBrowseSession(new PagedLibrary(0), SyntheticNest());
+
+ await session.SetShowAllAsync(showAll);
+ await session.SetSearchAsync(search);
+
+ Assert.Equal(expected, session.Summary);
+ }
+
+ [Fact]
+ public async Task OlderResponse_ArrivingLast_NeverReplacesTheNewerResult()
+ {
+ var library = new ControlledLibrary();
+ using var session = new DrawingLibraryBrowseSession(library, SyntheticNest());
+ var older = session.SetSearchAsync("a");
+ var newer = session.SetShowAllAsync(true);
+ var newerPage = Page("show all result");
+
+ library.Requests[1].Reply.SetResult(newerPage);
+ Assert.True(await newer);
+ library.Requests[0].Reply.SetResult(Page("a result"));
+ Assert.False(await older);
+
+ Assert.Same(newerPage, session.Page);
+ Assert.True(library.Requests[0].Token.IsCancellationRequested);
+ Assert.False(library.Requests[1].Token.IsCancellationRequested);
+ }
+
+ [Fact]
+ public async Task SupersededFailure_IsIgnored_LatestFailure_ClearsThePageAndPropagates()
+ {
+ var library = new ControlledLibrary();
+ using var session = new DrawingLibraryBrowseSession(library, SyntheticNest());
+ var older = session.SetSearchAsync("a");
+ var newer = session.SetSearchAsync("ab");
+
+ library.Requests[0].Reply.SetException(new IOException("superseded failure"));
+ Assert.False(await older);
+ library.Requests[1].Reply.SetResult(Page("ab result"));
+ Assert.True(await newer);
+
+ var failing = session.RefreshAsync();
+ library.Requests[2].Reply.SetException(new ServerTooOldException());
+ await Assert.ThrowsAsync(() => failing);
+
+ Assert.Null(session.Page);
+ Assert.Equal("", session.Summary);
+ Assert.False(session.IsLoading);
+ }
+
+ [Fact]
+ public async Task SupersededRequest_CompletingSynchronouslyOnCancellation_IsNotReportedAsTheLatestFailure()
+ {
+ var library = new ControlledLibrary { CompleteOnCancellation = true };
+ using var session = new DrawingLibraryBrowseSession(library, SyntheticNest());
+ var older = session.SetSearchAsync("a");
+ var newer = session.SetSearchAsync("ab");
+
+ Assert.False(await older);
+ library.Requests[1].Reply.SetResult(Page("ab result"));
+ Assert.True(await newer);
+
+ Assert.Equal("ab result", Assert.Single(session.Page!.Items).Name);
+ }
+
+ [Fact]
+ public async Task IsLoading_FollowsOnlyTheLatestRequest()
+ {
+ var library = new ControlledLibrary();
+ using var session = new DrawingLibraryBrowseSession(library, SyntheticNest());
+
+ var first = session.SetSearchAsync("a");
+ var second = session.SetSearchAsync("ab");
+ library.Requests[0].Reply.SetResult(Page("a result"));
+ Assert.False(await first);
+ Assert.True(session.IsLoading);
+ library.Requests[1].Reply.SetResult(Page("ab result"));
+ Assert.True(await second);
+ Assert.False(session.IsLoading);
+ }
+
+ [Fact]
+ public async Task Refresh_AfterTheLastPageWasEmptied_StepsBackToTheNewLastPage()
+ {
+ var library = new PagedLibrary(21);
+ using var session = new DrawingLibraryBrowseSession(library, SyntheticNest(), pageSize: 10);
+ await session.RefreshAsync();
+ await session.NextPageAsync();
+ await session.NextPageAsync();
+
+ library.RemoveLast();
+ Assert.True(await session.RefreshAsync());
+
+ Assert.Equal(new[] { 20, 10 }, library.Queries.TakeLast(2).Select(q => q.Offset));
+ Assert.Equal("Showing 11-20 of 20 drawings", session.Summary);
+ }
+
+ [Fact]
+ public async Task Dispose_CancelsTheRequestAndDiscardsItsLateResponse()
+ {
+ var library = new ControlledLibrary();
+ var session = new DrawingLibraryBrowseSession(library, SyntheticNest());
+ var pending = session.SetSearchAsync("a");
+
+ session.Dispose();
+ library.Requests[0].Reply.SetResult(Page("late"));
+
+ Assert.True(library.Requests[0].Token.IsCancellationRequested);
+ Assert.False(await pending);
+ Assert.Null(session.Page);
+ await Assert.ThrowsAsync(() => session.RefreshAsync());
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(DrawingLibraryQuery.MaxLimit + 1)]
+ public void Constructor_RejectsPageSizesTheServerWouldRefuse(int pageSize)
+ {
+ Assert.Throws(() => new DrawingLibraryBrowseSession(new PagedLibrary(0), SyntheticNest(), pageSize));
+ }
+
+ private static DrawingLibraryPage Page(string name) =>
+ new() { Items = new[] { new DrawingLibraryRecord { Id = Guid.NewGuid(), Revision = 1, Name = name } }, Total = 1, Limit = 100 };
+
+ /// Serves pages of an in-memory set (ignoring filters) and records each query.
+ private sealed class PagedLibrary : IDrawingLibrary
+ {
+ private readonly List _records;
+
+ public PagedLibrary(int count) =>
+ _records = Enumerable.Range(0, count)
+ .Select(i => new DrawingLibraryRecord { Id = Guid.NewGuid(), Revision = 1, Name = $"Drawing {i}" })
+ .ToList();
+
+ public List Queries { get; } = new();
+
+ public void RemoveLast() => _records.RemoveAt(_records.Count - 1);
+
+ public Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default)
+ {
+ Assert.Null(query.GetValidationError());
+ Queries.Add(query);
+ var matches = query.NormalizedSearch.Length == 0 ? _records : new List();
+ return Task.FromResult(new DrawingLibraryPage
+ {
+ Items = matches.Skip(query.Offset).Take(query.Limit).ToArray(),
+ Total = matches.Count,
+ Offset = query.Offset,
+ Limit = query.Limit,
+ });
+ }
+
+ public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+ }
+
+ ///
+ /// Leaves every query pending until the test completes it. With
+ /// , cancellation completes the query synchronously
+ /// inside the canceller's call, as some HTTP handlers do.
+ ///
+ private sealed class ControlledLibrary : IDrawingLibrary
+ {
+ public bool CompleteOnCancellation { get; init; }
+
+ public List<(DrawingLibraryQuery Query, TaskCompletionSource Reply, CancellationToken Token)> Requests { get; } = new();
+
+ public Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default)
+ {
+ var reply = new TaskCompletionSource();
+ if (CompleteOnCancellation)
+ cancellationToken.Register(() => reply.TrySetCanceled(cancellationToken));
+ Requests.Add((query, reply, cancellationToken));
+ return reply.Task;
+ }
+
+ public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+ }
+}