From 96267e760ef6089754bc3a353b44242030a3b049 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 2 Oct 2026 20:06:52 -0400 Subject: [PATCH] feat(desktop): page and filter database nests on the server The Database-mode Open dialog no longer downloads every record. It browses through NestBrowseSession, which sends one bounded query per change: the filter box (300 ms debounce) searches on the server and returns to the first page, column headers sort every match on the server (a second click reverses), and Previous/Next page by 100 with a range and total in the status line. A response superseded by a newer request is cancelled and discarded, refresh steps back when the current page was emptied, and failures clear the rows and show the error without a modal box per keystroke. --- OpenNest.Data/NestBrowseSession.cs | 170 ++++++++++++ OpenNest.Tests/Data/NestBrowseSessionTests.cs | 244 ++++++++++++++++++ OpenNest/Forms/SavedNestsForm.cs | 216 ++++++++++------ docs/nest-storage.md | 10 +- 4 files changed, 557 insertions(+), 83 deletions(-) create mode 100644 OpenNest.Data/NestBrowseSession.cs create mode 100644 OpenNest.Tests/Data/NestBrowseSessionTests.cs diff --git a/OpenNest.Data/NestBrowseSession.cs b/OpenNest.Data/NestBrowseSession.cs new file mode 100644 index 0000000..7c95056 --- /dev/null +++ b/OpenNest.Data/NestBrowseSession.cs @@ -0,0 +1,170 @@ +using System.Globalization; + +namespace OpenNest.Data; + +/// +/// Browse state for the Database-mode Open dialog: 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 sealed class NestBrowseSession : IDisposable +{ + private readonly INestRepository _repository; + private CancellationTokenSource? _pending; + private int _generation; + private bool _disposed; + + public NestBrowseSession(INestRepository repository, int pageSize = NestQuery.DefaultLimit) + { + ArgumentNullException.ThrowIfNull(repository); + if (pageSize < 1 || pageSize > NestQuery.MaxLimit) + throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {NestQuery.MaxLimit}."); + + _repository = repository; + PageSize = pageSize; + } + + public int PageSize { get; } + + /// Trimmed search text of the latest request. + public string Search { get; private set; } = ""; + + public NestSortField Sort { get; private set; } = NestSortField.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 NestPage? Page { 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 nests". + public string Summary + { + get + { + if (Page is not { } page) + return ""; + if (page.Total == 0) + return Search.Length == 0 ? "No nests on the server." : "No nests match the filter."; + if (page.Items.Count == 0) + return $"No nests on this page ({Count(page.Total)} in total)."; + + return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} nests"; + } + } + + /// Applies new search text and returns to the first page. + public Task SetSearchAsync(string? search) + { + Search = (search ?? "").Trim(); + Offset = 0; + return RunAsync(); + } + + /// Sorts by a column: a new column starts ascending, the same column reverses. + public Task SortByAsync(NestSortField 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); + // Superseded sources are cancelled but not disposed: their request may still observe the token. + _pending?.Cancel(); + var cancellation = new CancellationTokenSource(); + _pending = cancellation; + var generation = ++_generation; + + try + { + var page = await QueryAsync(cancellation.Token); + if (generation != _generation) + return false; + + // A deletion 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; + } + } + + private Task QueryAsync(CancellationToken cancellationToken) => + _repository.QueryAsync( + new NestQuery + { + Search = Search, + 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; the repository is not owned and stays open. + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _generation++; + _pending?.Cancel(); + _pending?.Dispose(); + _pending = null; + } +} diff --git a/OpenNest.Tests/Data/NestBrowseSessionTests.cs b/OpenNest.Tests/Data/NestBrowseSessionTests.cs new file mode 100644 index 0000000..ce4ea5f --- /dev/null +++ b/OpenNest.Tests/Data/NestBrowseSessionTests.cs @@ -0,0 +1,244 @@ +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +public class NestBrowseSessionTests +{ + [Fact] + public async Task Refresh_RequestsTheFirstBoundedPageNewestSavedFirst() + { + var repository = new PagedRepository(25); + using var session = new NestBrowseSession(repository, pageSize: 10); + + Assert.True(await session.RefreshAsync()); + + var query = Assert.Single(repository.Queries); + Assert.Equal("", query.Search); + Assert.Equal(NestSortField.SavedAt, query.Sort); + Assert.True(query.Descending); + Assert.Equal(0, query.Offset); + Assert.Equal(10, query.Limit); + Assert.Equal(10, session.Page!.Items.Count); + Assert.Equal("Showing 1-10 of 25 nests", session.Summary); + } + + [Fact] + public async Task NextAndPrevious_StayWithinTheMatches() + { + var repository = new PagedRepository(25); + using var session = new NestBrowseSession(repository, pageSize: 10); + await session.RefreshAsync(); + Assert.False(session.CanGoPrevious); + Assert.True(session.CanGoNext); + + Assert.True(await session.NextPageAsync()); + Assert.Equal("Showing 11-20 of 25 nests", session.Summary); + Assert.True(await session.NextPageAsync()); + Assert.Equal("Showing 21-25 of 25 nests", session.Summary); + Assert.False(session.CanGoNext); + Assert.True(session.CanGoPrevious); + + var sent = repository.Queries.Count; + Assert.False(await session.NextPageAsync()); + Assert.Equal(sent, repository.Queries.Count); + + Assert.True(await session.PreviousPageAsync()); + Assert.Equal(10, repository.Queries[^1].Offset); + Assert.Equal("Showing 11-20 of 25 nests", session.Summary); + } + + [Fact] + public async Task SetSearch_TrimsTextAndReturnsToTheFirstPage() + { + var repository = new PagedRepository(25); + using var session = new NestBrowseSession(repository, pageSize: 10); + await session.RefreshAsync(); + await session.NextPageAsync(); + + await session.SetSearchAsync(" beta works "); + + var query = repository.Queries[^1]; + Assert.Equal("beta works", query.Search); + Assert.Equal(0, query.Offset); + Assert.Equal("beta works", session.Search); + } + + [Fact] + public async Task SortBy_NewColumnAscends_SameColumnReverses_AndResetsTheOffset() + { + var repository = new PagedRepository(25); + using var session = new NestBrowseSession(repository, pageSize: 10); + await session.RefreshAsync(); + await session.NextPageAsync(); + + await session.SortByAsync(NestSortField.Customer); + Assert.Equal((NestSortField.Customer, false, 0), Last(repository)); + + await session.SortByAsync(NestSortField.Customer); + Assert.Equal((NestSortField.Customer, true, 0), Last(repository)); + + await session.SortByAsync(NestSortField.Thickness); + Assert.Equal((NestSortField.Thickness, false, 0), Last(repository)); + + await session.SortByAsync(NestSortField.SavedAt); + await session.SortByAsync(NestSortField.SavedAt); + Assert.Equal((NestSortField.SavedAt, true, 0), Last(repository)); + } + + [Fact] + public async Task OlderResponse_ArrivingLast_NeverReplacesTheNewerResult() + { + var repository = new ControlledRepository(); + using var session = new NestBrowseSession(repository); + var older = session.SetSearchAsync("a"); + var newer = session.SetSearchAsync("ab"); + var newerPage = Page("ab result"); + + repository.Requests[1].Reply.SetResult(newerPage); + Assert.True(await newer); + repository.Requests[0].Reply.SetResult(Page("a result")); + Assert.False(await older); + + Assert.Same(newerPage, session.Page); + Assert.True(repository.Requests[0].Token.IsCancellationRequested); + Assert.False(repository.Requests[1].Token.IsCancellationRequested); + } + + [Fact] + public async Task SupersededFailure_IsIgnored_LatestFailure_ClearsThePageAndPropagates() + { + var repository = new ControlledRepository(); + using var session = new NestBrowseSession(repository); + var older = session.SetSearchAsync("a"); + var newer = session.SetSearchAsync("ab"); + + repository.Requests[0].Reply.SetException(new IOException("superseded failure")); + Assert.False(await older); + repository.Requests[1].Reply.SetResult(Page("ab result")); + Assert.True(await newer); + Assert.NotNull(session.Page); + + var failing = session.RefreshAsync(); + repository.Requests[2].Reply.SetException(new IOException("server offline")); + var error = await Assert.ThrowsAsync(() => failing); + + Assert.Equal("server offline", error.Message); + Assert.Null(session.Page); + Assert.Equal("", session.Summary); + } + + [Fact] + public async Task Refresh_AfterTheLastPageWasEmptied_StepsBackToTheNewLastPage() + { + var repository = new PagedRepository(21); + using var session = new NestBrowseSession(repository, pageSize: 10); + await session.RefreshAsync(); + await session.NextPageAsync(); + await session.NextPageAsync(); + Assert.Equal("Showing 21-21 of 21 nests", session.Summary); + + repository.RemoveLast(); + Assert.True(await session.RefreshAsync()); + + Assert.Equal(new[] { 20, 10 }, repository.Queries.TakeLast(2).Select(q => q.Offset)); + Assert.Equal(10, session.Offset); + Assert.Equal("Showing 11-20 of 20 nests", session.Summary); + } + + [Theory] + [InlineData("", "No nests on the server.")] + [InlineData("no such text", "No nests match the filter.")] + public async Task Summary_DistinguishesAnEmptyServerFromNoMatches(string search, string expected) + { + using var session = new NestBrowseSession(new PagedRepository(0)); + + await session.SetSearchAsync(search); + + Assert.Equal(expected, session.Summary); + } + + [Fact] + public async Task Dispose_CancelsTheRequestAndDiscardsItsLateResponse() + { + var repository = new ControlledRepository(); + var session = new NestBrowseSession(repository); + var pending = session.SetSearchAsync("a"); + + session.Dispose(); + repository.Requests[0].Reply.SetResult(Page("late")); + + Assert.True(repository.Requests[0].Token.IsCancellationRequested); + Assert.False(await pending); + Assert.Null(session.Page); + await Assert.ThrowsAsync(() => session.RefreshAsync()); + } + + [Theory] + [InlineData(0)] + [InlineData(NestQuery.MaxLimit + 1)] + public void Constructor_RejectsPageSizesTheServerWouldRefuse(int pageSize) + { + Assert.Throws(() => new NestBrowseSession(new PagedRepository(0), pageSize)); + } + + private static (NestSortField, bool, int) Last(PagedRepository repository) + { + var query = repository.Queries[^1]; + return (query.Sort, query.Descending, query.Offset); + } + + private static NestPage Page(string name) => + new() { Items = new[] { new NestRecord { Id = Guid.NewGuid(), Name = name } }, Total = 1, Limit = 100 }; + + /// Serves pages of an in-memory set and records each query. + private sealed class PagedRepository : RepositoryBase + { + private readonly List _records; + + public PagedRepository(int count) => + _records = Enumerable.Range(0, count).Select(i => new NestRecord { Id = Guid.NewGuid(), Name = $"Nest {i}" }).ToList(); + + public List Queries { get; } = new(); + + public void RemoveLast() => _records.RemoveAt(_records.Count - 1); + + public override Task QueryAsync(NestQuery query, CancellationToken cancellationToken = default) + { + Queries.Add(query); + var matches = query.NormalizedSearch.Length == 0 ? _records : new List(); + return Task.FromResult(new NestPage + { + Items = matches.Skip(query.Offset).Take(query.Limit).ToArray(), + Total = matches.Count, + Offset = query.Offset, + Limit = query.Limit, + }); + } + } + + /// Leaves every query pending until the test completes it. + private sealed class ControlledRepository : RepositoryBase + { + public List<(NestQuery Query, TaskCompletionSource Reply, CancellationToken Token)> Requests { get; } = new(); + + public override Task QueryAsync(NestQuery query, CancellationToken cancellationToken = default) + { + var reply = new TaskCompletionSource(); + Requests.Add((query, reply, cancellationToken)); + return reply.Task; + } + } + + private abstract class RepositoryBase : INestRepository + { + public abstract Task QueryAsync(NestQuery query, CancellationToken cancellationToken = default); + + public Task> ListAsync(CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); + public Task UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException(); + public Task UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException(); + public Task UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException(); + public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); + } +} diff --git a/OpenNest/Forms/SavedNestsForm.cs b/OpenNest/Forms/SavedNestsForm.cs index d58d747..accc050 100644 --- a/OpenNest/Forms/SavedNestsForm.cs +++ b/OpenNest/Forms/SavedNestsForm.cs @@ -1,7 +1,5 @@ using System; -using System.Data; using System.Drawing; -using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; using OpenNest.Data; @@ -9,18 +7,26 @@ using OpenNest.Data; namespace OpenNest.Forms; /// -/// File > Open (Database mode): lists nests from the shared nest server with -/// filterable/sortable metadata columns, and lets the operator open or delete one. +/// File > Open (Database mode): browses nests on the shared nest server one bounded +/// page at a time. Filtering, sorting and paging run on the server through +/// ; this form only renders its state. /// public sealed class SavedNestsForm : Form { + private const string IdColumn = "Id"; + private const string LoadingText = "Loading..."; + private readonly INestRepository repository; + private readonly NestBrowseSession session; private readonly DataGridView grid; private readonly TextBox searchBox; + private readonly Timer searchTimer; private readonly Button openButton; private readonly Button deleteButton; - private readonly Button refreshButton; - private NestRecord[] records = Array.Empty(); + private readonly Button previousButton; + private readonly Button nextButton; + private readonly ToolStripStatusLabel statusLabel; + private int pendingRequests; /// Set to the chosen record's id when the dialog closes with OK. public Guid SelectedId { get; private set; } @@ -28,6 +34,7 @@ public sealed class SavedNestsForm : Form public SavedNestsForm(INestRepository repository) { this.repository = repository; + session = new NestBrowseSession(repository); Text = "Open Nest — Database"; StartPosition = FormStartPosition.CenterParent; @@ -36,15 +43,37 @@ public sealed class SavedNestsForm : Form MaximizeBox = false; ShowInTaskbar = false; - var topPanel = new Panel { Dock = DockStyle.Top, Height = 34, Padding = new Padding(6) }; - var searchLabel = new Label { Text = "Filter:", AutoSize = true, Location = new Point(6, 10) }; - searchBox = new TextBox { Location = new Point(50, 6), Width = 300 }; - searchBox.TextChanged += (_, _) => ApplyFilter(); - refreshButton = new Button { Text = "Refresh", AutoSize = true, Location = new Point(360, 5) }; - refreshButton.Click += async (_, _) => await LoadAsync(); + var topPanel = new FlowLayoutPanel + { + Dock = DockStyle.Top, + Height = 34, + Padding = new Padding(3), + WrapContents = false, + }; + var searchLabel = new Label { Text = "Filter:", AutoSize = true, Margin = new Padding(3, 7, 0, 0) }; + searchBox = new TextBox { Width = 300, MaxLength = NestQuery.MaxSearchLength }; + searchTimer = new Timer { Interval = 300 }; + searchTimer.Tick += async (_, _) => + { + searchTimer.Stop(); + await RunAsync(() => session.SetSearchAsync(searchBox.Text)); + }; + searchBox.TextChanged += (_, _) => + { + searchTimer.Stop(); + searchTimer.Start(); + }; + var refreshButton = new Button { Text = "Refresh", AutoSize = true }; + refreshButton.Click += async (_, _) => await RunAsync(session.RefreshAsync); + previousButton = new Button { Text = "< Previous", AutoSize = true, Enabled = false }; + previousButton.Click += async (_, _) => await RunAsync(session.PreviousPageAsync); + nextButton = new Button { Text = "Next >", AutoSize = true, Enabled = false }; + nextButton.Click += async (_, _) => await RunAsync(session.NextPageAsync); topPanel.Controls.Add(searchLabel); topPanel.Controls.Add(searchBox); topPanel.Controls.Add(refreshButton); + topPanel.Controls.Add(previousButton); + topPanel.Controls.Add(nextButton); grid = new DataGridView { @@ -56,6 +85,32 @@ public sealed class SavedNestsForm : Form MultiSelect = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells, }; + AddColumn("Name", NestSortField.Name, typeof(string)); + AddColumn("Customer", NestSortField.Customer, typeof(string)); + AddColumn("Status", NestSortField.Status, typeof(string)); + AddColumn("Material", NestSortField.Material, typeof(string)); + AddColumn("Date Created", NestSortField.DateCreated, typeof(DateTime)); + AddColumn("Date Modified", NestSortField.DateModified, typeof(DateTime)); + AddColumn("Thickness", NestSortField.Thickness, typeof(double)); + AddColumn("Plates", NestSortField.PlateCount, typeof(int)); + AddColumn("Parts", NestSortField.PartCount, typeof(int)); + AddColumn("Made By", NestSortField.MadeBy, typeof(string)); + AddColumn("Comments", NestSortField.Comments, typeof(string)); + AddColumn("File Size", NestSortField.FileSize, typeof(long)); + AddColumn("Saved", NestSortField.SavedAt, typeof(DateTime)); + grid.Columns.Add(new DataGridViewTextBoxColumn + { + Name = IdColumn, + ValueType = typeof(Guid), + Visible = false, + SortMode = DataGridViewColumnSortMode.NotSortable, + }); + // Sorting a page locally would misrepresent the archive; the server sorts all matches. + grid.ColumnHeaderMouseClick += async (_, e) => + { + if (e.Button == MouseButtons.Left && grid.Columns[e.ColumnIndex].Tag is NestSortField field) + await RunAsync(() => session.SortByAsync(field)); + }; grid.CellDoubleClick += (_, e) => { if (e.RowIndex >= 0) @@ -78,6 +133,10 @@ public sealed class SavedNestsForm : Form buttons.Controls.Add(openButton); buttons.Controls.Add(deleteButton); + var statusStrip = new StatusStrip { SizingGrip = false }; + statusLabel = new ToolStripStatusLabel { Spring = true, TextAlign = ContentAlignment.MiddleLeft }; + statusStrip.Items.Add(statusLabel); + grid.SelectionChanged += (_, _) => { var hasSelection = grid.SelectedRows.Count > 0; @@ -88,91 +147,72 @@ public sealed class SavedNestsForm : Form Controls.Add(grid); Controls.Add(topPanel); Controls.Add(buttons); + Controls.Add(statusStrip); CancelButton = cancel; - Shown += async (_, _) => await LoadAsync(); + Shown += async (_, _) => await RunAsync(session.RefreshAsync); } - private async Task LoadAsync() + private void AddColumn(string name, NestSortField sortField, Type valueType) { - Cursor = Cursors.WaitCursor; + grid.Columns.Add(new DataGridViewTextBoxColumn + { + Name = name, + HeaderText = name, + ValueType = valueType, + SortMode = DataGridViewColumnSortMode.Programmatic, + Tag = sortField, + }); + } + + /// + /// Sends one browse request and renders its page. A request superseded by a newer one + /// renders nothing; a failure clears the rows and shows the error in the status line. + /// + private async Task RunAsync(Func> request) + { + pendingRequests++; + UpdateNavigation(); + statusLabel.Text = LoadingText; try { - var list = await repository.ListAsync(); - if (IsDisposed) - return; - records = list.ToArray(); - ApplyFilter(); + if (await request() && !IsDisposed) + Populate(session.Page); } catch (Exception ex) { if (!IsDisposed) - MessageBox.Show( - this, - $"Could not load nests from the server: {ex.Message}", - "Open Nest — Database", - MessageBoxButtons.OK, - MessageBoxIcon.Error - ); + { + grid.Rows.Clear(); + statusLabel.Text = $"Could not load nests from the server: {ex.Message}"; + } } finally { + pendingRequests--; if (!IsDisposed) - Cursor = Cursors.Default; + { + UpdateNavigation(); + // A request that was declined or superseded renders nothing of its own. + if (pendingRequests == 0 && statusLabel.Text == LoadingText) + statusLabel.Text = session.Summary; + } } } - private void ApplyFilter() + private void UpdateNavigation() { - var filter = searchBox.Text.Trim(); - var filtered = string.IsNullOrEmpty(filter) - ? records - : records - .Where(r => - Contains(r.Name, filter) - || Contains(r.Customer, filter) - || Contains(r.Material, filter) - || Contains(r.MadeBy, filter) - || Contains(r.Comments, filter) - || Contains(r.Status.ToString(), filter) - || Contains(FormatStatus(r.Status), filter) - || Contains(r.DateCreated.ToString("g"), filter) - || Contains(r.DateModified.ToString("g"), filter) - || Contains(r.SavedAt.ToString("g"), filter) - || Contains(r.Thickness.ToString(), filter) - || Contains(r.PlateCount.ToString(), filter) - || Contains(r.PartCount.ToString(), filter) - || Contains(r.FileSize.ToString(), filter) - ) - .ToArray(); - - Populate(filtered); + var idle = pendingRequests == 0; + previousButton.Enabled = idle && session.CanGoPrevious; + nextButton.Enabled = idle && session.CanGoNext; } - private static bool Contains(string value, string filter) => - !string.IsNullOrEmpty(value) && value.Contains(filter, StringComparison.OrdinalIgnoreCase); - - private void Populate(NestRecord[] rows) + private void Populate(NestPage page) { - var table = new DataTable(); - table.Columns.Add("Name", typeof(string)); - table.Columns.Add("Customer", typeof(string)); - table.Columns.Add("Status", typeof(string)); - table.Columns.Add("Material", typeof(string)); - table.Columns.Add("Date Created", typeof(DateTime)); - table.Columns.Add("Date Modified", typeof(DateTime)); - table.Columns.Add("Thickness", typeof(double)); - table.Columns.Add("Plates", typeof(int)); - table.Columns.Add("Parts", typeof(int)); - table.Columns.Add("Made By", typeof(string)); - table.Columns.Add("Comments", typeof(string)); - table.Columns.Add("File Size", typeof(long)); - table.Columns.Add("Saved", typeof(DateTime)); - table.Columns.Add("Id", typeof(Guid)); - - foreach (var record in rows.OrderByDescending(r => r.SavedAt)) + grid.Rows.Clear(); + foreach (var record in page.Items) { - table.Rows.Add( + grid.Rows.Add( record.Name, record.Customer, FormatStatus(record.Status), @@ -190,9 +230,14 @@ public sealed class SavedNestsForm : Form ); } - grid.DataSource = table; - if (grid.Columns.Contains("Id")) - grid.Columns["Id"].Visible = false; + foreach (DataGridViewColumn column in grid.Columns) + { + column.HeaderCell.SortGlyphDirection = column.Tag is NestSortField field && field == session.Sort + ? session.Descending ? SortOrder.Descending : SortOrder.Ascending + : SortOrder.None; + } + + statusLabel.Text = session.Summary; } private static string FormatStatus(NestStatus status) => status switch @@ -207,7 +252,7 @@ public sealed class SavedNestsForm : Form { if (grid.SelectedRows.Count == 0) return; - SelectedId = (Guid)grid.SelectedRows[0].Cells["Id"].Value; + SelectedId = (Guid)grid.SelectedRows[0].Cells[IdColumn].Value; DialogResult = DialogResult.OK; Close(); } @@ -217,7 +262,7 @@ public sealed class SavedNestsForm : Form if (grid.SelectedRows.Count == 0) return; - var id = (Guid)grid.SelectedRows[0].Cells["Id"].Value; + var id = (Guid)grid.SelectedRows[0].Cells[IdColumn].Value; var name = grid.SelectedRows[0].Cells["Name"].Value?.ToString() ?? ""; var confirm = MessageBox.Show( @@ -235,7 +280,7 @@ public sealed class SavedNestsForm : Form { await repository.DeleteAsync(id); if (!IsDisposed) - await LoadAsync(); + await RunAsync(session.RefreshAsync); } catch (Exception ex) { @@ -254,4 +299,15 @@ public sealed class SavedNestsForm : Form deleteButton.Enabled = grid.SelectedRows.Count > 0; } } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + searchTimer.Dispose(); + session.Dispose(); + } + + base.Dispose(disposing); + } } diff --git a/docs/nest-storage.md b/docs/nest-storage.md index 45fca69..ece0875 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -4,9 +4,13 @@ The desktop app can save nests two ways: - **File mode** (default, unchanged behavior): Save/Save As write a `.nest` ZIP archive to disk via the normal file dialog. See [nest-file-format.md](nest-file-format.md). -- **Database mode**: Open lists server records with filterable metadata and - sortable columns; Save creates a record on first save and updates that same - record afterward. Save As creates a new record (a copy). A failed save keeps +- **Database mode**: Open browses server records 100 at a time. The filter box + searches name, customer, material, made by, comments and status on the server + after a short typing pause; clicking a column header sorts every match on the + server (click again to reverse); Previous/Next move between pages and the + status line shows the range and total. Dates and numbers are not searched as + text; sort their column instead. Save creates a record on first save and + updates that same record afterward. Save As creates a new record (a copy). A failed save keeps the previous record association; switching server URLs creates a record on the new server rather than updating an id from the previous one. Delete in the saved-nest list permanently removes the selected server record after confirmation.