From fca742e89071ca2bb918af847c7ff40454bced45 Mon Sep 17 00:00:00 2001 From: Andy McFadden Date: Fri, 9 Aug 2024 13:37:38 -0700 Subject: [PATCH] Auto-save, part 3 (of 3) Added the recovery file check when a project is opened, and the GUI elements for choosing which file to use. If a recovery file exists but can't be opened, presumably because it's open by another process, offer to open the project read-only. (This is a generally good idea, but we don't hold the project file open in normal usage, so it only works when auto-save is enabled.) After making a choice, auto-save is disabled until the first manual save. One thing we don't do: if we find a recovery file, but auto-save is disabled, the recovery file won't be deleted after the user makes a choice. This might be a feature. Updated documentation. (issue #161) --- SourceGen/MainController.cs | 151 +++++++++++++++++++++--- SourceGen/SourceGen.csproj | 7 ++ SourceGen/WpfGui/RecoveryChoice.xaml | 87 ++++++++++++++ SourceGen/WpfGui/RecoveryChoice.xaml.cs | 111 +++++++++++++++++ docs/sgmanual/index.html | 1 + docs/sgmanual/mainwin.html | 38 ++++-- docs/sgmanual/settings.html | 5 + 7 files changed, 375 insertions(+), 25 deletions(-) create mode 100644 SourceGen/WpfGui/RecoveryChoice.xaml create mode 100644 SourceGen/WpfGui/RecoveryChoice.xaml.cs diff --git a/SourceGen/MainController.cs b/SourceGen/MainController.cs index e9dd07df..6d832053 100644 --- a/SourceGen/MainController.cs +++ b/SourceGen/MainController.cs @@ -727,12 +727,17 @@ private void UpdateByteCounts() { #region Auto-save - private string mRecoveryPathName = string.Empty; - private Stream mRecoveryStream = null; + private const string RECOVERY_EXT_ADD = "_rec"; + private const string RECOVERY_EXT = ProjectFile.FILENAME_EXT + RECOVERY_EXT_ADD; - private DispatcherTimer mAutoSaveTimer = null; - private DateTime mLastEditWhen = DateTime.Now; - private DateTime mLastAutoSaveWhen = DateTime.Now; + private string mRecoveryPathName = string.Empty; // path to recovery file, or empty str + private Stream mRecoveryStream = null; // stream for recovery file, or null + + private DispatcherTimer mAutoSaveTimer = null; // auto-save timer, may be disabled + private DateTime mLastEditWhen = DateTime.Now; // timestamp of last user edit + private DateTime mLastAutoSaveWhen = DateTime.Now; // timestamp of last auto-save + + private bool mAutoSaveDeferred = false; /// @@ -827,14 +832,33 @@ private void AutoSaveTick(object sender, EventArgs e) { /// Creates or deletes the recovery file, based on the current app settings. /// /// - /// This is called when a new project is created, an existing project is opened, the - /// app settings are updated, or Save As is used to change the project name. + /// This is called when: + /// + /// a new project is created + /// an existing project is opened + /// app settings are updated + /// Save As is used to change the project path + /// the project is saved for the first time after a recovery file decision (i.e. + /// while mAutoSaveDeferred is true) + /// /// private void RefreshRecoveryFile() { if (mProject == null) { // Project not open, nothing to do. return; } + if (mProject.IsReadOnly) { + // Changes cannot be made, so there's no need for a recovery file. Also, we + // might be in read-only mode because the project is already open and has a + // recovery file opened by another process. + Debug.WriteLine("Recovery: project is read-only, not creating recovery file"); + Debug.Assert(mRecoveryStream == null); + return; + } + if (mAutoSaveDeferred) { + Debug.WriteLine("Recovery: auto-save deferred, not touching recovery file"); + return; + } int interval = AppSettings.Global.GetInt(AppSettings.PROJ_AUTO_SAVE_INTERVAL, 0); if (interval <= 0) { @@ -855,7 +879,7 @@ private void RefreshRecoveryFile() { // case auto-save was previously disabled. mLastAutoSaveWhen = mLastEditWhen.AddSeconds(-1); - string pathName = GenerateRecoveryPathName(); + string pathName = GenerateRecoveryPathName(mProjectPathName); if (!string.IsNullOrEmpty(mRecoveryPathName) && pathName == mRecoveryPathName) { // File is open and the filename hasn't changed. Nothing to do. Debug.Assert(mRecoveryStream != null); @@ -866,18 +890,18 @@ private void RefreshRecoveryFile() { "' in favor of '" + pathName + "'"); DiscardRecoveryFile(); } - Debug.WriteLine("Recovery: opening '" + pathName + "'"); + Debug.WriteLine("Recovery: creating '" + pathName + "'"); PrepareRecoveryFile(); } mAutoSaveTimer.Start(); } } - private string GenerateRecoveryPathName() { - if (string.IsNullOrEmpty(mProjectPathName)) { + private static string GenerateRecoveryPathName(string pathName) { + if (string.IsNullOrEmpty(pathName)) { return string.Empty; } else { - return mProjectPathName + "_rec"; + return pathName + RECOVERY_EXT_ADD; } } @@ -889,7 +913,7 @@ private void PrepareRecoveryFile() { Debug.Assert(mRecoveryStream == null); Debug.Assert(string.IsNullOrEmpty(mRecoveryPathName)); - string pathName = GenerateRecoveryPathName(); + string pathName = GenerateRecoveryPathName(mProjectPathName); try { mRecoveryStream = new FileStream(pathName, FileMode.OpenOrCreate, FileAccess.Write); mRecoveryPathName = pathName; @@ -924,6 +948,64 @@ private void DiscardRecoveryFile() { mAutoSaveTimer.Stop(); } + /// + /// Asks the user if they want to use the recovery file, if one is present and non-empty. + /// Both files must exist. + /// + /// Path to project file we're trying to open + /// Path to recovery file. + /// Result: path the user wishes to use. If we didn't ask the + /// user to choose, because the recovery file was empty or in use by another process, + /// this will be an empty string. + /// Result: true if project should be opened read-only. + /// False if the user cancelled the operation, true to continue. + private bool HandleRecoveryChoice(string projPathName, string recoveryPath, + out string pathToUse, out bool asReadOnly) { + pathToUse = string.Empty; + asReadOnly = false; + + try { + using (FileStream stream = new FileStream(recoveryPath, FileMode.Open, + FileAccess.ReadWrite, FileShare.None)) { + if (stream.Length == 0) { + // Recovery file exists, but is empty and not open by another process. + // Ignore it. (We could delete it here, but there's no need.) + Debug.WriteLine("Recovery: found existing zero-length file (ignoring)"); + return true; + } + } + } catch (Exception ex) { + // Unable to open recovery file. This is probably happening because another + // process has the file open. + Debug.WriteLine("Unable to open recovery file: " + ex.Message); + MessageBoxResult mbr = MessageBox.Show(mMainWin, + "The project has a recovery file that can't be opened, possibly because the " + + "project is currently open by another copy of the application. Do you wish " + + "to open the file read-only?", + "Unable to Open", MessageBoxButton.OKCancel, MessageBoxImage.Hand); + if (mbr == MessageBoxResult.OK) { + asReadOnly = true; + return true; + } else { + asReadOnly = false; + return false; + } + } + + RecoveryChoice dlg = new RecoveryChoice(mMainWin, projPathName, recoveryPath); + if (dlg.ShowDialog() != true) { + return false; + } + if (dlg.UseRecoveryFile) { + Debug.WriteLine("Recovery: user chose recovery file"); + pathToUse = recoveryPath; + } else { + Debug.WriteLine("Recovery: user chose project file"); + pathToUse = projPathName; + } + return true; + } + #endregion Auto-save @@ -1342,11 +1424,33 @@ private void DoOpenFile(string projPathName) { DisasmProject newProject = new DisasmProject(); newProject.UseMainAppDomainForPlugins = UseMainAppDomainForPlugins; + // Is there a recovery file? + mAutoSaveDeferred = false; + string recoveryPath = GenerateRecoveryPathName(projPathName); + string openPath = projPathName; + if (File.Exists(recoveryPath)) { + // Found a recovery file. + bool ok = HandleRecoveryChoice(projPathName, recoveryPath, out string pathToUse, + out bool asReadOnly); + if (!ok) { + // Open has been cancelled. + return; + } + if (!string.IsNullOrEmpty(pathToUse)) { + // One was chosen. This should be the case unless the recovery file was + // empty, or was open by a different process. + Debug.WriteLine("Open: user chose '" + pathToUse + "', deferring auto-save"); + openPath = pathToUse; + mAutoSaveDeferred = true; + } + newProject.IsReadOnly |= asReadOnly; + } + // Deserialize the project file. I want to do this before loading the data file // in case we decide to store the data file name in the project (e.g. the data // file is a disk image or zip archive, and we need to know which part(s) to // extract). - if (!ProjectFile.DeserializeFromFile(projPathName, newProject, + if (!ProjectFile.DeserializeFromFile(openPath, newProject, out FileLoadReport report)) { // Should probably use a less-busy dialog for something simple like // "permission denied", but the open file dialog handles most simple @@ -1363,11 +1467,16 @@ private void DoOpenFile(string projPathName) { // locate it manually, repeating the process until successful or canceled. const string UNKNOWN_FILE = "UNKNOWN"; string dataPathName; - if (projPathName.Length <= ProjectFile.FILENAME_EXT.Length) { - dataPathName = UNKNOWN_FILE; - } else { + if (projPathName.EndsWith(ProjectFile.FILENAME_EXT, + StringComparison.InvariantCultureIgnoreCase)) { dataPathName = projPathName.Substring(0, projPathName.Length - ProjectFile.FILENAME_EXT.Length); + } else if (projPathName.EndsWith(RECOVERY_EXT, + StringComparison.InvariantCultureIgnoreCase)) { + dataPathName = projPathName.Substring(0, + projPathName.Length - RECOVERY_EXT.Length); + } else { + dataPathName = UNKNOWN_FILE; } byte[] fileData; while ((fileData = FindValidDataFile(ref dataPathName, newProject, @@ -1391,7 +1500,7 @@ private void DoOpenFile(string projPathName) { return; } - newProject.IsReadOnly = dlg.WantReadOnly; + newProject.IsReadOnly |= dlg.WantReadOnly; } mProject = newProject; @@ -1539,6 +1648,7 @@ public bool SaveProject() { } private bool DoSave(string pathName) { + Debug.Assert(!mProject.IsReadOnly); // save commands should be disabled Debug.WriteLine("SAVING " + pathName); if (!ProjectFile.SerializeToFile(mProject, pathName, out string errorMessage)) { MessageBox.Show(Res.Strings.ERR_PROJECT_SAVE_FAIL + ": " + errorMessage, @@ -1560,6 +1670,11 @@ private bool DoSave(string pathName) { // Seems like a good time to save this off too. SaveAppSettings(); + if (mAutoSaveDeferred) { + mAutoSaveDeferred = false; + RefreshRecoveryFile(); + } + // The project file is saved, no need to auto-save for a while. ResetAutoSaveTimer(); diff --git a/SourceGen/SourceGen.csproj b/SourceGen/SourceGen.csproj index 0080c3a2..28ce524d 100644 --- a/SourceGen/SourceGen.csproj +++ b/SourceGen/SourceGen.csproj @@ -238,6 +238,9 @@ + + RecoveryChoice.xaml + ShowWireframeAnimation.xaml @@ -476,6 +479,10 @@ Designer MSBuild:Compile + + Designer + MSBuild:Compile + Designer MSBuild:Compile diff --git a/SourceGen/WpfGui/RecoveryChoice.xaml b/SourceGen/WpfGui/RecoveryChoice.xaml new file mode 100644 index 00000000..3d92ea1f --- /dev/null +++ b/SourceGen/WpfGui/RecoveryChoice.xaml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +