mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-03 17:01:38 -05:00
feat(ui): mark unsaved nests and ask before closing them
A nest window gave no sign that its nest had changed since the last save, and closing it (or the app) discarded the edits silently. The undo history already tracks a save point, so the title now ends in " *" while the nest is off it (or holds an edit no scope recorded), and closing asks to save, discard or cancel. Yes saves through the storage in use; a database save cancels the close and closes the window once the upload succeeds. Saves now commit any unrecorded edit first so the save point covers what was written. The window's cleanup moves from OnClosing to OnFormClosed: another window can still cancel closing the app, and a nest window must not lose its plate manager when that happens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fbc23d6b64
commit
d7fe7fe33c
7 files changed
+239
-9
No files matched your search
@@ -0,0 +1,142 @@
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Forms;
|
||||
|
||||
namespace OpenNest.WinForms.Tests.Forms;
|
||||
|
||||
// The title marks unsaved edits and closing asks before discarding them. Shares the undo
|
||||
// tests' collection, since it drives the same main-window guards.
|
||||
[Collection("Fill operation lifetime")]
|
||||
public class UnsavedChangesTests
|
||||
{
|
||||
[Fact]
|
||||
public void TheTitleIsMarkedWhileTheNestDiffersFromItsSavedState() => UndoTestEnv.Run(env =>
|
||||
{
|
||||
var editor = env.Editor;
|
||||
Assert.False(editor.HasUnsavedChanges);
|
||||
Assert.Equal("undo", editor.Text);
|
||||
|
||||
DeleteFirstPlatesParts(editor);
|
||||
Assert.True(editor.HasUnsavedChanges);
|
||||
Assert.Equal("undo *", editor.Text);
|
||||
|
||||
Assert.True(env.Host.TryUndo(editor));
|
||||
Assert.False(editor.HasUnsavedChanges);
|
||||
Assert.Equal("undo", editor.Text);
|
||||
|
||||
Assert.True(env.Host.TryRedo(editor));
|
||||
Assert.Equal("undo *", editor.Text);
|
||||
|
||||
var path = TempNestPath("saved");
|
||||
try
|
||||
{
|
||||
editor.SaveAs(path);
|
||||
Assert.False(editor.HasUnsavedChanges);
|
||||
Assert.Equal("saved", editor.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void AnEditNoScopeRecordedStillCountsAsUnsaved() => UndoTestEnv.Run(env =>
|
||||
{
|
||||
var editor = env.Editor;
|
||||
editor.Nest.Plates[0].Parts.RemoveAt(0);
|
||||
|
||||
Assert.True(editor.HasUnsavedChanges);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void ClosingAnUnchangedNestDoesNotAsk() => UndoTestEnv.Run(env =>
|
||||
{
|
||||
var asked = 0;
|
||||
WithPrompt(env.Editor, () => { asked++; return DialogResult.Cancel; }, () =>
|
||||
{
|
||||
env.Editor.Close();
|
||||
|
||||
Assert.Equal(0, asked);
|
||||
Assert.True(env.Editor.IsDisposed);
|
||||
});
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void CancelKeepsTheWindowOpenAndNoClosesWithoutSaving() => UndoTestEnv.Run(env =>
|
||||
{
|
||||
var editor = env.Editor;
|
||||
DeleteFirstPlatesParts(editor);
|
||||
var answer = DialogResult.Cancel;
|
||||
var asked = 0;
|
||||
WithPrompt(editor, () => { asked++; return answer; }, () =>
|
||||
{
|
||||
editor.Close();
|
||||
Assert.Equal(1, asked);
|
||||
Assert.False(editor.IsDisposed);
|
||||
Assert.True(editor.HasUnsavedChanges);
|
||||
|
||||
answer = DialogResult.No;
|
||||
editor.Close();
|
||||
Assert.Equal(2, asked);
|
||||
Assert.True(editor.IsDisposed);
|
||||
});
|
||||
});
|
||||
|
||||
// Without a main window (whose save follows the machine's storage mode) Yes saves to a file.
|
||||
[Fact]
|
||||
public void YesSavesToTheNestsFileAndCloses() => StaTestThread.Run(() =>
|
||||
{
|
||||
using var editor = new EditNestForm(UndoTestEnv.BuildNest());
|
||||
editor.PlateView.SetOverlapAutoCheck(null);
|
||||
editor.Show();
|
||||
var path = TempNestPath("yes");
|
||||
try
|
||||
{
|
||||
editor.SaveAs(path);
|
||||
var savedAt = File.GetLastWriteTimeUtc(path);
|
||||
DeleteFirstPlatesParts(editor);
|
||||
File.SetLastWriteTimeUtc(path, savedAt.AddMinutes(-1));
|
||||
|
||||
WithPrompt(editor, () => DialogResult.Yes, () =>
|
||||
{
|
||||
editor.Close();
|
||||
|
||||
Assert.True(editor.IsDisposed);
|
||||
Assert.True(File.GetLastWriteTimeUtc(path) > savedAt.AddMinutes(-1));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
|
||||
|
||||
private static void DeleteFirstPlatesParts(EditNestForm editor)
|
||||
{
|
||||
editor.PlateManager.LoadAt(0);
|
||||
editor.PlateView.SelectAll();
|
||||
editor.PlateView.Selection.DeleteSelected();
|
||||
}
|
||||
|
||||
private static string TempNestPath(string name)
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "OpenNest.WinForms.Tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
return Path.Combine(directory, name + ".nest");
|
||||
}
|
||||
|
||||
// Answers for this test's window only; other windows closing meanwhile keep the default.
|
||||
private static void WithPrompt(EditNestForm editor, Func<DialogResult> answer, Action body)
|
||||
{
|
||||
var previous = EditNestForm.UnsavedChangesPrompt;
|
||||
EditNestForm.UnsavedChangesPrompt = form => ReferenceEquals(form, editor) ? answer() : previous(form);
|
||||
try
|
||||
{
|
||||
body();
|
||||
}
|
||||
finally
|
||||
{
|
||||
EditNestForm.UnsavedChangesPrompt = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Forms;
|
||||
using OpenNest.Properties;
|
||||
|
||||
namespace OpenNest.WinForms.Tests;
|
||||
@@ -7,8 +9,12 @@ internal static class IsolatedUserSettings
|
||||
{
|
||||
#pragma warning disable CA2255 // Must run before anything reads Settings.Default.
|
||||
[ModuleInitializer]
|
||||
internal static void Redirect() =>
|
||||
internal static void Redirect()
|
||||
{
|
||||
AppDataSettingsProvider.PathOverride = Path.Combine(
|
||||
Path.GetTempPath(), "OpenNest.WinForms.Tests", Guid.NewGuid().ToString("N"), "user-settings.json");
|
||||
// A closing nest window never waits on the unsaved-changes message box.
|
||||
EditNestForm.UnsavedChangesPrompt = _ => DialogResult.No;
|
||||
}
|
||||
#pragma warning restore CA2255
|
||||
}
|
||||
@@ -136,11 +136,13 @@ namespace OpenNest.Forms.Commands
|
||||
renamed = chosen != originalName;
|
||||
form.Nest.Name = chosen;
|
||||
}
|
||||
// The save marks the history's current state saved, so it must include every edit.
|
||||
form.RecordUnrecordedChange();
|
||||
var saved = await form.Document.SaveToDatabaseAsync(
|
||||
repository, serverUrl, saveCopy);
|
||||
if (!form.IsDisposed)
|
||||
{
|
||||
form.Text = form.Document.Name;
|
||||
form.UpdateTitle();
|
||||
_ = form.HoldStatus.RefreshAfterSaveAsync();
|
||||
}
|
||||
if (!owner.IsDisposed)
|
||||
|
||||
@@ -98,10 +98,22 @@ namespace OpenNest.Forms
|
||||
RestoreNest(restored);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the nest differs from the state last saved (or opened): the history has
|
||||
/// moved off its save point, or an edit no scope recorded is pending.
|
||||
/// </summary>
|
||||
public bool HasUnsavedChanges =>
|
||||
History.Current != null
|
||||
&& (!History.IsAtSavePoint || NestFingerprint.Compute(Nest) != recordedFingerprint);
|
||||
|
||||
/// <summary>Shows the nest's name, marked with an asterisk while it has unsaved changes.</summary>
|
||||
internal void UpdateTitle() => Text = HasUnsavedChanges ? $"{Nest.Name} *" : Nest.Name;
|
||||
|
||||
private void InitializeHistory()
|
||||
{
|
||||
History.Reset(new NestUndoState(NestSnapshot.Capture(Nest)));
|
||||
recordedFingerprint = NestFingerprint.Compute(Nest);
|
||||
History.Changed += (_, _) => UpdateTitle();
|
||||
#if DEBUG
|
||||
Application.Idle += CheckForUnrecordedChange;
|
||||
FormClosed += (_, _) => Application.Idle -= CheckForUnrecordedChange;
|
||||
@@ -123,13 +135,15 @@ namespace OpenNest.Forms
|
||||
{
|
||||
History.Commit(label, new NestUndoState(NestSnapshot.Capture(Nest)), coalesceKey);
|
||||
recordedFingerprint = NestFingerprint.Compute(Nest);
|
||||
UpdateTitle();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits an edit no scope recorded as its own entry, so a rollback, Undo or Redo
|
||||
/// does not silently discard it. Detected by fingerprint, so best effort.
|
||||
/// does not silently discard it, and a save marks it saved. Detected by fingerprint, so
|
||||
/// best effort.
|
||||
/// </summary>
|
||||
private void RecordUnrecordedChange()
|
||||
internal void RecordUnrecordedChange()
|
||||
{
|
||||
if (History.Current != null && NestFingerprint.Compute(Nest) != recordedFingerprint)
|
||||
Commit("Change", null);
|
||||
@@ -210,6 +224,7 @@ namespace OpenNest.Forms
|
||||
}
|
||||
|
||||
recordedFingerprint = NestFingerprint.Compute(Nest);
|
||||
UpdateTitle();
|
||||
NestRestored?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
|
||||
@@ -500,7 +500,7 @@ namespace OpenNest.Forms
|
||||
UpdateDrawingList();
|
||||
UpdateRemovePlateButton();
|
||||
|
||||
Text = Nest.Name;
|
||||
UpdateTitle();
|
||||
drawingListBox1.Units = Nest.Units;
|
||||
drawingListBox1.Plate = PlateManager.CurrentPlate;
|
||||
}
|
||||
@@ -599,6 +599,8 @@ namespace OpenNest.Forms
|
||||
|
||||
public void SaveAs(string path)
|
||||
{
|
||||
// The save marks the history's current state saved, so it must include every edit.
|
||||
RecordUnrecordedChange();
|
||||
try
|
||||
{
|
||||
Document.SaveAs(path);
|
||||
@@ -610,7 +612,7 @@ namespace OpenNest.Forms
|
||||
"Save Nest", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return;
|
||||
}
|
||||
Text = Document.Name;
|
||||
UpdateTitle();
|
||||
}
|
||||
|
||||
public void Import()
|
||||
@@ -874,7 +876,7 @@ namespace OpenNest.Forms
|
||||
}
|
||||
if (form.UseForNewNests)
|
||||
MachineSettings.SaveNestDefaults(this, NestDefaults.FromNest(Nest), Nest.Machine);
|
||||
Text = Nest.Name;
|
||||
UpdateTitle();
|
||||
drawingListBox1.Units = Nest.Units;
|
||||
drawingListBox1.Invalidate();
|
||||
if (statusBefore != NestStatus.HasBeenCut && Nest.Status == NestStatus.HasBeenCut)
|
||||
@@ -1252,13 +1254,65 @@ namespace OpenNest.Forms
|
||||
}
|
||||
}
|
||||
base.OnClosing(e);
|
||||
if (!e.Cancel && !ConfirmCloseWithUnsavedChanges())
|
||||
e.Cancel = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks whether to save the nest's unsaved changes before closing (Yes, No or Cancel);
|
||||
/// tests replace it so a closing window never waits on a message box.
|
||||
/// </summary>
|
||||
internal static Func<EditNestForm, DialogResult> UnsavedChangesPrompt = form =>
|
||||
MessageBox.Show(form, $"Save changes to '{form.Nest.Name}' before closing?",
|
||||
"Unsaved Changes", MessageBoxButtons.YesNoCancel, MessageBoxIcon.Warning);
|
||||
|
||||
// Whether the window may close now. A save that has to wait on the nest server cancels
|
||||
// this close and closes the window once the nest is saved.
|
||||
private bool ConfirmCloseWithUnsavedChanges()
|
||||
{
|
||||
if (!HasUnsavedChanges)
|
||||
return true;
|
||||
switch (UnsavedChangesPrompt(this))
|
||||
{
|
||||
case DialogResult.No:
|
||||
return true;
|
||||
case DialogResult.Yes:
|
||||
var save = SaveChangesAsync();
|
||||
if (save.IsCompleted)
|
||||
return !HasUnsavedChanges;
|
||||
_ = CloseWhenSavedAsync(save);
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The main window saves to the nest storage in use; a window without one saves to a file.
|
||||
private Task SaveChangesAsync()
|
||||
{
|
||||
if (MdiParent is MainForm main)
|
||||
return main.SaveNestAsync(this);
|
||||
Save();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task CloseWhenSavedAsync(Task save)
|
||||
{
|
||||
await save;
|
||||
if (!IsDisposed && !HasUnsavedChanges)
|
||||
Close();
|
||||
}
|
||||
|
||||
protected override void OnFormClosed(FormClosedEventArgs e)
|
||||
{
|
||||
// Released here, not while closing: another window can still cancel closing the app.
|
||||
PlateManager.Dispose();
|
||||
|
||||
Settings.Default.PlateViewDrawBounds = PlateView.DrawBounds;
|
||||
Settings.Default.PlateViewDrawRapid = PlateView.DrawRapid;
|
||||
Settings.Default.SplitterDistance = splitContainer.SplitterDistance;
|
||||
Settings.Default.Save();
|
||||
base.OnFormClosed(e);
|
||||
}
|
||||
|
||||
protected override void OnResize(EventArgs e)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Data;
|
||||
using OpenNest.Engine.BestFit;
|
||||
@@ -230,8 +231,14 @@ namespace OpenNest.Forms
|
||||
|
||||
private async void Save_Click(object sender, EventArgs e)
|
||||
{
|
||||
var form = activeForm;
|
||||
if (form == null || storage.IsSaving || RefuseSaveDuringRelease(form))
|
||||
if (activeForm != null)
|
||||
await SaveNestAsync(activeForm);
|
||||
}
|
||||
|
||||
/// <summary>Saves <paramref name="form"/>'s nest to the nest storage in use.</summary>
|
||||
internal async Task SaveNestAsync(EditNestForm form)
|
||||
{
|
||||
if (storage.IsSaving || RefuseSaveDuringRelease(form))
|
||||
return;
|
||||
if (NestStorage.Settings.Mode == NestStorageMode.File)
|
||||
form.Save();
|
||||
|
||||
@@ -48,6 +48,10 @@ Deliberately excluded:
|
||||
|
||||
A save does not clear the history; it marks a save point (`UndoHistory.MarkSaved`,
|
||||
`IsAtSavePoint`). A database save marks it only when no edit was recorded while it ran.
|
||||
A save first commits any edit no scope recorded, so the point covers what was written.
|
||||
`EditNestForm.HasUnsavedChanges` is true off the save point or while such an edit is pending:
|
||||
the window title then ends in ` *`, and closing asks Save, Don't Save or Cancel. Yes saves to
|
||||
the storage in use; a database save cancels the close and closes the window once it succeeds.
|
||||
|
||||
## Recording rule
|
||||
|
||||
|
||||
Reference in new issue
Block a user