Skip to content

Commit

Permalink
Auto-save, part 3 (of 3)
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
fadden committed Aug 9, 2024
1 parent 1b2353c commit fca742e
Show file tree
Hide file tree
Showing 7 changed files with 375 additions and 25 deletions.
151 changes: 133 additions & 18 deletions SourceGen/MainController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;


/// <summary>
Expand Down Expand Up @@ -827,14 +832,33 @@ private void AutoSaveTick(object sender, EventArgs e) {
/// 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) {
Expand All @@ -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);
Expand All @@ -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;
}
}

Expand All @@ -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;
Expand Down Expand Up @@ -924,6 +948,64 @@ private void DiscardRecoveryFile() {
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


Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -1391,7 +1500,7 @@ private void DoOpenFile(string projPathName) {
return;
}

newProject.IsReadOnly = dlg.WantReadOnly;
newProject.IsReadOnly |= dlg.WantReadOnly;
}

mProject = newProject;
Expand Down Expand Up @@ -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,
Expand All @@ -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();

Expand Down
7 changes: 7 additions & 0 deletions SourceGen/SourceGen.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand Down
87 changes: 87 additions & 0 deletions SourceGen/WpfGui/RecoveryChoice.xaml
Original file line number Diff line number Diff line change
@@ -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>
Loading

0 comments on commit fca742e

Please sign in to comment.