diff --git a/OpenNest.Data/NestRecordFactory.cs b/OpenNest.Data/NestRecordFactory.cs
index cce90482..8378e335 100644
--- a/OpenNest.Data/NestRecordFactory.cs
+++ b/OpenNest.Data/NestRecordFactory.cs
@@ -9,6 +9,22 @@ namespace OpenNest.Data;
///
public static class NestRecordFactory
{
+ /// Overlays authoritative current metadata on an archived nest before editing.
+ public static void ApplyMetadata(Nest nest, NestRecord record)
+ {
+ ArgumentNullException.ThrowIfNull(nest);
+ ArgumentNullException.ThrowIfNull(record);
+ nest.Name = record.Name;
+ nest.Customer = record.Customer;
+ nest.Notes = record.Comments;
+ nest.Status = record.Status;
+ nest.MadeBy = record.MadeBy;
+ nest.DateLastModified = record.DateModified;
+ nest.DateCreated = record.DateCreated;
+ nest.Material = NestMaterialSelection.Apply(nest.Material, record.Material);
+ nest.Thickness = record.Thickness;
+ }
+
///
/// Builds a record for uploading . is the
/// client-tracked id: for a first save (the server
diff --git a/OpenNest.Tests/Data/NestRecordFactoryTests.cs b/OpenNest.Tests/Data/NestRecordFactoryTests.cs
index 6003743f..1a5b0d68 100644
--- a/OpenNest.Tests/Data/NestRecordFactoryTests.cs
+++ b/OpenNest.Tests/Data/NestRecordFactoryTests.cs
@@ -6,6 +6,52 @@ namespace OpenNest.Tests.Data;
public class NestRecordFactoryTests
{
+ [Theory]
+ [InlineData("Steel", "Aluminum", false)]
+ [InlineData("Aluminum", "Aluminum", true)]
+ public void ApplyMetadata_UsesAllCurrentFields_AndPreservesMatchingMaterialProperties(
+ string archiveMaterial, string currentMaterial, bool sameMaterial)
+ {
+ var nest = new Nest("Old archive")
+ {
+ Material = new Material(archiveMaterial, "Test grade", 0.1),
+ Thickness = 0.125,
+ DateCreated = new DateTime(2025, 1, 1),
+ };
+ var plate = nest.CreatePlate();
+ var current = new NestRecord
+ {
+ Name = "Current name",
+ Customer = "Current customer",
+ Comments = "Current notes",
+ Material = currentMaterial,
+ Thickness = 0.375,
+ DateCreated = new DateTime(2026, 1, 1),
+ DateModified = new DateTime(2026, 2, 1),
+ Status = NestStatus.HasBeenCut,
+ MadeBy = "Operator",
+ };
+ NestRecordFactory.ApplyMetadata(nest, current);
+ var roundTrip = NestRecordFactory.FromNest(nest, Guid.Empty, 0);
+ Assert.Equal(current.Material, roundTrip.Material);
+ Assert.Equal(current.Thickness, roundTrip.Thickness);
+ Assert.Equal(current.DateCreated, roundTrip.DateCreated);
+ Assert.Equal(current.DateModified, roundTrip.DateModified);
+ Assert.Equal(current.Name, roundTrip.Name);
+ Assert.Equal(current.Customer, roundTrip.Customer);
+ Assert.Equal(current.Comments, roundTrip.Comments);
+ Assert.Equal(current.Status, roundTrip.Status);
+ Assert.Equal(current.MadeBy, roundTrip.MadeBy);
+ if (sameMaterial)
+ {
+ Assert.Equal("Test grade", nest.Material.Grade);
+ Assert.Equal(0.1, nest.Material.Density);
+ }
+ else
+ Assert.NotEqual("Test grade", nest.Material.Grade);
+ Assert.Same(plate, Assert.Single(nest.Plates));
+ }
+
[Fact]
public void FromNest_CountsPlatesAndNonCutoffParts()
{
diff --git a/OpenNest.WinForms.Tests/Forms/SavedNestsFormTests.cs b/OpenNest.WinForms.Tests/Forms/SavedNestsFormTests.cs
index bacc76d8..fe3a4c5c 100644
--- a/OpenNest.WinForms.Tests/Forms/SavedNestsFormTests.cs
+++ b/OpenNest.WinForms.Tests/Forms/SavedNestsFormTests.cs
@@ -145,6 +145,140 @@ public class SavedNestsFormTests
"The saved-nest dialog test did not complete.");
}
+ [Theory]
+ [InlineData(true, false)]
+ [InlineData(false, false)]
+ [InlineData(true, true)]
+ public void NestInformation_SavesOnlyOnAccept_AndRefusesStaleWrites(bool accept, bool conflict)
+ {
+ StaTestThread.Run(() =>
+ {
+ // PerformClick outside Application.Run needs the real UI dispatcher installed.
+ using var context = new WindowsFormsSynchronizationContext();
+ var record = Record("Current name");
+ record.Revision = 3;
+ record.Comments = "Current metadata notes";
+ record.Status = NestStatus.ToBeCut;
+ record.Material = "Aluminum";
+ record.Thickness = 0.375;
+ record.DateCreated = new DateTime(2026, 1, 1);
+ var repository = new FakeRepository(record) { Conflict = conflict };
+ repository.Files[record.Id] = NestFile((Quantity: 2, Parts: 3));
+ record.FileSize = repository.Files[record.Id].Length;
+ var original = repository.Files[record.Id].ToArray();
+ using var form = new SavedNestsForm(repository, "http://nest-server:5000");
+ form.Show();
+ PumpUntil(() => Field(form, "nestGrid").Rows.Count == 1, "the nest page");
+ SynchronizationContext.SetSynchronizationContext(context);
+ var shown = false;
+ form.ShowNestInfoDialog = dialog =>
+ {
+ shown = true;
+ Assert.Equal(record.Name, dialog.NestName);
+ Assert.Equal(record.Comments, dialog.Notes);
+ Assert.Equal(NestStatus.ToBeCut, dialog.Status);
+ Assert.Equal("Aluminum", dialog.MaterialName);
+ Assert.Equal(0.375, dialog.Thickness);
+ Assert.Equal(new DateTime(2026, 1, 1), dialog.DateCreated);
+ dialog.Customer = "Updated customer";
+ dialog.Notes = "Updated notes";
+ dialog.Status = NestStatus.HasBeenCut;
+ dialog.MadeBy = "Operator";
+ return accept ? DialogResult.OK : DialogResult.Cancel;
+ };
+ var button = Field
public sealed class SavedNestsForm : Form
{
@@ -63,6 +63,11 @@ public sealed class SavedNestsForm : Form
private readonly ToolStripMenuItem openMenuItem;
private readonly ToolStripMenuItem deleteMenuItem;
private bool deleting;
+ private bool editingInfo;
+ private readonly Button infoButton;
+ private readonly ToolStripMenuItem infoMenuItem;
+
+ internal Func ShowNestInfoDialog { get; set; }
private IReadOnlyList previewPlates = Array.Empty();
private int previewIndex = -1;
@@ -143,7 +148,8 @@ public sealed class SavedNestsForm : Form
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 });
+ infoMenuItem = new ToolStripMenuItem("Nest Information...", null, async (_, _) => await EditSelectedInfoAsync());
+ menu.Items.AddRange(new ToolStripItem[] { openMenuItem, infoMenuItem, deleteMenuItem, new ToolStripSeparator(), refreshMenuItem });
nestGrid.ContextMenuStrip = menu;
// Details of the highlighted nest.
@@ -237,7 +243,14 @@ public sealed class SavedNestsForm : Form
SplitterWidth = 6,
};
split.Panel1.Controls.Add(nestGrid);
- split.Panel1.Controls.Add(SectionLabel("Nests", DockStyle.Top));
+ infoButton = new Button { Text = "Nest Information...", AutoSize = true, Dock = DockStyle.Right };
+ infoButton.Click += async (_, _) => await EditSelectedInfoAsync();
+ var nestsHeader = new Panel { Dock = DockStyle.Top, Height = 36 };
+ nestsHeader.Controls.Add(infoButton);
+ nestsHeader.Controls.Add(SectionLabel("Nests", DockStyle.Left));
+ split.Panel1.Controls.Add(nestsHeader);
+ ShowNestInfoDialog = dialog => dialog.ShowDialog(this);
+ FormClosing += (_, e) => e.Cancel = editingInfo;
split.Panel2.Controls.Add(detailsSplit);
split.Panel2.Controls.Add(detailsHeader);
@@ -322,6 +335,8 @@ public sealed class SavedNestsForm : Form
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
+ if (editingInfo)
+ return true;
switch (keyData)
{
case Keys.Enter when searchBox.Focused:
@@ -492,9 +507,11 @@ public sealed class SavedNestsForm : Form
private void UpdateCommands()
{
- var hasSelection = SelectedNestId() != null;
+ var hasSelection = SelectedNestId() != null && !editingInfo && !deleting;
openMenuItem.Enabled = hasSelection;
- deleteMenuItem.Enabled = hasSelection && !deleting;
+ infoButton.Enabled = hasSelection;
+ infoMenuItem.Enabled = hasSelection;
+ deleteMenuItem.Enabled = hasSelection;
}
private void UpdateTitle(string summary)
@@ -759,7 +776,7 @@ public sealed class SavedNestsForm : Form
private void OpenSelected()
{
- if (SelectedNestId() is not Guid id)
+ if (editingInfo || SelectedNestId() is not Guid id)
return;
SelectedId = id;
DialogResult = DialogResult.OK;
@@ -801,6 +818,84 @@ public sealed class SavedNestsForm : Form
}
}
+ /// Edits a detached current snapshot without opening an MDI document.
+ internal async Task EditSelectedInfoAsync()
+ {
+ if (editingInfo || deleting || SelectedNestId() is not Guid id)
+ return;
+ summaryLabel.Text = "Loading nest information...";
+ editingInfo = true;
+ Enabled = false;
+ UpdateCommands();
+ try
+ {
+ var snapshot = await NestHistorySession.ReadCurrentAsync(repository, id, serverUrl);
+ if (IsDisposed) return;
+ if (snapshot.Metadata.Revision < 1)
+ throw new NotSupportedException("Upgrade the nest server and refresh before editing nest information.");
+ var nest = await Task.Run(() =>
+ {
+ using var source = new MemoryStream(snapshot.Archive);
+ var loaded = new NestReader(source).Read();
+ // Metadata-only revisions can be newer than the archived fields.
+ NestRecordFactory.ApplyMetadata(loaded, snapshot.Metadata);
+ return loaded;
+ });
+ if (IsDisposed) return;
+ using var dialog = new EditNestInfoForm();
+ dialog.LoadNestInfo(nest);
+ if (ShowNestInfoDialog(dialog) != DialogResult.OK)
+ return;
+ // Same default propagation as Nest > Nest Info in the open editor.
+ using var manager = new PlateManager(nest);
+ manager.ChangePlateDefaults(() => dialog.SaveNestInfo(nest));
+ nest.DateLastModified = DateTime.Now;
+ summaryLabel.Text = "Saving nest information...";
+ var bytes = await Task.Run(() =>
+ {
+ using var output = new MemoryStream();
+ new NestWriter(nest).Write(output);
+ return output.ToArray();
+ });
+ var save = new NestSaveSession();
+ save.Bind(id, snapshot.ServerUrl, snapshot.Metadata.Revision);
+ summaryLabel.Text = "Sending nest information...";
+ var saved = await save.SaveAsync(repository, snapshot.ServerUrl, nest, bytes);
+ var verified = await NestHistorySession.ReadCurrentAsync(repository, id, snapshot.ServerUrl);
+ if (verified.Metadata.Revision != saved.Revision
+ || !System.Linq.Enumerable.SequenceEqual(bytes, verified.Archive))
+ throw new InvalidDataException("Could not verify the saved nest information.");
+ if (IsDisposed) return;
+ // Drop archive previews and embedded history, including prefetched snapshots.
+ details.Dispose();
+ details = new NestDetailsSession(LoadDetailsAsync);
+ historyView?.Dispose();
+ historyView = null;
+ historyId = null;
+ ShowDetailsMessage("");
+ await RunAsync(session.RefreshAsync);
+ foreach (DataGridViewRow row in nestGrid.Rows)
+ if (row.Cells[IdColumn].Value is Guid rowId && rowId == id)
+ nestGrid.CurrentCell = row.Cells[0];
+ }
+ catch (Exception ex)
+ {
+ if (!IsDisposed)
+ summaryLabel.Text = $"Could not edit nest information: {ex.Message} Refresh and reopen Nest Information before trying again; the save may already have reached the server.";
+ }
+ finally
+ {
+ editingInfo = false;
+ if (!IsDisposed)
+ {
+ Enabled = true;
+ if (summaryLabel.Text == "Loading nest information...")
+ summaryLabel.Text = session.Summary;
+ UpdateCommands();
+ }
+ }
+ }
+
private async Task DeleteSelectedAsync()
{
if (deleting || SelectedNestId() is not Guid id)
diff --git a/docs/nest-storage.md b/docs/nest-storage.md
index 70fae75c..0544d980 100644
--- a/docs/nest-storage.md
+++ b/docs/nest-storage.md
@@ -28,6 +28,17 @@ The desktop app can save nests two ways:
previous one. On the Versions tab, **Open read-only...** previews one snapshot
without binding an editable document, and **Download selected version...** saves
its exact archived bytes.
+ Select a nest and click **Nest Information...** above the list (or use the
+ row's right-click menu) to edit the same information as **Nest > Nest Info**
+ without opening the nest in the main editor. **OK** saves immediately to the
+ same database ID as a new version, then refreshes the list, preview and history;
+ **Cancel** makes no change. The dialog starts with the latest saved metadata.
+ Plate defaults follow the same rules as the open editor: populated sheets are
+ not resized or re-nested. This action requires a version-aware server and
+ refuses to overwrite a newer save from another PC. After a conflict or network
+ error, refresh and reopen the information dialog before trying again; a lost
+ reply can mean the save already reached the server. An already-open editor is
+ not modified; reload it before saving further changes to that same nest.
A separate confirmed **Restore selected version...** creates a new current
revision of the same ID and keeps all intervening versions. **Delete...** hides
the record from ordinary browsing but retains every archive; **Deleted nests...**