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.
This commit is contained in:
aj committed 2026-10-02 20:06:52 -04:00
1 parent 646d4c3975
commit 96267e760e
4 files changed
+557 -83

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
using System.Globalization;
namespace OpenNest.Data;
/// <summary>
/// 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).
/// </summary>
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; }
/// <summary>Trimmed search text of the latest request.</summary>
public string Search { get; private set; } = "";
public NestSortField Sort { get; private set; } = NestSortField.SavedAt;
public bool Descending { get; private set; } = true;
/// <summary>Offset of the latest request.</summary>
public int Offset { get; private set; }
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
public NestPage? Page { get; private set; }
public bool CanGoPrevious => Offset > 0;
public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 nests".</summary>
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";
}
}
/// <summary>Applies new search text and returns to the first page.</summary>
public Task<bool> SetSearchAsync(string? search)
{
Search = (search ?? "").Trim();
Offset = 0;
return RunAsync();
}
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
public Task<bool> SortByAsync(NestSortField field)
{
Descending = field == Sort && !Descending;
Sort = field;
Offset = 0;
return RunAsync();
}
public Task<bool> NextPageAsync()
{
if (!CanGoNext)
return Task.FromResult(false);
Offset = Page!.Offset + PageSize;
return RunAsync();
}
public Task<bool> PreviousPageAsync()
{
if (!CanGoPrevious)
return Task.FromResult(false);
Offset = System.Math.Max(0, Offset - PageSize);
return RunAsync();
}
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
public Task<bool> RefreshAsync() => RunAsync();
/// <summary>
/// Sends the current request. Returns true when its page was applied, false when a later
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
/// failures of superseded requests are ignored.
/// </summary>
private async Task<bool> 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<NestPage> 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);
/// <summary>Cancels any request in flight; the repository is not owned and stays open.</summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_generation++;
_pending?.Cancel();
_pending?.Dispose();
_pending = null;
}
}
@@ -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<IOException>(() => 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<ObjectDisposedException>(() => session.RefreshAsync());
}
[Theory]
[InlineData(0)]
[InlineData(NestQuery.MaxLimit + 1)]
public void Constructor_RejectsPageSizesTheServerWouldRefuse(int pageSize)
{
Assert.Throws<ArgumentOutOfRangeException>(() => 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 };
/// <summary>Serves pages of an in-memory set and records each query.</summary>
private sealed class PagedRepository : RepositoryBase
{
private readonly List<NestRecord> _records;
public PagedRepository(int count) =>
_records = Enumerable.Range(0, count).Select(i => new NestRecord { Id = Guid.NewGuid(), Name = $"Nest {i}" }).ToList();
public List<NestQuery> Queries { get; } = new();
public void RemoveLast() => _records.RemoveAt(_records.Count - 1);
public override Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
{
Queries.Add(query);
var matches = query.NormalizedSearch.Length == 0 ? _records : new List<NestRecord>();
return Task.FromResult(new NestPage
{
Items = matches.Skip(query.Offset).Take(query.Limit).ToArray(),
Total = matches.Count,
Offset = query.Offset,
Limit = query.Limit,
});
}
}
/// <summary>Leaves every query pending until the test completes it.</summary>
private sealed class ControlledRepository : RepositoryBase
{
public List<(NestQuery Query, TaskCompletionSource<NestPage> Reply, CancellationToken Token)> Requests { get; } = new();
public override Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
{
var reply = new TaskCompletionSource<NestPage>();
Requests.Add((query, reply, cancellationToken));
return reply.Task;
}
}
private abstract class RepositoryBase : INestRepository
{
public abstract Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default);
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord> UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
}
}
+136 -80
View File
@@ -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;
/// <summary>
/// File &gt; Open (Database mode): lists nests from the shared nest server with
/// filterable/sortable metadata columns, and lets the operator open or delete one.
/// File &gt; 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
/// <see cref="NestBrowseSession"/>; this form only renders its state.
/// </summary>
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<NestRecord>();
private readonly Button previousButton;
private readonly Button nextButton;
private readonly ToolStripStatusLabel statusLabel;
private int pendingRequests;
/// <summary>Set to the chosen record's id when the dialog closes with OK.</summary>
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,
});
}
/// <summary>
/// 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.
/// </summary>
private async Task RunAsync(Func<Task<bool>> 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);
}
}
+7 -3
View File
@@ -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.