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