using System.Globalization; namespace OpenNest.Data; /// The paging facts a reads from a server page. public interface IBrowsePage { int Offset { get; } /// Number of records matching the request across all pages. int Total { get; } /// Number of records on this page. int ItemCount { get; } } /// /// Browse state shared by the paged server lists: the requested search, sort and page, and the /// last page applied. 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 abstract class BrowseSession : IDisposable where TPage : class, IBrowsePage where TSort : struct, Enum { private CancellationTokenSource? _pending; private int _generation; private bool _disposed; protected BrowseSession(int pageSize, int maxPageSize, TSort sort, bool descending = true) { if (pageSize < 1 || pageSize > maxPageSize) throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {maxPageSize}."); PageSize = pageSize; Sort = sort; Descending = descending; } public int PageSize { get; } /// Trimmed search text of the latest request. public string Search { get; private set; } = ""; public TSort Sort { get; private set; } public bool Descending { get; private set; } /// 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 TPage? 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.ItemCount < page.Total; /// Operator-facing summary of , such as "Showing 101-200 of 1,234 nests". public abstract string Summary { get; } /// Applies new search text and returns to the first page. public Task SetSearchAsync(string? search) { Search = (search ?? "").Trim(); return FirstPageAsync(); } /// Sorts by a column: a new column starts ascending, the same column reverses. public Task SortByAsync(TSort field) { Descending = EqualityComparer.Default.Equals(field, Sort) && !Descending; Sort = field; return FirstPageAsync(); } 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(); /// Re-queries from the first page, after a derived filter changed. protected Task FirstPageAsync() { Offset = 0; return RunAsync(); } /// Queries the page at with the current filters. protected abstract Task QueryAsync(CancellationToken cancellationToken); /// The standard summary: when nothing matches, else the shown range of . protected string PageSummary(string noun, string empty) { if (Page is not { } page) return ""; if (page.Total == 0) return empty; if (page.ItemCount == 0) return $"No {noun} on this page ({Count(page.Total)} in total)."; return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.ItemCount)} of {Count(page.Total)} {noun}"; } /// /// 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; // A deletion, an archive or another PC can leave the requested page past the end. if (page.ItemCount == 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 static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture); /// /// Cancels any request in flight (which releases its own source when it ends); /// the server 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(); } }