feat(ui): rename nests from Nest Info in Database mode

The nest name box was always read-only, so a nest could only be renamed
by Save As, which leaves the old copy behind. In Database mode Nest Info
now edits the name (uploaded with the next Save), as does the saved-nest
browser's Nest Information (saved immediately). File mode keeps it
read-only because the file name is the nest name. Renaming to a name
another active nest already has asks first, and declining reopens the
dialog with the edits kept; a duplicate accepted there is not asked about
again by the nest's first Save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-19 11:09:08 -04:00
1 parent c7df3f5e23
commit e82f26b976
13 files changed
+241 -21

No files matched your search

+35 -9
View File
@@ -4,9 +4,9 @@ using System.Text.Json;
namespace OpenNest.Data;
/// <summary>
/// Duplicate-name check for a nest's first upload. Names may already be on posted CNC
/// programs and labels, so a nest is never renamed silently: callers offer the
/// replacement only when another active nest already has the exact same name.
/// Duplicate-name checks for a nest's first upload and for renames. Names may already be
/// on posted CNC programs and labels, so a nest is never renamed silently: callers offer
/// a replacement, or warn, only when another active nest has the exact same name.
/// </summary>
public static class NestNameConflicts
{
@@ -24,21 +24,42 @@ public static class NestNameConflicts
try
{
if (!await IsNameTakenAsync(repository, name, cancellationToken))
if (!await IsNameTakenAsync(repository, name, cancellationToken: cancellationToken))
return null;
var number = await repository.AllocateNestNumberAsync(year, cancellationToken);
return new NestNameAllocation { Year = year, Number = number, Name = NestNames.Format(year, number) };
}
catch (Exception ex) when (ex is HttpRequestException or IOException or OperationCanceledException
or NotSupportedException or ArgumentException or JsonException or InvalidOperationException)
catch (Exception ex) when (IsCheckFailure(ex))
{
return null;
}
}
/// <summary>True when an active (not deleted) server nest is named exactly <paramref name="name"/>.</summary>
/// <summary>
/// <see cref="IsNameTakenAsync"/>, or null when the server could not answer
/// (offline, too slow, or an older server), so the caller can go ahead without warning.
/// </summary>
public static async Task<bool?> TryIsNameTakenAsync(
INestRepository repository, string name, Guid excludeId = default,
CancellationToken cancellationToken = default)
{
try
{
return await IsNameTakenAsync(repository, name, excludeId, cancellationToken);
}
catch (Exception ex) when (IsCheckFailure(ex))
{
return null;
}
}
/// <summary>
/// True when an active (not deleted) server nest other than <paramref name="excludeId"/>
/// is named exactly <paramref name="name"/>.
/// </summary>
public static async Task<bool> IsNameTakenAsync(
INestRepository repository, string name, CancellationToken cancellationToken = default)
INestRepository repository, string name, Guid excludeId = default,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
// The search is a case-insensitive substring match; page through it for the exact name.
@@ -52,11 +73,16 @@ public static class NestNameConflicts
Offset = offset,
Limit = NestQuery.MaxLimit,
}, cancellationToken);
if (page.Items.Any(r => !r.Deleted && string.Equals(r.Name, name, StringComparison.Ordinal)))
if (page.Items.Any(r => !r.Deleted && r.Id != excludeId
&& string.Equals(r.Name, name, StringComparison.Ordinal)))
return true;
offset += page.Items.Count;
if (page.Items.Count == 0 || offset >= page.Total)
return false;
}
}
private static bool IsCheckFailure(Exception ex) =>
ex is HttpRequestException or IOException or OperationCanceledException
or NotSupportedException or ArgumentException or JsonException or InvalidOperationException;
}
@@ -73,6 +73,23 @@ public sealed class NestSequenceTests
Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, NestNames.Format(2026, 40).ToLowerInvariant(), 2026));
}
[Fact]
public async Task Rename_WarnsOnlyAboutOtherActiveNests()
{
using var factory = new ServerFactory();
Guid own;
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
own = database.Insert(Guid.NewGuid(), new NestRecord { Name = "Bracket job" }, [1]).Id;
database.Insert(Guid.NewGuid(), new NestRecord { Name = "Gusset job" }, [2]);
}
using var client = factory.CreateClient();
var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
Assert.False(await NestNameConflicts.TryIsNameTakenAsync(repository, "Bracket job", own));
Assert.True(await NestNameConflicts.TryIsNameTakenAsync(repository, "Gusset job", own));
}
[Fact]
public async Task Endpoint_RejectsYearsOutsideTheNameRange()
{
@@ -45,6 +45,26 @@ public class NestNameConflictsTests
Assert.False(await NestNameConflicts.IsNameTakenAsync(repository, "Job"));
}
[Fact]
public async Task Rename_IgnoresTheNestsOwnRecord()
{
var repository = new NamedRepository("Job");
var own = repository.Records[0].Id;
Assert.False(await NestNameConflicts.IsNameTakenAsync(repository, "Job", own));
Assert.True(await NestNameConflicts.IsNameTakenAsync(repository, "Job", Guid.NewGuid()));
}
[Fact]
public async Task TryIsNameTaken_IsUnknownWhenTheServerCannotAnswer()
{
var repository = new NamedRepository("Job") { QueryFailure = new HttpRequestException("offline") };
Assert.Null(await NestNameConflicts.TryIsNameTakenAsync(repository, "Job"));
repository.QueryFailure = null;
Assert.True(await NestNameConflicts.TryIsNameTakenAsync(repository, "Job"));
}
[Fact]
public async Task DuplicateBeyondTheFirstPage_IsFound()
{
@@ -107,7 +107,8 @@ public class EditNestInfoFormTests
accept.Start();
// This is the live Nest Info entry point; it owns LoadNestInfo,
// ShowDialog and SaveNestInfo after the actual OK button accepts.
editor.ShowNestInfoEditor();
// An unchanged name never checks the server, so this completes without awaiting.
editor.ShowNestInfoEditorAsync().GetAwaiter().GetResult();
accept.Stop();
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
@@ -122,6 +123,45 @@ public class EditNestInfoFormTests
}
}
[Fact]
public void Name_IsReadOnlyUnlessRenameIsAllowed()
{
RunSta(() =>
{
var nest = AcceptanceNest();
using var form = new EditNestInfoForm();
form.LoadNestInfo(nest);
var nameBox = Assert.IsType<TextBox>(form.Controls.Find("nameBox", true).Single());
Assert.True(nameBox.ReadOnly);
form.AllowRename = true;
Assert.False(nameBox.ReadOnly);
nameBox.Text = " Renamed job ";
form.SaveNestInfo(nest);
Assert.Equal("Renamed job", nest.Name);
});
}
[Fact]
public void BlankName_DisablesOk()
{
RunSta(() =>
{
using var form = new EditNestInfoForm { AllowRename = true };
form.LoadNestInfo(AcceptanceNest());
form.EnableCheck(); // The size box re-checks on a debounce timer.
var nameBox = Assert.IsType<TextBox>(form.Controls.Find("nameBox", true).Single());
var ok = Assert.IsType<Button>(form.Controls.Find("applyButton", true).Single());
Assert.True(ok.Enabled);
nameBox.Text = " ";
Assert.False(ok.Enabled);
nameBox.Text = "Job";
Assert.True(ok.Enabled);
});
}
[Fact]
public void SaveWithUnchangedMaterial_KeepsGradeAndDensity()
{
@@ -187,6 +187,9 @@ public class SavedNestsFormTests
dialog.Notes = "Updated notes";
dialog.Status = NestStatus.HasBeenCut;
dialog.MadeBy = "Operator";
var nameBox = Assert.IsType<TextBox>(dialog.Controls.Find("nameBox", true).Single());
Assert.False(nameBox.ReadOnly);
nameBox.Text = "Renamed job";
return accept ? DialogResult.OK : DialogResult.Cancel;
};
var button = Field<Button>(form, "infoButton");
@@ -206,6 +209,7 @@ public class SavedNestsFormTests
Assert.Equal(4, record.Revision);
Assert.Equal("Updated customer", record.Customer);
Assert.Equal("Updated notes", record.Comments);
Assert.Equal("Renamed job", record.Name);
using var stream = new MemoryStream(repository.Files[record.Id]);
var nest = new NestReader(stream).Read();
Assert.Equal("Updated customer", nest.Customer);
+7
View File
@@ -23,6 +23,13 @@ namespace OpenNest
public bool PendingCreateRequiresReconciliation => remoteSession.PendingCreateId != Guid.Empty && remoteSession.RequiresReload;
public bool PendingCreateWasCopy => remoteSession.PendingCreateWasCopy;
public bool IsFirstDatabaseSave(string serverUrl) => remoteSession.IsFirstSave(serverUrl);
/// <summary>
/// A duplicate name the user already accepted in Nest Info, so the first Save
/// does not ask about it again.
/// </summary>
public string AcceptedDuplicateName { get; set; }
public void BindRemote(Guid id, string serverUrl) => remoteSession.Bind(id, serverUrl);
public void BindRemote(Guid id, string serverUrl, int revision) => remoteSession.Bind(id, serverUrl, revision);
@@ -114,7 +114,8 @@ namespace OpenNest.Forms.Commands
}
if (copyName != null)
form.Nest.Name = copyName;
else if (!saveCopy && form.Document.IsFirstDatabaseSave(serverUrl))
else if (!saveCopy && form.Document.IsFirstDatabaseSave(serverUrl)
&& form.Nest.Name != form.Document.AcceptedDuplicateName)
{
var chosen = await ChooseFirstSaveNameAsync(repository, form.Nest.Name);
if (chosen is null)
+30 -3
View File
@@ -5,6 +5,8 @@ using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Actions;
using OpenNest.CNC.CuttingStrategy;
@@ -751,19 +753,44 @@ namespace OpenNest.Forms
PlateView.ZoomToFit();
}
public void ShowNestInfoEditor()
public async Task ShowNestInfoEditorAsync()
{
var form = new EditNestInfoForm();
var form = new EditNestInfoForm { AllowRename = NestStorage.Settings.IsDatabaseMode };
form.LoadNestInfo(Nest);
if (form.ShowDialog() == System.Windows.Forms.DialogResult.OK)
while (form.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
// A declined duplicate name reopens the editor with the user's edits intact.
if (form.NestName != Nest.Name && !await ConfirmRenameAsync(form.NestName))
continue;
PlateManager.ChangePlateDefaults(() => form.SaveNestInfo(Nest));
Text = Nest.Name;
drawingListBox1.Units = Nest.Units;
drawingListBox1.Invalidate();
return;
}
}
/// <summary>Renames go up with the next Save; warn now about a duplicate name.</summary>
private async Task<bool> ConfirmRenameAsync(string name)
{
INestRepository repository;
try
{
repository = NestStorage.CreateRepository();
}
catch (Exception ex) when (ex is ArgumentException or FormatException or InvalidOperationException)
{
return true; // No usable server address; Save reports that itself.
}
var duplicateAccepted = await NestRenameConfirmation.ConfirmAsync(this, repository, name, Document.RemoteId);
if (duplicateAccepted == true)
Document.AcceptedDuplicateName = name; // So the first Save does not ask again.
return duplicateAccepted != false;
}
public void ResizePlateToFitParts()
{
var options = new PlateSizeOptions
+17 -1
View File
@@ -31,15 +31,25 @@ namespace OpenNest.Forms
Interval = SystemInformation.KeyboardDelay + 1,
};
timer.Elapsed += (sender, e) => EnableCheck();
nameBox.TextChanged += (sender, e) => EnableCheck();
EnableCheck();
}
public string NestName
{
get { return nameBox.Text; }
get { return nameBox.Text.Trim(); }
private set { nameBox.Text = value; }
}
/// <summary>
/// Database mode only: in File mode the name is the file name, set by Save As.
/// </summary>
public bool AllowRename
{
get { return !nameBox.ReadOnly; }
set { nameBox.ReadOnly = !value; }
}
public string Customer
{
get { return customerBox.Text; }
@@ -187,6 +197,12 @@ namespace OpenNest.Forms
{
Size size;
if (NestName.Length == 0)
{
applyButton.Enabled = false;
return;
}
if (!OpenNest.Geometry.Size.TryParse(sizeBox.Text, out size))
{
applyButton.Enabled = false;
+5 -1
View File
@@ -45,7 +45,11 @@ namespace OpenNest.Forms
mnuNestDrawingLibrary.ToolTipText = databaseMode ? "" : NestStorage.DatabaseModeOnly;
}
private void EditNest_Click(object sender, EventArgs e) => WithActive(f => f.ShowNestInfoEditor());
private async void EditNest_Click(object sender, EventArgs e)
{
if (activeForm != null)
await activeForm.ShowNestInfoEditorAsync();
}
private void Clamps_Click(object sender, EventArgs e)
{
+43
View File
@@ -0,0 +1,43 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Data;
namespace OpenNest.Forms
{
/// <summary>
/// Nest Info renames in Database mode: warns before taking a name another active server
/// nest already has. A check that fails or takes over 3 seconds lets the rename through.
/// </summary>
internal static class NestRenameConfirmation
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(3);
/// <summary>
/// Null when no other nest is known to have <paramref name="name"/>; otherwise whether
/// the user chose to use the duplicate anyway.
/// </summary>
public static async Task<bool?> ConfirmAsync(Control owner, INestRepository repository, string name, Guid ownId)
{
bool? taken;
owner.UseWaitCursor = true;
try
{
using var timeout = new CancellationTokenSource(Timeout);
taken = await NestNameConflicts.TryIsNameTakenAsync(repository, name, ownId, timeout.Token);
}
finally
{
owner.UseWaitCursor = false;
}
if (taken != true)
return null;
return MessageBox.Show(owner,
$"A nest named \"{name}\" is already on the server. Use this name anyway?",
"Nest Info", MessageBoxButtons.YesNo, MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button2) == DialogResult.Yes;
}
}
}
+10 -3
View File
@@ -821,10 +821,17 @@ public sealed class SavedNestsForm : Form
return loaded;
});
if (IsDisposed) return;
using var dialog = new EditNestInfoForm();
using var dialog = new EditNestInfoForm { AllowRename = true };
dialog.LoadNestInfo(nest);
if (ShowNestInfoDialog(dialog) != DialogResult.OK)
return;
while (true)
{
if (ShowNestInfoDialog(dialog) != DialogResult.OK)
return;
// A declined duplicate name reopens the dialog with the user's edits intact.
if (dialog.NestName == nest.Name
|| await NestRenameConfirmation.ConfirmAsync(this, repository, dialog.NestName, id) != false)
break;
}
// Same default propagation as Nest > Nest Info in the open editor.
using var manager = new PlateManager(nest);
manager.ChangePlateDefaults(() => dialog.SaveNestInfo(nest));
+10 -2
View File
@@ -29,8 +29,8 @@ The desktop app can save nests two ways:
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
row's right-click menu) to edit the same information as **Nest > Nest Info**,
including the name, 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
@@ -142,6 +142,14 @@ allocate the year's next name and show it in an editable dialog: OK uploads
under the chosen name, Cancel saves nothing. If the check or the allocation
fails or takes longer than 3 seconds, the nest uploads under its current name.
In Database mode the name can be edited in **Nest > Nest Info** (it uploads with
the next Save) and in the saved-nest browser's **Nest Information...** (saved
immediately). In File mode it stays read-only, since the file name is the nest
name. If another active nest already has the exact new name, a warning asks
before using it; **No** reopens the dialog with the edits kept. A name accepted
this way is not asked about again by the nest's first Save. A rename that the
server cannot check within 3 seconds goes ahead without a warning.
There is no authentication; this is a LAN-only service. Do not expose it
outside the shop network without adding one.