1
0
mirror of https://github.com/fadden/6502bench.git synced 2025-02-12 00:30:48 +00:00

Auto-save, part 2

Periodically save the project to the recovery file if changes have
been made and the project is "dirty".

We need to handle a couple of things specially.  If the user uses
"Save As" to change the project name, we need to recreate the recovery
file as well.  If auto-save is enabled or disabled in app settings, we
need to create or discard the recovery file, and possibly change the
timer interval.  If the project is modified, auto-saved, and then the
change is un-done, the project won't be dirty, but will have a stale
recovery file with a newer modification date; we handle this by simply
truncating the stale recovery file.

To reduce the amount of auto-saving, we don't do an initial write to
the recovery file, and we reset the timer every time the user does a
manual save.  A user who saves diligently will always have an empty
recovery file.
This commit is contained in:
Andy McFadden 2024-08-07 17:48:19 -07:00
parent 3d4250cbc4
commit 1b2353c259
4 changed files with 290 additions and 37 deletions

View File

@ -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;
/// <summary>
/// Data backing the code list.
/// Data backing the code list. Will be null if the project is not open.
/// </summary>
public LineListGen CodeLineList { get; private set; }
@ -241,9 +242,14 @@ namespace SourceGen {
#region Init and settings
/// <summary>
/// Constructor, called from the main window code.
/// </summary>
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;
/// <summary>
/// Creates an interval timer that fires an event on the GUI thread.
/// </summary>
private void CreateAutoSaveTimer() {
mAutoSaveTimer = new DispatcherTimer();
mAutoSaveTimer.Tick += new EventHandler(AutoSaveTick);
mAutoSaveTimer.Interval = TimeSpan.FromSeconds(5);
}
/// <summary>
/// Resets the auto-save timer to the configured interval. Has no effect if the timer
/// isn't currently running.
/// </summary>
private void ResetAutoSaveTimer() {
if (mAutoSaveTimer.IsEnabled) {
// Setting the Interval resets the timer.
mAutoSaveTimer.Interval = mAutoSaveTimer.Interval;
}
}
/// <summary>
/// Handles the auto-save timer event.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
}
}
/// <summary>
/// Creates or deletes the recovery file, based on the current app settings.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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";
}
}
/// <summary>
/// Creates the recovery file, overwriting any existing file. If auto-save is disabled
/// (indicated by an empty recovery file name), this does nothing.
/// </summary>
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);
}
}
/// <summary>
/// If we have a recovery file, close and delete it. This does nothing if the recovery
/// file is not currently open.
/// </summary>
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;
}
/// <summary>
@ -1046,6 +1260,9 @@ namespace SourceGen {
#region Main window UI event handlers
/// <summary>
/// Handles creation of a new project.
/// </summary>
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();
}
/// <summary>
@ -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();

View File

@ -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 {
}
}
/// <summary>
/// Serializes the project and writes it to the specified stream.
/// </summary>
/// <param name="proj">Project to serialize.</param>
/// <param name="stream">Stream to write data to. Will not be seeked or truncated, and
/// will be left open.</param>
/// <param name="errorMessage">Human-readable error string, or an empty string if all
/// went well.</param>
/// <returns>True on success.</returns>
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;
}
/// <summary>
/// Reads the specified file and deserializes it into the project.
///

View File

@ -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),
};

View File

@ -867,45 +867,49 @@ namespace SourceGen.WpfGui {
/// StatusChanged event fires.
/// </summary>
/// <remarks>
/// Sample steps to reproduce problem:
/// 1. add or remove a note
/// 2. hit the down-arrow key
/// <para>Sample steps to reproduce problem:
/// <list type="number">
/// <item>add or remove a Note</item>
/// <item>hit the down-arrow key</item>
/// </list></para>
///
/// This causes the ListView's contents to change enough that the keyboard position
/// <para>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.</para>
///
/// 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,
/// <para>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.</para>
///
/// 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,
/// <para>The blog post
/// <see href="http://cytivrat.blogspot.com/2011/05/selecting-first-item-in-wpf-listview.html"/>
/// 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.</para>
///
/// Unfortunately, grabbing focus like this on every update causes problems with the
/// <para>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
/// <see href="https://stackoverflow.com/q/58652064/294248"/>.)</para>
///
/// 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.)
/// <para>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.)</para>
///
/// The current approach is to set an explicit "refocus needed" boolean when we make
/// <para>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 <see href="https://stackoverflow.com/a/53666203/294248"/></para>
/// </remarks>
private void ItemContainerGenerator_StatusChanged(object sender, EventArgs e) {
if (!mCodeViewRefocusNeeded) {