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 @@
DesignerMSBuild:Compile
+
+ Designer
+ MSBuild:Compile
+ DesignerMSBuild: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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SourceGen/WpfGui/RecoveryChoice.xaml.cs b/SourceGen/WpfGui/RecoveryChoice.xaml.cs
new file mode 100644
index 00000000..518984f3
--- /dev/null
+++ b/SourceGen/WpfGui/RecoveryChoice.xaml.cs
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2024 faddenSoft
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+using System;
+using System.IO;
+using System.Windows;
+using System.Windows.Media;
+
+namespace SourceGen.WpfGui {
+ ///
+ /// Present a choice between the project file and the recovery file.
+ ///
+ public partial class RecoveryChoice : Window {
+ ///
+ /// Dialog result: true if the recovery file was selected.
+ ///
+ public bool UseRecoveryFile { get; private set; }
+
+ //
+ // Dialog strings.
+ //
+
+ public string ProjPathName { get; set; }
+ public string ProjModWhen { get; set; }
+ public string ProjLength { get; set; }
+ public string RecovPathName { get; set; }
+ public string RecovModWhen { get; set; }
+ public string RecovLength { get; set; }
+
+
+ public RecoveryChoice(Window parent, string projPathName, string recoveryPathName) {
+ InitializeComponent();
+ Owner = parent;
+ DataContext = this;
+
+ string modWhenStr, lenStr;
+ GetFileInfo(projPathName, out DateTime projModWhen, out modWhenStr, out lenStr);
+ ProjPathName = projPathName;
+ ProjModWhen = modWhenStr;
+ ProjLength = lenStr;
+ GetFileInfo(recoveryPathName, out DateTime recovModWhen, out modWhenStr, out lenStr);
+ RecovPathName = recoveryPathName;
+ RecovModWhen = modWhenStr;
+ RecovLength = lenStr;
+
+ if (projModWhen >= recovModWhen) {
+ projectButton.BorderBrush = Brushes.Green;
+ projectButton.BorderThickness = new Thickness(2);
+ } else {
+ recoveryButton.BorderBrush = Brushes.Green;
+ recoveryButton.BorderThickness = new Thickness(2);
+ }
+ }
+
+ ///
+ /// Reads and formats some basic information about the file.
+ ///
+ /// Pathname to file.
+ /// Result: modification date.
+ /// Result: formatted modification date.
+ /// Result: formatted file length.
+ private static void GetFileInfo(string pathName, out DateTime modWhen,
+ out string modWhenStr, out string lenStr) {
+ try {
+ FileInfo fi = new FileInfo(pathName);
+ modWhen = fi.LastWriteTime;
+ modWhenStr = "Modified: " + modWhen.ToString("G");
+ long len = fi.Length;
+ if (len >= 4096) {
+ lenStr = "Length: " + (len / 1024.0).ToString("F2") + " kB";
+ } else {
+ lenStr = "Length: " + len + " bytes";
+ }
+ } catch (Exception ex) {
+ modWhenStr = "file error";
+ lenStr = ex.Message;
+ modWhen = DateTime.Now;
+ }
+ }
+
+ private void Window_ContentRendered(object sender, EventArgs e) {
+ // Don't allow the window to be resized smaller than its initial size in width.
+ MinWidth = ActualWidth;
+ // Don't allow the height to be changed.
+ MinHeight = ActualHeight;
+ MaxHeight = ActualHeight;
+ }
+
+ private void ProjectButton_Click(object sender, RoutedEventArgs e) {
+ UseRecoveryFile = false;
+ DialogResult = true;
+ }
+
+ private void RecoveryButton_Click(object sender, RoutedEventArgs e) {
+ UseRecoveryFile = true;
+ DialogResult = true;
+ }
+ }
+}
diff --git a/docs/sgmanual/index.html b/docs/sgmanual/index.html
index 652e7fb0..f41d7de2 100644
--- a/docs/sgmanual/index.html
+++ b/docs/sgmanual/index.html
@@ -73,6 +73,7 @@
NOTE: Support for very large 65816 programs is
incomplete. The maximum size for a data file is limited to 1 MiB.
-
The first time you save the project (with File > Save),
-you will be prompted for the project name. It's best to use the
-data file's name with ".dis65" added, so this will be set as
-the default. The data file's name is not stored in the project file,
-so if you pick a different name, or save the project in a different
-directory, you will have to select the data file manually whenever you
-open the project.
+
The application will ask you to save the new project to a file.
+It's best to use the data file's name with ".dis65" added,
+so this will be set as the default. The data file's name is not stored
+in the project file, so if you pick a different name, or save the project
+in a different directory, you will have to select the data file manually
+whenever you open the project. (You can cancel the dialog to proceed
+without creating a project file, but certain features will be unavailable.)
Opening an Existing Project
@@ -68,6 +68,30 @@
Opening an Existing Project
that open the two most-recently-opened projects will be available.
+
Saving a Project
+
+
You can save your project with File > Save, or by
+hitting Ctrl+S. To save the project to a different
+file, use File > Save As, but bear in mind that the data
+file is expected to have the same name as the project file, minus the
+".dis65" extension.
+
+
If auto-save is enabled in the application settings, a "recovery" file
+will be created and updated periodically. This file has the same name as
+the project file, but with "_rec" added. The recovery file is only updated
+if the project has been edited but not saved, and the auto-save timer is reset
+whenever the project is manually saved, so if you're diligent about saving
+your work the file may never be written to.
+
+
When a project is opened, if a recovery file exists, the file will be
+checked to see if it's empty or open in a different process. In the former
+case it will be ignored, in the latter you will be asked if you want to
+open the project read-only. If the file is non-empty and not in use, you
+will be given the opportunity to choose which file to use. After making a
+choice, the auto-save feature will be disabled until the first manual save
+is made.
+
+
Working With a Project
The main project window is divided into five areas:
can change the rest of the UI from the Windows display "personalization"
controls.)
+
The auto-save interval selection determines the frequency
+of saves to the recovery file. Setting it to disabled will
+disable the feature entirely, and prevent recovery files from being created
+(though they will still be checked for when projects are opened).