feat(ui): open Database-mode nests from a larger saved-nest browser

File > Open in Database mode now shows a resizable browser instead of
the small list dialog. The upper Nests grid keeps the server-side
search, sort and paging (row numbers count through the filtered list;
the title shows the server and the range). Below it, Details tabs list
the highlighted nest's plates and drawings, read from its archive after
the highlight rests for 250 ms; moving back to a nest already shown
reuses its result, and a missing archive is reported in the details
line.

Enter opens the highlighted nest (in the Find box it runs the search at
once instead) and Esc closes. Double-click opens a nest, and a context
menu offers Open, Delete (after confirmation) and Refresh. It only
browses saved nests; new nests are still created from the main window.

Windows tests drive the real form: page load, details for two nests,
Enter opening the highlighted one, the missing-archive message and Esc.
This commit is contained in:
aj committed 2026-10-05 11:38:28 -04:00
1 parent 0446127e11
commit 9920aba6eb
4 files changed
+628 -145

No files matched your search

@@ -0,0 +1,169 @@
using System.Reflection;
using System.Windows.Forms;
using OpenNest.CNC;
using OpenNest.Data;
using OpenNest.Forms;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.WinForms.Tests.Forms;
public class SavedNestsFormTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(60);
[Fact]
public void HighlightedNestShowsItsPlatesAndDrawings_AndEnterOpensIt()
{
StaTestThread.Run(
() =>
{
var first = Record("First job");
var second = Record("Second job");
var repository = new FakeRepository(first, second);
repository.Files[first.Id] = NestFile(plateQuantity: 2, partsOnPlate: 3);
repository.Files[second.Id] = NestFile(plateQuantity: 1, partsOnPlate: 1);
using var form = new SavedNestsForm(repository, "http://nest-server:5000");
form.Show();
var nests = Field<DataGridView>(form, "nestGrid");
var plates = Field<DataGridView>(form, "platesGrid");
var drawings = Field<DataGridView>(form, "drawingsGrid");
PumpUntil(() => nests.Rows.Count == 2, "the nest page");
Assert.Equal("First job", nests.Rows[0].Cells[0].Value);
Assert.Equal("1", nests.Rows[0].HeaderCell.Value);
Assert.Contains("http://nest-server:5000", form.Text);
Assert.Contains("Showing 1-2 of 2 nests", form.Text);
PumpUntil(() => plates.Rows.Count == 1, "the first nest's plates");
Assert.Equal(2, plates.Rows[0].Cells["Duplicates"].Value);
Assert.Equal(3, plates.Rows[0].Cells["Parts"].Value);
Assert.Equal("Plate size (in)", plates.Columns["PlateSize"].HeaderText);
Assert.Equal(6, drawings.Rows[0].Cells["Nested"].Value);
nests.CurrentCell = nests.Rows[1].Cells[0];
PumpUntil(
() => plates.Rows.Count == 1 && Equals(plates.Rows[0].Cells["Duplicates"].Value, 1),
"the second nest's plates");
Assert.Equal(1, drawings.Rows[0].Cells["Nested"].Value);
Assert.True(ProcessKey(form, Keys.Enter));
Assert.Equal(DialogResult.OK, form.DialogResult);
Assert.Equal(second.Id, form.SelectedId);
},
TestTimeout,
"The saved-nest dialog test did not complete.");
}
[Fact]
public void MissingArchive_ShowsTheErrorInTheDetailsLine_AndEscapeCancels()
{
StaTestThread.Run(
() =>
{
var gone = Record("Deleted elsewhere");
using var form = new SavedNestsForm(new FakeRepository(gone));
form.Show();
var status = Field<Label>(form, "detailsStatus");
PumpUntil(() => status.Text.Contains("no longer exists"), "the missing-nest message");
Assert.Equal(0, Field<DataGridView>(form, "platesGrid").Rows.Count);
Assert.True(ProcessKey(form, Keys.Escape));
Assert.Equal(DialogResult.Cancel, form.DialogResult);
Assert.Equal(Guid.Empty, form.SelectedId);
},
TestTimeout,
"The saved-nest dialog test did not complete.");
}
private static NestRecord Record(string name) =>
new() { Id = Guid.NewGuid(), Name = name, SavedAt = DateTime.Now };
private static byte[] NestFile(int plateQuantity, int partsOnPlate)
{
var nest = new Nest("Job") { Units = Units.Inches };
var program = new OpenNest.CNC.Program();
program.Codes.Add(new RapidMove(new Vector(0, 0)));
program.Codes.Add(new LinearMove(new Vector(10, 0)));
program.Codes.Add(new LinearMove(new Vector(10, 10)));
program.Codes.Add(new LinearMove(new Vector(0, 10)));
program.Codes.Add(new LinearMove(new Vector(0, 0)));
var drawing = new Drawing("Square", program);
drawing.Quantity.Required = 10;
nest.Drawings.Add(drawing);
var plate = nest.CreatePlate();
plate.Size = new OpenNest.Geometry.Size(48, 96);
plate.Quantity = plateQuantity;
for (var i = 0; i < partsOnPlate; i++)
plate.Parts.Add(new Part(drawing, new Vector(i * 12, 0)));
using var stream = new MemoryStream();
new NestWriter(nest).Write(stream);
return stream.ToArray();
}
private static void PumpUntil(Func<bool> condition, string what)
{
var until = DateTime.UtcNow.AddSeconds(15);
while (!condition() && DateTime.UtcNow < until)
{
Application.DoEvents();
Thread.Sleep(10);
}
Assert.True(condition(), $"Timed out waiting for {what}.");
}
private static bool ProcessKey(Form form, Keys key)
{
var method = form.GetType().GetMethod("ProcessCmdKey", BindingFlags.Instance | BindingFlags.NonPublic)!;
return (bool)method.Invoke(form, new object[] { new Message(), key })!;
}
private static T Field<T>(object owner, string name) =>
(T)owner.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(owner)!;
/// <summary>Serves one page of the given records and archives by id.</summary>
private sealed class FakeRepository : INestRepository
{
private readonly NestRecord[] _records;
public FakeRepository(params NestRecord[] records) => _records = records;
public Dictionary<Guid, byte[]> Files { get; } = new();
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default) =>
Task.FromResult(new NestPage
{
Items = _records,
Total = _records.Length,
Offset = query.Offset,
Limit = query.Limit,
});
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default) =>
Task.FromResult(Files.TryGetValue(id, out var file) ? file : null);
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<NestRecord> UploadAsync(byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<NestRecord> UpdateFileAsync(
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
}
+1 -1
View File
@@ -602,7 +602,7 @@ namespace OpenNest.Forms
try
{
var repository = NestStorage.CreateRepository();
using var dlg = new SavedNestsForm(repository);
using var dlg = new SavedNestsForm(repository, NestStorage.Settings.ServerUrl);
if (dlg.ShowDialog(this) != DialogResult.OK)
return;
+441 -132
View File
@@ -1,56 +1,176 @@
using System;
using System.Drawing;
using System.Globalization;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.IO;
using Timer = System.Windows.Forms.Timer;
namespace OpenNest.Forms;
/// <summary>
/// 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.
/// File &gt; Open (Database mode). The upper list browses saved nests on the shared nest
/// server one bounded page at a time; filtering, sorting and paging run on the server
/// through <see cref="NestBrowseSession"/>. The lower tabs show the plates and drawings of
/// the highlighted nest, read from its archive through <see cref="NestDetailsSession"/>.
/// This form only renders their state.
/// </summary>
public sealed class SavedNestsForm : Form
{
private const string IdColumn = "Id";
private const string PlateSizeColumn = "PlateSize";
private const string AreaColumn = "Area";
private const string LoadingText = "Loading...";
private const string LoadingDetailsText = "Loading details...";
private const string WindowTitle = "Open Nest — Database";
private static readonly Font SectionFont = new(SystemFonts.MessageBoxFont.FontFamily, 14f);
private readonly INestRepository repository;
private readonly string serverUrl;
private readonly NestBrowseSession session;
private readonly DataGridView grid;
private readonly NestDetailsSession details;
private readonly SplitContainer split;
private readonly DataGridView nestGrid;
private readonly DataGridView platesGrid;
private readonly DataGridView drawingsGrid;
private readonly TabControl detailsTabs;
private readonly Label detailsStatus;
private readonly TextBox searchBox;
private readonly Timer searchTimer;
private readonly Button openButton;
private readonly Button deleteButton;
private readonly Timer detailsTimer;
private readonly Button previousButton;
private readonly Button nextButton;
private readonly ToolStripStatusLabel statusLabel;
private readonly Label summaryLabel;
private readonly ToolStripMenuItem openMenuItem;
private readonly ToolStripMenuItem deleteMenuItem;
private bool deleting;
/// <summary>Set to the chosen record's id when the dialog closes with OK.</summary>
public Guid SelectedId { get; private set; }
public SavedNestsForm(INestRepository repository)
public SavedNestsForm(INestRepository repository, string serverUrl = null)
{
this.repository = repository;
this.serverUrl = serverUrl ?? "";
session = new NestBrowseSession(repository);
details = new NestDetailsSession(LoadDetailsAsync);
Text = "Open Nest — Database";
Text = WindowTitle;
StartPosition = FormStartPosition.CenterParent;
ClientSize = new Size(880, 480);
ClientSize = new Size(1280, 800);
MinimumSize = new Size(800, 560);
MinimizeBox = false;
MaximizeBox = false;
ShowInTaskbar = false;
var topPanel = new FlowLayoutPanel
// Nests: one server page, sorted by the server when a header is clicked.
nestGrid = CreateGrid();
nestGrid.RowHeadersWidth = 64;
AddNestColumn("Nest name", NestSortField.Name, 170);
AddNestColumn("Status", NestSortField.Status, 100);
AddNestColumn("Modified", NestSortField.DateModified, 140);
AddNestColumn("Plates", NestSortField.PlateCount, 60, alignRight: true);
AddNestColumn("Parts", NestSortField.PartCount, 60, alignRight: true);
AddNestColumn("Comment", NestSortField.Comments, 260);
AddNestColumn("Customer", NestSortField.Customer, 160);
AddNestColumn("Made by", NestSortField.MadeBy, 90);
AddNestColumn("Material", NestSortField.Material, 150);
AddNestColumn("Thickness", NestSortField.Thickness, 80, alignRight: true);
AddNestColumn("Created", NestSortField.DateCreated, 140);
AddNestColumn("Saved", NestSortField.SavedAt, 140);
AddNestColumn("File size", NestSortField.FileSize, 80, alignRight: true);
nestGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Dock = DockStyle.Top,
Height = 34,
Padding = new Padding(3),
WrapContents = false,
Name = IdColumn,
ValueType = typeof(Guid),
Visible = false,
SortMode = DataGridViewColumnSortMode.NotSortable,
});
// Sorting a page locally would misrepresent the archive; the server sorts all matches.
nestGrid.ColumnHeaderMouseClick += async (_, e) =>
{
if (e.Button == MouseButtons.Left && nestGrid.Columns[e.ColumnIndex].Tag is NestSortField field)
await RunAsync(() => session.SortByAsync(field));
};
nestGrid.CellDoubleClick += (_, e) =>
{
if (e.RowIndex >= 0)
OpenSelected();
};
nestGrid.CellMouseDown += (_, e) =>
{
// Right-click highlights the row under the pointer before its menu opens.
if (e.Button == MouseButtons.Right && e.RowIndex >= 0)
nestGrid.CurrentCell = nestGrid.Rows[e.RowIndex].Cells[0];
};
nestGrid.SelectionChanged += (_, _) =>
{
UpdateCommands();
ScheduleDetails();
};
var menu = new ContextMenuStrip();
openMenuItem = new ToolStripMenuItem("Open", null, (_, _) => OpenSelected()) { ShortcutKeyDisplayString = "Enter" };
deleteMenuItem = new ToolStripMenuItem("Delete...", null, async (_, _) => await DeleteSelectedAsync());
var refreshMenuItem = new ToolStripMenuItem("Refresh", null, async (_, _) => await RunAsync(session.RefreshAsync));
menu.Items.AddRange(new ToolStripItem[] { openMenuItem, deleteMenuItem, new ToolStripSeparator(), refreshMenuItem });
nestGrid.ContextMenuStrip = menu;
// Details of the highlighted nest.
platesGrid = CreateGrid();
platesGrid.RowHeadersVisible = false;
AddColumn(platesGrid, "Number", "#", 50, typeof(int), alignRight: true);
AddColumn(platesGrid, "Duplicates", "Duplicates", 90, typeof(int), alignRight: true);
AddColumn(platesGrid, PlateSizeColumn, "Plate size", 140, typeof(string));
AddColumn(platesGrid, "Parts", "Parts", 70, typeof(int), alignRight: true);
AddColumn(platesGrid, "Drawings", "Drawings", 80, typeof(int), alignRight: true);
AddColumn(platesGrid, "Utilization", "Utilization", 90, typeof(double), alignRight: true, format: "P1");
drawingsGrid = CreateGrid();
drawingsGrid.RowHeadersVisible = false;
AddColumn(drawingsGrid, "Drawing", "Drawing", 220, typeof(string));
AddColumn(drawingsGrid, "Customer", "Customer", 160, typeof(string));
AddColumn(drawingsGrid, "Required", "Required", 80, typeof(int), alignRight: true);
AddColumn(drawingsGrid, "Nested", "Nested", 80, typeof(int), alignRight: true);
AddColumn(drawingsGrid, "Remaining", "Remaining", 90, typeof(int), alignRight: true);
AddColumn(drawingsGrid, AreaColumn, "Area", 100, typeof(double), alignRight: true, format: "0.###");
detailsTabs = new TabControl { Dock = DockStyle.Fill };
detailsTabs.TabPages.Add(CreateTab("Plates", platesGrid));
detailsTabs.TabPages.Add(CreateTab("Drawings", drawingsGrid));
detailsStatus = new Label
{
Dock = DockStyle.Fill,
TextAlign = ContentAlignment.MiddleLeft,
ForeColor = SystemColors.GrayText,
AutoEllipsis = true,
};
var detailsHeader = new Panel { Dock = DockStyle.Top, Height = 32 };
detailsHeader.Controls.Add(detailsStatus);
detailsHeader.Controls.Add(SectionLabel("Details", DockStyle.Left));
split = new SplitContainer
{
Dock = DockStyle.Fill,
Orientation = Orientation.Horizontal,
SplitterWidth = 6,
};
split.Panel1.Controls.Add(nestGrid);
split.Panel1.Controls.Add(SectionLabel("Nests", DockStyle.Top));
split.Panel2.Controls.Add(detailsTabs);
split.Panel2.Controls.Add(detailsHeader);
// Find bar and paging.
searchBox = new TextBox
{
Width = 320,
MaxLength = NestQuery.MaxSearchLength,
PlaceholderText = "Name, customer, material, made by, comment or status",
};
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 (_, _) =>
{
@@ -62,116 +182,172 @@ public sealed class SavedNestsForm : Form
searchTimer.Stop();
searchTimer.Start();
};
detailsTimer = new Timer { Interval = 250 };
detailsTimer.Tick += async (_, _) =>
{
detailsTimer.Stop();
if (SelectedNestId() is Guid id)
await ShowDetailsAsync(id);
};
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 = new Button { Text = "\u25C0", Width = 32, Enabled = false };
previousButton.Click += async (_, _) => await RunAsync(session.PreviousPageAsync);
nextButton = new Button { Text = "Next >", AutoSize = true, Enabled = false };
nextButton = new Button { Text = "\u25B6", Width = 32, 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);
summaryLabel = new Label { AutoSize = true, Margin = new Padding(12, 8, 3, 0) };
grid = new DataGridView
var findBar = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, WrapContents = false };
findBar.Controls.Add(new Label { Text = "Find:", AutoSize = true, Margin = new Padding(3, 8, 0, 0) });
findBar.Controls.Add(searchBox);
findBar.Controls.Add(refreshButton);
findBar.Controls.Add(previousButton);
findBar.Controls.Add(nextButton);
findBar.Controls.Add(summaryLabel);
var bottom = new TableLayoutPanel
{
Dock = DockStyle.Bottom,
AutoSize = true,
ColumnCount = 1,
Padding = new Padding(3, 3, 3, 6),
};
bottom.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
bottom.Controls.Add(findBar);
Controls.Add(split);
Controls.Add(bottom);
Load += (_, _) =>
{
// Minimum sizes are applied once the container has its real height; setting them
// on the default-sized container throws.
split.Panel1MinSize = 120;
split.Panel2MinSize = 120;
split.SplitterDistance = System.Math.Max(split.Panel1MinSize, split.Height * 3 / 5);
};
Shown += async (_, _) =>
{
nestGrid.Focus();
await RunAsync(session.RefreshAsync);
};
UpdateCommands();
}
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
switch (keyData)
{
case Keys.Enter when searchBox.Focused:
// Run the typed search now instead of opening a row from the previous results.
searchTimer.Stop();
_ = RunAsync(() => session.SetSearchAsync(searchBox.Text));
nestGrid.Focus();
return true;
case Keys.Enter:
OpenSelected();
return true;
case Keys.Escape:
DialogResult = DialogResult.Cancel;
return true;
}
return base.ProcessCmdKey(ref msg, keyData);
}
private static DataGridView CreateGrid()
{
var grid = new DataGridView
{
Dock = DockStyle.Fill,
ReadOnly = true,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
AllowUserToResizeRows = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
MultiSelect = false,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None,
BackgroundColor = Color.White,
BorderStyle = BorderStyle.FixedSingle,
GridColor = Color.Silver,
EnableHeadersVisualStyles = false,
ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize,
RowHeadersWidthSizeMode = DataGridViewRowHeadersWidthSizeMode.DisableResizing,
StandardTab = true,
};
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)
Open_Click(this, EventArgs.Empty);
};
var buttons = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
Height = 44,
FlowDirection = FlowDirection.RightToLeft,
Padding = new Padding(6),
};
var cancel = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
deleteButton = new Button { Text = "Delete", AutoSize = true, Enabled = false };
deleteButton.Click += Delete_Click;
openButton = new Button { Text = "Open", AutoSize = true, Enabled = false };
openButton.Click += Open_Click;
buttons.Controls.Add(cancel);
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;
openButton.Enabled = hasSelection;
deleteButton.Enabled = hasSelection;
};
Controls.Add(grid);
Controls.Add(topPanel);
Controls.Add(buttons);
Controls.Add(statusStrip);
CancelButton = cancel;
Shown += async (_, _) => await RunAsync(session.RefreshAsync);
grid.RowTemplate.Height = 22;
grid.DefaultCellStyle.SelectionBackColor = Color.Black;
grid.DefaultCellStyle.SelectionForeColor = Color.White;
grid.ColumnHeadersDefaultCellStyle.BackColor = SystemColors.Control;
grid.ColumnHeadersDefaultCellStyle.SelectionBackColor = SystemColors.Control;
grid.ColumnHeadersDefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleCenter;
grid.RowHeadersDefaultCellStyle.BackColor = SystemColors.Control;
grid.RowHeadersDefaultCellStyle.SelectionBackColor = Color.Black;
grid.RowHeadersDefaultCellStyle.SelectionForeColor = Color.White;
grid.RowHeadersDefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleRight;
return grid;
}
private void AddColumn(string name, NestSortField sortField, Type valueType)
private static DataGridViewTextBoxColumn AddColumn(
DataGridView grid,
string name,
string header,
int width,
Type valueType,
bool alignRight = false,
string format = null)
{
grid.Columns.Add(new DataGridViewTextBoxColumn
var column = new DataGridViewTextBoxColumn
{
Name = name,
HeaderText = name,
HeaderText = header,
Width = width,
ValueType = valueType,
SortMode = DataGridViewColumnSortMode.Programmatic,
Tag = sortField,
});
};
if (alignRight)
column.DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleRight;
if (format != null)
column.DefaultCellStyle.Format = format;
grid.Columns.Add(column);
return column;
}
private void AddNestColumn(string header, NestSortField sortField, int width, bool alignRight = false)
{
// Cells hold display text: the server has already sorted the page.
var column = AddColumn(nestGrid, header, header, width, typeof(string), alignRight);
column.SortMode = DataGridViewColumnSortMode.Programmatic;
column.Tag = sortField;
}
private static TabPage CreateTab(string text, Control content)
{
var page = new TabPage(text) { Padding = new Padding(0) };
page.Controls.Add(content);
return page;
}
private static Label SectionLabel(string text, DockStyle dock) =>
new()
{
Text = text,
Font = SectionFont,
Dock = dock,
AutoSize = dock == DockStyle.Left,
Height = 32,
TextAlign = ContentAlignment.MiddleLeft,
Padding = new Padding(0, 0, 12, 0),
};
/// <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.
/// renders nothing; a failure clears the rows and shows the error in the summary line.
/// Navigation follows only the latest request (<see cref="NestBrowseSession.IsLoading"/>).
/// </summary>
private async Task RunAsync(Func<Task<bool>> request)
{
statusLabel.Text = LoadingText;
summaryLabel.Text = LoadingText;
try
{
var started = request();
@@ -183,8 +359,9 @@ public sealed class SavedNestsForm : Form
{
if (!IsDisposed)
{
grid.Rows.Clear();
statusLabel.Text = $"Could not load nests from the server: {ex.Message}";
nestGrid.Rows.Clear();
summaryLabel.Text = $"Could not load nests from the server: {ex.Message}";
UpdateTitle("");
}
}
finally
@@ -193,8 +370,8 @@ public sealed class SavedNestsForm : Form
{
UpdateNavigation();
// A request that was declined or superseded renders nothing of its own.
if (!session.IsLoading && statusLabel.Text == LoadingText)
statusLabel.Text = session.Summary;
if (!session.IsLoading && summaryLabel.Text == LoadingText)
summaryLabel.Text = session.Summary;
}
}
}
@@ -205,37 +382,57 @@ public sealed class SavedNestsForm : Form
nextButton.Enabled = !session.IsLoading && session.CanGoNext;
}
private void UpdateCommands()
{
var hasSelection = SelectedNestId() != null;
openMenuItem.Enabled = hasSelection;
deleteMenuItem.Enabled = hasSelection && !deleting;
}
private void UpdateTitle(string summary)
{
var parts = new[] { WindowTitle, serverUrl, summary };
Text = string.Join(" ", Array.FindAll(parts, part => part.Length > 0));
}
private void Populate(NestPage page)
{
grid.Rows.Clear();
foreach (var record in page.Items)
nestGrid.Rows.Clear();
for (var i = 0; i < page.Items.Count; i++)
{
grid.Rows.Add(
var record = page.Items[i];
var index = nestGrid.Rows.Add(
record.Name,
record.Customer,
FormatStatus(record.Status),
record.Material,
record.DateCreated,
record.DateModified,
record.Thickness,
record.PlateCount,
record.PartCount,
record.MadeBy,
FormatDate(record.DateModified),
record.PlateCount.ToString("N0", CultureInfo.CurrentCulture),
record.PartCount.ToString("N0", CultureInfo.CurrentCulture),
record.Comments,
record.FileSize,
record.SavedAt,
record.Customer,
record.MadeBy,
record.Material,
record.Thickness.ToString("0.####", CultureInfo.CurrentCulture),
FormatDate(record.DateCreated),
FormatDate(record.SavedAt),
FormatFileSize(record.FileSize),
record.Id
);
// Row numbers count through the whole filtered list, not just this page.
nestGrid.Rows[index].HeaderCell.Value = (page.Offset + i + 1).ToString("N0", CultureInfo.CurrentCulture);
}
foreach (DataGridViewColumn column in grid.Columns)
foreach (DataGridViewColumn column in nestGrid.Columns)
{
column.HeaderCell.SortGlyphDirection = column.Tag is NestSortField field && field == session.Sort
? session.Descending ? SortOrder.Descending : SortOrder.Ascending
: SortOrder.None;
}
statusLabel.Text = session.Summary;
if (nestGrid.Rows.Count > 0 && nestGrid.CurrentCell == null)
nestGrid.CurrentCell = nestGrid.Rows[0].Cells[0];
summaryLabel.Text = session.Summary;
UpdateTitle(session.Summary);
}
private static string FormatStatus(NestStatus status) => status switch
@@ -246,23 +443,131 @@ public sealed class SavedNestsForm : Form
_ => status.ToString(),
};
private void Open_Click(object sender, EventArgs e)
private static string FormatDate(DateTime value) =>
value == default ? "" : value.ToString("g", CultureInfo.CurrentCulture);
private static string FormatFileSize(long bytes) =>
$"{System.Math.Ceiling(bytes / 1024.0).ToString("N0", CultureInfo.CurrentCulture)} KB";
private Guid? SelectedNestId() =>
nestGrid.SelectedRows.Count > 0 && nestGrid.SelectedRows[0].Cells[IdColumn].Value is Guid id ? id : null;
/// <summary>
/// Shows the highlighted nest's details after a short pause, so arrowing through the
/// list downloads only the nest the highlight settles on.
/// </summary>
private void ScheduleDetails()
{
if (grid.SelectedRows.Count == 0)
detailsTimer.Stop();
var id = SelectedNestId();
if (id == null)
{
details.Clear();
ShowDetailsMessage("");
return;
SelectedId = (Guid)grid.SelectedRows[0].Cells[IdColumn].Value;
DialogResult = DialogResult.OK;
Close();
}
if (id == details.NestId)
{
// Back on the nest already shown or loading (for example after arrowing away and
// back before the pause ended): keep that result rather than downloading it again.
if (details.Details != null)
{
PopulateDetails(details.Details);
return;
}
if (details.IsLoading)
{
ShowDetailsMessage(LoadingDetailsText);
return;
}
}
ShowDetailsMessage(LoadingDetailsText);
detailsTimer.Start();
}
private async void Delete_Click(object sender, EventArgs e)
private async Task ShowDetailsAsync(Guid id)
{
if (grid.SelectedRows.Count == 0)
try
{
if (await details.LoadAsync(id) && !IsDisposed)
PopulateDetails(details.Details);
}
catch (Exception ex)
{
if (!IsDisposed)
ShowDetailsMessage($"Could not load details: {ex.Message}");
}
}
/// <summary>Downloads one nest's archive and reads its plates and drawings off the UI thread.</summary>
private async Task<NestDetails> LoadDetailsAsync(Guid id, CancellationToken cancellationToken)
{
var file = await repository.GetFileAsync(id, cancellationToken)
?? throw new InvalidOperationException("This nest no longer exists on the server.");
return await Task.Run(
() =>
{
using var stream = new MemoryStream(file);
return NestDetails.FromNest(new NestReader(stream).Read());
},
cancellationToken);
}
private void ShowDetailsMessage(string message)
{
platesGrid.Rows.Clear();
drawingsGrid.Rows.Clear();
detailsStatus.Text = message;
}
private void PopulateDetails(NestDetails nestDetails)
{
var unit = nestDetails.Units == Units.Millimeters ? "mm" : "in";
platesGrid.Columns[PlateSizeColumn].HeaderText = $"Plate size ({unit})";
drawingsGrid.Columns[AreaColumn].HeaderText = $"Area ({unit}\u00B2)";
ShowDetailsMessage(
$"{nestDetails.Plates.Count:N0} plate(s), {nestDetails.Drawings.Count:N0} drawing(s)");
foreach (var plate in nestDetails.Plates)
{
platesGrid.Rows.Add(
plate.Number,
plate.Duplicates,
$"{plate.Width.ToString("0.###", CultureInfo.CurrentCulture)} x {plate.Length.ToString("0.###", CultureInfo.CurrentCulture)}",
plate.PartCount,
plate.DrawingCount,
plate.Utilization);
}
foreach (var drawing in nestDetails.Drawings)
{
drawingsGrid.Rows.Add(
drawing.Name,
drawing.Customer,
drawing.Required,
drawing.Nested,
drawing.Remaining,
drawing.Area);
}
}
private void OpenSelected()
{
if (SelectedNestId() is not Guid id)
return;
SelectedId = id;
DialogResult = DialogResult.OK;
}
private async Task DeleteSelectedAsync()
{
if (deleting || SelectedNestId() is not Guid id)
return;
var id = (Guid)grid.SelectedRows[0].Cells[IdColumn].Value;
var name = grid.SelectedRows[0].Cells["Name"].Value?.ToString() ?? "";
var name = nestGrid.SelectedRows[0].Cells[0].Value?.ToString() ?? "";
var confirm = MessageBox.Show(
this,
$"Delete '{name}' from the server? This cannot be undone.",
@@ -273,7 +578,8 @@ public sealed class SavedNestsForm : Form
if (confirm != DialogResult.Yes)
return;
deleteButton.Enabled = false;
deleting = true;
UpdateCommands();
try
{
await repository.DeleteAsync(id);
@@ -293,8 +599,9 @@ public sealed class SavedNestsForm : Form
}
finally
{
deleting = false;
if (!IsDisposed)
deleteButton.Enabled = grid.SelectedRows.Count > 0;
UpdateCommands();
}
}
@@ -303,7 +610,9 @@ public sealed class SavedNestsForm : Form
if (disposing)
{
searchTimer.Dispose();
detailsTimer.Dispose();
session.Dispose();
details.Dispose();
}
base.Dispose(disposing);
+17 -12
View File
@@ -4,19 +4,24 @@ 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 browses server records 100 at a time. The filter box
- **Database mode**: Open browses server records 100 at a time. The Find 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.
A separate **File > Export .nest...** command is always available (in both
modes) for producing a local file to share or back up. Export does not change
the document's file save path or database record association.
after a short typing pause (Enter in the box searches at once); clicking a
column header sorts every match on the server (click again to reverse); the
arrow buttons move between pages and the title and find bar show the range and
total. Dates and numbers are not searched as text; sort their column instead.
Below the list, the **Details** tabs show the highlighted nest's plates
(duplicates, size, parts, drawings, utilization) and drawings (required, nested,
remaining, area); they are read from that nest's archive, which is downloaded
shortly after the highlight stops moving. Enter or a double-click opens the
highlighted nest and Esc closes. 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 on the
Open dialog's right-click menu permanently removes the highlighted server record
after confirmation. A separate **File > Export .nest...** command is always
available (in both modes) for producing a local file to share or back up. Export
does not change the document's file save path or database record association.
The mode and server address are stored per-PC at `%APPDATA%\OpenNest\storage.json`
(`OpenNest.Data.NestStorageSettings`), defaulting to File mode so existing