diff --git a/OpenNest.Data/NestRecordFactory.cs b/OpenNest.Data/NestRecordFactory.cs
new file mode 100644
index 0000000..cce9048
--- /dev/null
+++ b/OpenNest.Data/NestRecordFactory.cs
@@ -0,0 +1,45 @@
+using System.Linq;
+
+namespace OpenNest.Data;
+
+///
+/// Builds the shareable metadata from a live .
+/// Counts are computed here (not carried on itself) so every caller
+/// that saves to the nest server gets the same definition of "plate" and "part" counts.
+///
+public static class NestRecordFactory
+{
+ ///
+ /// Builds a record for uploading . is the
+ /// client-tracked id: for a first save (the server
+ /// assigns one), or the previously returned id to update an existing record.
+ /// is the size of the serialized .nest archive being
+ /// uploaded alongside this record; the server also recomputes it independently.
+ ///
+ public static NestRecord FromNest(Nest nest, System.Guid id, long fileSize)
+ {
+ ArgumentNullException.ThrowIfNull(nest);
+
+ return new NestRecord
+ {
+ Id = id,
+ Name = nest.Name ?? "",
+ Customer = nest.Customer ?? "",
+ DateCreated = nest.DateCreated,
+ DateModified = nest.DateLastModified,
+ Material = nest.Material?.Name ?? "",
+ Thickness = nest.Thickness,
+ Status = nest.Status,
+ PlateCount = nest.Plates.Count,
+ PartCount = nest.Plates.Sum(CountNonCutoffParts),
+ // Nest has no separate "comments" field; Notes is the closest analog
+ // shown in the nest info dialog, so it round-trips as Comments here.
+ Comments = nest.Notes ?? "",
+ MadeBy = nest.MadeBy ?? "",
+ FileSize = fileSize,
+ };
+ }
+
+ private static int CountNonCutoffParts(Plate plate) =>
+ plate.Parts.Count(part => !part.BaseDrawing.IsCutOff);
+}
diff --git a/OpenNest.Data/NestSaveSession.cs b/OpenNest.Data/NestSaveSession.cs
new file mode 100644
index 0000000..c337e3d
--- /dev/null
+++ b/OpenNest.Data/NestSaveSession.cs
@@ -0,0 +1,46 @@
+namespace OpenNest.Data;
+
+///
+/// Tracks a document's database identity, bound to a particular server address.
+/// A failed upload leaves that identity untouched. Switching servers creates a new
+/// record rather than accidentally updating an unrelated record with the same id.
+///
+public sealed class NestSaveSession
+{
+ public Guid RemoteId { get; private set; }
+ public string ServerUrl { get; private set; } = "";
+
+ public void Bind(Guid id, string serverUrl)
+ {
+ if (id == Guid.Empty)
+ throw new ArgumentException("A saved nest needs a non-empty id.", nameof(id));
+ RemoteId = id;
+ ServerUrl = Normalize(serverUrl);
+ }
+
+ public async Task SaveAsync(
+ INestRepository repository, string serverUrl, Nest nest, byte[] archive,
+ bool saveCopy = false, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(repository);
+ ArgumentNullException.ThrowIfNull(nest);
+ ArgumentNullException.ThrowIfNull(archive);
+
+ var normalizedUrl = Normalize(serverUrl);
+ if (normalizedUrl.Length == 0)
+ throw new ArgumentException("A database server URL is required.", nameof(serverUrl));
+
+ var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty;
+ var record = NestRecordFactory.FromNest(nest, id, archive.LongLength);
+ var saved = id == Guid.Empty
+ ? await repository.UploadAsync(archive, record, cancellationToken)
+ : await repository.UpdateFileAsync(id, archive, record, cancellationToken);
+ if (saved.Id == Guid.Empty || (id != Guid.Empty && saved.Id != id))
+ throw new InvalidDataException("Server returned an invalid nest id.");
+
+ Bind(saved.Id, normalizedUrl);
+ return saved;
+ }
+
+ private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/');
+}
diff --git a/OpenNest.Tests/Data/NestRecordFactoryTests.cs b/OpenNest.Tests/Data/NestRecordFactoryTests.cs
new file mode 100644
index 0000000..6003743
--- /dev/null
+++ b/OpenNest.Tests/Data/NestRecordFactoryTests.cs
@@ -0,0 +1,82 @@
+using OpenNest.Data;
+
+using Vector = OpenNest.Geometry.Vector;
+
+namespace OpenNest.Tests.Data;
+
+public class NestRecordFactoryTests
+{
+ [Fact]
+ public void FromNest_CountsPlatesAndNonCutoffParts()
+ {
+ var nest = new Nest("Job 42")
+ {
+ Customer = "Cozy Cat",
+ Material = new Material("Aluminum"),
+ Thickness = 0.125,
+ Status = NestStatus.ToBeCut,
+ MadeBy = "AJ",
+ Notes = "rush order",
+ DateCreated = new DateTime(2026, 1, 1),
+ DateLastModified = new DateTime(2026, 2, 2),
+ };
+
+ var plate1 = nest.CreatePlate();
+ var drawingA = new Drawing("A");
+ var drawingB = new Drawing("B");
+ nest.Drawings.Add(drawingA);
+ nest.Drawings.Add(drawingB);
+ plate1.Parts.Add(new Part(drawingA));
+ plate1.Parts.Add(new Part(drawingB));
+
+ var plate2 = nest.CreatePlate();
+ var drawingC = new Drawing("C");
+ nest.Drawings.Add(drawingC);
+ plate2.Parts.Add(new Part(drawingC));
+ var cutoff = new CutOff(new Vector(0, 0), CutOffAxis.Vertical);
+ plate2.CutOffs.Add(cutoff);
+ plate2.Parts.Add(new Part(cutoff.Drawing));
+
+ var record = NestRecordFactory.FromNest(nest, Guid.Empty, fileSize: 1024);
+
+ Assert.Equal(Guid.Empty, record.Id);
+ Assert.Equal("Job 42", record.Name);
+ Assert.Equal("Cozy Cat", record.Customer);
+ Assert.Equal("Aluminum", record.Material);
+ Assert.Equal(0.125, record.Thickness);
+ Assert.Equal(NestStatus.ToBeCut, record.Status);
+ Assert.Equal("AJ", record.MadeBy);
+ Assert.Equal("rush order", record.Comments);
+ Assert.Equal(new DateTime(2026, 1, 1), record.DateCreated);
+ Assert.Equal(new DateTime(2026, 2, 2), record.DateModified);
+ Assert.Equal(2, record.PlateCount);
+ // 2 real parts on plate1 + 1 real part on plate2; the cutoff part is excluded.
+ Assert.Equal(3, record.PartCount);
+ Assert.Equal(1024, record.FileSize);
+ }
+
+ [Fact]
+ public void FromNest_EmptyNest_ZeroCounts()
+ {
+ var nest = new Nest("Empty");
+
+ var record = NestRecordFactory.FromNest(nest, Guid.NewGuid(), fileSize: 0);
+
+ Assert.Equal(0, record.PlateCount);
+ Assert.Equal(0, record.PartCount);
+ Assert.Equal("", record.Material);
+ Assert.Equal("", record.Comments);
+ Assert.Equal("", record.MadeBy);
+ }
+
+ [Fact]
+ public void FromNest_PreservesGivenId()
+ {
+ var id = Guid.NewGuid();
+ var nest = new Nest("Keep Id");
+
+ var record = NestRecordFactory.FromNest(nest, id, fileSize: 5);
+
+ Assert.Equal(id, record.Id);
+ }
+}
diff --git a/OpenNest.Tests/Data/NestSaveSessionTests.cs b/OpenNest.Tests/Data/NestSaveSessionTests.cs
new file mode 100644
index 0000000..01db1fb
--- /dev/null
+++ b/OpenNest.Tests/Data/NestSaveSessionTests.cs
@@ -0,0 +1,85 @@
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+public class NestSaveSessionTests
+{
+ [Fact]
+ public async Task Save_CreatesThenUpdatesAndSaveCopyCreatesNewRecord()
+ {
+ var repository = new FakeRepository();
+ var session = new NestSaveSession();
+ var nest = new Nest("Shop job");
+ var bytes = new byte[] { 1, 2, 3 };
+
+ await session.SaveAsync(repository, "http://shop:8090/", nest, bytes);
+ var firstId = session.RemoteId;
+ Assert.NotEqual(Guid.Empty, firstId);
+ Assert.Equal(3, repository.LastRecord!.FileSize);
+ Assert.Equal("Shop job", repository.LastRecord.Name);
+ Assert.Equal(1, repository.Uploads);
+
+ await session.SaveAsync(repository, "http://shop:8090", nest, bytes);
+ Assert.Equal(firstId, repository.UpdatedId);
+ Assert.Equal(1, repository.Updates);
+
+ await session.SaveAsync(repository, "http://shop:8090", nest, bytes, saveCopy: true);
+ Assert.NotEqual(firstId, session.RemoteId);
+ Assert.Equal(2, repository.Uploads);
+ }
+
+ [Fact]
+ public async Task FailedSaveAndServerSwitch_NeverOverwritePriorIdentity()
+ {
+ var repository = new FakeRepository();
+ var session = new NestSaveSession();
+ var oldId = Guid.NewGuid();
+ session.Bind(oldId, "http://old:8090");
+ repository.Fail = true;
+
+ await Assert.ThrowsAsync(() =>
+ session.SaveAsync(repository, "http://new:8090", new Nest("Job"), [1]));
+ Assert.Equal(oldId, session.RemoteId);
+ Assert.Equal("http://old:8090", session.ServerUrl);
+ Assert.Equal(Guid.Empty, repository.LastRecord!.Id);
+ Assert.Equal(0, repository.Updates);
+
+ repository.Fail = false;
+ await session.SaveAsync(repository, "http://new:8090", new Nest("Job"), [1]);
+ Assert.NotEqual(oldId, session.RemoteId);
+ Assert.Equal("http://new:8090", session.ServerUrl);
+ }
+
+ private sealed class FakeRepository : INestRepository
+ {
+ public int Uploads { get; private set; }
+ public int Updates { get; private set; }
+ public Guid UpdatedId { get; private set; }
+ public NestRecord? LastRecord { get; private set; }
+ public bool Fail { get; set; }
+
+ public Task UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default)
+ {
+ Uploads++;
+ LastRecord = record;
+ if (Fail) throw new IOException("Offline");
+ record.Id = Guid.NewGuid();
+ return Task.FromResult(record);
+ }
+
+ public Task UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default)
+ {
+ Updates++;
+ UpdatedId = id;
+ LastRecord = record;
+ if (Fail) throw new IOException("Offline");
+ return Task.FromResult(record);
+ }
+
+ public Task> ListAsync(CancellationToken ct = default) => throw new NotImplementedException();
+ public Task GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
+ public Task GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
+ public Task UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
+ public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
+ }
+}
diff --git a/OpenNest/Document.cs b/OpenNest/Document.cs
index d2ec600..4379bee 100644
--- a/OpenNest/Document.cs
+++ b/OpenNest/Document.cs
@@ -1,5 +1,8 @@
using System;
using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using OpenNest.Data;
using OpenNest.IO;
namespace OpenNest
@@ -12,6 +15,12 @@ namespace OpenNest
public string LastSavePath { get; private set; }
+ private readonly NestSaveSession remoteSession = new();
+
+ public Guid RemoteId => remoteSession.RemoteId;
+
+ public void BindRemote(Guid id, string serverUrl) => remoteSession.Bind(id, serverUrl);
+
public string Name => Nest?.Name;
public bool HasSavePath => File.Exists(LastSavePath);
@@ -26,5 +35,25 @@ namespace OpenNest
var writer = new NestWriter(Nest);
writer.Write(path);
}
+
+ /// Serializes the current nest to a .nest archive in memory.
+ public byte[] Serialize()
+ {
+ using var stream = new MemoryStream();
+ new NestWriter(Nest).Write(stream);
+ return stream.ToArray();
+ }
+
+ /// Uploads this nest, preserving its server identity only on success.
+ public async Task SaveToDatabaseAsync(
+ INestRepository repository, string serverUrl, bool saveCopy = false,
+ CancellationToken cancellationToken = default)
+ {
+ var bytes = Serialize();
+ var saved = await remoteSession.SaveAsync(
+ repository, serverUrl, Nest, bytes, saveCopy, cancellationToken);
+ LastSaveDate = DateTime.Now;
+ return saved;
+ }
}
}
diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs
index 5d31d5b..c605d8b 100644
--- a/OpenNest/Forms/MainForm.cs
+++ b/OpenNest/Forms/MainForm.cs
@@ -30,6 +30,10 @@ namespace OpenNest.Forms
private bool clickUpdateLocation;
private bool nestingInProgress;
private CancellationTokenSource nestingCts;
+ private bool databaseSaveInProgress;
+ private bool databaseOpenInProgress;
+ private readonly ToolStripMenuItem storageModeMenu = new("Storage Mode...");
+ private readonly ToolStripMenuItem exportNestMenu = new("Export .nest...");
private const float ZoomInFactor = 1.5f;
private const float ZoomOutFactor = 1.0f / ZoomInFactor;
@@ -48,6 +52,10 @@ namespace OpenNest.Forms
public MainForm()
{
InitializeComponent();
+ storageModeMenu.Click += StorageMode_Click;
+ exportNestMenu.Click += ExportNest_Click;
+ mnuFile.DropDownItems.Insert(mnuFile.DropDownItems.IndexOf(mnuFileExport), exportNestMenu);
+ mnuFile.DropDownItems.Insert(mnuFile.DropDownItems.IndexOf(mnuFileExit), storageModeMenu);
UpdateOverlapMenu();
LoadSettings();
@@ -150,9 +158,12 @@ namespace OpenNest.Forms
return result;
}
- private void LoadNest(Nest nest, FormWindowState windowState = FormWindowState.Maximized)
+ private void LoadNest(Nest nest, FormWindowState windowState = FormWindowState.Maximized,
+ Guid remoteId = default, string serverUrl = null)
{
var editForm = new EditNestForm(nest);
+ if (remoteId != Guid.Empty)
+ editForm.Document.BindRemote(remoteId, serverUrl);
editForm.MdiParent = this;
editForm.PlateChanged += (sender, e) =>
{
@@ -198,10 +209,11 @@ namespace OpenNest.Forms
var hasValue = activeForm != null;
btnZoomToFit.Enabled = hasValue;
- mnuFileSave.Enabled = hasValue;
- btnSave.Enabled = hasValue;
- btnSaveAs.Enabled = hasValue;
- mnuFileSaveAs.Enabled = hasValue;
+ mnuFileSave.Enabled = hasValue && !databaseSaveInProgress;
+ btnSave.Enabled = hasValue && !databaseSaveInProgress;
+ btnSaveAs.Enabled = hasValue && !databaseSaveInProgress;
+ mnuFileSaveAs.Enabled = hasValue && !databaseSaveInProgress;
+ exportNestMenu.Enabled = hasValue;
mnuFileExport.Enabled = hasValue;
mnuFileExportAll.Enabled = hasValue;
btnZoomOut.Enabled = hasValue;
@@ -563,21 +575,57 @@ namespace OpenNest.Forms
LoadNest(nest, windowState);
}
- private void Open_Click(object sender, EventArgs e)
+ private async void Open_Click(object sender, EventArgs e)
{
- var dlg = new OpenFileDialog();
- dlg.Filter = NestFormat.FileFilter;
- dlg.Multiselect = true;
-
- if (dlg.ShowDialog() == DialogResult.OK)
+ if (NestStorage.Settings.Mode == NestStorageMode.File)
{
- var reader = new NestReader(dlg.FileName);
- var nest = reader.Read();
- LoadNest(nest);
- if (reader.Warnings.Count > 0)
- MessageBox.Show(this, string.Join(Environment.NewLine, reader.Warnings),
- "Nest Load Warnings", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ using var dlg = new OpenFileDialog { Filter = NestFormat.FileFilter, Multiselect = true };
+ if (dlg.ShowDialog(this) == DialogResult.OK)
+ {
+ var reader = new NestReader(dlg.FileName);
+ var nest = reader.Read();
+ LoadNest(nest);
+ ShowNestWarnings(reader);
+ }
+ return;
}
+
+ try
+ {
+ var repository = NestStorage.CreateRepository();
+ using var dlg = new SavedNestsForm(repository);
+ if (dlg.ShowDialog(this) != DialogResult.OK)
+ return;
+
+ var serverUrl = NestStorage.Settings.ServerUrl;
+ databaseOpenInProgress = true;
+ storageModeMenu.Enabled = false;
+ var file = await repository.GetFileAsync(dlg.SelectedId);
+ if (file == null)
+ throw new FileNotFoundException("This nest no longer exists on the server.");
+ using var stream = new MemoryStream(file);
+ var reader = new NestReader(stream);
+ var nest = reader.Read();
+ LoadNest(nest, remoteId: dlg.SelectedId, serverUrl: serverUrl);
+ ShowNestWarnings(reader);
+ }
+ catch (Exception ex)
+ {
+ ShowStorageError("open", ex);
+ }
+ finally
+ {
+ databaseOpenInProgress = false;
+ if (!IsDisposed)
+ storageModeMenu.Enabled = !databaseSaveInProgress;
+ }
+ }
+
+ private void ShowNestWarnings(NestReader reader)
+ {
+ if (reader.Warnings.Count > 0)
+ MessageBox.Show(this, string.Join(Environment.NewLine, reader.Warnings),
+ "Nest Load Warnings", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
private void ImportBom_Click(object sender, EventArgs e)
@@ -587,18 +635,99 @@ namespace OpenNest.Forms
form.ShowDialog(this);
}
- private void Save_Click(object sender, EventArgs e)
+ private async void Save_Click(object sender, EventArgs e)
{
- if (activeForm != null)
- activeForm.Save();
+ var form = activeForm;
+ if (form == null || databaseSaveInProgress)
+ return;
+ if (NestStorage.Settings.Mode == NestStorageMode.File)
+ form.Save();
+ else
+ await SaveDatabaseAsync(form, saveCopy: false);
}
- private void SaveAs_Click(object sender, EventArgs e)
+ private async void SaveAs_Click(object sender, EventArgs e)
{
- if (activeForm != null)
- activeForm.SaveAs();
+ var form = activeForm;
+ if (form == null || databaseSaveInProgress)
+ return;
+ if (NestStorage.Settings.Mode == NestStorageMode.File)
+ form.SaveAs();
+ else
+ await SaveDatabaseAsync(form, saveCopy: true);
}
+ private async Task SaveDatabaseAsync(EditNestForm form, bool saveCopy)
+ {
+ databaseSaveInProgress = true;
+ storageModeMenu.Enabled = false;
+ EnableCheck();
+ try
+ {
+ var serverUrl = NestStorage.Settings.ServerUrl;
+ var saved = await form.Document.SaveToDatabaseAsync(
+ NestStorage.CreateRepository(), serverUrl, saveCopy);
+ if (!form.IsDisposed)
+ form.Text = form.Document.Name;
+ if (!IsDisposed)
+ statusLabel1.Text = $"Saved {saved.Name} to nest database";
+ }
+ catch (Exception ex)
+ {
+ if (!IsDisposed)
+ ShowStorageError("save", ex);
+ }
+ finally
+ {
+ databaseSaveInProgress = false;
+ if (!IsDisposed)
+ {
+ storageModeMenu.Enabled = !databaseOpenInProgress;
+ EnableCheck();
+ }
+ }
+ }
+
+ private void StorageMode_Click(object sender, EventArgs e)
+ {
+ using var dlg = new StorageModeForm();
+ if (dlg.ShowDialog(this) != DialogResult.OK)
+ return;
+ try
+ {
+ NestStorage.Save(dlg.SelectedMode, dlg.ServerUrl);
+ statusLabel1.Text = $"Nest storage: {dlg.SelectedMode}";
+ }
+ catch (Exception ex)
+ {
+ ShowStorageError("change storage mode", ex);
+ }
+ }
+
+ private void ExportNest_Click(object sender, EventArgs e)
+ {
+ var form = activeForm;
+ if (form == null)
+ return;
+ using var dlg = new SaveFileDialog { Filter = NestFormat.FileFilter, FileName = form.Nest.Name };
+ if (dlg.ShowDialog(this) != DialogResult.OK)
+ return;
+ try
+ {
+ // Export never changes the document's save path, remote id, or nest name.
+ new NestWriter(form.Nest).Write(dlg.FileName);
+ statusLabel1.Text = $"Exported .nest to {dlg.FileName}";
+ }
+ catch (Exception ex)
+ {
+ ShowStorageError("export", ex);
+ }
+ }
+
+ private void ShowStorageError(string action, Exception ex) =>
+ MessageBox.Show(this, $"Could not {action} nest: {ex.Message}",
+ "Nest Storage", MessageBoxButtons.OK, MessageBoxIcon.Error);
+
private void Export_Click(object sender, EventArgs e)
{
if (activeForm == null)
diff --git a/OpenNest/Forms/NestStorage.cs b/OpenNest/Forms/NestStorage.cs
new file mode 100644
index 0000000..23531de
--- /dev/null
+++ b/OpenNest/Forms/NestStorage.cs
@@ -0,0 +1,51 @@
+using System;
+using OpenNest.Data;
+
+namespace OpenNest.Forms
+{
+ ///
+ /// App-scoped nest storage-mode selection (File vs Database) and the shared
+ /// for the configured server. Settings persist
+ /// to %APPDATA%\OpenNest\storage.json via .
+ ///
+ public static class NestStorage
+ {
+ private static NestStorageSettings settings =
+ NestStorageSettings.Load(NestStorageSettings.DefaultPath);
+
+ private static RemoteNestRepository cachedRepository;
+ private static string cachedServerUrl;
+
+ /// Current settings, loaded once at process start.
+ public static NestStorageSettings Settings => settings;
+
+ ///
+ /// Persists new settings and invalidates the cached repository so the next
+ /// call picks up a changed server URL.
+ /// If persistence fails the current mode remains unchanged.
+ ///
+ public static void Save(NestStorageMode mode, string serverUrl)
+ {
+ var next = new NestStorageSettings { Mode = mode, ServerUrl = serverUrl };
+ next.Save(NestStorageSettings.DefaultPath);
+ settings = next;
+ }
+
+ ///
+ /// The shared repository for the current server URL, recreated only when the
+ /// URL changes. Throws if the configured URL
+ /// is not a valid absolute http/https address.
+ ///
+ public static INestRepository CreateRepository()
+ {
+ if (cachedRepository == null || cachedServerUrl != settings.ServerUrl)
+ {
+ cachedRepository?.Dispose();
+ cachedRepository = new RemoteNestRepository(settings.ServerUrl);
+ cachedServerUrl = settings.ServerUrl;
+ }
+
+ return cachedRepository;
+ }
+ }
+}
diff --git a/OpenNest/Forms/SavedNestsForm.cs b/OpenNest/Forms/SavedNestsForm.cs
new file mode 100644
index 0000000..d58d747
--- /dev/null
+++ b/OpenNest/Forms/SavedNestsForm.cs
@@ -0,0 +1,257 @@
+using System;
+using System.Data;
+using System.Drawing;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows.Forms;
+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.
+///
+public sealed class SavedNestsForm : Form
+{
+ private readonly INestRepository repository;
+ private readonly DataGridView grid;
+ private readonly TextBox searchBox;
+ private readonly Button openButton;
+ private readonly Button deleteButton;
+ private readonly Button refreshButton;
+ private NestRecord[] records = Array.Empty();
+
+ /// Set to the chosen record's id when the dialog closes with OK.
+ public Guid SelectedId { get; private set; }
+
+ public SavedNestsForm(INestRepository repository)
+ {
+ this.repository = repository;
+
+ Text = "Open Nest — Database";
+ StartPosition = FormStartPosition.CenterParent;
+ ClientSize = new Size(880, 480);
+ MinimizeBox = false;
+ 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();
+ topPanel.Controls.Add(searchLabel);
+ topPanel.Controls.Add(searchBox);
+ topPanel.Controls.Add(refreshButton);
+
+ grid = new DataGridView
+ {
+ Dock = DockStyle.Fill,
+ ReadOnly = true,
+ AllowUserToAddRows = false,
+ AllowUserToDeleteRows = false,
+ SelectionMode = DataGridViewSelectionMode.FullRowSelect,
+ MultiSelect = false,
+ AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells,
+ };
+ 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);
+
+ grid.SelectionChanged += (_, _) =>
+ {
+ var hasSelection = grid.SelectedRows.Count > 0;
+ openButton.Enabled = hasSelection;
+ deleteButton.Enabled = hasSelection;
+ };
+
+ Controls.Add(grid);
+ Controls.Add(topPanel);
+ Controls.Add(buttons);
+ CancelButton = cancel;
+
+ Shown += async (_, _) => await LoadAsync();
+ }
+
+ private async Task LoadAsync()
+ {
+ Cursor = Cursors.WaitCursor;
+ try
+ {
+ var list = await repository.ListAsync();
+ if (IsDisposed)
+ return;
+ records = list.ToArray();
+ ApplyFilter();
+ }
+ catch (Exception ex)
+ {
+ if (!IsDisposed)
+ MessageBox.Show(
+ this,
+ $"Could not load nests from the server: {ex.Message}",
+ "Open Nest — Database",
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Error
+ );
+ }
+ finally
+ {
+ if (!IsDisposed)
+ Cursor = Cursors.Default;
+ }
+ }
+
+ private void ApplyFilter()
+ {
+ 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);
+ }
+
+ private static bool Contains(string value, string filter) =>
+ !string.IsNullOrEmpty(value) && value.Contains(filter, StringComparison.OrdinalIgnoreCase);
+
+ private void Populate(NestRecord[] rows)
+ {
+ 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))
+ {
+ table.Rows.Add(
+ record.Name,
+ record.Customer,
+ FormatStatus(record.Status),
+ record.Material,
+ record.DateCreated,
+ record.DateModified,
+ record.Thickness,
+ record.PlateCount,
+ record.PartCount,
+ record.MadeBy,
+ record.Comments,
+ record.FileSize,
+ record.SavedAt,
+ record.Id
+ );
+ }
+
+ grid.DataSource = table;
+ if (grid.Columns.Contains("Id"))
+ grid.Columns["Id"].Visible = false;
+ }
+
+ private static string FormatStatus(NestStatus status) => status switch
+ {
+ NestStatus.Quote => "Quote",
+ NestStatus.ToBeCut => "To Be Cut",
+ NestStatus.HasBeenCut => "Has Been Cut",
+ _ => status.ToString(),
+ };
+
+ private void Open_Click(object sender, EventArgs e)
+ {
+ if (grid.SelectedRows.Count == 0)
+ return;
+ SelectedId = (Guid)grid.SelectedRows[0].Cells["Id"].Value;
+ DialogResult = DialogResult.OK;
+ Close();
+ }
+
+ private async void Delete_Click(object sender, EventArgs e)
+ {
+ if (grid.SelectedRows.Count == 0)
+ return;
+
+ var id = (Guid)grid.SelectedRows[0].Cells["Id"].Value;
+ var name = grid.SelectedRows[0].Cells["Name"].Value?.ToString() ?? "";
+
+ var confirm = MessageBox.Show(
+ this,
+ $"Delete '{name}' from the server? This cannot be undone.",
+ "Delete Nest",
+ MessageBoxButtons.YesNo,
+ MessageBoxIcon.Warning
+ );
+ if (confirm != DialogResult.Yes)
+ return;
+
+ deleteButton.Enabled = false;
+ try
+ {
+ await repository.DeleteAsync(id);
+ if (!IsDisposed)
+ await LoadAsync();
+ }
+ catch (Exception ex)
+ {
+ if (!IsDisposed)
+ MessageBox.Show(
+ this,
+ $"Could not delete the nest: {ex.Message}",
+ "Delete Nest",
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Error
+ );
+ }
+ finally
+ {
+ if (!IsDisposed)
+ deleteButton.Enabled = grid.SelectedRows.Count > 0;
+ }
+ }
+}
diff --git a/OpenNest/Forms/StorageModeForm.cs b/OpenNest/Forms/StorageModeForm.cs
new file mode 100644
index 0000000..ef3409b
--- /dev/null
+++ b/OpenNest/Forms/StorageModeForm.cs
@@ -0,0 +1,140 @@
+using System;
+using System.Drawing;
+using System.Windows.Forms;
+using OpenNest.Data;
+
+namespace OpenNest.Forms;
+
+///
+/// File > Storage Mode...: choose File mode (local .nest files, the default and
+/// current behavior) or Database mode (Save/Open go through the shared nest server).
+///
+public sealed class StorageModeForm : Form
+{
+ private readonly RadioButton fileModeRadio;
+ private readonly RadioButton databaseModeRadio;
+ private readonly TextBox serverUrlBox;
+ private readonly Label serverUrlLabel;
+
+ public StorageModeForm()
+ {
+ Text = "Storage Mode";
+ StartPosition = FormStartPosition.CenterParent;
+ ClientSize = new Size(420, 190);
+ MinimizeBox = false;
+ MaximizeBox = false;
+ ShowInTaskbar = false;
+ FormBorderStyle = FormBorderStyle.FixedDialog;
+
+ var info = new Label
+ {
+ Dock = DockStyle.Top,
+ Height = 40,
+ Padding = new Padding(10, 10, 10, 0),
+ Text = "Database mode shares saved nests with every shop PC through a central server.",
+ };
+
+ fileModeRadio = new RadioButton
+ {
+ Text = "File — save/open local .nest files (default)",
+ AutoSize = true,
+ Location = new Point(14, 54),
+ Checked = true,
+ };
+ databaseModeRadio = new RadioButton
+ {
+ Text = "Database — save/open through a shared nest server",
+ AutoSize = true,
+ Location = new Point(14, 80),
+ };
+
+ serverUrlLabel = new Label
+ {
+ Text = "Server URL:",
+ AutoSize = true,
+ Location = new Point(32, 112),
+ Enabled = false,
+ };
+ serverUrlBox = new TextBox
+ {
+ Location = new Point(110, 108),
+ Width = 280,
+ PlaceholderText = "http://barge.lan:8090",
+ Enabled = false,
+ };
+
+ fileModeRadio.CheckedChanged += (_, _) => UpdateServerUrlEnabled();
+ databaseModeRadio.CheckedChanged += (_, _) => UpdateServerUrlEnabled();
+
+ 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 };
+ var ok = new Button { Text = "OK", AutoSize = true };
+ ok.Click += Ok_Click;
+ buttons.Controls.Add(cancel);
+ buttons.Controls.Add(ok);
+
+ Controls.Add(databaseModeRadio);
+ Controls.Add(fileModeRadio);
+ Controls.Add(serverUrlBox);
+ Controls.Add(serverUrlLabel);
+ Controls.Add(info);
+ Controls.Add(buttons);
+
+ AcceptButton = ok;
+ CancelButton = cancel;
+
+ LoadCurrentSettings();
+ }
+
+ public NestStorageMode SelectedMode =>
+ databaseModeRadio.Checked ? NestStorageMode.Database : NestStorageMode.File;
+
+ public string ServerUrl => serverUrlBox.Text.Trim();
+
+ private void LoadCurrentSettings()
+ {
+ var settings = NestStorage.Settings;
+ databaseModeRadio.Checked = settings.Mode == NestStorageMode.Database;
+ fileModeRadio.Checked = !databaseModeRadio.Checked;
+ serverUrlBox.Text = settings.ServerUrl;
+ UpdateServerUrlEnabled();
+ }
+
+ private void UpdateServerUrlEnabled()
+ {
+ serverUrlBox.Enabled = databaseModeRadio.Checked;
+ serverUrlLabel.Enabled = databaseModeRadio.Checked;
+ }
+
+ private void Ok_Click(object sender, EventArgs e)
+ {
+ if (databaseModeRadio.Checked)
+ {
+ var url = ServerUrl;
+ var isValidUrl =
+ Uri.TryCreate(url, UriKind.Absolute, out var uri)
+ && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
+
+ if (!isValidUrl)
+ {
+ MessageBox.Show(
+ this,
+ "Enter a valid server URL, e.g. http://barge.lan:8090",
+ "Storage Mode",
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Warning
+ );
+ return;
+ }
+ }
+
+ DialogResult = DialogResult.OK;
+ Close();
+ }
+}
diff --git a/docs/nest-storage.md b/docs/nest-storage.md
index 29f0776..d72b1ea 100644
--- a/docs/nest-storage.md
+++ b/docs/nest-storage.md
@@ -4,10 +4,15 @@ 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**: Save uploads the nest to a central `OpenNest.Server`
- instance shared by every shop PC, instead of writing a local file. A separate
- **File > Export .nest...** command is always available (in both modes) for
- producing a local file to share or back up.
+- **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
+ 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.
The mode and server address are stored per-PC at `%APPDATA%\OpenNest\storage.json`
(`OpenNest.Data.NestStorageSettings`), defaulting to File mode so existing