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) {