Pārlūkot izejas kodu

Merge pull request #799 from PixiEditor/autosave-take2

Autosave
Krzysztof Krysiński 5 mēneši atpakaļ
vecāks
revīzija
37fe92ed35
67 mainītis faili ar 2281 papildinājumiem un 259 dzēšanām
  1. 1 0
      src/PixiEditor.AnimationRenderer.Core/IAnimationRenderer.cs
  2. 65 0
      src/PixiEditor.AnimationRenderer.FFmpeg/FFMpegRenderer.cs
  3. 17 0
      src/PixiEditor.Extensions.CommonApi/UserPreferences/PreferencesConstants.cs
  4. 6 0
      src/PixiEditor.UI.Common/Accents/Base.axaml
  5. 12 2
      src/PixiEditor/Data/Localization/Languages/en.json
  6. 20 0
      src/PixiEditor/Helpers/AutosaveUtility.cs
  7. 1 0
      src/PixiEditor/Helpers/ServiceCollectionHelpers.cs
  8. 6 0
      src/PixiEditor/Helpers/ThemeResources.cs
  9. 3 0
      src/PixiEditor/Models/Constants.cs
  10. 10 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistoryEntry.cs
  11. 8 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistoryResult.cs
  12. 8 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistorySession.cs
  13. 8 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistoryType.cs
  14. 16 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaveSaveData.cs
  15. 9 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaveState.cs
  16. 10 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaveStateData.cs
  17. 27 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaverAwaitUpdateableChangeEndJob.cs
  18. 76 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaverSaveBackupJob.cs
  19. 70 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaverSaveUserFileJob.cs
  20. 31 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaverWaitJob.cs
  21. 9 0
      src/PixiEditor/Models/DocumentModels/Autosave/BackupAutosaveResult.cs
  22. 207 0
      src/PixiEditor/Models/DocumentModels/Autosave/DocumentAutosaver.cs
  23. 10 0
      src/PixiEditor/Models/DocumentModels/Autosave/IAutosaverJob.cs
  24. 7 0
      src/PixiEditor/Models/DocumentModels/Autosave/LastUserFileAutosaveData.cs
  25. 13 0
      src/PixiEditor/Models/DocumentModels/Autosave/SessionFile.cs
  26. 8 0
      src/PixiEditor/Models/DocumentModels/Autosave/UserFileAutosaveResult.cs
  27. 6 6
      src/PixiEditor/Models/DocumentModels/ChangeExecutionController.cs
  28. 8 0
      src/PixiEditor/Models/DocumentModels/DocumentUpdater.cs
  29. 14 0
      src/PixiEditor/Models/DocumentPassthroughActions/MarkAsAutosaved_PassthroughAction.cs
  30. 36 8
      src/PixiEditor/Models/ExceptionHandling/CrashReport.cs
  31. 3 1
      src/PixiEditor/Models/ExceptionHandling/CrashedFileInfo.cs
  32. 102 23
      src/PixiEditor/Models/Files/ImageFileType.cs
  33. 2 1
      src/PixiEditor/Models/Files/IoFileType.cs
  34. 8 1
      src/PixiEditor/Models/Files/OtfFileType.cs
  35. 29 2
      src/PixiEditor/Models/Files/PixiFileType.cs
  36. 24 3
      src/PixiEditor/Models/Files/SvgFileType.cs
  37. 8 1
      src/PixiEditor/Models/Files/TtfFileType.cs
  38. 54 9
      src/PixiEditor/Models/Files/VideoFileType.cs
  39. 2 0
      src/PixiEditor/Models/Handlers/IDocument.cs
  40. 5 0
      src/PixiEditor/Models/IO/ExportConfig.cs
  41. 30 2
      src/PixiEditor/Models/IO/Exporter.cs
  42. 23 8
      src/PixiEditor/Models/IO/Paths.cs
  43. 4 0
      src/PixiEditor/PixiEditor.csproj
  44. 4 2
      src/PixiEditor/Styles/Templates/DocumentTabTemplate.axaml
  45. 1 0
      src/PixiEditor/ViewLocator.cs
  46. 20 0
      src/PixiEditor/ViewModels/CrashReportViewModel.cs
  47. 29 6
      src/PixiEditor/ViewModels/Dock/DocumentTabCustomizationSettings.cs
  48. 6 6
      src/PixiEditor/ViewModels/Dock/LayoutManager.cs
  49. 161 0
      src/PixiEditor/ViewModels/Document/AutosaveDocumentViewModel.cs
  50. 10 0
      src/PixiEditor/ViewModels/Document/DocumentManagerViewModel.cs
  51. 41 4
      src/PixiEditor/ViewModels/Document/DocumentViewModel.cs
  52. 68 0
      src/PixiEditor/ViewModels/Document/LazyDocumentViewModel.cs
  53. 59 0
      src/PixiEditor/ViewModels/SubViewModels/AutosaveViewModel.cs
  54. 60 0
      src/PixiEditor/ViewModels/SubViewModels/DebugViewModel.cs
  55. 370 23
      src/PixiEditor/ViewModels/SubViewModels/FileViewModel.cs
  56. 12 0
      src/PixiEditor/ViewModels/SubViewModels/LayoutViewModel.cs
  57. 50 0
      src/PixiEditor/ViewModels/SubViewModels/LazyViewportWindowViewModel.cs
  58. 31 8
      src/PixiEditor/ViewModels/SubViewModels/ViewportWindowViewModel.cs
  59. 40 0
      src/PixiEditor/ViewModels/SubViewModels/WindowViewModel.cs
  60. 28 0
      src/PixiEditor/ViewModels/UserPreferences/Settings/FileSettings.cs
  61. 77 9
      src/PixiEditor/ViewModels/ViewModelMain.cs
  62. 1 1
      src/PixiEditor/Views/Dialogs/ExportFileDialog.cs
  63. 27 0
      src/PixiEditor/Views/Dock/LazyDocumentTemplate.axaml
  64. 12 0
      src/PixiEditor/Views/Dock/LazyDocumentTemplate.axaml.cs
  65. 1 14
      src/PixiEditor/Views/MainWindow.axaml.cs
  66. 1 1
      src/PixiEditor/Views/Windows/BetaExampleButton.axaml.cs
  67. 156 118
      src/PixiEditor/Views/Windows/Settings/SettingsWindow.axaml

+ 1 - 0
src/PixiEditor.AnimationRenderer.Core/IAnimationRenderer.cs

@@ -5,4 +5,5 @@ namespace PixiEditor.AnimationRenderer.Core;
 public interface IAnimationRenderer
 {
     public Task<bool> RenderAsync(List<Image> imageStream, string outputPath, CancellationToken cancellationToken, Action<double>? progressCallback);
+    public bool Render(List<Image> imageStream, string outputPath, CancellationToken cancellationToken, Action<double>? progressCallback);
 }

+ 65 - 0
src/PixiEditor.AnimationRenderer.FFmpeg/FFMpegRenderer.cs

@@ -83,6 +83,71 @@ public class FFMpegRenderer : IAnimationRenderer
         }
     }
 
+    public bool Render(List<Image> rawFrames, string outputPath, CancellationToken cancellationToken,
+        Action<double>? progressCallback)
+    {
+        string path = $"ThirdParty/{IOperatingSystem.Current.Name}/ffmpeg";
+
+        string binaryPath = Path.Combine(Path.GetDirectoryName(AppDomain.CurrentDomain.BaseDirectory), path);
+
+        GlobalFFOptions.Configure(new FFOptions() { BinaryFolder = binaryPath });
+
+        if (IOperatingSystem.Current.IsUnix)
+        {
+            MakeExecutableIfNeeded(binaryPath);
+        }
+
+        string paletteTempPath = Path.Combine(Path.GetDirectoryName(outputPath), "RenderTemp", "palette.png");
+
+        try
+        {
+            List<ImgFrame> frames = new();
+
+            foreach (var frame in rawFrames)
+            {
+                frames.Add(new ImgFrame(frame));
+            }
+
+            RawVideoPipeSource streamPipeSource = new(frames) { FrameRate = FrameRate, };
+
+
+            if (!Directory.Exists(Path.GetDirectoryName(paletteTempPath)))
+            {
+                Directory.CreateDirectory(Path.GetDirectoryName(paletteTempPath));
+            }
+
+            if (RequiresPaletteGeneration())
+            {
+                GeneratePalette(streamPipeSource, paletteTempPath);
+            }
+
+            streamPipeSource = new(frames) { FrameRate = FrameRate, };
+
+            var args = FFMpegArguments
+                .FromPipeInput(streamPipeSource, options =>
+                {
+                    options.WithFramerate(FrameRate);
+                });
+
+            var outputArgs = GetProcessorForFormat(args, outputPath, paletteTempPath);
+            TimeSpan totalTimeSpan = TimeSpan.FromSeconds(frames.Count / (float)FrameRate);
+            var result = outputArgs.CancellableThrough(cancellationToken)
+                .NotifyOnProgress(progressCallback, totalTimeSpan).ProcessSynchronously();
+
+            DisposeStream(frames);
+
+            return result;
+        }
+        finally
+        {
+            if (RequiresPaletteGeneration() && File.Exists(paletteTempPath))
+            {
+                File.Delete(paletteTempPath);
+                Directory.Delete(Path.GetDirectoryName(paletteTempPath));
+            }
+        }
+    }
+
     private static void MakeExecutableIfNeeded(string binaryPath)
     {
         string filePath = Path.Combine(binaryPath, "ffmpeg");

+ 17 - 0
src/PixiEditor.Extensions.CommonApi/UserPreferences/PreferencesConstants.cs

@@ -10,4 +10,21 @@ public static class PreferencesConstants
     public const string DisableNewsPanel = "DisableNewsPanel";
     public const string LastCheckedNewsIds = "LastCheckedNewsIds";
     public const string NewsPanelCollapsed = "NewsPanelCollapsed";
+
+    public const string SaveSessionStateEnabled = "SaveSessionStateEnabled";
+    public const bool SaveSessionStateDefault = true;
+
+    public const string AutosaveEnabled = "AutosaveEnabled";
+    public const bool AutosaveEnabledDefault = true;
+
+    public const string AutosaveHistory = "AutosaveHistory";
+
+    public const string AutosavePeriodMinutes = "AutosavePeriodMinutes";
+    public const double AutosavePeriodDefault = 3;
+
+    public const string AutosaveToDocumentPath = "AutosaveToDocumentPath";
+    public const bool AutosaveToDocumentPathDefault = false;
+
+    public const string LastCrashFile = "LastCrashFile";
+    public const string NextSessionFiles = "NextSessionFiles";
 }

+ 6 - 0
src/PixiEditor.UI.Common/Accents/Base.axaml

@@ -96,6 +96,9 @@
             <Color x:Key="VerticalSnapAxisColor">#5fad65</Color>
             <Color x:Key="SnapPointPreviewColor">#68abdf</Color>
 
+            <Color x:Key="AutosaveDotColor">white</Color>
+            <Color x:Key="UnsavedDotColor">white</Color>
+
             <system:Double x:Key="ThemeDisabledOpacity">0.4</system:Double>
 
             <SolidColorBrush x:Key="ThemeForegroundLowBrush" Color="gray"></SolidColorBrush>
@@ -205,6 +208,9 @@
             <SolidColorBrush x:Key="DockApplicationAccentForegroundBrush" Color="{DynamicResource ThemeForegroundColor}"/>
             <SolidColorBrush x:Key="DockApplicationAccentBrushIndicator" Color="{DynamicResource AccentColor}"/>
 
+            <SolidColorBrush x:Key="UnsavedDotBrush" Color="{DynamicResource UnsavedDotColor}"/>
+            <SolidColorBrush x:Key="AutosaveDotBrush" Color="{DynamicResource AutosaveDotColor}"/>
+
             <SolidColorBrush x:Key="DockThemeBorderLowBrush" Color="{DynamicResource ThemeBorderMidColor}" />
             <SolidColorBrush x:Key="DockThemeBackgroundBrush" Color="{DynamicResource ThemeBackgroundColor}" />
             <SolidColorBrush x:Key="DockThemeAccentBrush" Color="{DynamicResource ThemeAccentColor}" />

+ 12 - 2
src/PixiEditor/Data/Localization/Languages/en.json

@@ -908,6 +908,16 @@
   "TEMPERATURE_VALUE": "Temperature",
   "TINT_VALUE": "Tint",
   "FAILED_DOWNLOADING_UPDATE_TITLE": "Failed to download update",
-  "FAILED_DOWNLOADING_UPDATE": "Failed to download the update. Try again later."
-
+  "FAILED_DOWNLOADING_UPDATE": "Failed to download the update. Try again later.",
+  "UNEXPECTED_SHUTDOWN": "Unexpected shutdown",
+  "UNEXPECTED_SHUTDOWN_MSG": "PixiEditor was unexpectedly shut down. We've loaded latest autosave of your files.",
+  "OK": "OK",
+  "OPEN_AUTOSAVES": "Browse Autosaves",
+  "AUTOSAVE_SETTINGS_HEADER": "Autosave",
+  "AUTOSAVE_SETTINGS_SAVE_STATE": "Reopen last files on startup",
+  "AUTOSAVE_SETTINGS_PERIOD": "Autosave period",
+  "AUTOSAVE_ENABLED": "Autosave enabled",
+  "MINUTE_UNIVERSAL": "min",
+  "AUTOSAVE_SETTINGS_SAVE_USER_FILE": "Autosave to selected file",
+  "LOAD_LAZY_FILE_MESSAGE": "To improve startup time, PixiEditor didn't load this file. Click the button below to load it.",
 }

+ 20 - 0
src/PixiEditor/Helpers/AutosaveUtility.cs

@@ -0,0 +1,20 @@
+using PixiEditor.Models.IO;
+
+namespace PixiEditor.Helpers;
+
+internal class AutosaveHelper
+{
+    public static Guid? GetAutosaveGuid(string? path)
+    {
+        if (path is null)
+            return null;
+
+        string guidString = Path.GetFileNameWithoutExtension(path)["autosave-".Length..];
+        return Guid.Parse(guidString);
+    }
+
+    public static string GetAutosavePath(Guid guid)
+    {
+        return Path.Join(Paths.PathToUnsavedFilesFolder, $"autosave-{guid}.pixi");
+    }
+}

+ 1 - 0
src/PixiEditor/Helpers/ServiceCollectionHelpers.cs

@@ -67,6 +67,7 @@ internal static class ServiceCollectionHelpers
             .AddSingleton<ColorsViewModel>()
             .AddSingleton<AnimationsViewModel>()
             .AddSingleton<NodeGraphManagerViewModel>()
+            .AddSingleton<AutosaveViewModel>()
             .AddSingleton<IColorsHandler, ColorsViewModel>(x => x.GetRequiredService<ColorsViewModel>())
             .AddSingleton<RegistryViewModel>()
             .AddSingleton(static x => new DiscordViewModel(x.GetService<ViewModels_ViewModelMain>(), "764168193685979138"))

+ 6 - 0
src/PixiEditor/Helpers/ThemeResources.cs

@@ -35,4 +35,10 @@ public static class ThemeResources
 
     public static SolidColorBrush ThemeControlLowBrush =>
         ResourceLoader.GetResource<SolidColorBrush>("ThemeControlLowBrush", Application.Current.ActualThemeVariant);
+
+    public static IBrush AutosaveDotBrush =>
+        ResourceLoader.GetResource<IBrush>("AutosaveDotBrush", Application.Current.ActualThemeVariant);
+
+    public static IBrush UnsavedDotBrush =>
+        ResourceLoader.GetResource<IBrush>("UnsavedDotBrush", Application.Current.ActualThemeVariant);
 }

+ 3 - 0
src/PixiEditor/Models/Constants.cs

@@ -10,4 +10,7 @@ internal class Constants
 
     public const string NativeExtensionNoDot = "pixi";
     public const string NativeExtension = "." + NativeExtensionNoDot;
+
+    public const double MaxAutosaveFilesLifetimeDays = 10;
+
 }

+ 10 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistoryEntry.cs

@@ -0,0 +1,10 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal class AutosaveHistoryEntry(DateTime dateTime, AutosaveHistoryType type, AutosaveHistoryResult result, Guid tempFileGuid, string? originalPath)
+{
+    public DateTime DateTime { get; set; } = dateTime;
+    public AutosaveHistoryType Type { get; set; } = type;
+    public AutosaveHistoryResult Result { get; set; } = result;
+    public Guid TempFileGuid { get; set; } = tempFileGuid;
+    public string? OriginalPath { get; set; } = originalPath;
+}

+ 8 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistoryResult.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal enum AutosaveHistoryResult
+{
+    SavedUserFile,
+    SavedBackup,
+    NothingToSave
+}

+ 8 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistorySession.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal class AutosaveHistorySession(Guid sessionGuid, DateTime launchDateTime)
+{
+    public List<AutosaveHistoryEntry> AutosaveEntries { get; set; } = new();
+    public Guid SessionGuid { get; set; } = sessionGuid;
+    public DateTime LaunchDateTime { get; set; } = launchDateTime;
+}

+ 8 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistoryType.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal enum AutosaveHistoryType
+{
+    Periodic,
+    OnClose,
+    Crash
+}

+ 16 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaveSaveData.cs

@@ -0,0 +1,16 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+public struct AutosaveSaveData
+{
+    public LastBackupAutosaveData? LastBackupAutosaveData { get; set; }
+    public LastUserFileAutosaveData? LastUserFileAutosaveData { get; set; }
+    public AutosaveState AutosaveState { get; set; }
+    public DateTime AutosaveLaunchDateTime { get; set; }
+    public TimeSpan AutosaveInterval { get; set; }
+}
+
+public struct LastBackupAutosaveData
+{
+    public DateTime Time { get; set; }
+    public BackupAutosaveResult SaveResult { get; set; }
+}

+ 9 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaveState.cs

@@ -0,0 +1,9 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+public enum AutosaveState
+{
+    Paused,
+    Idle,
+    AwaitingUpdateableChangeEnd,
+    InProgress
+}

+ 10 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaveStateData.cs

@@ -0,0 +1,10 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+public struct AutosaveStateData
+{
+    public LastBackupAutosaveData? LastBackupAutosaveData { get; set; }
+    public LastUserFileAutosaveData? LastUserFileAutosaveData { get; set; }
+    public AutosaveState AutosaveState { get; set; }
+    public DateTime AutosaveLaunchDateTime { get; set; }
+    public TimeSpan AutosaveInterval { get; set; }
+}

+ 27 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaverAwaitUpdateableChangeEndJob.cs

@@ -0,0 +1,27 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal class AutosaverAwaitUpdateableChangeEndJob : IAutosaverJob
+{
+    public event Action? OnCompleted;
+    private bool isStopped = true;
+
+    public AutosaveState CorrespondingState => AutosaveState.AwaitingUpdateableChangeEnd;
+
+    public void OnUpdateableChangeEnded()
+    {
+        if (isStopped)
+            return;
+        OnCompleted?.Invoke();
+        isStopped = true;
+    }
+
+    public void Start()
+    {
+        isStopped = false;
+    }
+
+    public void ForceStop()
+    {
+        isStopped = true;
+    }
+}

+ 76 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaverSaveBackupJob.cs

@@ -0,0 +1,76 @@
+using Avalonia.Threading;
+using PixiEditor.Models.IO;
+using PixiEditor.ViewModels.Document;
+
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal class AutosaverSaveBackupJob(DocumentViewModel documentToSave, int backupAttempt = 1) : IAutosaverJob
+{
+    public event Action? OnCompleted;
+    public event Action<AutosaverSaveBackupJob, BackupAutosaveResult>? OnNonCompleted;
+    public Exception? Exception { get; private set; }
+
+    public AutosaveState CorrespondingState => AutosaveState.InProgress;
+
+    private DispatcherTimer? waitingTimer;
+
+    public void Start()
+    {
+        string filePath = documentToSave.AutosaveViewModel.AutosavePath;
+        BackupAutosaveResult result = Autosave(filePath);
+        if (result == BackupAutosaveResult.Success)
+        {
+            documentToSave.AutosaveViewModel.LastAutosavedPath = filePath;
+            documentToSave.MarkAsAutosaved();
+        }
+
+        if (result == BackupAutosaveResult.Success)
+            OnCompleted?.Invoke();
+        else
+            OnNonCompleted?.Invoke(this, result);
+    }
+
+    public void OnUpdateableChangeEnded() { }
+
+    public void ForceStop()
+    {
+        waitingTimer!.Stop();
+    }
+
+    private BackupAutosaveResult Autosave(string filePath)
+    {
+        if (documentToSave.AllChangesSaved)
+        {
+            documentToSave.AutosaveViewModel.AddAutosaveHistoryEntry(AutosaveHistoryType.Periodic,
+                AutosaveHistoryResult.NothingToSave);
+            return BackupAutosaveResult.NothingToSave;
+        }
+
+        if (documentToSave.BlockingUpdateableChangeActive)
+            return BackupAutosaveResult.BlockedByUpdateableChange;
+
+        try
+        {
+            Directory.CreateDirectory(Directory.GetParent(filePath)!.FullName);
+
+            ExportConfig config = new ExportConfig(documentToSave.SizeBindable);
+            var result = Exporter.TrySave(documentToSave, filePath, config, null);
+
+            if (result == SaveResult.Success)
+            {
+                documentToSave.MarkAsAutosaved();
+                documentToSave.AutosaveViewModel.AddAutosaveHistoryEntry(AutosaveHistoryType.Periodic,
+                    AutosaveHistoryResult.SavedBackup);
+                return BackupAutosaveResult.Success;
+            }
+
+            Exception = new Exception($"Failed to autosave for the {backupAttempt}. time due to {result}");
+            return BackupAutosaveResult.Error;
+        }
+        catch (Exception e)
+        {
+            Exception = e;
+            return BackupAutosaveResult.Error;
+        }
+    }
+}

+ 70 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaverSaveUserFileJob.cs

@@ -0,0 +1,70 @@
+using Avalonia.Threading;
+using PixiEditor.ViewModels.Document;
+
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal class AutosaverSaveUserFileJob(DocumentViewModel document) : IAutosaverJob
+{
+    public event Action OnCompleted;
+    public event Action<AutosaverSaveUserFileJob, UserFileAutosaveResult>? OnNonCompleted;
+    public Exception? Exception { get; private set; }
+
+    public AutosaveState CorrespondingState => AutosaveState.InProgress;
+
+    private DispatcherTimer? waitingTimer;
+
+    public async void Start()
+    {
+        UserFileAutosaveResult result;
+        try
+        {
+            result = await Task.Run(Copy);
+        }
+        catch (Exception e)
+        {
+            result = UserFileAutosaveResult.ExceptionWhileSaving;
+            Exception = e;
+        }
+
+        if (result == UserFileAutosaveResult.Success)
+        {
+            document.MarkAsSaved();
+        }
+
+        if (result == UserFileAutosaveResult.Success)
+            OnCompleted?.Invoke();
+        else
+            OnNonCompleted?.Invoke(this, result);
+
+        UserFileAutosaveResult Copy()
+        {
+            try
+            {
+                string path = document.FullFilePath;
+                if (!File.Exists(path))
+                    return UserFileAutosaveResult.NoUserFile;
+
+                File.Copy(document.AutosaveViewModel.LastAutosavedPath, path, true);
+
+                document.MarkAsSaved();
+                document.AutosaveViewModel.AddAutosaveHistoryEntry(AutosaveHistoryType.Periodic, AutosaveHistoryResult.SavedUserFile);
+                return UserFileAutosaveResult.Success;
+            }
+            catch (Exception e) when (e is UnauthorizedAccessException or DirectoryNotFoundException)
+            {
+                return UserFileAutosaveResult.NoUserFile;
+            }
+            catch (Exception e)
+            {
+                Exception = e;
+                return UserFileAutosaveResult.ExceptionWhileSaving;
+            }
+        }
+    }
+
+    public void OnUpdateableChangeEnded() { }
+    public void ForceStop()
+    {
+        waitingTimer!.Stop();
+    }
+}

+ 31 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaverWaitJob.cs

@@ -0,0 +1,31 @@
+using Avalonia.Threading;
+
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal class AutosaverWaitJob(TimeSpan duration)
+    : IAutosaverJob
+{
+    public event Action? OnCompleted;
+    private DispatcherTimer? waitingTimer;
+
+    public AutosaveState CorrespondingState => AutosaveState.Idle;
+
+    public void Start()
+    {
+        waitingTimer = new(duration, DispatcherPriority.Normal, WaitEndCallback);
+        waitingTimer.Start();
+    }
+
+    private void WaitEndCallback(object sender, EventArgs e)
+    {
+        waitingTimer!.Stop();
+        OnCompleted?.Invoke();
+    }
+
+    public void OnUpdateableChangeEnded() { }
+
+    public void ForceStop()
+    {
+        waitingTimer!.Stop();
+    }
+}

+ 9 - 0
src/PixiEditor/Models/DocumentModels/Autosave/BackupAutosaveResult.cs

@@ -0,0 +1,9 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+public enum BackupAutosaveResult
+{
+    Success,
+    Error,
+    NothingToSave,
+    BlockedByUpdateableChange
+}

+ 207 - 0
src/PixiEditor/Models/DocumentModels/Autosave/DocumentAutosaver.cs

@@ -0,0 +1,207 @@
+using PixiEditor.Helpers;
+using PixiEditor.ViewModels.Document;
+
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal class DocumentAutosaver : IDisposable
+{
+    public event EventHandler? JobChanged;
+
+    public AutosaveStateData State
+    {
+        get
+        {
+            return new AutosaveStateData
+            {
+                AutosaveInterval = autosavePeriod,
+                AutosaveLaunchDateTime = autosaveStartedAt,
+                AutosaveState = currentJob?.CorrespondingState ?? AutosaveState.Idle,
+                LastBackupAutosaveData =
+                    lastBackupAutosaveResult is null
+                        ? null
+                        : new LastBackupAutosaveData
+                        {
+                            Time = lastBackupAutosaveDateTime.Value, SaveResult = lastBackupAutosaveResult.Value
+                        },
+                LastUserFileAutosaveData = lastUserFileAutosaveResult is null
+                    ? null
+                    : new LastUserFileAutosaveData
+                    {
+                        Time = lastUserFileAutosaveDateTime.Value, SaveResult = lastUserFileAutosaveResult.Value
+                    }
+            };
+        }
+    }
+
+    private readonly DateTime autosaveStartedAt;
+
+    private int backupSaveFailureCount = 0;
+    private int userFileSaveFailureCount = 0;
+
+    private IAutosaverJob? currentJob;
+
+    private IAutosaverJob? CurrentJob
+    {
+        get => currentJob;
+        set
+        {
+            currentJob = value;
+            JobChanged?.Invoke(this, EventArgs.Empty);
+        }
+    }
+
+    private readonly bool saveUserFile;
+    private readonly DocumentViewModel document;
+    private readonly TimeSpan autosavePeriod;
+
+    private bool isDisposed = false;
+
+    private UserFileAutosaveResult? lastUserFileAutosaveResult;
+    private DateTime? lastUserFileAutosaveDateTime;
+    private BackupAutosaveResult? lastBackupAutosaveResult;
+    private DateTime? lastBackupAutosaveDateTime;
+
+    public DocumentAutosaver(DocumentViewModel document, TimeSpan autosavePeriod, bool saveUserFile)
+    {
+        this.document = document;
+        this.autosavePeriod = autosavePeriod;
+        this.saveUserFile = saveUserFile;
+        autosaveStartedAt = DateTime.Now;
+
+        AutosaverWaitJob initialWaitJob = new(autosavePeriod);
+        initialWaitJob.OnCompleted += OnWaitingCompleted;
+        StartJob(initialWaitJob);
+    }
+
+    public void OnUpdateableChangeEnded() => CurrentJob?.OnUpdateableChangeEnded();
+
+    private void OnWaitingCompleted()
+    {
+        if (isDisposed)
+            return;
+        InitiateSaving();
+    }
+
+    private void StartJob(IAutosaverJob job)
+    {
+        CurrentJob = job;
+        job.Start();
+    }
+
+    private void WaitForNextSave()
+    {
+        AutosaverWaitJob waitJob = new(autosavePeriod);
+        waitJob.OnCompleted += OnWaitingCompleted;
+        StartJob(waitJob);
+    }
+
+    private void InitiateSaving()
+    {
+        AutosaverSaveBackupJob saveBackupJob = new(document, backupSaveFailureCount + 1);
+        saveBackupJob.OnCompleted += OnBackupSavingCompleted;
+        saveBackupJob.OnNonCompleted += OnBackupSavingNonCompleted;
+        StartJob(saveBackupJob);
+    }
+
+    private void OnBackupSavingCompleted()
+    {
+        if (isDisposed)
+            return;
+
+        lastBackupAutosaveResult = BackupAutosaveResult.Success;
+        lastBackupAutosaveDateTime = DateTime.Now;
+
+        backupSaveFailureCount = 0;
+        if (saveUserFile)
+        {
+            AutosaverSaveUserFileJob saveUserFileJob = new(document);
+            saveUserFileJob.OnCompleted += OnUserFileSavingCompleted;
+            saveUserFileJob.OnNonCompleted += OnUserFileSavingNonCompleted;
+            StartJob(saveUserFileJob);
+        }
+        else
+        {
+            WaitForNextSave();
+        }
+    }
+
+    private void OnUserFileSavingCompleted()
+    {
+        if (isDisposed)
+            return;
+
+        lastUserFileAutosaveResult = UserFileAutosaveResult.Success;
+        lastUserFileAutosaveDateTime = DateTime.Now;
+
+        userFileSaveFailureCount = 0;
+        WaitForNextSave();
+    }
+
+    private void OnBackupSavingNonCompleted(AutosaverSaveBackupJob job, BackupAutosaveResult result)
+    {
+        if (isDisposed)
+            return;
+
+        lastBackupAutosaveResult = result;
+        lastBackupAutosaveDateTime = DateTime.Now;
+
+        switch (result)
+        {
+            case BackupAutosaveResult.Error:
+                backupSaveFailureCount++;
+                if (backupSaveFailureCount < 3)
+                    CrashHelper.SendExceptionInfo(job.Exception);
+                WaitForNextSave();
+                break;
+
+            case BackupAutosaveResult.NothingToSave:
+                WaitForNextSave();
+                break;
+
+            case BackupAutosaveResult.BlockedByUpdateableChange:
+                AutosaverAwaitUpdateableChangeEndJob waitJob = new();
+                waitJob.OnCompleted += OnAwaitUpdateableChangeCompleted;
+                StartJob(waitJob);
+                break;
+
+            default:
+                throw new ArgumentOutOfRangeException(nameof(result), result, null);
+        }
+    }
+
+    private void OnAwaitUpdateableChangeCompleted()
+    {
+        if (isDisposed)
+            return;
+
+        InitiateSaving();
+    }
+
+    private void OnUserFileSavingNonCompleted(AutosaverSaveUserFileJob job, UserFileAutosaveResult result)
+    {
+        if (isDisposed)
+            return;
+
+        lastUserFileAutosaveResult = result;
+        lastUserFileAutosaveDateTime = DateTime.Now;
+
+        switch (result)
+        {
+            case UserFileAutosaveResult.NoUserFile:
+            case UserFileAutosaveResult.ExceptionWhileSaving:
+                userFileSaveFailureCount++;
+                if (userFileSaveFailureCount < 3)
+                    CrashHelper.SendExceptionInfo(job.Exception);
+                WaitForNextSave();
+                break;
+            default:
+                throw new ArgumentOutOfRangeException(nameof(result), result, null);
+        }
+    }
+
+    public void Dispose()
+    {
+        CurrentJob?.ForceStop();
+        isDisposed = true;
+    }
+}

+ 10 - 0
src/PixiEditor/Models/DocumentModels/Autosave/IAutosaverJob.cs

@@ -0,0 +1,10 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal interface IAutosaverJob
+{
+    event Action OnCompleted;
+    AutosaveState CorrespondingState { get; }
+    void OnUpdateableChangeEnded();
+    void Start();
+    void ForceStop();
+}

+ 7 - 0
src/PixiEditor/Models/DocumentModels/Autosave/LastUserFileAutosaveData.cs

@@ -0,0 +1,7 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+public struct LastUserFileAutosaveData
+{
+    public DateTime Time { get; set; }
+    public UserFileAutosaveResult SaveResult { get; set; }
+}

+ 13 - 0
src/PixiEditor/Models/DocumentModels/Autosave/SessionFile.cs

@@ -0,0 +1,13 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+public struct SessionFile
+{
+    public string? OriginalFilePath { get; set; }
+    public string? AutosaveFilePath { get; set; }
+
+    public SessionFile(string? originalFilePath, string? autosaveFilePath)
+    {
+        OriginalFilePath = originalFilePath;
+        AutosaveFilePath = autosaveFilePath;
+    }
+}

+ 8 - 0
src/PixiEditor/Models/DocumentModels/Autosave/UserFileAutosaveResult.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+public enum UserFileAutosaveResult
+{
+    Success,
+    NoUserFile,
+    ExceptionWhileSaving
+}

+ 6 - 6
src/PixiEditor/Models/DocumentModels/ChangeExecutionController.cs

@@ -229,7 +229,7 @@ internal class ChangeExecutionController
             transformableExecutor.OnTransformChanged(corners);
         }
     }
-    
+
     public void TransformDraggedInlet(VecD from, VecD to)
     {
         if (currentSession is ITransformDraggedEvent transformableExecutor)
@@ -240,7 +240,7 @@ internal class ChangeExecutionController
 
     public void TransformStoppedInlet()
     {
-        if(currentSession is ITransformStoppedEvent transformStoppedEvent)
+        if (currentSession is ITransformStoppedEvent transformStoppedEvent)
         {
             transformStoppedEvent.OnTransformStopped();
         }
@@ -257,7 +257,7 @@ internal class ChangeExecutionController
         {
             transformableExecutor.OnTransformApplied();
         }
-    } 
+    }
 
     public void SettingsChangedInlet(string name, object value)
     {
@@ -269,7 +269,7 @@ internal class ChangeExecutionController
         if (currentSession is ITransformableExecutor lineOverlayExecutor)
         {
             lineOverlayExecutor.OnLineOverlayMoved(start, end);
-        } 
+        }
     }
 
     public void SelectedObjectNudgedInlet(VecI distance)
@@ -277,7 +277,7 @@ internal class ChangeExecutionController
         if (currentSession is ITransformableExecutor transformableExecutor)
         {
             transformableExecutor.OnSelectedObjectNudged(distance);
-        } 
+        }
     }
 
     public void PrimaryColorChangedInlet(Color color)
@@ -294,7 +294,7 @@ internal class ChangeExecutionController
     {
         if (currentSession is T feature)
             return feature;
-        
+
         return default;
     }
 

+ 8 - 0
src/PixiEditor/Models/DocumentModels/DocumentUpdater.cs

@@ -214,6 +214,9 @@ internal class DocumentUpdater
             case ProcessingColorSpace_ChangeInfo info:
                 ProcessProcessingColorSpace(info);
                 break;
+            case MarkAsAutosaved_PassthroughAction info:
+                MarkAsAutosaved(info);
+                break;
         }
     }
 
@@ -796,4 +799,9 @@ internal class DocumentUpdater
     {
         doc.SetProcessingColorSpace(info.NewColorSpace);
     }
+
+    private void MarkAsAutosaved(MarkAsAutosaved_PassthroughAction info)
+    {
+        doc.InternalMarkSaveState(info.Type);
+    }
 }

+ 14 - 0
src/PixiEditor/Models/DocumentPassthroughActions/MarkAsAutosaved_PassthroughAction.cs

@@ -0,0 +1,14 @@
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+
+namespace PixiEditor.Models.DocumentPassthroughActions;
+
+internal enum DocumentMarkType
+{
+    Saved,
+    Unsaved,
+    Autosaved,
+    UnAutosaved
+}
+
+internal record class MarkAsAutosaved_PassthroughAction(DocumentMarkType Type) : IChangeInfo, IAction;

+ 36 - 8
src/PixiEditor/Models/ExceptionHandling/CrashReport.cs

@@ -13,6 +13,7 @@ using PixiEditor.Linux;
 #endif
 using PixiEditor.Models.AnalyticsAPI;
 using PixiEditor.Models.Commands;
+using PixiEditor.Models.DocumentModels.Autosave;
 using PixiEditor.OperatingSystem;
 using PixiEditor.Parser;
 using PixiEditor.ViewModels;
@@ -198,6 +199,8 @@ internal class CrashReport : IDisposable
             .AppendLine($"  Has shared toolbar enabled: {GetPreferenceFormatted("EnableSharedToolbar", true, false)}")
             .AppendLine($"  Right click mode: {GetPreferenceFormatted<RightClickMode>("RightClickMode", true)}")
             .AppendLine($"  Has Rich presence enabled: {GetPreferenceFormatted("EnableRichPresence", true, true)}")
+            .AppendLine(
+                $"   Autosaving Enabled: {GetPreferenceFormatted(PreferencesConstants.AutosaveEnabled, true, PreferencesConstants.AutosaveEnabledDefault)}")
             .AppendLine($"  Debug Mode enabled: {GetPreferenceFormatted("IsDebugModeEnabled", true, false)}")
             .AppendLine("\nUI:")
             .AppendLine($"  MainWindow not null: {GetFormatted(() => MainWindow.Current != null)}")
@@ -305,6 +308,7 @@ internal class CrashReport : IDisposable
             .AppendLine($"  Size: {document.SizeBindable}")
             .AppendLine($"  Layer Count: {FormatObject(document.StructureHelper.GetAllLayers().Count)}")
             .AppendLine($"  Has all changes saved: {document.AllChangesSaved}")
+            .AppendLine($"  Has all changes autosaved: {document.AllChangesAutosaved}")
             .AppendLine($"  Horizontal Symmetry Enabled: {document.HorizontalSymmetryAxisEnabledBindable}")
             .AppendLine($"  Horizontal Symmetry Value: {FormatObject(document.HorizontalSymmetryAxisYBindable)}")
             .AppendLine($"  Vertical Symmetry Enabled: {document.VerticalSymmetryAxisEnabledBindable}")
@@ -455,6 +459,11 @@ internal class CrashReport : IDisposable
         List<RecoveredPixi> recoveredDocuments = new();
 
         sessionInfo = TryGetSessionInfo();
+        if (sessionInfo == null)
+        {
+            return recoveredDocuments;
+        }
+
         if (sessionInfo?.OpenedDocuments == null)
         {
             recoveredDocuments.AddRange(
@@ -462,13 +471,16 @@ internal class CrashReport : IDisposable
                     .Where(x =>
                         x.FullName.StartsWith("Documents") &&
                         x.FullName.EndsWith(".pixi"))
-                    .Select(entry => new RecoveredPixi(null, entry)));
+                    .Select(entry => new RecoveredPixi(null, null, entry)));
 
             return recoveredDocuments;
         }
 
-        recoveredDocuments.AddRange(sessionInfo.OpenedDocuments.Select(path =>
-            new RecoveredPixi(path.OriginalPath, ZipFile.GetEntry($"Documents/{path.ZipName}"))));
+        foreach (var doc in sessionInfo.OpenedDocuments)
+        {
+            recoveredDocuments.Add(new RecoveredPixi(doc.OriginalPath, doc.AutosavePath,
+                ZipFile.GetEntry($"Documents/{doc.ZipName}")));
+        }
 
         return recoveredDocuments;
 
@@ -543,7 +555,6 @@ internal class CrashReport : IDisposable
         // Write the documents into zip
         int counter = 0;
         var originalPaths = new List<CrashedFileInfo>();
-        //TODO: Implement
         foreach (var document in documents)
         {
             try
@@ -560,8 +571,11 @@ internal class CrashReport : IDisposable
 
                 using Stream documentStream = archive.CreateEntry($"Documents/{nameInZip}").Open();
                 documentStream.Write(serialized);
+                document.AutosaveViewModel.Autosave(AutosaveHistoryType.Crash);
+
+                originalPaths.Add(new CrashedFileInfo(nameInZip, document.FullFilePath,
+                    document.AutosaveViewModel.LastAutosavedPath));
 
-                originalPaths.Add(new CrashedFileInfo(nameInZip, document.FullFilePath));
             }
             catch { }
 
@@ -604,7 +618,8 @@ internal class CrashReport : IDisposable
 
     public class RecoveredPixi
     {
-        public string? Path { get; }
+        public string? OriginalPath { get; }
+        public string? AutosavePath { get; }
 
         public ZipArchiveEntry RecoveredEntry { get; }
 
@@ -618,10 +633,23 @@ internal class CrashReport : IDisposable
             return buffer;
         }
 
-        public RecoveredPixi(string? path, ZipArchiveEntry recoveredEntry)
+        public RecoveredPixi(string? originalPath, string? autosavePath, ZipArchiveEntry recoveredEntry)
         {
-            Path = path;
+            OriginalPath = originalPath;
+            AutosavePath = autosavePath;
             RecoveredEntry = recoveredEntry;
         }
+
+        public byte[] TryGetAutoSaveBytes()
+        {
+            if (AutosavePath == null)
+                return [];
+
+            string autosavePixiFile = AutosavePath;
+            if (!File.Exists(autosavePixiFile))
+                return [];
+
+            return File.ReadAllBytes(autosavePixiFile);
+        }
     }
 }

+ 3 - 1
src/PixiEditor/Models/ExceptionHandling/CrashedFileInfo.cs

@@ -5,12 +5,14 @@ public class CrashedFileInfo
     public string ZipName { get; set; }
     
     public string OriginalPath { get; set; }
+    public string AutosavePath { get; set; }
     
     public CrashedFileInfo() { }
 
-    public CrashedFileInfo(string zipName, string originalPath)
+    public CrashedFileInfo(string zipName, string originalPath, string autosavePath)
     {
         ZipName = zipName;
         OriginalPath = originalPath;
+        AutosavePath = autosavePath;
     }
 }

+ 102 - 23
src/PixiEditor/Models/Files/ImageFileType.cs

@@ -19,7 +19,7 @@ internal abstract class ImageFileType : IoFileType
 
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Image;
 
-    public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document,
+    public override async Task<SaveResult> TrySaveAsync(string pathWithExtension, DocumentViewModel document,
         ExportConfig exportConfig, ExportJob? job)
     {
         Surface finalSurface;
@@ -33,13 +33,13 @@ internal abstract class ImageFileType : IoFileType
         else
         {
             job?.Report(0, new LocalizedString("RENDERING_IMAGE"));
-            
+
             var exportSize = exportConfig.ExportSize;
             if (exportSize.X <= 0 || exportSize.Y <= 0)
             {
                 return SaveResult.UnknownError; // TODO: Add InvalidParameters error type
             }
-            
+
             var maybeBitmap = document.TryRenderWholeImage(0, exportSize);
             if (maybeBitmap.IsT0)
                 return SaveResult.ConcurrencyError;
@@ -55,9 +55,53 @@ internal abstract class ImageFileType : IoFileType
         }
 
         UniversalFileEncoder encoder = new(mappedFormat);
-        var result = await TrySaveAs(encoder, pathWithExtension, finalSurface);
+        var result = await TrySaveAsAsync(encoder, pathWithExtension, finalSurface);
         finalSurface.Dispose();
-        
+
+        job?.Report(1, new LocalizedString("FINISHED"));
+
+        return result;
+    }
+
+    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config,
+        ExportJob? job)
+    {
+        Surface finalSurface;
+        if (config.ExportAsSpriteSheet)
+        {
+            job?.Report(0, new LocalizedString("GENERATING_SPRITE_SHEET"));
+            finalSurface = GenerateSpriteSheet(document, config, job);
+            if (finalSurface == null)
+                return SaveResult.UnknownError;
+        }
+        else
+        {
+            job?.Report(0, new LocalizedString("RENDERING_IMAGE"));
+
+            var exportSize = config.ExportSize;
+            if (exportSize.X <= 0 || exportSize.Y <= 0)
+            {
+                return SaveResult.UnknownError; // TODO: Add InvalidParameters error type
+            }
+
+            var maybeBitmap = document.TryRenderWholeImage(0, exportSize);
+            if (maybeBitmap.IsT0)
+                return SaveResult.ConcurrencyError;
+
+            finalSurface = maybeBitmap.AsT1;
+        }
+
+        EncodedImageFormat mappedFormat = EncodedImageFormat;
+
+        if (mappedFormat == EncodedImageFormat.Unknown)
+        {
+            return SaveResult.UnknownError;
+        }
+
+        UniversalFileEncoder encoder = new(mappedFormat);
+        var result = TrySaveAs(encoder, pathWithExtension, finalSurface);
+        finalSurface.Dispose();
+
         job?.Report(1, new LocalizedString("FINISHED"));
 
         return result;
@@ -69,32 +113,34 @@ internal abstract class ImageFileType : IoFileType
             return null;
 
         var (rows, columns) = (config.SpriteSheetRows, config.SpriteSheetColumns);
-        
+
         rows = Math.Max(1, rows);
         columns = Math.Max(1, columns);
 
         Surface surface = new Surface(new VecI(config.ExportSize.X * columns, config.ExportSize.Y * rows));
-        
+
         job?.Report(0, new LocalizedString("RENDERING_FRAME", 0, document.AnimationDataViewModel.FramesCount));
 
         document.RenderFramesProgressive(
             (frame, index) =>
-        {
-            job?.CancellationTokenSource.Token.ThrowIfCancellationRequested();
-            
-            job?.Report(index / (double)document.AnimationDataViewModel.FramesCount, new LocalizedString("RENDERING_FRAME", index, document.AnimationDataViewModel.FramesCount));
-            int x = index % columns;
-            int y = index / columns;
-            Surface target = frame;
-            if (config.ExportSize != frame.Size)
             {
-                target =
-                    frame.ResizeNearestNeighbor(new VecI(config.ExportSize.X, config.ExportSize.Y));
-            }
-            
-            surface!.DrawingSurface.Canvas.DrawSurface(target.DrawingSurface, x * config.ExportSize.X, y * config.ExportSize.Y);
-            target.Dispose();
-        }, job?.CancellationTokenSource.Token ?? CancellationToken.None);
+                job?.CancellationTokenSource.Token.ThrowIfCancellationRequested();
+
+                job?.Report(index / (double)document.AnimationDataViewModel.FramesCount,
+                    new LocalizedString("RENDERING_FRAME", index, document.AnimationDataViewModel.FramesCount));
+                int x = index % columns;
+                int y = index / columns;
+                Surface target = frame;
+                if (config.ExportSize != frame.Size)
+                {
+                    target =
+                        frame.ResizeNearestNeighbor(new VecI(config.ExportSize.X, config.ExportSize.Y));
+                }
+
+                surface!.DrawingSurface.Canvas.DrawSurface(target.DrawingSurface, x * config.ExportSize.X,
+                    y * config.ExportSize.Y);
+                target.Dispose();
+            }, job?.CancellationTokenSource.Token ?? CancellationToken.None);
 
         return surface;
     }
@@ -102,7 +148,7 @@ internal abstract class ImageFileType : IoFileType
     /// <summary>
     /// Saves image to PNG file. Messes with the passed bitmap.
     /// </summary>
-    private static async Task<SaveResult> TrySaveAs(IFileEncoder encoder, string savePath, Surface bitmap)
+    private static async Task<SaveResult> TrySaveAsAsync(IFileEncoder encoder, string savePath, Surface bitmap)
     {
         try
         {
@@ -131,4 +177,37 @@ internal abstract class ImageFileType : IoFileType
 
         return SaveResult.Success;
     }
+
+    /// <summary>
+    /// Saves image to PNG file. Messes with the passed bitmap.
+    /// </summary>
+    private static SaveResult TrySaveAs(IFileEncoder encoder, string savePath, Surface bitmap)
+    {
+        try
+        {
+            if (!encoder.SupportsTransparency)
+                bitmap.DrawingSurface.Canvas.DrawColor(Colors.White, BlendMode.Multiply);
+
+            using var stream = new FileStream(savePath, FileMode.Create);
+            encoder.Save(stream, bitmap);
+        }
+        catch (SecurityException)
+        {
+            return SaveResult.SecurityError;
+        }
+        catch (UnauthorizedAccessException)
+        {
+            return SaveResult.SecurityError;
+        }
+        catch (IOException)
+        {
+            return SaveResult.IoError;
+        }
+        catch
+        {
+            return SaveResult.UnknownError;
+        }
+
+        return SaveResult.Success;
+    }
 }

+ 2 - 1
src/PixiEditor/Models/Files/IoFileType.cs

@@ -47,5 +47,6 @@ internal abstract class IoFileType
         return "*" + extension;
     }
 
-    public abstract Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job);
+    public abstract Task<SaveResult> TrySaveAsync(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job);
+    public abstract SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job);
 }

+ 8 - 1
src/PixiEditor/Models/Files/OtfFileType.cs

@@ -11,7 +11,14 @@ internal class OtfFileType : IoFileType
 
     public override bool CanSave => false;
 
-    public override Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job)
+    public override Task<SaveResult> TrySaveAsync(string pathWithExtension, DocumentViewModel document,
+        ExportConfig config, ExportJob? job)
+    {
+        throw new NotSupportedException("Saving OTF files is not supported.");
+    }
+
+    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config,
+        ExportJob? job)
     {
         throw new NotSupportedException("Saving OTF files is not supported.");
     }

+ 29 - 2
src/PixiEditor/Models/Files/PixiFileType.cs

@@ -14,11 +14,12 @@ internal class PixiFileType : IoFileType
     public override string DisplayName => new LocalizedString("PIXI_FILE");
     public override string[] Extensions => new[] { ".pixi" };
 
-    public override SolidColorBrush EditorColor { get;  } = new SolidColorBrush(new Color(255, 226, 1, 45));
+    public override SolidColorBrush EditorColor { get; } = new SolidColorBrush(new Color(255, 226, 1, 45));
 
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Pixi;
 
-    public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job)
+    public override async Task<SaveResult> TrySaveAsync(string pathWithExtension, DocumentViewModel document,
+        ExportConfig config, ExportJob? job)
     {
         try
         {
@@ -42,4 +43,30 @@ internal class PixiFileType : IoFileType
 
         return SaveResult.Success;
     }
+
+    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config,
+        ExportJob? job)
+    {
+        try
+        {
+            job?.Report(0, "Serializing document");
+            Parser.PixiParser.V5.Serialize(document.ToSerializable(), pathWithExtension);
+            job?.Report(1, "Document serialized");
+        }
+        catch (UnauthorizedAccessException e)
+        {
+            return SaveResult.SecurityError;
+        }
+        catch (IOException)
+        {
+            return SaveResult.IoError;
+        }
+        catch (Exception e)
+        {
+            CrashHelper.SendExceptionInfo(e);
+            return SaveResult.UnknownError;
+        }
+
+        return SaveResult.Success;
+    }
 }

+ 24 - 3
src/PixiEditor/Models/Files/SvgFileType.cs

@@ -18,12 +18,13 @@ internal class SvgFileType : IoFileType
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Vector;
     public override SolidColorBrush EditorColor { get; } = new SolidColorBrush(Color.FromRgb(0, 128, 0));
 
-    public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job)
+    public override async Task<SaveResult> TrySaveAsync(string pathWithExtension, DocumentViewModel document,
+        ExportConfig config, ExportJob? job)
     {
         job?.Report(0, string.Empty);
         SvgDocument svgDocument = document.ToSvgDocument(0, config.ExportSize, config.VectorExportConfig);
 
-        job?.Report(0.5, string.Empty); 
+        job?.Report(0.5, string.Empty);
         string xml = svgDocument.ToXml();
 
         xml = $"<!-- Created with PixiEditor (https://pixieditor.net) -->{Environment.NewLine}" + xml;
@@ -32,7 +33,27 @@ internal class SvgFileType : IoFileType
         await using FileStream fileStream = new(pathWithExtension, FileMode.Create);
         await using StreamWriter writer = new(fileStream);
         await writer.WriteAsync(xml);
-        
+
+        job?.Report(1, string.Empty);
+        return SaveResult.Success;
+    }
+
+    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config,
+        ExportJob? job)
+    {
+        job?.Report(0, string.Empty);
+        SvgDocument svgDocument = document.ToSvgDocument(0, config.ExportSize, config.VectorExportConfig);
+
+        job?.Report(0.5, string.Empty);
+        string xml = svgDocument.ToXml();
+
+        xml = $"<!-- Created with PixiEditor (https://pixieditor.net) -->{Environment.NewLine}" + xml;
+
+        job?.Report(0.75, string.Empty);
+        using FileStream fileStream = new(pathWithExtension, FileMode.Create);
+        using StreamWriter writer = new(fileStream);
+        writer.Write(xml);
+
         job?.Report(1, string.Empty);
         return SaveResult.Success;
     }

+ 8 - 1
src/PixiEditor/Models/Files/TtfFileType.cs

@@ -11,7 +11,14 @@ internal class TtfFileType : IoFileType
 
     public override bool CanSave => false;
 
-    public override Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job)
+    public override Task<SaveResult> TrySaveAsync(string pathWithExtension, DocumentViewModel document,
+        ExportConfig config, ExportJob? job)
+    {
+        throw new NotSupportedException("Saving TTF files is not supported.");
+    }
+
+    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config,
+        ExportJob? job)
     {
         throw new NotSupportedException("Saving TTF files is not supported.");
     }

+ 54 - 9
src/PixiEditor/Models/Files/VideoFileType.cs

@@ -10,16 +10,16 @@ internal abstract class VideoFileType : IoFileType
 {
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Video;
 
-    public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document,
+    public override async Task<SaveResult> TrySaveAsync(string pathWithExtension, DocumentViewModel document,
         ExportConfig config, ExportJob? job)
     {
         if (config.AnimationRenderer is null)
             return SaveResult.UnknownError;
 
-        List<Image> frames = new(); 
-        
+        List<Image> frames = new();
+
         job?.Report(0, new LocalizedString("WARMING_UP"));
-        
+
         int frameRendered = 0;
         int totalFrames = document.AnimationDataViewModel.FramesCount;
 
@@ -27,7 +27,8 @@ internal abstract class VideoFileType : IoFileType
         {
             job?.CancellationTokenSource.Token.ThrowIfCancellationRequested();
             frameRendered++;
-            job?.Report(((double)frameRendered / totalFrames) / 2, new LocalizedString("RENDERING_FRAME", frameRendered, totalFrames));
+            job?.Report(((double)frameRendered / totalFrames) / 2,
+                new LocalizedString("RENDERING_FRAME", frameRendered, totalFrames));
             if (config.ExportSize != surface.Size)
             {
                 return surface.ResizeNearestNeighbor(config.ExportSize);
@@ -35,20 +36,64 @@ internal abstract class VideoFileType : IoFileType
 
             return surface;
         });
-        
+
         job?.Report(0.5, new LocalizedString("RENDERING_VIDEO"));
         CancellationToken token = job?.CancellationTokenSource.Token ?? CancellationToken.None;
         var result = await config.AnimationRenderer.RenderAsync(frames, pathWithExtension, token, progress =>
         {
             job?.Report((progress / 100f) * 0.5f + 0.5, new LocalizedString("RENDERING_VIDEO"));
         });
-        
+
+        job?.Report(1, new LocalizedString("FINISHED"));
+
+        foreach (var frame in frames)
+        {
+            frame.Dispose();
+        }
+
+        return result ? SaveResult.Success : SaveResult.UnknownError;
+    }
+
+    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config,
+        ExportJob? job)
+    {
+        if (config.AnimationRenderer is null)
+            return SaveResult.UnknownError;
+
+        List<Image> frames = new();
+
+        job?.Report(0, new LocalizedString("WARMING_UP"));
+
+        int frameRendered = 0;
+        int totalFrames = document.AnimationDataViewModel.FramesCount;
+
+        document.RenderFrames(frames, surface =>
+        {
+            job?.CancellationTokenSource.Token.ThrowIfCancellationRequested();
+            frameRendered++;
+            job?.Report(((double)frameRendered / totalFrames) / 2,
+                new LocalizedString("RENDERING_FRAME", frameRendered, totalFrames));
+            if (config.ExportSize != surface.Size)
+            {
+                return surface.ResizeNearestNeighbor(config.ExportSize);
+            }
+
+            return surface;
+        });
+
+        job?.Report(0.5, new LocalizedString("RENDERING_VIDEO"));
+        CancellationToken token = job?.CancellationTokenSource.Token ?? CancellationToken.None;
+        var result = config.AnimationRenderer.Render(frames, pathWithExtension, token, progress =>
+        {
+            job?.Report((progress / 100f) * 0.5f + 0.5, new LocalizedString("RENDERING_VIDEO"));
+        });
+
         job?.Report(1, new LocalizedString("FINISHED"));
-        
+
         foreach (var frame in frames)
         {
             frame.Dispose();
-        } 
+        }
 
         return result ? SaveResult.Success : SaveResult.UnknownError;
     }

+ 2 - 0
src/PixiEditor/Models/Handlers/IDocument.cs

@@ -16,6 +16,7 @@ using PixiEditor.Models.Rendering;
 using PixiEditor.Models.Structures;
 using PixiEditor.Models.Tools;
 using Drawie.Numerics;
+using PixiEditor.Models.DocumentPassthroughActions;
 
 namespace PixiEditor.Models.Handlers;
 
@@ -66,4 +67,5 @@ internal interface IDocument : IHandler
     public void UpdateSavedState();
 
     internal void InternalRaiseLayersChanged(LayersChangedEventArgs e);
+    internal void InternalMarkSaveState(DocumentMarkType type);
 }

+ 5 - 0
src/PixiEditor/Models/IO/ExportConfig.cs

@@ -13,4 +13,9 @@ public class ExportConfig
    public IAnimationRenderer? AnimationRenderer { get; set; }
    
    public VectorExportConfig? VectorExportConfig { get; set; }
+
+   public ExportConfig(VecI exportSize)
+   {
+        ExportSize = exportSize;
+   }
 }

+ 30 - 2
src/PixiEditor/Models/IO/Exporter.cs

@@ -61,7 +61,8 @@ internal class Exporter
             var file = await desktop.MainWindow.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
             {
                 FileTypeChoices = SupportedFilesHelper.BuildSaveFilter(
-                    FileTypeDialogDataSet.SetKind.Any & ~FileTypeDialogDataSet.SetKind.Video), DefaultExtension = "pixi"
+                    FileTypeDialogDataSet.SetKind.Any & ~FileTypeDialogDataSet.SetKind.Video),
+                DefaultExtension = "pixi"
             });
 
             if (file is null)
@@ -117,7 +118,34 @@ internal class Exporter
 
         try
         {
-            var result = await typeFromPath.TrySave(pathWithExtension, document, exportConfig, job);
+            var result = await typeFromPath.TrySaveAsync(pathWithExtension, document, exportConfig, job);
+            job?.Finish();
+            return result;
+        }
+        catch (Exception e)
+        {
+            job?.Finish();
+            Console.WriteLine(e);
+            CrashHelper.SendExceptionInfo(e);
+            return SaveResult.UnknownError;
+        }
+    }
+
+    public static SaveResult TrySave(DocumentViewModel document, string pathWithExtension,
+        ExportConfig exportConfig, ExportJob? job)
+    {
+        string directory = Path.GetDirectoryName(pathWithExtension);
+        if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+            return SaveResult.InvalidPath;
+
+        var typeFromPath = SupportedFilesHelper.ParseImageFormat(Path.GetExtension(pathWithExtension));
+
+        if (typeFromPath is null)
+            return SaveResult.UnknownError;
+
+        try
+        {
+            var result = typeFromPath.TrySave(pathWithExtension, document, exportConfig, job);
             job?.Finish();
             return result;
         }

+ 23 - 8
src/PixiEditor/Models/IO/Paths.cs

@@ -2,16 +2,23 @@
 using System.Reflection;
 
 namespace PixiEditor.Models.IO;
+
 public static class Paths
 {
     public static string DataResourceUri { get; } = $"avares://{typeof(Paths).Assembly.GetName().Name}/Data/";
-    public static string DataFullPath { get; } = Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), "Data");
-    public static string ExtensionPackagesPath { get; } = Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), "Extensions");
+
+    public static string DataFullPath { get; } =
+        Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), "Data");
+
+    public static string ExtensionPackagesPath { get; } =
+        Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), "Extensions");
+
     public static string UserConfigPath { get; } = Path.Combine(
-        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 
+        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
         "PixiEditor", "Configs");
+
     public static string UserExtensionsPath { get; } = Path.Combine(
-        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 
+        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
         "PixiEditor", "Extensions");
 
     public static string PathToPalettesFolder { get; } = Path.Join(
@@ -20,18 +27,26 @@ public static class Paths
 
     public static string InternalResourceDataPath { get; } =
         $"avares://{Assembly.GetExecutingAssembly().GetName().Name}/Data";
-    
+
     public static string TempRenderingPath { get; } = Path.Combine(Path.GetTempPath(), "PixiEditor", "Rendering");
-    
+
     public static string TempFilesPath { get; } = Path.Combine(Path.GetTempPath(), "PixiEditor");
     public static string TempResourcesPath { get; } = Path.Combine(Path.GetTempPath(), "PixiEditor", "Resources");
 
+    /// <summary>
+    /// Path to %temp%/PixiEditor/Autosave
+    /// </summary>
+    public static string PathToUnsavedFilesFolder { get; } = Path.Join(
+        Path.GetTempPath(),
+        "PixiEditor", "Autosave");
+
     public static string ParseSpecialPathOrDefault(string path)
     {
         path = path.Replace("%appdata%", Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData));
-        path = path.Replace("%localappdata%", Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
+        path = path.Replace("%localappdata%",
+            Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
         path = path.Replace("%temp%", Path.GetTempPath());
-        
+
         return path;
     }
 }

+ 4 - 0
src/PixiEditor/PixiEditor.csproj

@@ -147,6 +147,10 @@
       <DependentUpon>ColorPropertyView.axaml</DependentUpon>
       <SubType>Code</SubType>
     </Compile>
+    <Compile Update="Views\Dock\LazyDocumentTemplate.axaml.cs">
+      <DependentUpon>LazyDocumentTemplate.axaml</DependentUpon>
+      <SubType>Code</SubType>
+    </Compile>
   </ItemGroup>
 
   <ItemGroup>

+ 4 - 2
src/PixiEditor/Styles/Templates/DocumentTabTemplate.axaml

@@ -21,7 +21,7 @@
                 Converter={StaticResource ConditionToSizeConverter}, ConverterParameter=20}" />
                 <ColumnDefinition Width="*" />
                 <ColumnDefinition
-                    Width="{Binding !IsSaved,
+                    Width="{Binding ShowUnsavedDot,
                 Converter={StaticResource ConditionToSizeConverter}, ConverterParameter=8}" />
                 <ColumnDefinition
                     Width="{Binding ShowCloseButton,
@@ -41,7 +41,9 @@
                            Foreground="{DynamicResource ThemeForegroundBrush}"
                            VerticalAlignment="Center"
                            Text="{Binding $parent[ContentControl].Tag.Title, FallbackValue=TITLE}" />
-                <Ellipse Grid.Column="2" Width="5" Height="5" Fill="White" IsVisible="{Binding !IsSaved}" />
+                <Ellipse Grid.Column="2" Width="5" Height="5"
+                         Fill="{Binding SavedStateColor}"
+                         IsVisible="{Binding ShowUnsavedDot}" />
                 <Button Grid.Column="3" Classes="CloseTabButton"
                         Height="16" Width="16" Margin="5, 0, 0, 0"
                         VerticalAlignment="Center"

+ 1 - 0
src/PixiEditor/ViewLocator.cs

@@ -15,6 +15,7 @@ public class ViewLocator : IDataTemplate
     public static Dictionary<Type, Type> ViewBindingsMap = new Dictionary<Type, Type>()
     {
         [typeof(ViewportWindowViewModel)] = typeof(DocumentTemplate),
+        [typeof(LazyViewportWindowViewModel)] = typeof(LazyDocumentTemplate),
         [typeof(LayersDockViewModel)] = typeof(LayersManager),
     };
 

+ 20 - 0
src/PixiEditor/ViewModels/CrashReportViewModel.cs

@@ -84,6 +84,26 @@ internal partial class CrashReportViewModel : Window
         return hasRecoveredDocuments;
     }
 
+    public static void ShowMissingFilesDialog(CrashReport crashReport)
+    {
+        var dialog = new OptionsDialog<LocalizedString>(
+            "CRASH_NOT_ALL_DOCUMENTS_RECOVERED_TITLE",
+            new LocalizedString("CRASH_NOT_ALL_DOCUMENTS_RECOVERED"), MainWindow.Current)
+        {
+            {
+                "SEND", _ =>
+                {
+                    var sendReportDialog = new SendCrashReportDialog(crashReport);
+                    sendReportDialog.ShowDialog(MainWindow.Current);
+                }
+            },
+            "CLOSE"
+        };
+
+        dialog.ShowDialog(true);
+    }
+
+
     [Conditional("DEBUG")]
     private void SetIsDebug()
     {

+ 29 - 6
src/PixiEditor/ViewModels/Dock/DocumentTabCustomizationSettings.cs

@@ -1,18 +1,41 @@
+using Avalonia.Media;
 using PixiDocks.Core.Docking;
+using PixiEditor.Helpers;
 
 namespace PixiEditor.ViewModels.Dock;
 
 public class DocumentTabCustomizationSettings : TabCustomizationSettings
 {
-    private bool isSaved;
-    public bool IsSaved
+    private SavedState savedState;
+    public SavedState SavedState
     {
-        get => isSaved;
-        set => SetField(ref isSaved, value);
+        get => savedState;
+        set
+        {
+            SetField(ref savedState, value);
+            OnPropertyChanged(nameof(ShowUnsavedDot));
+            OnPropertyChanged(nameof(SavedStateColor));
+        }
     }
 
-    public DocumentTabCustomizationSettings(object? icon = null, bool showCloseButton = false, bool isSaved = true) : base(icon, showCloseButton)
+    public bool ShowUnsavedDot => SavedState != SavedState.Saved;
+    public IBrush SavedStateColor => SavedState switch
     {
-        IsSaved = isSaved;
+        SavedState.Saved => Brushes.Transparent,
+        SavedState.Autosaved => ThemeResources.AutosaveDotBrush,
+        SavedState.Unsaved => ThemeResources.UnsavedDotBrush,
+        _ => Brushes.Transparent
+    };
+
+    public DocumentTabCustomizationSettings(object? icon = null, bool showCloseButton = false, SavedState savedState = SavedState.Saved) : base(icon, showCloseButton)
+    {
+        SavedState = savedState;
     }
 }
+
+public enum SavedState
+{
+    Saved,
+    Autosaved,
+    Unsaved
+}

+ 6 - 6
src/PixiEditor/ViewModels/Dock/LayoutManager.cs

@@ -139,11 +139,11 @@ internal class LayoutManager
         registeredDockables.Remove(dockable);
     }
 
-    public void AddViewport(ViewportWindowViewModel viewportWindowViewModel)
+    public void AddViewport(IDockableContent viewport)
     {
-        RegisterDockable(viewportWindowViewModel);
+        RegisterDockable(viewport);
         DockableArea? documentsArea = TryFindArea("DocumentArea");
-        IDockable dockable = DockContext.CreateDockable(viewportWindowViewModel);
+        IDockable dockable = DockContext.CreateDockable(viewport);
         if (documentsArea != null)
         {
             documentsArea.AddDockable(dockable);
@@ -169,17 +169,17 @@ internal class LayoutManager
         return result;
     }
 
-    public void RemoveViewport(ViewportWindowViewModel viewportWindowViewModel)
+    public void RemoveViewport(IDockableContent content)
     {
         foreach (var element in ActiveLayout.Root)
         {
             if (element is IDockableHost dockableHost)
             {
-                var dockable = dockableHost.Dockables.FirstOrDefault(x => x.Id == viewportWindowViewModel.Id);
+                var dockable = dockableHost.Dockables.FirstOrDefault(x => x.Id == content.Id);
                 if (dockable != null)
                 {
                     dockableHost?.RemoveDockable(dockable);
-                    UnregisterDockable(viewportWindowViewModel);
+                    UnregisterDockable(content);
                     return;
                 }
             }

+ 161 - 0
src/PixiEditor/ViewModels/Document/AutosaveDocumentViewModel.cs

@@ -0,0 +1,161 @@
+using ColorPicker.Models;
+using CommunityToolkit.Mvvm.ComponentModel;
+using PixiEditor.Extensions.CommonApi.UserPreferences;
+using PixiEditor.Helpers;
+using PixiEditor.Models.DocumentModels;
+using PixiEditor.Models.DocumentModels.Autosave;
+using PixiEditor.Models.IO;
+
+namespace PixiEditor.ViewModels.Document;
+
+internal class AutosaveDocumentViewModel : ObservableObject
+{
+    private AutosaveStateData? autosaveStateData;
+
+    public AutosaveStateData? AutosaveStateData
+    {
+        get => autosaveStateData;
+        set => SetProperty(ref autosaveStateData, value);
+    }
+
+    private bool currentDocumentAutosaveEnabled = true;
+
+    public bool CurrentDocumentAutosaveEnabled
+    {
+        get => currentDocumentAutosaveEnabled;
+        set
+        {
+            if (currentDocumentAutosaveEnabled == value)
+                return;
+
+            SetProperty(ref currentDocumentAutosaveEnabled, value);
+            StopOrStartAutosaverIfNecessary();
+        }
+    }
+
+    private DocumentAutosaver? autosaver;
+    private DocumentViewModel Document { get; }
+    private Guid autosaveFileGuid = Guid.NewGuid();
+    public string AutosavePath => AutosaveHelper.GetAutosavePath(autosaveFileGuid);
+
+    public string LastAutosavedPath { get; set; }
+
+    private static bool SaveUserFileEnabled => IPreferences.Current!.GetPreference(
+        PreferencesConstants.AutosaveToDocumentPath, PreferencesConstants.AutosaveToDocumentPathDefault);
+
+    private static double AutosavePeriod =>
+        IPreferences.Current!.GetPreference(PreferencesConstants.AutosavePeriodMinutes,
+            PreferencesConstants.AutosavePeriodDefault);
+
+    private static bool AutosaveEnabledGlobally =>
+        IPreferences.Current!.GetPreference(PreferencesConstants.AutosaveEnabled,
+            PreferencesConstants.AutosaveEnabledDefault);
+
+    public AutosaveDocumentViewModel(DocumentViewModel document, DocumentInternalParts internals)
+    {
+        Document = document;
+        internals.ChangeController.ToolSessionFinished += (() => autosaver?.OnUpdateableChangeEnded());
+        IPreferences.Current!.AddCallback(PreferencesConstants.AutosaveEnabled, PreferenceUpdateCallback);
+        IPreferences.Current!.AddCallback(PreferencesConstants.AutosavePeriodMinutes, PreferenceUpdateCallback);
+        IPreferences.Current!.AddCallback(PreferencesConstants.AutosaveToDocumentPath, PreferenceUpdateCallback);
+        StopOrStartAutosaverIfNecessary();
+    }
+
+    private void PreferenceUpdateCallback(string str, object obj)
+    {
+        StopOrStartAutosaverIfNecessary();
+    }
+
+    private void StopAutosaver()
+    {
+        autosaver?.Dispose();
+        autosaver = null;
+        AutosaveStateData = null;
+    }
+
+    private void StopOrStartAutosaverIfNecessary()
+    {
+        StopAutosaver();
+        if (!AutosaveEnabledGlobally || !CurrentDocumentAutosaveEnabled)
+            return;
+
+        autosaver = new DocumentAutosaver(Document, TimeSpan.FromMinutes(AutosavePeriod), SaveUserFileEnabled);
+        autosaver.JobChanged += (_, _) => AutosaveStateData = autosaver.State;
+        AutosaveStateData = autosaver.State;
+    }
+
+    public bool Autosave(AutosaveHistoryType type)
+    {
+        if (Document.AllChangesSaved)
+        {
+            AddAutosaveHistoryEntry(
+                type,
+                AutosaveHistoryResult.NothingToSave);
+            return true;
+        }
+
+        try
+        {
+            string filePath = AutosavePath;
+            Directory.CreateDirectory(Directory.GetParent(filePath)!.FullName);
+            ExportConfig config = new ExportConfig(Document.SizeBindable);
+            bool success = Exporter.TrySave(Document, filePath, config, null) == SaveResult.Success;
+            if (success)
+            {
+                AddAutosaveHistoryEntry(type, AutosaveHistoryResult.SavedBackup);
+                LastAutosavedPath = filePath;
+            }
+
+            return success;
+        }
+        catch (Exception e)
+        {
+            return false;
+        }
+    }
+
+    public bool AutosaveOnClose()
+    {
+        return Autosave(AutosaveHistoryType.OnClose);
+    }
+
+    public void AddAutosaveHistoryEntry(AutosaveHistoryType type, AutosaveHistoryResult result)
+    {
+        List<AutosaveHistorySession>? historySessions =
+            IPreferences.Current!.GetLocalPreference<List<AutosaveHistorySession>>(PreferencesConstants
+                .AutosaveHistory);
+        if (historySessions is null)
+            historySessions = new();
+
+        AutosaveHistorySession currentSession;
+        if (historySessions.Count == 0 || historySessions[^1].SessionGuid != ViewModelMain.Current.CurrentSessionId)
+        {
+            currentSession = new AutosaveHistorySession(ViewModelMain.Current.CurrentSessionId,
+                ViewModelMain.Current.LaunchDateTime);
+            historySessions.Add(currentSession);
+        }
+        else
+        {
+            currentSession = historySessions[^1];
+        }
+
+        AutosaveHistoryEntry entry = new(DateTime.Now, type, result, autosaveFileGuid, Document.FullFilePath);
+        currentSession.AutosaveEntries.Add(entry);
+
+        IPreferences.Current.UpdateLocalPreference(PreferencesConstants.AutosaveHistory, historySessions);
+    }
+
+    public void SetTempFileGuidAndLastSavedPath(Guid? guid, string lastSavedPath)
+    {
+        autosaveFileGuid = guid == null || guid.Value == Guid.Empty ? Guid.NewGuid() : guid.Value;
+        LastAutosavedPath = lastSavedPath;
+    }
+
+    public void OnDocumentClosed()
+    {
+        CurrentDocumentAutosaveEnabled = false;
+        IPreferences.Current!.RemoveCallback(PreferencesConstants.AutosaveEnabled, PreferenceUpdateCallback);
+        IPreferences.Current!.RemoveCallback(PreferencesConstants.AutosavePeriodMinutes, PreferenceUpdateCallback);
+        IPreferences.Current!.RemoveCallback(PreferencesConstants.AutosaveToDocumentPath, PreferenceUpdateCallback);
+    }
+}

+ 10 - 0
src/PixiEditor/ViewModels/Document/DocumentManagerViewModel.cs

@@ -2,6 +2,7 @@
 using System.Threading.Tasks;
 using Avalonia.Input;
 using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.Helpers;
 using PixiEditor.Models.Commands.Attributes.Commands;
 using PixiEditor.Models.Commands.Attributes.Evaluators;
 using PixiEditor.Models.Dialogs;
@@ -18,6 +19,7 @@ namespace PixiEditor.ViewModels.Document;
 internal class DocumentManagerViewModel : SubViewModel<ViewModelMain>, IDocumentManagerHandler
 {
     public ObservableCollection<DocumentViewModel> Documents { get; } = new ObservableCollection<DocumentViewModel>();
+    public ObservableCollection<LazyDocumentViewModel> LazyDocuments { get; } = new ObservableCollection<LazyDocumentViewModel>();
     public event EventHandler<DocumentChangedEventArgs>? ActiveDocumentChanged;
 
     private DocumentViewModel? activeDocument;
@@ -261,6 +263,14 @@ internal class DocumentManagerViewModel : SubViewModel<ViewModelMain>, IDocument
         ActiveDocument.Operations.UseSrgbProcessing();
     }
 
+    [Command.Internal("PixiEditor.Document.LoadLazyDocument")]
+    public void LoadLazyDocument(LazyDocumentViewModel lazyDocument)
+    {
+        Owner.FileSubViewModel.LoadLazyDocument(lazyDocument);
+        LazyDocuments.Remove(lazyDocument);
+        Owner.WindowSubViewModel.CloseViewportForLazyDocument(lazyDocument);
+    }
+
     [Evaluator.CanExecute("PixiEditor.DocumentUsesSrgbBlending", nameof(ActiveDocument),
         nameof(ActiveDocument.UsesSrgbBlending))]
     public bool DocumentUsesSrgbBlending() => ActiveDocument?.UsesSrgbBlending ?? false;

+ 41 - 4
src/PixiEditor/ViewModels/Document/DocumentViewModel.cs

@@ -107,6 +107,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
     }
 
     private Guid? lastChangeOnSave = null;
+    private Guid? lastChangeOnAutosave = null;
 
     public bool AllChangesSaved
     {
@@ -116,6 +117,14 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         }
     }
 
+    public bool AllChangesAutosaved
+    {
+        get
+        {
+            return Internals.Tracker.LastChangeGuid == lastChangeOnAutosave;
+        }
+    }
+
     public DateTime OpenedUTC { get; } = DateTime.UtcNow;
 
     private bool horizontalSymmetryAxisEnabled;
@@ -225,6 +234,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
     IReferenceLayerHandler IDocument.ReferenceLayerHandler => ReferenceLayerViewModel;
     IAnimationHandler IDocument.AnimationHandler => AnimationDataViewModel;
     public bool UsesSrgbBlending { get; private set; }
+    public AutosaveDocumentViewModel AutosaveViewModel { get; }
 
     private DocumentViewModel()
     {
@@ -240,6 +250,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         AnimationDataViewModel = new(this, Internals);
 
         NodeGraph = new NodeGraphViewModel(this, Internals);
+        AutosaveViewModel = new AutosaveDocumentViewModel(this, Internals);
 
         TransformViewModel = new(this);
         TransformViewModel.TransformChanged += (args) => Internals.ChangeController.TransformChangedInlet(args);
@@ -537,14 +548,40 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
 
     public void MarkAsSaved()
     {
-        lastChangeOnSave = Internals.Tracker.LastChangeGuid;
-        OnPropertyChanged(nameof(AllChangesSaved));
+        Internals.ActionAccumulator.AddActions(new MarkAsAutosaved_PassthroughAction(DocumentMarkType.Saved));
+    }
+
+    public void MarkAsAutosaved()
+    {
+        Internals.ActionAccumulator.AddActions(new MarkAsAutosaved_PassthroughAction(DocumentMarkType.Autosaved));
     }
 
     public void MarkAsUnsaved()
     {
-        lastChangeOnSave = Guid.NewGuid();
-        OnPropertyChanged(nameof(AllChangesSaved));
+        Internals.ActionAccumulator.AddActions(new MarkAsAutosaved_PassthroughAction(DocumentMarkType.Unsaved));
+    }
+
+    public void InternalMarkSaveState(DocumentMarkType type)
+    {
+        switch (type)
+        {
+            case DocumentMarkType.Saved:
+                lastChangeOnSave = Internals.Tracker.LastChangeGuid;
+                OnPropertyChanged(nameof(AllChangesSaved));
+                break;
+            case DocumentMarkType.Unsaved:
+                lastChangeOnSave = Guid.NewGuid();
+                OnPropertyChanged(nameof(AllChangesSaved));
+                break;
+            case DocumentMarkType.Autosaved:
+                lastChangeOnAutosave = Internals.Tracker.LastChangeGuid;
+                OnPropertyChanged(nameof(AllChangesAutosaved));
+                break;
+            case DocumentMarkType.UnAutosaved:
+                lastChangeOnAutosave = Guid.NewGuid();
+                OnPropertyChanged(nameof(AllChangesAutosaved));
+                break;
+        }
     }
 
     public OneOf<Error, Surface> TryRenderWholeImage(KeyFrameTime frameTime, VecI renderSize)

+ 68 - 0
src/PixiEditor/ViewModels/Document/LazyDocumentViewModel.cs

@@ -0,0 +1,68 @@
+using PixiEditor.Helpers;
+
+namespace PixiEditor.ViewModels.Document;
+
+internal class LazyDocumentViewModel : PixiObservableObject
+{
+    private string path;
+    private bool associatePath;
+    private Guid tempFileGuid;
+    private string? autosavePath;
+    private string? originalPath;
+
+    public string Path
+    {
+        get => path;
+        set => SetProperty(ref path, value);
+    }
+
+    public bool AssociatePath
+    {
+        get => associatePath;
+        set => SetProperty(ref associatePath, value);
+    }
+
+    public Guid TempFileGuid
+    {
+        get => tempFileGuid;
+        set => SetProperty(ref tempFileGuid, value);
+    }
+
+    public string? AutosavePath
+    {
+        get => autosavePath;
+        set => SetProperty(ref autosavePath, value);
+    }
+
+    public string FileName => System.IO.Path.GetFileName(OriginalPath ?? Path);
+
+    public string? OriginalPath
+    {
+        get => originalPath;
+        set => SetProperty(ref originalPath, value);
+    }
+
+    public LazyDocumentViewModel(string path, bool associatePath)
+    {
+        Path = path;
+        OriginalPath = path;
+        AssociatePath = associatePath;
+    }
+
+    public void SetTempFileGuidAndLastSavedPath(Guid? tempGuid, string? tempPath)
+    {
+        if (tempGuid == null)
+        {
+            return;
+        }
+
+        TempFileGuid = tempGuid.Value;
+
+        if (tempPath == null)
+        {
+            return;
+        }
+
+        AutosavePath = tempPath;
+    }
+}

+ 59 - 0
src/PixiEditor/ViewModels/SubViewModels/AutosaveViewModel.cs

@@ -0,0 +1,59 @@
+using PixiEditor.Extensions.CommonApi.UserPreferences;
+using PixiEditor.Helpers;
+using PixiEditor.Models;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Commands.Attributes.Evaluators;
+using PixiEditor.Models.DocumentModels.Autosave;
+using PixiEditor.Models.IO;
+using PixiEditor.ViewModels.Document;
+
+namespace PixiEditor.ViewModels.SubViewModels;
+
+internal class AutosaveViewModel(ViewModelMain owner, DocumentManagerViewModel documentManager) : SubViewModel<ViewModelMain>(owner)
+{
+    public static bool SaveSessionStateEnabled => IPreferences.Current!.GetPreference(PreferencesConstants.SaveSessionStateEnabled, PreferencesConstants.SaveSessionStateDefault);
+
+    [Command.Basic("PixiEditor.Autosave.ToggleAutosave", "AUTOSAVE_TOGGLE", "AUTOSAVE_TOGGLE_DESCRIPTION",
+        CanExecute = "PixiEditor.Autosave.HasDocumentAndAutosaveEnabled")]
+    public void ToggleAutosave()
+    {
+        var autosaveViewModel = documentManager.ActiveDocument!.AutosaveViewModel;
+
+        autosaveViewModel.CurrentDocumentAutosaveEnabled = !autosaveViewModel.CurrentDocumentAutosaveEnabled;
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Autosave.HasDocumentAndAutosaveEnabled")]
+    public bool HasDocumentAndAutosaveEnabled() =>
+        documentManager.DocumentNotNull() && IPreferences.Current!.GetPreference<bool>(PreferencesConstants.AutosaveEnabled);
+
+    public void CleanupAutosavedFilesAndHistory()
+    {
+        if (!Directory.Exists(Paths.PathToUnsavedFilesFolder))
+            return;
+
+        List<AutosaveHistorySession>? autosaveHistory = IPreferences.Current!.GetLocalPreference<List<AutosaveHistorySession>>(PreferencesConstants.AutosaveHistory);
+        if (autosaveHistory is null)
+            autosaveHistory = new();
+
+        if (autosaveHistory.Count > 3)
+            autosaveHistory = autosaveHistory[^3..];
+
+        foreach (var path in Directory.EnumerateFiles(Paths.PathToUnsavedFilesFolder))
+        {
+            try
+            {
+                Guid fileGuid = AutosaveHelper.GetAutosaveGuid(path)!.Value;
+                bool lastWriteIsOld = (DateTime.Now - File.GetLastWriteTime(path)).TotalDays > Constants.MaxAutosaveFilesLifetimeDays;
+                bool creationDateIsOld = (DateTime.Now - File.GetCreationTime(path)).TotalDays > Constants.MaxAutosaveFilesLifetimeDays;
+                bool presentInHistory = autosaveHistory.Any(sess => sess.AutosaveEntries.Any(entry => entry.TempFileGuid == fileGuid));
+
+                if (!presentInHistory && lastWriteIsOld && creationDateIsOld)
+                    File.Delete(path);
+            }
+            catch (Exception e)
+            {
+                CrashHelper.SendExceptionInfo(e);
+            }
+        }
+    }
+}

+ 60 - 0
src/PixiEditor/ViewModels/SubViewModels/DebugViewModel.cs

@@ -76,6 +76,8 @@ internal class DebugViewModel : SubViewModel<ViewModelMain>
         }
     }
 
+    public bool ModifiedEditorData { get; set; }
+
     public DebugViewModel(ViewModelMain owner)
         : base(owner)
     {
@@ -411,4 +413,62 @@ internal class DebugViewModel : SubViewModel<ViewModelMain>
         IsDebugModeEnabled = value;
         UseDebug = IsDebugBuild || IsDebugModeEnabled;
     }
+
+    [Command.Debug("PixiEditor.Debug.BackupUserPreferences", @"%appdata%\PixiEditor\user_preferences.json",
+        "BACKUP_USR_PREFS", "BACKUP_USR_PREFS")]
+    [Command.Debug("PixiEditor.Debug.BackupEditorData", @"%localappdata%\PixiEditor\editor_data.json",
+        "BACKUP_EDITOR_DATA", "BACKUP_EDITOR_DATA")]
+    [Command.Debug("PixiEditor.Debug.BackupShortcutFile", @"%appdata%\PixiEditor\shortcuts.json",
+        "BACKUP_SHORTCUT_FILE", "BACKUP_SHORTCUT_FILE")]
+    public static void BackupFile(string path)
+    {
+        string file = Environment.ExpandEnvironmentVariables(path);
+        string backup = $"{file}.bak";
+
+        if (!File.Exists(file))
+        {
+            NoticeDialog.Show(new LocalizedString("File {0} does not exist\n(Full Path: {1})", path, file),
+                "FILE_NOT_FOUND");
+            return;
+        }
+
+        File.Copy(file, backup, true);
+    }
+
+    [Command.Debug("PixiEditor.Debug.LoadUserPreferencesBackup", @"%appdata%\PixiEditor\user_preferences.json",
+        "LOAD_USR_PREFS_BACKUP", "LOAD_USR_PREFS_BACKUP")]
+    [Command.Debug("PixiEditor.Debug.LoadEditorDataBackup", @"%localappdata%\PixiEditor\editor_data.json",
+        "LOAD_EDITOR_DATA_BACKUP", "LOAD_EDITOR_DATA_BACKUP")]
+    [Command.Debug("PixiEditor.Debug.LoadShortcutFileBackup", @"%appdata%\PixiEditor\shortcuts.json",
+        "LOAD_SHORTCUT_FILE_BACKUP", "LOAD_SHORTCUT_FILE_BACKUP")]
+    public void LoadBackupFile(string path)
+    {
+        if (path.EndsWith("editor_data.json"))
+        {
+            ModifiedEditorData = true;
+        }
+
+        string file = Environment.ExpandEnvironmentVariables(path);
+        string backup = $"{file}.bak";
+
+        if (!File.Exists(backup))
+        {
+            NoticeDialog.Show(new LocalizedString("File {0} does not exist\n(Full Path: {1})", path, file),
+                "FILE_NOT_FOUND");
+            return;
+        }
+
+        if (File.Exists(file))
+        {
+            OptionsDialog<string> dialog =
+                new("ARE_YOU_SURE", $"Are you sure you want to overwrite {path}\n(Full Path: {file})", MainWindow.Current)
+                {
+                    { "Yes", x => File.Delete(file) }, "Cancel"
+                };
+
+            dialog.ShowDialog();
+        }
+
+        File.Copy(backup, file, true);
+    }
 }

+ 370 - 23
src/PixiEditor/ViewModels/SubViewModels/FileViewModel.cs

@@ -20,6 +20,9 @@ using PixiEditor.Models.IO;
 using PixiEditor.Models.UserData;
 using Drawie.Numerics;
 using Microsoft.Extensions.DependencyInjection;
+using PixiEditor.Extensions.CommonApi.UserPreferences;
+using PixiEditor.Models.DocumentModels.Autosave;
+using PixiEditor.Models.ExceptionHandling;
 using PixiEditor.Models.IO.CustomDocumentFormats;
 using PixiEditor.OperatingSystem;
 using PixiEditor.Parser;
@@ -34,6 +37,7 @@ namespace PixiEditor.ViewModels.SubViewModels;
 [Command.Group("PixiEditor.File", "FILE")]
 internal class FileViewModel : SubViewModel<ViewModelMain>
 {
+    public static long LazyFileThreshold = 2 * 1024 * 1024; // 2MB
     private bool hasRecent;
 
     public bool HasRecent
@@ -101,22 +105,52 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
 
     private void OpenHelloTherePopup()
     {
-        var popup = new HelloTherePopup(this);
-        popup.Show();
+        new HelloTherePopup(this).Show();
     }
 
     private void Owner_OnStartupEvent()
     {
         List<string> args = StartupArgs.Args;
         string file = args.FirstOrDefault(x => Importer.IsSupportedFile(x) && File.Exists(x));
+
+        var preferences = IPreferences.Current;
+
+        try
+        {
+            if (!args.Contains("--crash"))
+            {
+                var lastCrash = preferences!.GetLocalPreference<string>(PreferencesConstants.LastCrashFile);
+
+                if (lastCrash == null)
+                {
+                    TryReopenTempAutosavedFiles();
+                }
+                else
+                {
+                    preferences.UpdateLocalPreference<string>(PreferencesConstants.LastCrashFile, null);
+
+                    var report = CrashReport.Parse(lastCrash);
+                    OpenFromReport(report, out bool showMissingFilesDialog);
+
+                    if (showMissingFilesDialog)
+                    {
+                        CrashReportViewModel.ShowMissingFilesDialog(report);
+                    }
+                }
+            }
+        }
+        catch (Exception exc)
+        {
+            CrashHelper.SendExceptionInfo(exc);
+        }
+
         if (file != null)
         {
             OpenFromPath(file);
         }
-        else if ((Owner.DocumentManagerSubViewModel.Documents.Count == 0 && !args.Contains("--crash")) &&
-                 !args.Contains("--openedInExisting"))
+        else if (!args.Contains("--crash") && !args.Contains("--openedInExisting"))
         {
-            if (PixiEditorSettings.StartupWindow.ShowStartupWindow.Value)
+            if (preferences!.GetPreference("ShowStartupWindow", true))
             {
                 OpenHelloTherePopup();
             }
@@ -196,25 +230,24 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
     /// <summary>
     /// Tries to open the passed file if it isn't already open
     /// </summary>
-    public void OpenFromPath(string path, bool associatePath = true)
+    public DocumentViewModel OpenFromPath(string path, bool associatePath = true)
     {
         if (MakeExistingDocumentActiveIfOpened(path))
-            return;
+            return null;
 
         try
         {
             if (path.EndsWith(".pixi"))
             {
-                OpenDotPixi(path, associatePath);
-            }
-            else if (IsCustomFormat(path))
-            {
-                OpenCustomFormat(path, associatePath);
+                return OpenDotPixi(path, associatePath);
             }
-            else
+
+            if (IsCustomFormat(path))
             {
-                OpenRegularImage(path, associatePath);
+                return OpenCustomFormat(path, associatePath);
             }
+
+            return OpenRegularImage(path, associatePath);
         }
         catch (RecoverableException ex)
         {
@@ -224,6 +257,19 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         {
             NoticeDialog.Show("OLD_FILE_FORMAT_DESCRIPTION", "OLD_FILE_FORMAT");
         }
+
+        return null;
+    }
+
+    public LazyDocumentViewModel OpenFromPathLazy(string path, bool associatePath = true)
+    {
+        if (MakeExistingDocumentActiveIfOpened(path))
+            return null;
+
+        LazyDocumentViewModel lazyDoc = new LazyDocumentViewModel(path, associatePath);
+        AddLazyDocumentToTheSystem(lazyDoc);
+
+        return lazyDoc;
     }
 
     private bool IsCustomFormat(string path)
@@ -232,7 +278,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         return documentBuilders.Any(x => x.Extensions.Contains(extension, StringComparer.OrdinalIgnoreCase));
     }
 
-    private void OpenCustomFormat(string path, bool associatePath)
+    private DocumentViewModel? OpenCustomFormat(string path, bool associatePath)
     {
         IDocumentBuilder builder = documentBuilders.First(x =>
             x.Extensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase));
@@ -240,7 +286,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         if (!File.Exists(path))
         {
             NoticeDialog.Show("FILE_NOT_FOUND", "FAILED_TO_OPEN_FILE");
-            return;
+            return null;
         }
 
         try
@@ -254,6 +300,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
             }
 
             AddRecentlyOpened(document.FullFilePath);
+            return document;
         }
         catch (Exception ex)
         {
@@ -261,12 +308,14 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
             Console.WriteLine(ex);
             CrashHelper.SendExceptionInfo(ex);
         }
+
+        return null;
     }
 
     /// <summary>
     /// Opens a .pixi file from path, creates a document from it, and adds it to the system
     /// </summary>
-    private void OpenDotPixi(string path, bool associatePath = true)
+    private DocumentViewModel OpenDotPixi(string path, bool associatePath = true)
     {
         DocumentViewModel document = Importer.ImportDocument(path, associatePath);
 
@@ -275,26 +324,41 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
 
         var fileSize = new FileInfo(path).Length;
         Analytics.SendOpenFile(PixiFileType.PixiFile, fileSize, document.SizeBindable);
+
+        return document;
     }
 
     /// <summary>
     /// Opens a .pixi file from path, creates a document from it, and adds it to the system
     /// </summary>
-    public void OpenRecoveredDotPixi(string? originalPath, byte[] dotPixiBytes)
+    public void OpenRecoveredDotPixi(string? originalPath, string? autosavePath, Guid? autosaveGuid,
+        byte[] dotPixiBytes)
     {
         DocumentViewModel document = Importer.ImportDocument(dotPixiBytes, originalPath);
         document.MarkAsUnsaved();
+
+        if (autosavePath != null)
+        {
+            document.AutosaveViewModel.SetTempFileGuidAndLastSavedPath(autosaveGuid!.Value, autosavePath);
+        }
+
+        AddDocumentViewModelToTheSystem(document);
+    }
+
+    public void OpenFromPixiBytes(byte[] bytes)
+    {
+        DocumentViewModel document = Importer.ImportDocument(bytes, null);
         AddDocumentViewModelToTheSystem(document);
     }
 
     /// <summary>
     /// Opens a regular image file from path, creates a document from it, and adds it to the system.
     /// </summary>
-    private void OpenRegularImage(string path, bool associatePath)
+    private DocumentViewModel OpenRegularImage(string path, bool associatePath)
     {
         var image = Importer.ImportImage(path, VecI.NegativeOne);
 
-        if (image == null) return;
+        if (image == null) return null;
 
         var doc = NewDocument(b => b
             .WithSize(image.Size)
@@ -326,8 +390,58 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
             CrashHelper.SendExceptionInfo(new InvalidFileTypeException(default,
                 $"Invalid file type '{fileType}'"));
         }
+
+        return doc;
     }
 
+    public void OpenFromReport(CrashReport report, out bool showMissingFilesDialog)
+    {
+        var documents = report.RecoverDocuments(out var info);
+
+        var i = 0;
+
+        Exception firstException = null;
+        Exception secondException = null;
+        Exception thirdException = null;
+
+        foreach (var document in documents)
+        {
+            try
+            {
+                OpenRecoveredDotPixi(document.OriginalPath, document.AutosavePath,
+                    AutosaveHelper.GetAutosaveGuid(document.AutosavePath), document.GetRecoveredBytes());
+                i++;
+            }
+            catch (Exception e)
+            {
+                firstException = e;
+
+                try
+                {
+                    OpenFromPath(document.AutosavePath, false);
+                }
+                catch (Exception deepE)
+                {
+                    secondException = deepE;
+
+                    try
+                    {
+                        OpenRecoveredDotPixi(document.OriginalPath, document.AutosavePath,
+                            AutosaveHelper.GetAutosaveGuid(document.AutosavePath), document.TryGetAutoSaveBytes());
+                    }
+                    catch (Exception veryDeepE)
+                    {
+                        thirdException = veryDeepE;
+                    }
+                }
+            }
+
+            var exceptions = new[] { firstException, secondException, thirdException };
+            CrashHelper.SendExceptionInfo(new AggregateException(exceptions.Where(x => x != null).ToArray()));
+        }
+
+        showMissingFilesDialog = documents.Count != i;
+    }
 
     /// <summary>
     /// Opens a regular image file from path, creates a document from it, and adds it to the system.
@@ -386,6 +500,13 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         return doc;
     }
 
+    public void AddLazyDocumentToTheSystem(LazyDocumentViewModel doc)
+    {
+        Owner.DocumentManagerSubViewModel.LazyDocuments.Add(doc);
+        Owner.WindowSubViewModel.CreateNewViewport(doc);
+        Owner.WindowSubViewModel.MakeDocumentViewportActive(doc);
+    }
+
     private void AddDocumentViewModelToTheSystem(DocumentViewModel doc)
     {
         Owner.DocumentManagerSubViewModel.Documents.Add(doc);
@@ -412,7 +533,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         string finalPath = null;
         if (asNew || string.IsNullOrEmpty(document.FullFilePath))
         {
-            ExportConfig config = new ExportConfig() { ExportSize = document.SizeBindable };
+            ExportConfig config = new ExportConfig(document.SizeBindable);
             var result = await Exporter.TrySaveWithDialog(document, config, null);
             if (result.Result == DialogSaveResult.Cancelled)
                 return false;
@@ -427,7 +548,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         }
         else
         {
-            ExportConfig config = new ExportConfig() { ExportSize = document.SizeBindable };
+            ExportConfig config = new ExportConfig(document.SizeBindable);
             var result = await Exporter.TrySaveAsync(document, document.FullFilePath, config, null);
             if (result != SaveResult.Success)
             {
@@ -539,9 +660,216 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         }
     }
 
+    private void TryReopenTempAutosavedFiles()
+    {
+        var preferences = Owner.Preferences;
+
+        var history =
+            preferences.GetLocalPreference<List<AutosaveHistorySession>>(PreferencesConstants.AutosaveHistory);
+
+        // There are no autosave attempts .. but what if the user has just launched pixieditor for the first time,
+        // and it unexpectedly closed before auto saving anything. They could've still had some files open, and they won't be reopened in this session
+        // I'll say this is by design
+        if (history is null || history.Count == 0)
+            return;
+        var lastSession = history[^1];
+        if (lastSession.AutosaveEntries.Count == 0)
+            return;
+
+        bool shutdownWasUnexpected = lastSession.AutosaveEntries.All(a => a.Type != AutosaveHistoryType.OnClose);
+        if (shutdownWasUnexpected)
+        {
+            LoadFromUnexpectedShutdown(lastSession);
+            return;
+        }
+
+        if (!preferences.GetPreference<bool>(PreferencesConstants.SaveSessionStateEnabled,
+                PreferencesConstants.SaveSessionStateDefault))
+            return;
+
+        var nextSessionFiles = preferences.GetLocalPreference<SessionFile[]>(PreferencesConstants.NextSessionFiles, []);
+        var perDocumentHistories = GetHistoriesFromSession(lastSession, nextSessionFiles);
+
+        var toLoad = nextSessionFiles.ToList();
+
+        TryOpenFromSession(perDocumentHistories, toLoad, true);
+
+        // Files that were inside a previous session as lazy document and was not opened
+        if (toLoad.Any(x => x.OriginalFilePath == null && !string.IsNullOrEmpty(x.AutosaveFilePath)))
+        {
+            for (int i = history.Count - 2; i >= 0; i--)
+            {
+                var session = history[i];
+                var histories = GetHistoriesFromSession(session, nextSessionFiles);
+                TryOpenFromSession(histories, toLoad, false);
+
+                if (toLoad.Count == 0 || toLoad.All(x => x.OriginalFilePath != null))
+                    break;
+            }
+        }
+
+        foreach (var file in toLoad)
+        {
+            if (file.OriginalFilePath != null)
+            {
+                LoadNewest(file.OriginalFilePath, file.AutosaveFilePath, true);
+            }
+        }
+
+        Owner.AutosaveViewModel.CleanupAutosavedFilesAndHistory();
+        preferences.UpdateLocalPreference(PreferencesConstants.NextSessionFiles, Array.Empty<SessionFile>());
+    }
+
+    private static List<List<AutosaveHistoryEntry>> GetHistoriesFromSession(AutosaveHistorySession lastSession,
+        SessionFile[]? nextSessionFiles)
+    {
+        List<List<AutosaveHistoryEntry>> perDocumentHistories = (
+            from entry in lastSession.AutosaveEntries
+            where nextSessionFiles.Any(a =>
+                a.AutosaveFilePath == AutosaveHelper.GetAutosavePath(entry.TempFileGuid) ||
+                entry.Type != AutosaveHistoryType.OnClose)
+            group entry by entry.TempFileGuid
+            into entryGroup
+            select entryGroup.OrderBy(a => a.DateTime).ToList()
+        ).ToList();
+        return perDocumentHistories;
+    }
+
+    private void TryOpenFromSession(List<List<AutosaveHistoryEntry>> perDocumentHistories, List<SessionFile> toLoad,
+        bool tryRecover)
+    {
+        foreach (var documentHistory in perDocumentHistories)
+        {
+            AutosaveHistoryEntry lastEntry = documentHistory[^1];
+            try
+            {
+                if (tryRecover && lastEntry.Type != AutosaveHistoryType.OnClose)
+                {
+                    // unexpected shutdown happened, this file wasn't saved on close, but we supposedly have a backup
+                    LoadNewest(lastEntry.OriginalPath, AutosaveHelper.GetAutosavePath(lastEntry.TempFileGuid), true);
+                    toLoad.RemoveAll(a =>
+                        (a.AutosaveFilePath != null &&
+                         a.AutosaveFilePath == AutosaveHelper.GetAutosavePath(lastEntry.TempFileGuid))
+                        || (a.OriginalFilePath != null && a.OriginalFilePath == lastEntry.OriginalPath));
+                }
+                else if (lastEntry.Result == AutosaveHistoryResult.SavedBackup || lastEntry.OriginalPath == null)
+                {
+                    var matchingFile = toLoad.FirstOrDefault(x =>
+                        x.OriginalFilePath == null &&
+                        x.AutosaveFilePath == AutosaveHelper.GetAutosavePath(lastEntry.TempFileGuid));
+                    if (string.IsNullOrEmpty(matchingFile.AutosaveFilePath))
+                    {
+                        continue;
+                    }
+
+                    LoadFromAutosave(AutosaveHelper.GetAutosavePath(lastEntry.TempFileGuid), lastEntry.OriginalPath,
+                        true);
+                    toLoad.RemoveAll(a =>
+                        (a.AutosaveFilePath != null && a.AutosaveFilePath ==
+                            AutosaveHelper.GetAutosavePath(lastEntry.TempFileGuid))
+                        || (a.OriginalFilePath != null && a.OriginalFilePath == lastEntry.OriginalPath));
+                }
+            }
+            catch (Exception e)
+            {
+                CrashHelper.SendExceptionInfo(e);
+            }
+        }
+    }
+
+    private void LoadFromUnexpectedShutdown(AutosaveHistorySession lastSession)
+    {
+        List<List<AutosaveHistoryEntry>> lastBackups = (
+            from entry in lastSession.AutosaveEntries
+            group entry by entry.TempFileGuid
+            into entryGroup
+            select entryGroup.OrderBy(a => a.DateTime).ToList()
+        ).ToList();
+
+        try
+        {
+            foreach (var backup in lastBackups)
+            {
+                AutosaveHistoryEntry lastEntry = backup[^1];
+                LoadNewest(lastEntry.OriginalPath, AutosaveHelper.GetAutosavePath(lastEntry.TempFileGuid), false);
+            }
+
+            OptionsDialog<LocalizedString> dialog = new OptionsDialog<LocalizedString>("UNEXPECTED_SHUTDOWN",
+                new LocalizedString("UNEXPECTED_SHUTDOWN_MSG"),
+                MainWindow.Current!)
+            {
+                { "OPEN_AUTOSAVES", _ => { IOperatingSystem.Current.OpenFolder(Paths.PathToUnsavedFilesFolder); } },
+                "OK"
+            };
+            dialog.ShowDialog(true);
+        }
+        catch (Exception e)
+        {
+            CrashHelper.SendExceptionInfo(e);
+        }
+    }
+
+    private void LoadNewest(string? originalPath, string? autosavePath, bool allowLazy)
+    {
+        bool loadFromUserFile = false;
+
+        if (originalPath != null && File.Exists(originalPath))
+        {
+            DateTime saveFileWriteTime = File.GetLastWriteTime(originalPath);
+            bool autosaveExists = autosavePath != null && File.Exists(autosavePath);
+            DateTime autosaveWriteTime = autosaveExists ? File.GetLastWriteTime(autosavePath) : DateTime.MinValue;
+
+            loadFromUserFile = saveFileWriteTime > autosaveWriteTime;
+        }
+
+        if (loadFromUserFile)
+        {
+            var fileSize = new FileInfo(originalPath).Length;
+            if (allowLazy && fileSize > LazyFileThreshold)
+            {
+                OpenFromPathLazy(originalPath);
+            }
+            else
+            {
+                OpenFromPath(originalPath);
+            }
+        }
+        else
+        {
+            LoadFromAutosave(autosavePath, originalPath, allowLazy);
+        }
+    }
+
+    private void LoadFromAutosave(string autosavePath, string? originalPath, bool allowLazy)
+    {
+        string path = autosavePath;
+        if (path == null || !File.Exists(path))
+        {
+            // TODO: Notice user when non-blocking notification system is implemented
+            return;
+        }
+
+        Guid? autosaveGuid = AutosaveHelper.GetAutosaveGuid(autosavePath);
+        var fileSize = new FileInfo(path).Length;
+        if (allowLazy && fileSize > LazyFileThreshold)
+        {
+            var lazyDoc = OpenFromPathLazy(path, false);
+            lazyDoc.SetTempFileGuidAndLastSavedPath(autosaveGuid, path);
+            lazyDoc.OriginalPath = originalPath;
+        }
+        else
+        {
+            var document = OpenFromPath(path, false);
+            document.AutosaveViewModel.SetTempFileGuidAndLastSavedPath(autosaveGuid, path);
+            document.FullFilePath = originalPath;
+            document.MarkAsUnsaved();
+        }
+    }
+
     private List<RecentlyOpenedDocument> GetRecentlyOpenedDocuments()
     {
-        var paths = PixiEditorSettings.File.RecentlyOpened.Value.Take(PixiEditorSettings.File.MaxOpenedRecently.Value);
+        var paths = PixiEditorSettings.File.RecentlyOpened.Value.Take(PixiEditorSettings.File.MaxOpenedRecently
+            .Value);
         List<RecentlyOpenedDocument> documents = new List<RecentlyOpenedDocument>();
 
         foreach (string path in paths)
@@ -553,4 +881,23 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
 
         return documents;
     }
+
+    public void LoadLazyDocument(LazyDocumentViewModel lazyDocument)
+    {
+        var document = OpenFromPath(lazyDocument.Path, lazyDocument.AssociatePath);
+
+        if (document is null)
+        {
+            NoticeDialog.Show("FAILED_TO_OPEN_FILE", "ERROR");
+            return;
+        }
+
+        document.FullFilePath = lazyDocument.OriginalPath;
+        document.AutosaveViewModel.SetTempFileGuidAndLastSavedPath(lazyDocument.TempFileGuid,
+            lazyDocument.AutosavePath);
+        if (lazyDocument.Path != lazyDocument.OriginalPath)
+        {
+            document.MarkAsUnsaved();
+        }
+    }
 }

+ 12 - 0
src/PixiEditor/ViewModels/SubViewModels/LayoutViewModel.cs

@@ -19,7 +19,9 @@ internal class LayoutViewModel : SubViewModel<ViewModelMain>
     {
         LayoutManager = layoutManager;
         owner.WindowSubViewModel.ViewportAdded += WindowSubViewModel_ViewportAdded;
+        owner.WindowSubViewModel.LazyViewportAdded += WindowSubViewModel_LazyViewportAdded;
         owner.WindowSubViewModel.ViewportClosed += WindowSubViewModel_ViewportRemoved;
+        owner.WindowSubViewModel.LazyViewportRemoved += WindowSubViewModel_LazyViewportRemoved;
     }
 
     private void WindowSubViewModel_ViewportAdded(ViewportWindowViewModel obj)
@@ -31,4 +33,14 @@ internal class LayoutViewModel : SubViewModel<ViewModelMain>
     {
         LayoutManager.RemoveViewport(obj);
     }
+
+    private void WindowSubViewModel_LazyViewportAdded(LazyViewportWindowViewModel obj)
+    {
+        LayoutManager.AddViewport(obj);
+    }
+
+    private void WindowSubViewModel_LazyViewportRemoved(LazyViewportWindowViewModel obj)
+    {
+        LayoutManager.RemoveViewport(obj);
+    }
 }

+ 50 - 0
src/PixiEditor/ViewModels/SubViewModels/LazyViewportWindowViewModel.cs

@@ -0,0 +1,50 @@
+using System.ComponentModel;
+using PixiDocks.Core.Docking;
+using PixiDocks.Core.Docking.Events;
+using PixiEditor.ViewModels.Dock;
+using PixiEditor.ViewModels.Document;
+
+namespace PixiEditor.ViewModels.SubViewModels;
+
+internal class LazyViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockableContent, IDockableCloseEvents,
+    IDockableSelectionEvents
+{
+    public string Id { get; } = Guid.NewGuid().ToString();
+    public string Title => LazyDocument.FileName;
+    public bool CanFloat { get; } = true;
+    public bool CanClose { get; } = true;
+    public TabCustomizationSettings TabCustomizationSettings { get; } = new DocumentTabCustomizationSettings();
+
+    public LazyDocumentViewModel LazyDocument { get; }
+
+    public LazyViewportWindowViewModel(WindowViewModel owner, LazyDocumentViewModel lazyDoc) : base(owner)
+    {
+        LazyDocument = lazyDoc;
+        LazyDocument.PropertyChanged += LazyDocumentOnPropertyChanged;
+    }
+
+    private void LazyDocumentOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
+    {
+        if (e.PropertyName == nameof(LazyDocumentViewModel.OriginalPath))
+        {
+            OnPropertyChanged(nameof(Title));
+        }
+    }
+
+    void IDockableSelectionEvents.OnSelected()
+    {
+        Owner.ActiveWindow = this;
+        Owner.Owner.ShortcutController.OverwriteContext(this.GetType());
+    }
+
+    void IDockableSelectionEvents.OnDeselected()
+    {
+        Owner.Owner.ShortcutController.ClearContext(GetType());
+    }
+
+    async Task<bool> IDockableCloseEvents.OnClose()
+    {
+        Owner.OnLazyViewportWindowCloseButtonPressed(this);
+        return await Task.FromResult(true);
+    }
+}

+ 31 - 8
src/PixiEditor/ViewModels/SubViewModels/ViewportWindowViewModel.cs

@@ -11,20 +11,23 @@ using PixiEditor.Views.Visuals;
 
 namespace PixiEditor.ViewModels.SubViewModels;
 #nullable enable
-internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockableContent, IDockableCloseEvents, IDockableSelectionEvents
+internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockableContent, IDockableCloseEvents,
+    IDockableSelectionEvents
 {
     public DocumentViewModel Document { get; }
     public ExecutionTrigger<VecI> CenterViewportTrigger { get; } = new ExecutionTrigger<VecI>();
     public ExecutionTrigger<double> ZoomViewportTrigger { get; } = new ExecutionTrigger<double>();
 
-    
+
     public string Index => _index;
 
     public string Id => id;
     public string Title => $"{Document.FileName}{Index}";
     public bool CanFloat => true;
     public bool CanClose => true;
-    public DocumentTabCustomizationSettings TabCustomizationSettings { get; } = new DocumentTabCustomizationSettings(showCloseButton: true);
+
+    public DocumentTabCustomizationSettings TabCustomizationSettings { get; } =
+        new DocumentTabCustomizationSettings(showCloseButton: true);
 
     TabCustomizationSettings IDockableContent.TabCustomizationSettings => TabCustomizationSettings;
 
@@ -44,7 +47,7 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
             OnPropertyChanged(nameof(FlipX));
         }
     }
-    
+
     private bool _flipY;
 
     public bool FlipY
@@ -56,7 +59,7 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
             OnPropertyChanged(nameof(FlipY));
         }
     }
-    
+
     public string RenderOutputName
     {
         get => renderOutputName;
@@ -68,7 +71,7 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
     }
 
     private ViewportColorChannels _channels = ViewportColorChannels.Default;
-    
+
     public ViewportColorChannels Channels
     {
         get => _channels;
@@ -102,7 +105,8 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
         Document = document;
         Document.SizeChanged += DocumentOnSizeChanged;
         Document.PropertyChanged += DocumentOnPropertyChanged;
-        previewPainterControl = new PreviewPainterControl(Document.PreviewPainter, Document.AnimationDataViewModel.ActiveFrameTime.Frame);
+        previewPainterControl = new PreviewPainterControl(Document.PreviewPainter,
+            Document.AnimationDataViewModel.ActiveFrameTime.Frame);
         TabCustomizationSettings.Icon = previewPainterControl;
     }
 
@@ -119,7 +123,11 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
         }
         else if (e.PropertyName == nameof(DocumentViewModel.AllChangesSaved))
         {
-            TabCustomizationSettings.IsSaved = Document.AllChangesSaved;
+            TabCustomizationSettings.SavedState = GetSaveState(Document);
+        }
+        else if (e.PropertyName == nameof(DocumentViewModel.AllChangesAutosaved))
+        {
+            TabCustomizationSettings.SavedState = GetSaveState(Document);
         }
     }
 
@@ -152,6 +160,21 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
         return _closeRequested;
     }
 
+    private static SavedState GetSaveState(DocumentViewModel document)
+    {
+        if (document.AllChangesSaved)
+        {
+            return SavedState.Saved;
+        }
+
+        if (document.AllChangesAutosaved)
+        {
+            return SavedState.Autosaved;
+        }
+
+        return SavedState.Unsaved;
+    }
+
     void IDockableSelectionEvents.OnSelected()
     {
         Owner.ActiveWindow = this;

+ 40 - 0
src/PixiEditor/ViewModels/SubViewModels/WindowViewModel.cs

@@ -26,10 +26,14 @@ internal class WindowViewModel : SubViewModel<ViewModelMain>
     private CommandController commandController;
     public RelayCommand<string> ShowAvalonDockWindowCommand { get; set; }
     public ObservableCollection<ViewportWindowViewModel> Viewports { get; } = new();
+    public ObservableCollection<LazyViewportWindowViewModel> LazyViewports { get; } = new();
     public event EventHandler<ViewportWindowViewModel>? ActiveViewportChanged;
     public event Action<ViewportWindowViewModel> ViewportAdded;
     public event Action<ViewportWindowViewModel> ViewportClosed;
 
+    public event Action<LazyViewportWindowViewModel> LazyViewportAdded;
+    public event Action<LazyViewportWindowViewModel> LazyViewportRemoved;
+
     private object? activeWindow;
 
     public object? ActiveWindow
@@ -118,6 +122,14 @@ internal class WindowViewModel : SubViewModel<ViewModelMain>
         ViewportAdded?.Invoke(newViewport);
     }
 
+    public void CreateNewViewport(LazyDocumentViewModel lazyDoc)
+    {
+        LazyViewportWindowViewModel newViewport = new LazyViewportWindowViewModel(this, lazyDoc);
+        LazyViewports.Add(newViewport);
+
+        LazyViewportAdded?.Invoke(newViewport);
+    }
+
     public void MakeDocumentViewportActive(DocumentViewModel? doc)
     {
         if (doc is null)
@@ -130,6 +142,17 @@ internal class WindowViewModel : SubViewModel<ViewModelMain>
         ActiveWindow = Viewports.FirstOrDefault(viewport => viewport.Document == doc);
     }
 
+    public void MakeDocumentViewportActive(LazyDocumentViewModel? doc)
+    {
+        if (doc is null)
+        {
+            ActiveWindow = null;
+            return;
+        }
+
+        ActiveWindow = LazyViewports.FirstOrDefault(viewport => viewport.LazyDocument == doc);
+    }
+
     public string CalculateViewportIndex(ViewportWindowViewModel viewport)
     {
         ViewportWindowViewModel[] viewports = Viewports.Where(a => a.Document == viewport.Document).ToArray();
@@ -159,6 +182,13 @@ internal class WindowViewModel : SubViewModel<ViewModelMain>
         return true;
     }
 
+    public void OnLazyViewportWindowCloseButtonPressed(LazyViewportWindowViewModel viewport)
+    {
+        LazyViewports.Remove(viewport);
+        LazyViewportRemoved?.Invoke(viewport);
+        Owner.CloseLazyDocument(viewport.LazyDocument);
+    }
+
     public void CloseViewportsForDocument(DocumentViewModel document)
     {
         var viewports = Viewports.Where(vp => vp.Document == document).ToArray();
@@ -169,6 +199,16 @@ internal class WindowViewModel : SubViewModel<ViewModelMain>
         }
     }
 
+    public void CloseViewportForLazyDocument(LazyDocumentViewModel lazyDoc)
+    {
+        var viewport = LazyViewports.FirstOrDefault(vp => vp.LazyDocument == lazyDoc);
+        if (viewport is not null)
+        {
+            LazyViewports.Remove(viewport);
+            LazyViewportRemoved?.Invoke(viewport);
+        }
+    }
+
     [Commands_Command.Basic("PixiEditor.Window.OpenSettingsWindow", "OPEN_SETTINGS", "OPEN_SETTINGS_DESCRIPTIVE",
         Key = Key.OemComma, Modifiers = KeyModifiers.Control,
         MenuItemPath = "EDIT/SETTINGS", MenuItemOrder = 16, Icon = PixiPerfectIcons.Settings, AnalyticsTrack = true)]

+ 28 - 0
src/PixiEditor/ViewModels/UserPreferences/Settings/FileSettings.cs

@@ -54,4 +54,32 @@ internal class FileSettings : SettingsGroup
         get => disableNewsPanel;
         set => RaiseAndUpdatePreference(ref disableNewsPanel, value, PreferencesConstants.DisableNewsPanel);
     }
+
+    private bool autosaveEnabled = GetPreference(PreferencesConstants.AutosaveEnabled, PreferencesConstants.AutosaveEnabledDefault);
+    public bool AutosaveEnabled
+    {
+        get => autosaveEnabled;
+        set => RaiseAndUpdatePreference(ref autosaveEnabled, value);
+    }
+
+    private bool saveSessionStateEnabled = GetPreference(PreferencesConstants.SaveSessionStateEnabled, PreferencesConstants.SaveSessionStateDefault);
+    public bool SaveSessionStateEnabled
+    {
+        get => saveSessionStateEnabled;
+        set => RaiseAndUpdatePreference(ref saveSessionStateEnabled, value);
+    }
+
+    private double autosavePeriodMinutes = GetPreference(PreferencesConstants.AutosavePeriodMinutes, PreferencesConstants.AutosavePeriodDefault);
+    public double AutosavePeriodMinutes
+    {
+        get => autosavePeriodMinutes;
+        set => RaiseAndUpdatePreference(ref autosavePeriodMinutes, value);
+    }
+
+    private bool autosaveToDocumentPath = GetPreference(PreferencesConstants.AutosaveToDocumentPath, PreferencesConstants.AutosaveToDocumentPathDefault);
+    public bool AutosaveToDocumentPath
+    {
+        get => autosaveToDocumentPath;
+        set => RaiseAndUpdatePreference(ref autosaveToDocumentPath, value);
+    }
 }

+ 77 - 9
src/PixiEditor/ViewModels/ViewModelMain.cs

@@ -15,6 +15,7 @@ using PixiEditor.Models.Config;
 using PixiEditor.Models.Controllers;
 using PixiEditor.Models.Dialogs;
 using PixiEditor.Models.DocumentModels;
+using PixiEditor.Models.DocumentModels.Autosave;
 using PixiEditor.Models.Files;
 using PixiEditor.Models.Handlers;
 using PixiEditor.OperatingSystem;
@@ -60,12 +61,13 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
     public MenuBarViewModel MenuBarViewModel { get; set; }
     public AnimationsViewModel AnimationsSubViewModel { get; set; }
     public NodeGraphManagerViewModel NodeGraphManager { get; set; }
+    public AutosaveViewModel AutosaveViewModel { get; set; }
 
     public IPreferences Preferences { get; set; }
     public ILocalizationProvider LocalizationProvider { get; set; }
 
-    public ConfigManager Config { get; set; }    
-    
+    public ConfigManager Config { get; set; }
+
     public LocalizedString ActiveActionDisplay
     {
         get
@@ -87,6 +89,11 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
 
     public ActionDisplayList ActionDisplays { get; }
     public bool UserWantsToClose { get; private set; }
+    public Guid CurrentSessionId { get; } = Guid.NewGuid();
+    public DateTime LaunchDateTime { get; } = DateTime.Now;
+
+    public event Action<DocumentViewModel> BeforeDocumentClosed;
+    public event Action<LazyDocumentViewModel> LazyDocumentClosed;
 
     public ViewModelMain()
     {
@@ -98,11 +105,11 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
     {
         Services = services;
 
-        Config = new ConfigManager(); 
+        Config = new ConfigManager();
 
         Preferences = services.GetRequiredService<IPreferences>();
         Preferences.Init();
-        
+
         SupportedFilesHelper.InitFileTypes(services.GetServices<IoFileType>());
 
         CommandController = services.GetService<CommandController>();
@@ -153,14 +160,18 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
         ToolsSubViewModel?.SetupToolsTooltipShortcuts();
 
         SearchSubViewModel = services.GetService<SearchViewModel>();
-        
+
         AnimationsSubViewModel = services.GetService<AnimationsViewModel>();
-        
+
         NodeGraphManager = services.GetService<NodeGraphManagerViewModel>();
-        
+
+        AutosaveViewModel = services.GetService<AutosaveViewModel>();
+
         ExtensionsSubViewModel = services.GetService<ExtensionsViewModel>(); // Must be last
 
         DocumentManagerSubViewModel.ActiveDocumentChanged += OnActiveDocumentChanged;
+        BeforeDocumentClosed += OnBeforeDocumentClosed;
+        LazyDocumentClosed += OnLazyDocumentClosed;
     }
 
     public bool DocumentIsNotNull(object property)
@@ -176,6 +187,7 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
     [RelayCommand]
     public async Task CloseWindow()
     {
+        ResetNextSessionFiles();
         UserWantsToClose = await DisposeAllDocumentsWithSaveConfirmation();
 
         if (UserWantsToClose)
@@ -188,6 +200,11 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
         }
     }
 
+    public void ResetNextSessionFiles()
+    {
+        IPreferences.Current.UpdateLocalPreference(PreferencesConstants.NextSessionFiles, Array.Empty<SessionFile>());
+    }
+
     private void ToolsSubViewModel_SelectedToolChanged(object sender, SelectedToolEventArgs e)
     {
         if (e.OldTool != null)
@@ -227,9 +244,53 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
             }
         }
 
+        int lazyDocCount = DocumentManagerSubViewModel.LazyDocuments.Count;
+        for (int i = 0; i < lazyDocCount; i++)
+        {
+            var lazyDoc = DocumentManagerSubViewModel.LazyDocuments.First();
+            CloseLazyDocument(lazyDoc);
+            WindowSubViewModel.CloseViewportForLazyDocument(lazyDoc);
+        }
+
         return true;
     }
 
+    private void OnBeforeDocumentClosed(DocumentViewModel document)
+    {
+        if (!AutosaveViewModel.SaveSessionStateEnabled || DebugSubViewModel.ModifiedEditorData)
+            return;
+
+        document.AutosaveViewModel.AutosaveOnClose();
+
+        List<SessionFile> sessionFiles = IPreferences.Current
+            .GetLocalPreference<SessionFile[]>(PreferencesConstants.NextSessionFiles)?.ToList() ?? new();
+        sessionFiles.RemoveAll(x =>
+            (x.OriginalFilePath != null && x.OriginalFilePath == document.FullFilePath) ||
+            (x.AutosaveFilePath != null && x.AutosaveFilePath == document.AutosaveViewModel.LastAutosavedPath));
+        sessionFiles.Add(new SessionFile(document.FullFilePath, document.AutosaveViewModel.LastAutosavedPath));
+
+        IPreferences.Current.UpdateLocalPreference(PreferencesConstants.NextSessionFiles, sessionFiles.ToArray());
+    }
+
+    private void OnLazyDocumentClosed(LazyDocumentViewModel document)
+    {
+        List<SessionFile> sessionFiles = IPreferences.Current
+            .GetLocalPreference<SessionFile[]>(PreferencesConstants.NextSessionFiles)?.ToList() ?? new();
+        sessionFiles.RemoveAll(x =>
+            (x.OriginalFilePath != null && x.OriginalFilePath == document.OriginalPath) ||
+            (x.AutosaveFilePath != null && x.AutosaveFilePath == document.AutosavePath));
+
+        sessionFiles.Add(new SessionFile(document.OriginalPath, document.AutosavePath));
+
+        IPreferences.Current.UpdateLocalPreference(PreferencesConstants.NextSessionFiles, sessionFiles.ToArray());
+    }
+
+    internal void CloseLazyDocument(LazyDocumentViewModel document)
+    {
+        DocumentManagerSubViewModel.LazyDocuments.Remove(document);
+        LazyDocumentClosed?.Invoke(document);
+    }
+
     /// <summary>
     /// Disposes the active document after showing the unsaved changes confirmation dialog.
     /// </summary>
@@ -248,6 +309,7 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
 
 
         ConfirmationType result = ConfirmationType.No;
+        bool saved = false;
         if (!document.AllChangesSaved)
         {
             result = await ConfirmationDialog.Show(ConfirmationDialogMessage, ConfirmationDialogTitle);
@@ -255,27 +317,33 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
             {
                 if (!await FileSubViewModel.SaveDocument(document, false))
                     return false;
+
+                saved = true;
             }
         }
 
         if (result != ConfirmationType.Canceled)
         {
+            BeforeDocumentClosed?.Invoke(document);
             if (!DocumentManagerSubViewModel.Documents.Remove(document))
-                throw new InvalidOperationException("Trying to close a document that's not in the documents collection. Likely, the document wasn't added there after creation by mistake.");
+                throw new InvalidOperationException(
+                    "Trying to close a document that's not in the documents collection. Likely, the document wasn't added there after creation by mistake.");
 
             if (DocumentManagerSubViewModel.ActiveDocument == document)
             {
                 if (DocumentManagerSubViewModel.Documents.Count > 0)
                     WindowSubViewModel.MakeDocumentViewportActive(DocumentManagerSubViewModel.Documents.Last());
                 else
-                    WindowSubViewModel.MakeDocumentViewportActive(null);
+                    WindowSubViewModel.MakeDocumentViewportActive((DocumentViewModel)null);
             }
 
             WindowSubViewModel.CloseViewportsForDocument(document);
             document.Dispose();
+            document.AutosaveViewModel.OnDocumentClosed();
 
             return true;
         }
+
         return false;
     }
 

+ 1 - 1
src/PixiEditor/Views/Dialogs/ExportFileDialog.cs

@@ -24,7 +24,7 @@ internal class ExportFileDialog : CustomDialog
     
     private DocumentViewModel document;
     
-    public ExportConfig ExportConfig { get; set; } = new ExportConfig();
+    public ExportConfig ExportConfig { get; set; } = new ExportConfig(VecI.Zero);
 
     public ExportFileDialog(Window owner, VecI size, DocumentViewModel doc) : base(owner)
     {

+ 27 - 0
src/PixiEditor/Views/Dock/LazyDocumentTemplate.axaml

@@ -0,0 +1,27 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             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:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
+             xmlns:xaml="clr-namespace:PixiEditor.Models.Commands.XAML"
+             xmlns:viewModels1="clr-namespace:PixiEditor.ViewModels"
+             xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
+             xmlns:palettes1="clr-namespace:PixiEditor.Views.Palettes"
+             xmlns:viewportControls="clr-namespace:PixiEditor.Views.Main.ViewportControls"
+             xmlns:subViewModels="clr-namespace:PixiEditor.ViewModels.SubViewModels"
+             xmlns:document="clr-namespace:PixiEditor.ViewModels.Document"
+             xmlns:palettes="clr-namespace:PixiEditor.Extensions.CommonApi.Palettes;assembly=PixiEditor.Extensions.CommonApi"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:DataType="subViewModels:LazyViewportWindowViewModel"
+             x:Class="PixiEditor.Views.Dock.LazyDocumentTemplate">
+    <Design.DataContext>
+        <subViewModels:LazyViewportWindowViewModel />
+    </Design.DataContext>
+
+    <StackPanel Spacing="5" HorizontalAlignment="Center" VerticalAlignment="Center">
+        <TextBlock ui:Translator.Key="LOAD_LAZY_FILE_MESSAGE" />
+        <TextBlock Text="{Binding LazyDocument.Path}" />
+        <Button ui:Translator.Key="OPEN" Command="{xaml:Command PixiEditor.Document.LoadLazyDocument, UseProvided=True}"
+                CommandParameter="{Binding LazyDocument}"/>
+    </StackPanel>
+</UserControl>

+ 12 - 0
src/PixiEditor/Views/Dock/LazyDocumentTemplate.axaml.cs

@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+
+namespace PixiEditor.Views.Dock;
+
+public partial class LazyDocumentTemplate : UserControl
+{
+    public LazyDocumentTemplate()
+    {
+        InitializeComponent();
+    }
+}
+

+ 1 - 14
src/PixiEditor/Views/MainWindow.axaml.cs

@@ -100,20 +100,7 @@ internal partial class MainWindow : Window
 
         var i = 0;
 
-        foreach (var document in documents)
-        {
-            try
-            {
-                fileVM.OpenRecoveredDotPixi(document.Path, document.GetRecoveredBytes());
-                i++;
-            }
-            catch (Exception e)
-            {
-                CrashHelper.SendExceptionInfo(e);
-            }
-        }
-
-        showMissingFilesDialog = documents.Count != i;
+        fileVM.OpenFromReport(report, out showMissingFilesDialog);
 
         return window;
 

+ 1 - 1
src/PixiEditor/Views/Windows/BetaExampleButton.axaml.cs

@@ -97,7 +97,7 @@ public partial class BetaExampleButton : UserControl
         Application.Current.ForDesktopMainWindow(mainWindow => mainWindow.Activate());
         CloseCommand.Execute(null);
 
-        ViewModelMain.Current.FileSubViewModel.OpenRecoveredDotPixi(null, bytes);
+        ViewModelMain.Current.FileSubViewModel.OpenFromPixiBytes(bytes);
         ViewModelMain.Current.DocumentManagerSubViewModel.Documents[^1].Operations.UseSrgbProcessing();
         ViewModelMain.Current.DocumentManagerSubViewModel.Documents[^1].Operations.ClearUndo();
         Analytics.SendOpenExample(FileName);

+ 156 - 118
src/PixiEditor/Views/Windows/Settings/SettingsWindow.axaml

@@ -16,6 +16,7 @@
     xmlns:preferences="clr-namespace:PixiEditor.Extensions.CommonApi.UserPreferences.Settings.PixiEditor;assembly=PixiEditor.Extensions.CommonApi"
     xmlns:dialogs="clr-namespace:PixiEditor.Views.Dialogs"
     xmlns:settings="clr-namespace:PixiEditor.Views.Windows.Settings"
+    xmlns:localization="clr-namespace:PixiEditor.Extensions.Common.Localization;assembly=PixiEditor.Extensions"
     mc:Ignorable="d"
     x:Class="PixiEditor.Views.Windows.Settings.SettingsWindow"
     Name="window"
@@ -27,7 +28,7 @@
     ui:Translator.Key="SETTINGS">
 
     <Window.Resources>
-        <vm:SettingsWindowViewModel x:Key="SettingsWindowViewModel"/>
+        <vm:SettingsWindowViewModel x:Key="SettingsWindowViewModel" />
     </Window.Resources>
 
     <DockPanel>
@@ -39,7 +40,8 @@
                      SelectedIndex="{Binding CurrentPage}">
                 <ListBox.ItemTemplate>
                     <DataTemplate>
-                        <TextBlock Classes="h5" Foreground="{DynamicResource ThemeForegroundLowBrush}" Text="{Binding Path=Name.Value}" VerticalAlignment="Center">
+                        <TextBlock Classes="h5" Foreground="{DynamicResource ThemeForegroundLowBrush}"
+                                   Text="{Binding Path=Name.Value}" VerticalAlignment="Center">
                             <TextBlock.Styles>
                                 <Style Selector="ListBoxItem:selected TextBlock">
                                     <Setter Property="Foreground" Value="{DynamicResource ThemeForegroundBrush}" />
@@ -53,132 +55,167 @@
                 <Grid>
                     <Grid.Styles>
                         <Style Selector=":is(Control).leftOffset">
-                            <Setter Property="Margin" Value="20, 0, 0, 0"/>
+                            <Setter Property="Margin" Value="20, 0, 0, 0" />
                         </Style>
                     </Grid.Styles>
-                    <!--Background="{StaticResource AccentColor}"-->
-                    <controls:FixedSizeStackPanel Orientation="Vertical" ChildSize="32" VerticalChildrenAlignment="Center" Margin="12">
-                        <controls:FixedSizeStackPanel.IsVisible>
+                    <ScrollViewer>
+                        <ScrollViewer.IsVisible>
                             <Binding Path="CurrentPage" Converter="{converters:IsEqualConverter}">
                                 <Binding.ConverterParameter>
                                     <sys:Int32>0</sys:Int32>
                                 </Binding.ConverterParameter>
                             </Binding>
-                        </controls:FixedSizeStackPanel.IsVisible>
+                        </ScrollViewer.IsVisible>
+                        <!--Background="{StaticResource AccentColor}"-->
+                        <controls:FixedSizeStackPanel Orientation="Vertical" ChildSize="32"
+                                                      VerticalChildrenAlignment="Center" Margin="12">
 
-                        <TextBlock ui:Translator.Key="LANGUAGE" Classes="h5"/>
-                        <ComboBox Classes="leftOffset" Width="200" HorizontalAlignment="Left"
-                                  ItemsSource="{Binding SettingsSubViewModel.General.AvailableLanguages}"
-                                  SelectedItem="{Binding SettingsSubViewModel.General.SelectedLanguage, Mode=TwoWay}">
-                            <ComboBox.ItemTemplate>
-                                <DataTemplate>
-                                    <StackPanel Orientation="Horizontal" Height="20">
-                                        <Image
-                                            MaxHeight="20"
-                                            Margin="3, 0"
-                                            VerticalAlignment="Center"
-                                            Source="{Binding IconFullPath, Converter={converters:ImagePathToBitmapConverter}}" />
-                                        <TextBlock VerticalAlignment="Center" Text="{Binding Name}"/>
-                                    </StackPanel>
-                                </DataTemplate>
-                            </ComboBox.ItemTemplate>
-                        </ComboBox>
+                            <TextBlock ui:Translator.Key="LANGUAGE" Classes="h5" />
+                            <ComboBox Classes="leftOffset" Width="200" HorizontalAlignment="Left"
+                                      ItemsSource="{Binding SettingsSubViewModel.General.AvailableLanguages}"
+                                      SelectedItem="{Binding SettingsSubViewModel.General.SelectedLanguage, Mode=TwoWay}">
+                                <ComboBox.ItemTemplate>
+                                    <DataTemplate>
+                                        <StackPanel Orientation="Horizontal" Height="20">
+                                            <Image
+                                                MaxHeight="20"
+                                                Margin="3, 0"
+                                                VerticalAlignment="Center"
+                                                Source="{Binding IconFullPath, Converter={converters:ImagePathToBitmapConverter}}" />
+                                            <TextBlock VerticalAlignment="Center" Text="{Binding Name}" />
+                                        </StackPanel>
+                                    </DataTemplate>
+                                </ComboBox.ItemTemplate>
+                            </ComboBox>
 
-                        <TextBlock ui:Translator.Key="MISC" Classes="h5"/>
+                            <TextBlock ui:Translator.Key="MISC" Classes="h5" />
 
-                        <CheckBox Classes="leftOffset" ui:Translator.Key="SHOW_STARTUP_WINDOW"
-                                  IsChecked="{Binding SettingsSubViewModel.File.ShowStartupWindow}"/>
+                            <CheckBox Classes="leftOffset" ui:Translator.Key="SHOW_STARTUP_WINDOW"
+                                      IsChecked="{Binding SettingsSubViewModel.File.ShowStartupWindow}" />
 
-                        <CheckBox Classes="leftOffset" ui:Translator.Key="DISABLE_NEWS_PANEL"
-                                  IsChecked="{Binding SettingsSubViewModel.File.DisableNewsPanel}"/>
+                            <CheckBox Classes="leftOffset" ui:Translator.Key="DISABLE_NEWS_PANEL"
+                                      IsChecked="{Binding SettingsSubViewModel.File.DisableNewsPanel}" />
 
-                        <CheckBox Classes="leftOffset" ui:Translator.Key="SHOW_IMAGE_PREVIEW_TASKBAR"
-                                  IsChecked="{Binding SettingsSubViewModel.General.ImagePreviewInTaskbar}"/>
+                            <CheckBox Classes="leftOffset" ui:Translator.Key="SHOW_IMAGE_PREVIEW_TASKBAR"
+                                      IsChecked="{Binding SettingsSubViewModel.General.ImagePreviewInTaskbar}" />
 
-                        <StackPanel Classes="leftOffset" Orientation="Horizontal">
-                            <Label
-                                ui:Translator.Key="RECENT_FILE_LENGTH"
-                                ui:Translator.TooltipKey="RECENT_FILE_LENGTH_TOOLTIP"/>
-                            <input:NumberInput Min="0" FontSize="12" HorizontalAlignment="Left"
-                                           Value="{Binding SettingsSubViewModel.File.MaxOpenedRecently, Mode=TwoWay}" Width="40"/>
-                        </StackPanel>
+                            <StackPanel Classes="leftOffset" Orientation="Horizontal">
+                                <Label
+                                    ui:Translator.Key="RECENT_FILE_LENGTH"
+                                    ui:Translator.TooltipKey="RECENT_FILE_LENGTH_TOOLTIP" />
+                                <input:NumberInput Min="0" FontSize="12" HorizontalAlignment="Left"
+                                                   Value="{Binding SettingsSubViewModel.File.MaxOpenedRecently, Mode=TwoWay}"
+                                                   Width="40" />
+                            </StackPanel>
 
-                        <TextBlock
-                            Classes="h5"
-                            d:Content="Default new file size"
-                            ui:Translator.Key="DEFAULT_NEW_SIZE"/>
+                            <TextBlock Classes="h5" ui:Translator.Key="AUTOSAVE_SETTINGS_HEADER" />
 
-                        <StackPanel Orientation="Horizontal"  Classes="leftOffset">
-                            <Label d:Content="Width" ui:Translator.Key="WIDTH"/>
-                            <input:SizeInput
-                                         Size="{Binding SettingsSubViewModel.File.DefaultNewFileWidth, Mode=TwoWay}" MaxSize="9999" HorizontalAlignment="Left"/>
-                        </StackPanel>
+                            <CheckBox Classes="leftOffset"
+                                      VerticalAlignment="Center" ui:Translator.Key="AUTOSAVE_SETTINGS_SAVE_STATE"
+                                      IsChecked="{Binding SettingsSubViewModel.File.SaveSessionStateEnabled, Mode=TwoWay}" />
 
-                        <StackPanel Orientation="Horizontal" Classes="leftOffset">
-                            <Label d:Content="Height" ui:Translator.Key="HEIGHT"/>
-                            <input:SizeInput
-                                         Size="{Binding SettingsSubViewModel.File.DefaultNewFileHeight, Mode=TwoWay}" MaxSize="9999" HorizontalAlignment="Left"/>
-                        </StackPanel>
+                            <CheckBox Classes="leftOffset" ui:Translator.Key="AUTOSAVE_ENABLED"
+                                      IsChecked="{Binding SettingsSubViewModel.File.AutosaveEnabled, Mode=TwoWay}" />
 
-                        <TextBlock d:Content="Tools" ui:Translator.Key="TOOLS" Classes="h5" />
+                            <StackPanel Classes="leftOffset" Orientation="Horizontal">
+                                <Label ui:Translator.Key="AUTOSAVE_SETTINGS_PERIOD" />
+                                <input:NumberInput Min="0.1" FontSize="12" HorizontalAlignment="Left"
+                                                   IsEnabled="{Binding SettingsSubViewModel.File.AutosaveEnabled}"
+                                                   Value="{Binding SettingsSubViewModel.File.AutosavePeriodMinutes, Mode=TwoWay}"
+                                                   Width="55" />
+                                <Label ui:Translator.Key="MINUTE_UNIVERSAL" />
+                            </StackPanel>
 
-                        <StackPanel Orientation="Horizontal" Classes="leftOffset">
-                            <Label Target="rightClickModeComboBox" ui:Translator.Key="RIGHT_CLICK_MODE" VerticalAlignment="Center"/>
-                            <ComboBox SelectedItem="{Binding RightClickMode, Source={vm:MainVM ToolsSVM}, Mode=TwoWay}"
-                                      Name="rightClickModeComboBox"
-                                      ItemsSource="{markupExtensions:Enum preferences:RightClickMode}"
-                                      Width="160"
-                                      VerticalAlignment="Center">
-                                <ComboBox.ItemTemplate>
-                                    <DataTemplate>
-                                        <TextBlock ui:Translator.Key="{Binding Converter={converters:EnumToLocalizedStringConverter}}"/>
-                                    </DataTemplate>
-                                </ComboBox.ItemTemplate>
-                            </ComboBox>
-                            <!--Styles="{StaticResource TranslatedEnum}"-->
-                        </StackPanel>
+                            <CheckBox Classes="leftOffset"
+                                      VerticalAlignment="Center" ui:Translator.Key="AUTOSAVE_SETTINGS_SAVE_USER_FILE"
+                                      IsEnabled="{Binding SettingsSubViewModel.File.AutosaveEnabled}"
+                                      IsChecked="{Binding SettingsSubViewModel.File.AutosaveToDocumentPath}" />
 
-                        <CheckBox Classes="leftOffset"
-                                  IsChecked="{Binding SettingsSubViewModel.Tools.EnableSharedToolbar}"
-                                  ui:Translator.Key="ENABLE_SHARED_TOOLBAR"/>
+                            <TextBlock
+                                Classes="h5"
+                                ui:Translator.Key="DEFAULT_NEW_SIZE" />
 
-                        <TextBlock ui:Translator.Key="AUTOMATIC_UPDATES" Classes="h5"/>
+                            <StackPanel Orientation="Horizontal" Classes="leftOffset">
+                                <Label d:Content="Width" ui:Translator.Key="WIDTH" />
+                                <input:SizeInput
+                                    Size="{Binding SettingsSubViewModel.File.DefaultNewFileWidth, Mode=TwoWay}"
+                                    MaxSize="9999" HorizontalAlignment="Left" />
+                            </StackPanel>
 
-                        <CheckBox
-                            VerticalAlignment="Center"
-                            IsEnabled="{Binding Path=ShowUpdateTab}"
-                            IsChecked="{Binding SettingsSubViewModel.Update.CheckUpdatesOnStartup}"
-                            ui:Translator.Key="CHECK_FOR_UPDATES"
-                            Classes="leftOffset"/>
+                            <StackPanel Orientation="Horizontal" Classes="leftOffset">
+                                <Label d:Content="Height" ui:Translator.Key="HEIGHT" />
+                                <input:SizeInput
+                                    Size="{Binding SettingsSubViewModel.File.DefaultNewFileHeight, Mode=TwoWay}"
+                                    MaxSize="9999" HorizontalAlignment="Left" />
+                            </StackPanel>
 
-                        <StackPanel Orientation="Horizontal" Classes="leftOffset">
-                            <Label Target="updateStreamComboBox" ui:Translator.Key="UPDATE_STREAM" VerticalAlignment="Center"/>
-                            <StackPanel Orientation="Horizontal" VerticalAlignment="Center" HorizontalAlignment="Left">
-                                <ComboBox Width="110"
-                                          Name="updateStreamComboBox"
-                                          VerticalAlignment="Center"
-                                          IsEnabled="{Binding Path=ShowUpdateTab}"
-                                          ItemsSource="{Binding SettingsSubViewModel.Update.UpdateChannels}"
-                                          SelectedValue="{Binding SettingsSubViewModel.Update.UpdateChannelName}"/>
-                                <Image Cursor="Help" Source="/Images/Commands/PixiEditor/Links/OpenDocumentation.png"
-                                       VerticalAlignment="Center"
-                                       ToolTip.ShowDelay="0"
-                                       IsVisible="{Binding !ShowUpdateTab}"
-                                       ui:Translator.TooltipKey="UPDATE_CHANNEL_HELP_TOOLTIP"/>
-                                <!-- ToolTipService.InitialShowDelay="0"-->
+                            <TextBlock d:Content="Tools" ui:Translator.Key="TOOLS" Classes="h5" />
+
+                            <StackPanel Orientation="Horizontal" Classes="leftOffset">
+                                <Label Target="rightClickModeComboBox" ui:Translator.Key="RIGHT_CLICK_MODE"
+                                       VerticalAlignment="Center" />
+                                <ComboBox
+                                    SelectedItem="{Binding RightClickMode, Source={vm:MainVM ToolsSVM}, Mode=TwoWay}"
+                                    Name="rightClickModeComboBox"
+                                    ItemsSource="{markupExtensions:Enum preferences:RightClickMode}"
+                                    Width="160"
+                                    VerticalAlignment="Center">
+                                    <ComboBox.ItemTemplate>
+                                        <DataTemplate>
+                                            <TextBlock
+                                                ui:Translator.Key="{Binding Converter={converters:EnumToLocalizedStringConverter}}" />
+                                        </DataTemplate>
+                                    </ComboBox.ItemTemplate>
+                                </ComboBox>
+                                <!--Styles="{StaticResource TranslatedEnum}"-->
                             </StackPanel>
-                        </StackPanel>
 
-                        <TextBlock ui:Translator.Key="DEBUG" Classes="h5"/>
-                        <CheckBox Classes="leftOffset"
-                            IsChecked="{Binding SettingsSubViewModel.General.IsDebugModeEnabled}" ui:Translator.Key="ENABLE_DEBUG_MODE" d:Content="Enable Debug Mode"/>
-                        <!--<Label Classes="{StaticResource SettingsText}" VerticalAlignment="Center">
+                            <CheckBox Classes="leftOffset"
+                                      IsChecked="{Binding SettingsSubViewModel.Tools.EnableSharedToolbar}"
+                                      ui:Translator.Key="ENABLE_SHARED_TOOLBAR" />
+
+                            <TextBlock ui:Translator.Key="AUTOMATIC_UPDATES" Classes="h5" />
+
+                            <CheckBox
+                                VerticalAlignment="Center"
+                                IsEnabled="{Binding Path=ShowUpdateTab}"
+                                IsChecked="{Binding SettingsSubViewModel.Update.CheckUpdatesOnStartup}"
+                                ui:Translator.Key="CHECK_FOR_UPDATES"
+                                Classes="leftOffset" />
+
+                            <StackPanel Orientation="Horizontal" Classes="leftOffset">
+                                <Label Target="updateStreamComboBox" ui:Translator.Key="UPDATE_STREAM"
+                                       VerticalAlignment="Center" />
+                                <StackPanel Orientation="Horizontal" VerticalAlignment="Center"
+                                            HorizontalAlignment="Left">
+                                    <ComboBox Width="110"
+                                              Name="updateStreamComboBox"
+                                              VerticalAlignment="Center"
+                                              IsEnabled="{Binding Path=ShowUpdateTab}"
+                                              ItemsSource="{Binding SettingsSubViewModel.Update.UpdateChannels}"
+                                              SelectedValue="{Binding SettingsSubViewModel.Update.UpdateChannelName}" />
+                                    <Image Cursor="Help"
+                                           Source="/Images/Commands/PixiEditor/Links/OpenDocumentation.png"
+                                           VerticalAlignment="Center"
+                                           ToolTip.ShowDelay="0"
+                                           IsVisible="{Binding !ShowUpdateTab}"
+                                           ui:Translator.TooltipKey="UPDATE_CHANNEL_HELP_TOOLTIP" />
+                                    <!-- ToolTipService.InitialShowDelay="0"-->
+                                </StackPanel>
+                            </StackPanel>
+
+                            <TextBlock ui:Translator.Key="DEBUG" Classes="h5" />
+                            <CheckBox Classes="leftOffset"
+                                      IsChecked="{Binding SettingsSubViewModel.General.IsDebugModeEnabled}"
+                                      ui:Translator.Key="ENABLE_DEBUG_MODE" d:Content="Enable Debug Mode" />
+                            <!--<Label Classes="{StaticResource SettingsText}" VerticalAlignment="Center">
                             <ui1:Hyperlink Command="{cmds:Command PixiEditor.Debug.OpenCrashReportsDirectory}" Style="{StaticResource SettingsLink}">
                                 <Run ui:Translator.Key="OPEN_CRASH_REPORTS_DIR" d:Text="Open crash reports directory"/>
                                 <Run Text="" FontFamily="{StaticResource Feather}"/>
                             </ui1:Hyperlink>
                         </Label>-->
-                    </controls:FixedSizeStackPanel>
+                        </controls:FixedSizeStackPanel>
+                    </ScrollViewer>
 
                     <StackPanel Margin="12">
                         <StackPanel.IsVisible>
@@ -189,20 +226,21 @@
                             </Binding>
                         </StackPanel.IsVisible>
 
-                        <controls:FixedSizeStackPanel ChildSize="32" Orientation="Vertical" VerticalChildrenAlignment="Center">
-                            <TextBlock ui:Translator.Key="DISCORD_RICH_PRESENCE" Classes="h5"/>
+                        <controls:FixedSizeStackPanel ChildSize="32" Orientation="Vertical"
+                                                      VerticalChildrenAlignment="Center">
+                            <TextBlock ui:Translator.Key="DISCORD_RICH_PRESENCE" Classes="h5" />
 
                             <CheckBox IsChecked="{Binding SettingsSubViewModel.Discord.EnableRichPresence}"
-                                      ui:Translator.Key="ENABLED"/>
+                                      ui:Translator.Key="ENABLED" />
                             <CheckBox IsEnabled="{Binding SettingsSubViewModel.Discord.EnableRichPresence}"
                                       IsChecked="{Binding SettingsSubViewModel.Discord.ShowDocumentName}"
-                                      ui:Translator.Key="SHOW_IMAGE_NAME"/>
+                                      ui:Translator.Key="SHOW_IMAGE_NAME" />
                             <CheckBox IsEnabled="{Binding SettingsSubViewModel.Discord.EnableRichPresence}"
                                       IsChecked="{Binding SettingsSubViewModel.Discord.ShowDocumentSize}"
-                                      ui:Translator.Key="SHOW_IMAGE_SIZE"/>
+                                      ui:Translator.Key="SHOW_IMAGE_SIZE" />
                             <CheckBox IsEnabled="{Binding SettingsSubViewModel.Discord.EnableRichPresence}"
                                       IsChecked="{Binding SettingsSubViewModel.Discord.ShowLayerCount}"
-                                      ui:Translator.Key="SHOW_LAYER_COUNT" d:Content="Show layer count"/>
+                                      ui:Translator.Key="SHOW_LAYER_COUNT" d:Content="Show layer count" />
                         </controls:FixedSizeStackPanel>
 
                         <settings:DiscordRichPresencePreview
@@ -210,10 +248,11 @@
                             Width="280"
                             State="{Binding SettingsSubViewModel.Discord.StatePreview}"
                             Detail="{Binding SettingsSubViewModel.Discord.DetailPreview}"
-                            IsPlaying="{Binding SettingsSubViewModel.Discord.EnableRichPresence}"/>
+                            IsPlaying="{Binding SettingsSubViewModel.Discord.EnableRichPresence}" />
                     </StackPanel>
 
-                    <Grid Margin="12" Height="{Binding ElementName=window, Path=Height, Converter={converters:SubtractConverter}, ConverterParameter=50}">
+                    <Grid Margin="12"
+                          Height="{Binding ElementName=window, Path=Height, Converter={converters:SubtractConverter}, ConverterParameter=50}">
                         <Grid.IsVisible>
                             <Binding Path="CurrentPage" Converter="{converters:IsEqualConverter}">
                                 <Binding.ConverterParameter>
@@ -222,9 +261,9 @@
                             </Binding>
                         </Grid.IsVisible>
                         <Grid.RowDefinitions>
-                            <RowDefinition Height="Auto"/>
-                            <RowDefinition Height="Auto"/>
-                            <RowDefinition/>
+                            <RowDefinition Height="Auto" />
+                            <RowDefinition Height="Auto" />
+                            <RowDefinition />
                         </Grid.RowDefinitions>
                         <StackPanel HorizontalAlignment="Center" Orientation="Horizontal">
                             <!--<StackPanel.Resources>
@@ -237,28 +276,27 @@
                                 </Style>
                             </StackPanel.Resources>-->
                             <Button Command="{cmds:Command PixiEditor.Shortcuts.Export}"
-                                    d:Content="Export" ui:Translator.Key="EXPORT"/>
+                                    d:Content="Export" ui:Translator.Key="EXPORT" />
                             <Button Command="{cmds:Command PixiEditor.Shortcuts.Import}"
-                                    d:Content="Import" ui:Translator.Key="IMPORT"/>
+                                    d:Content="Import" ui:Translator.Key="IMPORT" />
                             <Button Command="{cmds:Command PixiEditor.Shortcuts.OpenTemplatePopup}"
-                                    d:Content="Shortcut Templates" ui:Translator.Key="SHORTCUT_TEMPLATES"/>
+                                    d:Content="Shortcut Templates" ui:Translator.Key="SHORTCUT_TEMPLATES" />
                             <Button Command="{cmds:Command PixiEditor.Shortcuts.Reset}"
-                                    d:Content="Reset all" ui:Translator.Key="RESET_ALL"/>
+                                    d:Content="Reset all" ui:Translator.Key="RESET_ALL" />
                         </StackPanel>
                         <TextBox Grid.Row="1"
                                  Text="{Binding SearchTerm, Mode=TwoWay}">
                             <!--Styles="{StaticResource DarkTextBoxStyle}"-->
                             <i:Interaction.Behaviors>
-                                <behaviours:GlobalShortcutFocusBehavior/>
+                                <behaviours:GlobalShortcutFocusBehavior />
                             </i:Interaction.Behaviors>
                         </TextBox>
 
-                        <settings:ShortcutsBinder Grid.Row="2"/>
+                        <settings:ShortcutsBinder Grid.Row="2" />
                     </Grid>
                 </Grid>
             </Border>
         </DockPanel>
     </DockPanel>
 
-</dialogs:PixiEditorPopup>
-
+</dialogs:PixiEditorPopup>