1
0
mirror of https://github.com/fadden/6502bench.git synced 2024-10-31 19:04:44 +00:00

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)
This commit is contained in:
Andy McFadden 2024-08-09 13:37:38 -07:00
parent 1b2353c259
commit fca742e890
7 changed files with 375 additions and 25 deletions

View File

@ -727,12 +727,17 @@ namespace SourceGen {
#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;
/// <summary>
@ -827,14 +832,33 @@ namespace SourceGen {
/// 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.
/// <para>This is called when:</para>
/// <list type="bullet">
/// <item>a new project is created</item>
/// <item>an existing project is opened</item>
/// <item>app settings are updated</item>
/// <item>Save As is used to change the project path</item>
/// <item>the project is saved for the first time after a recovery file decision (i.e.
/// while mAutoSaveDeferred is true)</item>
/// </list>
/// </remarks>
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 @@ namespace SourceGen {
// 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 @@ namespace SourceGen {
"' 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 @@ namespace SourceGen {
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 @@ namespace SourceGen {
mAutoSaveTimer.Stop();
}
/// <summary>
/// Asks the user if they want to use the recovery file, if one is present and non-empty.
/// Both files must exist.
/// </summary>
/// <param name="projPathName">Path to project file we're trying to open</param>
/// <param name="recoveryPath">Path to recovery file.</param>
/// <param name="pathToUse">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.</param>
/// <param name="asReadOnly">Result: true if project should be opened read-only.</param>
/// <returns>False if the user cancelled the operation, true to continue.</returns>
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 @@ namespace SourceGen {
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 @@ namespace SourceGen {
// 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 @@ namespace SourceGen {
return;
}
newProject.IsReadOnly = dlg.WantReadOnly;
newProject.IsReadOnly |= dlg.WantReadOnly;
}
mProject = newProject;
@ -1539,6 +1648,7 @@ namespace SourceGen {
}
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 @@ namespace SourceGen {
// 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();

View File

@ -238,6 +238,9 @@
<Compile Include="UndoableChange.cs" />
<Compile Include="DisplayListSelection.cs" />
<Compile Include="WeakSymbolRef.cs" />
<Compile Include="WpfGui\RecoveryChoice.xaml.cs">
<DependentUpon>RecoveryChoice.xaml</DependentUpon>
</Compile>
<Compile Include="WpfGui\ShowWireframeAnimation.xaml.cs">
<DependentUpon>ShowWireframeAnimation.xaml</DependentUpon>
</Compile>
@ -476,6 +479,10 @@
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="WpfGui\RecoveryChoice.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="WpfGui\ShowWireframeAnimation.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>

View File

@ -0,0 +1,87 @@
<!--
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.
-->
<Window x:Class="SourceGen.WpfGui.RecoveryChoice"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:SourceGen.WpfGui"
mc:Ignorable="d"
Title="Use Recovery File?"
SizeToContent="Height" Width="500" MinWidth="500" ResizeMode="CanResizeWithGrip"
ShowInTaskbar="False" WindowStartupLocation="CenterOwner"
ContentRendered="Window_ContentRendered">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" TextWrapping="Wrap"
Text="A recovery file, created by the auto-save feature, was found. Please choose which you would like to use."/>
<Grid Grid.Row="1" Margin="0,8,0,0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!--<TextBlock Grid.Column="0" Grid.Row="0" Grid.ColumnSpan="2" Margin="0,0,0,4"
Text="Or leave the existing file alone:"/>-->
<Button Name="projectButton" Grid.Column="0" Grid.Row="1" Grid.RowSpan="4"
Width="80" Height="60" Margin="0,0,8,0" HorizontalAlignment="Left" BorderThickness="1"
Content="Project File" Click="ProjectButton_Click"/>
<TextBlock Grid.Column="1" Grid.Row="1" Text="{Binding ProjPathName, FallbackValue=ProjPath}" FontWeight="Bold"/>
<TextBlock Grid.Column="1" Grid.Row="2" Text="{Binding ProjModWhen, FallbackValue=Modified:yesterday}"/>
<TextBlock Grid.Column="1" Grid.Row="3" Text="{Binding ProjLength, FallbackValue=len:1234kB}"/>
</Grid>
<Grid Grid.Row="2" Margin="0,8,0,0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!--<TextBlock Grid.Column="0" Grid.Row="0" Grid.ColumnSpan="2" Margin="0,0,0,4"
Text="You can use the project file:"/>-->
<Button Name="recoveryButton" Grid.Column="0" Grid.Row="1" Grid.RowSpan="4"
Width="80" Height="60" Margin="0,0,8,0" HorizontalAlignment="Left" BorderThickness="1"
Content="Recovery File" Click="RecoveryButton_Click"/>
<TextBlock Grid.Column="1" Grid.Row="1" Text="{Binding RecovPathName, FallbackValue=RecovPath}" FontWeight="Bold"/>
<TextBlock Grid.Column="1" Grid.Row="2" Text="{Binding RecovModWhen, FallbackValue=Modified:today}"/>
<TextBlock Grid.Column="1" Grid.Row="3" Text="{Binding RecovLength, FallbackValue=len:2345kB}"/>
</Grid>
<DockPanel Grid.Row="3" Margin="0,16,0,0" LastChildFill="False">
<Button DockPanel.Dock="Right" Name="cancelButton" Content="Cancel" IsCancel="True"
Width="70" Margin="4,0,4,0"/>
</DockPanel>
</Grid>
</Window>

View File

@ -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 {
/// <summary>
/// Present a choice between the project file and the recovery file.
/// </summary>
public partial class RecoveryChoice : Window {
/// <summary>
/// Dialog result: true if the recovery file was selected.
/// </summary>
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);
}
}
/// <summary>
/// Reads and formats some basic information about the file.
/// </summary>
/// <param name="pathName">Pathname to file.</param>
/// <param name="modWhen">Result: modification date.</param>
/// <param name="modWhenStr">Result: formatted modification date.</param>
/// <param name="lenStr">Result: formatted file length.</param>
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;
}
}
}

View File

@ -73,6 +73,7 @@ using the <samp>Help &gt; Help</samp> menu item or by hitting
<ul>
<li><a href="mainwin.html#starting-new">Starting a New Project</a></li>
<li><a href="mainwin.html#opening">Opening an Existing Project</a></li>
<li><a href="mainwin.html#saving">Saving a Project</a></li>
<li><a href="mainwin.html#working">Working With a Project</a>
<ul>
<li><a href="mainwin.html#code-list">Code List</a></li>

View File

@ -34,13 +34,13 @@ project.</p>
<p><strong>NOTE:</strong> Support for very large 65816 programs is
incomplete. The maximum size for a data file is limited to 1 MiB.</p>
<p>The first time you save the project (with <samp>File &gt; Save</samp>),
you will be prompted for the project name. It's best to use the
data file's name with "<samp>.dis65</samp>" 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.</p>
<p>The application will ask you to save the new project to a file.
It's best to use the data file's name with "<samp>.dis65</samp>" 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.)</p>
<h2 id="opening">Opening an Existing Project</h2>
@ -68,6 +68,30 @@ in the application settings. You can access them from
that open the two most-recently-opened projects will be available.</p>
<h2 id="saving">Saving a Project</h2>
<p>You can save your project with <samp>File &gt; Save</samp>, or by
hitting <kbd class="key">Ctrl+S</kbd>. To save the project to a different
file, use <samp>File &gt; Save As</samp>, but bear in mind that the data
file is expected to have the same name as the project file, minus the
".dis65" extension.</p>
<p>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.</p>
<p>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.</p>
<h2 id="working">Working With a Project</h2>
<p>The main project window is divided into five areas:</p>

View File

@ -81,6 +81,11 @@ from the system theme, but the disassembly list uses a custom style. You
can change the rest of the UI from the Windows display "personalization"
controls.)</p>
<p>The <samp>auto-save interval</samp> selection determines the frequency
of saves to the recovery file. Setting it to <samp>disabled</samp> will
disable the feature entirely, and prevent recovery files from being created
(though they will still be checked for when projects are opened).</p>
<h3 id="appset-textdelim">Text Delimiters</h3>