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.