diff --git a/SourceGen/MainController.cs b/SourceGen/MainController.cs
index 2b02938..e9dd07d 100644
--- a/SourceGen/MainController.cs
+++ b/SourceGen/MainController.cs
@@ -22,6 +22,7 @@ using System.Web.Script.Serialization;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
+using System.Windows.Threading;
using Microsoft.Win32;
using Asm65;
@@ -53,7 +54,7 @@ namespace SourceGen {
private string mProjectPathName;
///
- /// Data backing the code list.
+ /// Data backing the code list. Will be null if the project is not open.
///
public LineListGen CodeLineList { get; private set; }
@@ -241,9 +242,14 @@ namespace SourceGen {
#region Init and settings
+ ///
+ /// Constructor, called from the main window code.
+ ///
public MainController(MainWindow win) {
mMainWin = win;
+ CreateAutoSaveTimer();
+
ScriptManager.UseKeepAliveHack = true;
}
@@ -590,7 +596,7 @@ namespace SourceGen {
mMainWin.DoShowCycleCounts =
AppSettings.Global.GetBool(AppSettings.FMT_SHOW_CYCLE_COUNTS, false);
- // Finally, update the display list generator with all the fancy settings.
+ // Update the display list generator with all the fancy settings.
if (CodeLineList != null) {
// Regenerate the display list with the latest formatter config and
// pseudo-op definition. (These are set as part of the refresh.)
@@ -598,6 +604,9 @@ namespace SourceGen {
UndoableChange.CreateDummyChange(UndoableChange.ReanalysisScope.DisplayOnly);
ApplyChanges(new ChangeSet(uc), false);
}
+
+ // If auto-save was enabled or disabled, create or remove the recovery file.
+ RefreshRecoveryFile();
}
private void SetCodeLineListColorMultiplier() {
@@ -716,6 +725,208 @@ namespace SourceGen {
#endregion Init and settings
+ #region Auto-save
+
+ private string mRecoveryPathName = string.Empty;
+ private Stream mRecoveryStream = null;
+
+ private DispatcherTimer mAutoSaveTimer = null;
+ private DateTime mLastEditWhen = DateTime.Now;
+ private DateTime mLastAutoSaveWhen = DateTime.Now;
+
+
+ ///
+ /// Creates an interval timer that fires an event on the GUI thread.
+ ///
+ private void CreateAutoSaveTimer() {
+ mAutoSaveTimer = new DispatcherTimer();
+ mAutoSaveTimer.Tick += new EventHandler(AutoSaveTick);
+ mAutoSaveTimer.Interval = TimeSpan.FromSeconds(5);
+ }
+
+ ///
+ /// Resets the auto-save timer to the configured interval. Has no effect if the timer
+ /// isn't currently running.
+ ///
+ private void ResetAutoSaveTimer() {
+ if (mAutoSaveTimer.IsEnabled) {
+ // Setting the Interval resets the timer.
+ mAutoSaveTimer.Interval = mAutoSaveTimer.Interval;
+ }
+ }
+
+ ///
+ /// Handles the auto-save timer event.
+ ///
+ ///
+ /// We're using a DispatcherTimer, which appears to execute as part of the dispatcher,
+ /// not a System.Timers.Timer thread, which runs asynchronously. So not only do we not
+ /// have to worry about SynchronizationObjects, it seems likely that this won't fire
+ /// after the timer is disabled.
+ ///
+ private void AutoSaveTick(object sender, EventArgs e) {
+ try {
+ if (mRecoveryStream == null) {
+ Debug.WriteLine("AutoSave tick: no recovery file");
+ return;
+ }
+ if (mLastEditWhen <= mLastAutoSaveWhen) {
+ Debug.WriteLine("AutoSave tick: recovery file is current (edit at " +
+ mLastEditWhen + ", auto-save at " + mLastAutoSaveWhen + ")");
+ return;
+ }
+ if (!mProject.IsDirty) {
+ // This may seem off, because of the following scenario: open a file, make a
+ // single edit, wait for auto-save, then hit Undo. Changes have been made,
+ // but the project is now back to its original form, so IsDirty is false. If
+ // we don't auto-save now, the recovery file will have a newer modification
+ // date than the project file, but will be stale.
+ //
+ // Technically, we don't need to update the recovery file, because the base
+ // project file has the correct and complete project. There's no real need
+ // for us to save another copy. If we crash, we'll have a stale recovery file
+ // with a newer timestamp, but we could handle that by back-dating the file
+ // timestamp or simply by truncating the recovery stream.
+ //
+ // The real reason for this test is that we don't want to auto-save if the
+ // user is being good about manual saves.
+ if (mRecoveryStream.Length != 0) {
+ Debug.WriteLine("AutoSave tick: project not dirty, truncating recovery");
+ mRecoveryStream.SetLength(0);
+ } else {
+ Debug.WriteLine("AutoSave tick: project not dirty");
+ }
+ mLastAutoSaveWhen = mLastEditWhen; // bump this so earlier test fires
+ return;
+ }
+
+ // The project is dirty, and we haven't auto-saved since the last change was
+ // made. Serialize the project to the recovery file.
+ Mouse.OverrideCursor = Cursors.Wait;
+
+ DateTime startWhen = DateTime.Now;
+ mRecoveryStream.Position = 0;
+ mRecoveryStream.SetLength(0);
+ if (!ProjectFile.SerializeToStream(mProject, mRecoveryStream,
+ out string errorMessage)) {
+ Debug.WriteLine("AutoSave FAILED: " + errorMessage);
+ }
+ mRecoveryStream.Flush(); // flush is very important, timing is not; try Async?
+ mLastAutoSaveWhen = DateTime.Now;
+ Debug.WriteLine("AutoSave tick: recovery file updated: " + mRecoveryStream.Length +
+ " bytes (" + (mLastAutoSaveWhen - startWhen).TotalMilliseconds + " ms)");
+ } catch (Exception ex) {
+ // Not expected, but let's not crash just because auto-save is broken.
+ Debug.WriteLine("AutoSave FAILED ENTIRELY: " + ex);
+ } finally {
+ Mouse.OverrideCursor = null;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ private void RefreshRecoveryFile() {
+ if (mProject == null) {
+ // Project not open, nothing to do.
+ return;
+ }
+
+ int interval = AppSettings.Global.GetInt(AppSettings.PROJ_AUTO_SAVE_INTERVAL, 0);
+ if (interval <= 0) {
+ // We don't want a recovery file. If one exists, close it and remove it.
+ if (mRecoveryStream != null) {
+ Debug.WriteLine("Recovery: auto-save is disabled");
+ DiscardRecoveryFile();
+ Debug.Assert(string.IsNullOrEmpty(mRecoveryPathName));
+ } else {
+ Debug.WriteLine("Recovery: auto-save is disabled, file was not open");
+ }
+ mAutoSaveTimer.Stop();
+ } else {
+ // Configure auto-save. We need to update the interval in case it was changed
+ // by an app settings update.
+ mAutoSaveTimer.Interval = TimeSpan.FromSeconds(interval);
+ // Force an initial auto-save (on next timer tick) if the project is dirty, in
+ // case auto-save was previously disabled.
+ mLastAutoSaveWhen = mLastEditWhen.AddSeconds(-1);
+
+ string pathName = GenerateRecoveryPathName();
+ if (!string.IsNullOrEmpty(mRecoveryPathName) && pathName == mRecoveryPathName) {
+ // File is open and the filename hasn't changed. Nothing to do.
+ Debug.Assert(mRecoveryStream != null);
+ Debug.WriteLine("Recovery: open, no changes");
+ } else {
+ if (mRecoveryStream != null) {
+ Debug.WriteLine("Recovery: closing '" + mRecoveryPathName +
+ "' in favor of '" + pathName + "'");
+ DiscardRecoveryFile();
+ }
+ Debug.WriteLine("Recovery: opening '" + pathName + "'");
+ PrepareRecoveryFile();
+ }
+ mAutoSaveTimer.Start();
+ }
+ }
+
+ private string GenerateRecoveryPathName() {
+ if (string.IsNullOrEmpty(mProjectPathName)) {
+ return string.Empty;
+ } else {
+ return mProjectPathName + "_rec";
+ }
+ }
+
+ ///
+ /// Creates the recovery file, overwriting any existing file. If auto-save is disabled
+ /// (indicated by an empty recovery file name), this does nothing.
+ ///
+ private void PrepareRecoveryFile() {
+ Debug.Assert(mRecoveryStream == null);
+ Debug.Assert(string.IsNullOrEmpty(mRecoveryPathName));
+
+ string pathName = GenerateRecoveryPathName();
+ try {
+ mRecoveryStream = new FileStream(pathName, FileMode.OpenOrCreate, FileAccess.Write);
+ mRecoveryPathName = pathName;
+ } catch (Exception ex) {
+ MessageBox.Show(mMainWin, "Failed to create recovery file '" +
+ pathName + "': " + ex.Message, "File Error",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ }
+
+ ///
+ /// If we have a recovery file, close and delete it. This does nothing if the recovery
+ /// file is not currently open.
+ ///
+ private void DiscardRecoveryFile() {
+ if (mRecoveryStream == null) {
+ Debug.Assert(string.IsNullOrEmpty(mRecoveryPathName));
+ return;
+ }
+ Debug.WriteLine("Recovery: discarding recovery file '" + mRecoveryPathName + "'");
+ mRecoveryStream.Close();
+ try {
+ File.Delete(mRecoveryPathName);
+ } catch (Exception ex) {
+ MessageBox.Show(mMainWin, "Failed to delete recovery file '" +
+ mRecoveryPathName + "': " + ex.Message, "File Error",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ // Discard our internal state.
+ }
+ mRecoveryStream = null;
+ mRecoveryPathName = string.Empty;
+ mAutoSaveTimer.Stop();
+ }
+
+ #endregion Auto-save
+
+
#region Project management
private bool PrepareNewProject(string dataPathName, SystemDef sysDef) {
@@ -935,6 +1146,9 @@ namespace SourceGen {
// ListView's selection index could be referencing a line off the end.
// (This may not be necessary with WPF, because the way highlights work changed.)
UpdateSelectionHighlight();
+
+ // Bump the edit timestamp so the auto-save will run.
+ mLastEditWhen = DateTime.Now;
}
///
@@ -1046,6 +1260,9 @@ namespace SourceGen {
#region Main window UI event handlers
+ ///
+ /// Handles creation of a new project.
+ ///
public void NewProject() {
if (!CloseProject()) {
return;
@@ -1076,6 +1293,7 @@ namespace SourceGen {
if (ok) {
FinishPrep();
SaveProjectAs();
+ RefreshRecoveryFile();
}
}
@@ -1180,6 +1398,7 @@ namespace SourceGen {
mProjectPathName = mProject.ProjectPathName = projPathName;
mDataPathName = dataPathName;
FinishPrep();
+ RefreshRecoveryFile();
}
///
@@ -1300,6 +1519,8 @@ namespace SourceGen {
// Success, record the path name.
mProjectPathName = mProject.ProjectPathName = pathName;
+ RefreshRecoveryFile();
+
// add it to the title bar
UpdateTitle();
return true;
@@ -1339,6 +1560,9 @@ namespace SourceGen {
// Seems like a good time to save this off too.
SaveAppSettings();
+ // The project file is saved, no need to auto-save for a while.
+ ResetAutoSaveTimer();
+
return true;
}
@@ -1410,6 +1634,7 @@ namespace SourceGen {
}
mDataPathName = null;
mProjectPathName = null;
+ CodeLineList = null;
// We may get a "selection changed" message as things are being torn down. Clear
// these so we don't try to remove the highlight from something that doesn't exist.
@@ -1423,6 +1648,8 @@ namespace SourceGen {
UpdateTitle();
+ DiscardRecoveryFile();
+
// Not necessary, but it lets us check the memory monitor to see if we got
// rid of everything.
GC.Collect();
diff --git a/SourceGen/ProjectFile.cs b/SourceGen/ProjectFile.cs
index 48407f6..a16103b 100644
--- a/SourceGen/ProjectFile.cs
+++ b/SourceGen/ProjectFile.cs
@@ -75,15 +75,6 @@ namespace SourceGen {
public static bool SerializeToFile(DisasmProject proj, string pathName,
out string errorMessage) {
try {
- string serializedData = SerializableProjectFile1.SerializeProject(proj);
- if (ADD_CRLF) {
- // Add some line breaks. This looks awful, but it makes text diffs
- // much more useful.
- serializedData = TextUtil.NonQuoteReplace(serializedData, "{", "{\r\n");
- serializedData = TextUtil.NonQuoteReplace(serializedData, "},", "},\r\n");
- serializedData = TextUtil.NonQuoteReplace(serializedData, ",", ",\r\n");
- }
-
// Check to see if the project file is read-only. We want to fail early
// so we don't leave our .TMP file sitting around -- the File.Delete() call
// will fail if the destination is read-only.
@@ -93,14 +84,14 @@ namespace SourceGen {
pathName));
}
- // The BOM is not required or recommended for UTF-8 files, but anecdotal
- // evidence suggests that it's sometimes useful. Shouldn't cause any harm
- // to have it in the project file. The explicit Encoding.UTF8 argument
- // causes it to appear -- WriteAllText normally doesn't.
- //
// Write to a temp file, then rename over original after write has succeeded.
string tmpPath = pathName + ".TMP";
- File.WriteAllText(tmpPath, serializedData, Encoding.UTF8);
+ using (FileStream stream = new FileStream(tmpPath, FileMode.OpenOrCreate,
+ FileAccess.Write, FileShare.None)) {
+ if (!SerializeToStream(proj, stream, out errorMessage)) {
+ return false;
+ }
+ }
if (File.Exists(pathName)) {
File.Delete(pathName);
}
@@ -113,6 +104,36 @@ namespace SourceGen {
}
}
+ ///
+ /// Serializes the project and writes it to the specified stream.
+ ///
+ /// Project to serialize.
+ /// Stream to write data to. Will not be seeked or truncated, and
+ /// will be left open.
+ /// Human-readable error string, or an empty string if all
+ /// went well.
+ /// True on success.
+ public static bool SerializeToStream(DisasmProject proj, Stream stream,
+ out string errorMessage) {
+ string serializedData = SerializableProjectFile1.SerializeProject(proj);
+ if (ADD_CRLF) {
+ // Add some line breaks. This looks awful, but it makes text diffs
+ // much more useful.
+ serializedData = TextUtil.NonQuoteReplace(serializedData, "{", "{\r\n");
+ serializedData = TextUtil.NonQuoteReplace(serializedData, "},", "},\r\n");
+ serializedData = TextUtil.NonQuoteReplace(serializedData, ",", ",\r\n");
+ }
+
+ // Use UTF-8 encoding, with a byte-order mark. It's not required or recommended,
+ // but it's harmless, and might help something decide that the file is UTF-8.
+ using (StreamWriter sw = new StreamWriter(stream, Encoding.UTF8, 4096, true)) {
+ sw.Write(serializedData);
+ }
+
+ errorMessage = string.Empty;
+ return true;
+ }
+
///
/// Reads the specified file and deserializes it into the project.
///
diff --git a/SourceGen/WpfGui/EditAppSettings.xaml.cs b/SourceGen/WpfGui/EditAppSettings.xaml.cs
index 690c57f..a60cc5a 100644
--- a/SourceGen/WpfGui/EditAppSettings.xaml.cs
+++ b/SourceGen/WpfGui/EditAppSettings.xaml.cs
@@ -268,6 +268,7 @@ namespace SourceGen.WpfGui {
}
private static readonly AutoSaveItem[] sAutoSaveItems = {
new AutoSaveItem(Res.Strings.AUTO_SAVE_OFF, 0),
+ //new AutoSaveItem("5 seconds!", 5), // DEBUG
new AutoSaveItem(Res.Strings.AUTO_SAVE_1_MIN, 60),
new AutoSaveItem(Res.Strings.AUTO_SAVE_5_MIN, 300),
};
diff --git a/SourceGen/WpfGui/MainWindow.xaml.cs b/SourceGen/WpfGui/MainWindow.xaml.cs
index 4076465..5a145c5 100644
--- a/SourceGen/WpfGui/MainWindow.xaml.cs
+++ b/SourceGen/WpfGui/MainWindow.xaml.cs
@@ -867,45 +867,49 @@ namespace SourceGen.WpfGui {
/// StatusChanged event fires.
///
///
- /// Sample steps to reproduce problem:
- /// 1. add or remove a note
- /// 2. hit the down-arrow key
+ /// Sample steps to reproduce problem:
+ ///
+ /// - add or remove a Note
+ /// - hit the down-arrow key
+ ///
///
- /// This causes the ListView's contents to change enough that the keyboard position
+ /// This causes the ListView's contents to change enough that the keyboard position
/// is reset to zero, so attempting to move cursor up or down with an arrow key causes
/// the ListView position to jump to the top of the file. The keyboard navigation
- /// appears to be independent of which element(s) are selected.
+ /// appears to be independent of which element(s) are selected.
///
- /// The workaround for this is to set the focus to the specific item where you want the
- /// keyboard to be after making a change to the list. This isn't quite so simple,
+ /// The workaround for this is to set the focus to the specific item where you want
+ /// the keyboard to be after making a change to the list. This isn't quite so simple,
/// because at the point where we're restoring the selection flags, the UI elements
/// haven't yet been generated. We need to wait for a "status changed" event to arrive
- /// from the ItemContainerGenerator.
+ /// from the ItemContainerGenerator.
///
- /// This: http://cytivrat.blogspot.com/2011/05/selecting-first-item-in-wpf-listview.html
- /// formed the basis of my initial solution. The blog post was about a different problem,
+ /// The blog post
+ ///
+ /// formed the basis of my initial solution. The post was about a different problem,
/// where you'd have to hit the down-arrow twice after the control was first created
/// because the focus is on the control rather than the item. The same approach applies
- /// here as well.
+ /// here as well.
///
- /// Unfortunately, grabbing focus like this on every update causes problems with the
+ /// Unfortunately, grabbing focus like this on every update causes problems with the
/// GridSplitters. As soon as the splitter start to move, the ListView grabs focus and
/// prevents them from moving more than a few pixels. The workaround was to do nothing
/// while the splitters are being moved. This didn't solve the problem completely,
/// e.g. you couldn't move the splitters with the arrow keys by more than one step
/// because the ListView gets a StatusChanged event and steals focus away, but at least the
- /// mouse worked. (See issue #52 and https://stackoverflow.com/q/58652064/294248.)
+ /// mouse worked. (See issue #52 and
+ /// .)
///
- /// Unfortunately, this update didn't solve other problems created by the initial solution,
- /// because setting the item focus clears multi-select. If you held shift down while
- /// hitting down-arrow, things would work fine until you reached the bottom of the
- /// screen, at which point the virtual UI stuff would cause the item container generator
- /// to do work and change state. (See issue #105.)
+ /// Unfortunately, this update didn't solve other problems created by the initial
+ /// solution, because setting the item focus clears multi-select. If you held shift
+ /// down while hitting down-arrow, things would work fine until you reached the bottom of
+ /// the screen, at which point the virtual UI stuff would cause the item container
+ /// generator to do work and change state. (See issue #105.)
///
- /// The current approach is to set an explicit "refocus needed" boolean when we make
+ /// The current approach is to set an explicit "refocus needed" boolean when we make
/// changes to the list, and ignore the "status changed" events in other circumstances.
/// This seems to have the correct behavior (so far).
- /// Hat tip to https://stackoverflow.com/a/53666203/294248
+ /// Hat tip to
///
private void ItemContainerGenerator_StatusChanged(object sender, EventArgs e) {
if (!mCodeViewRefocusNeeded) {