diff --git a/src/XTMF2.GUI/Resources/Strings.Designer.cs b/src/XTMF2.GUI/Resources/Strings.Designer.cs
index 1f8b1f9..69aefe6 100644
--- a/src/XTMF2.GUI/Resources/Strings.Designer.cs
+++ b/src/XTMF2.GUI/Resources/Strings.Designer.cs
@@ -155,7 +155,6 @@ public static string Format(string key, params object[] args)
public static string ModelSystemEditor_SaveTooltip => Get(nameof(ModelSystemEditor_SaveTooltip));
public static string ModelSystemEditor_ExportTooltip => Get(nameof(ModelSystemEditor_ExportTooltip));
public static string ModelSystemEditor_RevertTooltip => Get(nameof(ModelSystemEditor_RevertTooltip));
- public static string ModelSystemEditor_ToastSaving => Get(nameof(ModelSystemEditor_ToastSaving));
public static string ModelSystemEditor_ToastSaved => Get(nameof(ModelSystemEditor_ToastSaved));
public static string ModelSystemEditor_ToastSaveFailed => Get(nameof(ModelSystemEditor_ToastSaveFailed));
diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
index ca76a78..3bad3df 100644
--- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
+++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
@@ -81,6 +81,7 @@ public sealed partial class ModelSystemEditorViewModel : ObservableObject, IDisp
{
private bool _disposed;
private bool _suppressSearchRebuild;
+ private int _saveInProgress;
// ── Session / model ────────────────────────────────────────────────────
/// The active editing session for the model system.
@@ -618,6 +619,9 @@ public System.Collections.ObjectModel.ReadOnlyObservableCollection Availab
/// True when the model system changed since its last successful save.
public bool IsDirty => Session.IsDirty;
+ /// True while the model system is being saved.
+ public bool IsSaving => Volatile.Read(ref _saveInProgress) != 0;
+
// Unsubscribe from the outgoing element before the field changes.
partial void OnSelectedElementChanging(ICanvasElement? value)
{
@@ -885,6 +889,12 @@ public ModelSystemEditorViewModel(
private void OnSessionPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
+ if (!Avalonia.Threading.Dispatcher.UIThread.CheckAccess())
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() => OnSessionPropertyChanged(sender, e));
+ return;
+ }
+
if (e.PropertyName == nameof(Session.CanUndo))
OnPropertyChanged(nameof(CanUndo));
else if (e.PropertyName == nameof(Session.CanRedo))
@@ -3658,25 +3668,52 @@ private async Task RunCalibration()
}
/// Save the model system to its project file.
- [RelayCommand]
+ [RelayCommand(CanExecute = nameof(CanSaveModelSystem))]
private Task SaveModelSystem() => SaveModelSystemAsync();
+ private bool CanSaveModelSystem() => !IsSaving;
+
public async Task SaveModelSystemAsync()
{
- ShowToast(Strings.ModelSystemEditor_ToastSaving);
- // Yield to let the UI render the "saving" toast before the synchronous save runs.
- await Task.Yield();
- if (Session.Save(out var error))
+ if (Interlocked.CompareExchange(ref _saveInProgress, 1, 0) != 0)
+ return false;
+
+ try
{
- ShowToast(Strings.ModelSystemEditor_ToastSaved);
- return true;
+ await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ OnPropertyChanged(nameof(IsSaving));
+ SaveModelSystemCommand.NotifyCanExecuteChanged();
+ });
+
+ var (saved, error) = await Task.Run(async () =>
+ {
+ var saveSucceeded = Session.Save(out var saveError);
+ return (saveSucceeded, saveError);
+ }).ConfigureAwait(false);
+
+ return await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ if (saved)
+ {
+ ShowToast(Strings.ModelSystemEditor_ToastSaved);
+ return true;
+ }
+
+ var msg = string.Format(Strings.ModelSystemEditor_ToastSaveFailed,
+ error?.Message ?? Strings.ModelSystems_UnknownError);
+ ShowToast(msg, isError: true, durationMs: 6000);
+ return false;
+ });
}
- else
+ finally
{
- var msg = string.Format(Strings.ModelSystemEditor_ToastSaveFailed,
- error?.Message ?? Strings.ModelSystems_UnknownError);
- ShowToast(msg, isError: true, durationMs: 6000);
- return false;
+ await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ Volatile.Write(ref _saveInProgress, 0);
+ OnPropertyChanged(nameof(IsSaving));
+ SaveModelSystemCommand.NotifyCanExecuteChanged();
+ });
}
}
diff --git a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml
index 491690a..c192530 100644
--- a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml
+++ b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml
@@ -59,6 +59,22 @@
+
@@ -274,29 +290,47 @@
diff --git a/tests/XTMF2.GUI.Tests/ViewModels/ModelSystemEditorViewModelSaveTests.cs b/tests/XTMF2.GUI.Tests/ViewModels/ModelSystemEditorViewModelSaveTests.cs
new file mode 100644
index 0000000..7599cde
--- /dev/null
+++ b/tests/XTMF2.GUI.Tests/ViewModels/ModelSystemEditorViewModelSaveTests.cs
@@ -0,0 +1,52 @@
+using Avalonia.Headless;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using XTMF2.GUI.Tests.Modules;
+using XTMF2.GUI.ViewModels;
+using XTMF2.ModelSystemConstruct;
+
+namespace XTMF2.GUI.Tests.ViewModels;
+
+[TestClass]
+public class ModelSystemEditorViewModelSaveTests
+{
+ [TestMethod]
+ public void SaveModelSystemAsync_DisablesCommandAndRejectsOverlappingSave()
+ {
+ TestGuiHelper.RunInModelSystemContext(
+ nameof(SaveModelSystemAsync_DisablesCommandAndRejectsOverlappingSave),
+ (user, _, session) =>
+ {
+ Assert.IsTrue(session.AddNode(user, session.ModelSystem.GlobalBoundary,
+ "SaveTestNode", typeof(SimpleGuiTestModule), Rectangle.Hidden,
+ out var node, out var error), error?.Message);
+ Assert.IsNotNull(node);
+ Assert.IsTrue(session.IsDirty);
+
+ using var viewModel = new ModelSystemEditorViewModel(session, user, runController: null);
+ Task? overlappingSave = null;
+ bool commandDisabledWhileSaving = false;
+ viewModel.PropertyChanged += (_, args) =>
+ {
+ if (args.PropertyName == nameof(viewModel.IsSaving) && viewModel.IsSaving)
+ {
+ commandDisabledWhileSaving = !viewModel.SaveModelSystemCommand.CanExecute(null);
+ overlappingSave = viewModel.SaveModelSystemAsync();
+ }
+ };
+
+ Task? save = null;
+ HeadlessAppLifetime.HeadlessSession!.Dispatch(
+ () => save = viewModel.SaveModelSystemAsync(),
+ System.Threading.CancellationToken.None).GetAwaiter().GetResult();
+
+ Assert.IsTrue(save!.GetAwaiter().GetResult());
+ Assert.IsNotNull(overlappingSave);
+ Assert.IsFalse(overlappingSave!.GetAwaiter().GetResult());
+ Assert.IsTrue(commandDisabledWhileSaving);
+ Assert.IsFalse(viewModel.IsSaving);
+ Assert.IsFalse(session.IsDirty);
+ Assert.IsTrue(viewModel.SaveModelSystemCommand.CanExecute(null));
+ });
+ }
+}
\ No newline at end of file