Editor-side autosave system for the Prism editor and its recovery UI. It tracks open PrismWindow instances, periodically serializes dirty PrismGraph sessions to per-document folders under the project autosave root, prunes old snapshots, lists and restores snapshots, and provides a recovery window UI with rows for each snapshot.
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Serialization;
using Editor.Prism.Ui;
using System.IO;
using System.Text;
namespace Editor.Prism.Integration;
/// <summary>One retained autosave generation of one document.</summary>
public sealed record PrismAutosaveEntry(
string File, string DocumentPath, string Title, DateTime TimestampUtc, long Size, bool IsSubgraph )
{
/// <summary>How long ago this snapshot was taken, in words.</summary>
public string Age
{
get
{
var span = DateTime.UtcNow - TimestampUtc;
if ( span.TotalSeconds < 90 ) return "just now";
if ( span.TotalMinutes < 90 ) return $"{(int)span.TotalMinutes} minutes ago";
if ( span.TotalHours < 36 ) return $"{(int)span.TotalHours} hours ago";
return $"{(int)span.TotalDays} days ago";
}
}
/// <summary>True when the original document is gone, so this snapshot is all that is left.</summary>
public bool OriginalMissing =>
string.IsNullOrWhiteSpace( DocumentPath ) || !System.IO.File.Exists( DocumentPath );
/// <summary>True when this snapshot is newer than the document it came from.</summary>
public bool NewerThanOriginal
{
get
{
if ( OriginalMissing ) return true;
try
{
return TimestampUtc > System.IO.File.GetLastWriteTimeUtc( DocumentPath ).AddSeconds( 2 );
}
catch ( Exception )
{
return true;
}
}
}
/// <inheritdoc/>
public override string ToString() => $"{Title} — {Age}";
}
/// <summary>
/// Periodic snapshots of unsaved work, and the recovery pass that offers them back after a crash.
/// <para>
/// Snapshots live under <c><project>/.sbox/prism/autosave/<document>/</c>, one folder per
/// document, newest last, capped by <see cref="PrismCookies.AutosaveRetained"/>. They are never
/// written over the document itself — restoring is always an explicit choice, and it keeps a
/// <c>.bak</c> of whatever it replaced.
/// </para>
/// <para>
/// Tracking is by <b>window</b>, not by session: the session object behind a window is swapped every
/// time a document is opened or an undo restores the graph, so the window is the stable handle and
/// its <c>Session</c> is re-read on every tick.
/// </para>
/// </summary>
public static class PrismAutosave
{
static readonly List<PrismWindow> s_windows = new();
static readonly Dictionary<string, DateTime> s_lastSnapshotUtc = new();
static DateTime s_nextSweepUtc = DateTime.MinValue;
/// <summary>Raised after a snapshot is written. The absolute path of the snapshot is passed.</summary>
public static event Action<string> Snapshotted;
/// <summary>Absolute path of the autosave root, or null when there is no current project.</summary>
public static string Root => PrismLog.Guard( "Resolving the Prism autosave root", () =>
{
var project = Project.Current?.GetRootPath();
if ( string.IsNullOrWhiteSpace( project ) ) return null;
return Path.Combine( project, PrismConstants.AutosaveRoot.Replace( '/', Path.DirectorySeparatorChar ) );
}, null );
// ---- tracking ----------------------------------------------------------
/// <summary>Start autosaving whatever document this window is showing. Idempotent.</summary>
public static void Track( PrismWindow window )
{
if ( window is null ) return;
PrismLog.Guard( "Tracking a Prism window for autosave", () =>
{
Prune();
foreach ( var existing in s_windows )
{
if ( ReferenceEquals( existing, window ) ) return;
}
s_windows.Add( window );
} );
}
/// <summary>Stop autosaving a window. Called when it closes.</summary>
public static void Untrack( PrismWindow window )
{
if ( window is null ) return;
PrismLog.Guard( "Untracking a Prism window", () => s_windows.RemoveAll( x => ReferenceEquals( x, window ) ) );
}
/// <summary>Windows currently being watched.</summary>
public static IReadOnlyList<PrismWindow> Tracked
{
get
{
Prune();
return s_windows;
}
}
/// <summary>Forget every tracked window and every timer. Used by teardown, not by hotload.</summary>
public static void Reset()
{
s_windows.Clear();
s_lastSnapshotUtc.Clear();
s_nextSweepUtc = DateTime.MinValue;
}
/// <summary>
/// What hotload calls. The window instances are migrated by the hotload system rather than
/// destroyed, so a window that was open before the reload is still open and still worth watching —
/// clearing the list here would silently stop autosaving it. Only the dead entries and the
/// throttle timers go.
/// </summary>
public static void Revalidate()
{
Prune();
s_lastSnapshotUtc.Clear();
s_nextSweepUtc = DateTime.MinValue;
}
/// <summary>Drop every <see cref="Snapshotted"/> handler — they point into the outgoing assembly.</summary>
public static void ClearSubscribers()
{
Snapshotted = null;
}
static void Prune()
{
s_windows.RemoveAll( x => x is null || !x.IsValid );
}
// ---- the timer ---------------------------------------------------------
/// <summary>
/// Runs once a frame and does nothing at all until the interval is up. Everything expensive —
/// serializing, writing, sweeping old generations — is behind the timestamp check.
/// </summary>
[EditorEvent.Frame]
static void Frame()
{
if ( s_windows.Count == 0 ) return;
var now = DateTime.UtcNow;
if ( now < s_nextSweepUtc ) return;
s_nextSweepUtc = now.AddSeconds( 5 );
if ( !PrismCookies.AutosaveEnabled ) return;
PrismLog.Guard( "Running the Prism autosave pass", () => Pass( now ) );
}
static void Pass( DateTime now )
{
Prune();
if ( s_windows.Count == 0 ) return;
var interval = TimeSpan.FromSeconds( PrismCookies.AutosaveIntervalSeconds );
var open = new List<string>();
var anyDirty = false;
foreach ( var window in s_windows )
{
var session = PrismLog.Guard( "Reading a Prism session", () => window.Session, null );
if ( session is null ) continue;
if ( !string.IsNullOrWhiteSpace( session.FilePath ) ) open.Add( session.FilePath );
if ( !session.IsDirty ) continue;
anyDirty = true;
var key = KeyOf( session );
if ( string.IsNullOrEmpty( key ) ) continue;
if ( s_lastSnapshotUtc.TryGetValue( key, out var last ) && now - last < interval ) continue;
s_lastSnapshotUtc[key] = now;
Snapshot( session );
}
PrismCookies.OpenDocuments = open;
// The marker is what tells the next startup that the editor went down with work in it.
if ( anyDirty && !PrismCookies.SessionMarker ) PrismCookies.SessionMarker = true;
}
// ---- writing -----------------------------------------------------------
/// <summary>Snapshot a session now, regardless of the timer. Returns the file written, or null.</summary>
public static string Snapshot( PrismSession session )
{
if ( session is null ) return null;
return Snapshot( session.Graph, session.FilePath );
}
/// <summary>
/// Snapshot a graph now. <paramref name="documentPath"/> may be null for a document that has never
/// been saved — it is only used to name the folder and to point recovery back at the original.
/// </summary>
public static string Snapshot( PrismGraph graph, string documentPath )
{
if ( graph is null ) return null;
return PrismLog.Guard( "Writing a Prism autosave", () =>
{
var folder = FolderFor( graph, documentPath );
if ( string.IsNullOrWhiteSpace( folder ) ) return null;
Directory.CreateDirectory( folder );
var text = PrismSerializer.Write( graph );
if ( string.IsNullOrEmpty( text ) ) return null;
var extension = graph.IsSubgraph ? PrismConstants.SubgraphExtension : PrismConstants.GraphExtension;
var file = Path.Combine( folder, $"{DateTime.UtcNow:yyyyMMdd-HHmmss-fff}.{extension}" );
File.WriteAllText( file, text, Encoding.UTF8 );
WriteManifest( folder, graph, documentPath );
Sweep( folder );
PrismLog.Trace( $"Autosaved to '{file}'" );
PrismLog.Guard( "Raising PrismAutosave.Snapshotted", () => Snapshotted?.Invoke( file ) );
return file;
}, null );
}
static void WriteManifest( string folder, PrismGraph graph, string documentPath )
{
var manifest = new JsonObject
{
["path"] = documentPath ?? string.Empty,
["title"] = graph.Meta?.Title ?? Path.GetFileNameWithoutExtension( documentPath ?? "untitled" ),
["documentId"] = graph.DocumentId ?? string.Empty,
["subgraph"] = graph.IsSubgraph,
["editorVersion"] = PrismConstants.EditorVersion
};
File.WriteAllText( Path.Combine( folder, "manifest.json" ), manifest.ToJsonString(), Encoding.UTF8 );
}
static void Sweep( string folder )
{
var keep = PrismCookies.AutosaveRetained;
var files = Directory.GetFiles( folder )
.Where( x => !x.EndsWith( "manifest.json", StringComparison.OrdinalIgnoreCase ) )
.OrderBy( x => x, StringComparer.Ordinal )
.ToList();
for ( var index = 0; index < files.Count - keep; index++ )
{
try
{
File.Delete( files[index] );
}
catch ( Exception e )
{
PrismLog.Trace( $"Could not remove the old autosave '{files[index]}': {e.Message}" );
}
}
}
// ---- reading -----------------------------------------------------------
/// <summary>Every document that has at least one retained snapshot, newest snapshot first.</summary>
public static IReadOnlyList<PrismAutosaveEntry> All()
{
return PrismLog.Guard( "Listing Prism autosaves", () =>
{
var root = Root;
var results = new List<PrismAutosaveEntry>();
if ( string.IsNullOrWhiteSpace( root ) || !Directory.Exists( root ) ) return (IReadOnlyList<PrismAutosaveEntry>)results;
foreach ( var folder in Directory.GetDirectories( root ) )
{
var newest = Newest( folder );
if ( newest is not null ) results.Add( newest );
}
return (IReadOnlyList<PrismAutosaveEntry>)results
.OrderByDescending( x => x.TimestampUtc )
.ToList();
}, Array.Empty<PrismAutosaveEntry>() );
}
/// <summary>Every retained snapshot of one document, newest first.</summary>
public static IReadOnlyList<PrismAutosaveEntry> History( string documentPath )
{
return PrismLog.Guard( "Listing the autosave history of a document", () =>
{
var folder = FolderFor( null, documentPath );
if ( string.IsNullOrWhiteSpace( folder ) || !Directory.Exists( folder ) )
{
return (IReadOnlyList<PrismAutosaveEntry>)Array.Empty<PrismAutosaveEntry>();
}
return (IReadOnlyList<PrismAutosaveEntry>)Entries( folder )
.OrderByDescending( x => x.TimestampUtc )
.ToList();
}, Array.Empty<PrismAutosaveEntry>() );
}
static PrismAutosaveEntry Newest( string folder )
{
return Entries( folder ).OrderByDescending( x => x.TimestampUtc ).FirstOrDefault();
}
static List<PrismAutosaveEntry> Entries( string folder )
{
var results = new List<PrismAutosaveEntry>();
string documentPath = null;
string title = Path.GetFileName( folder );
var subgraph = false;
var manifestPath = Path.Combine( folder, "manifest.json" );
if ( File.Exists( manifestPath ) )
{
PrismLog.Guard( "Reading an autosave manifest", () =>
{
var manifest = JsonNode.Parse( File.ReadAllText( manifestPath ) ) as JsonObject;
if ( manifest is null ) return;
documentPath = manifest["path"]?.GetValue<string>();
title = manifest["title"]?.GetValue<string>() ?? title;
subgraph = manifest["subgraph"]?.GetValue<bool>() ?? false;
} );
}
if ( string.IsNullOrWhiteSpace( documentPath ) ) documentPath = null;
foreach ( var file in Directory.GetFiles( folder ) )
{
if ( file.EndsWith( "manifest.json", StringComparison.OrdinalIgnoreCase ) ) continue;
var info = new FileInfo( file );
results.Add( new PrismAutosaveEntry( file, documentPath, title,
info.LastWriteTimeUtc, info.Length, subgraph ) );
}
return results;
}
// ---- restoring ---------------------------------------------------------
/// <summary>
/// Put a snapshot back over the document it came from, keeping a <c>.bak</c> of whatever was
/// there. Returns the path that now holds the recovered document.
/// </summary>
public static string Restore( PrismAutosaveEntry entry )
{
if ( entry is null || !File.Exists( entry.File ) ) return null;
return PrismLog.Guard( "Restoring a Prism autosave", () =>
{
var destination = entry.DocumentPath;
if ( string.IsNullOrWhiteSpace( destination ) ) return null;
var directory = Path.GetDirectoryName( destination );
if ( !string.IsNullOrWhiteSpace( directory ) ) Directory.CreateDirectory( directory );
if ( File.Exists( destination ) )
{
var backup = destination + ".bak";
try
{
File.Copy( destination, backup, true );
}
catch ( Exception e )
{
PrismLog.Warn( $"Could not back up '{destination}' before restoring: {e.Message}" );
}
}
File.Copy( entry.File, destination, true );
PrismLog.Guard( "Registering the restored document", () => AssetSystem.RegisterFile( destination ) );
PrismLog.Info( $"Restored '{Path.GetFileName( destination )}' from the autosave taken {entry.Age}" );
return destination;
}, null );
}
/// <summary>Delete every snapshot of one document.</summary>
public static void Discard( PrismAutosaveEntry entry )
{
if ( entry is null ) return;
PrismLog.Guard( "Discarding a Prism autosave", () =>
{
var folder = Path.GetDirectoryName( entry.File );
if ( string.IsNullOrWhiteSpace( folder ) || !Directory.Exists( folder ) ) return;
Directory.Delete( folder, true );
} );
}
/// <summary>Delete every snapshot of every document.</summary>
public static void DiscardAll()
{
PrismLog.Guard( "Discarding every Prism autosave", () =>
{
var root = Root;
if ( string.IsNullOrWhiteSpace( root ) || !Directory.Exists( root ) ) return;
Directory.Delete( root, true );
} );
PrismCookies.SessionMarker = false;
}
// ---- crash recovery ----------------------------------------------------
/// <summary>
/// Startup pass. Only offers anything when the previous session left the dirty marker set, so a
/// clean shutdown never nags — the snapshots are still there for <c>Prism ▸ Recover Autosave</c>.
/// </summary>
public static void ScanForRecovery()
{
PrismLog.Guard( "Scanning for recoverable Prism documents", () =>
{
if ( !PrismCookies.SessionMarker ) return;
PrismCookies.SessionMarker = false;
var recoverable = All().Where( x => x.NewerThanOriginal ).ToList();
if ( recoverable.Count == 0 ) return;
PrismLog.Warn( $"Prism found {recoverable.Count} document(s) with unsaved changes from the previous session" );
ShowRecovery( recoverable, true );
} );
}
/// <summary>Open the recovery window. Pass false to skip it when there is nothing to show.</summary>
public static void ShowRecovery( bool force )
{
var entries = All().ToList();
if ( entries.Count == 0 && !force ) return;
ShowRecovery( entries, false );
}
/// <summary>Open the recovery window filtered to one document's history.</summary>
public static void ShowRecovery( string documentPath )
{
ShowRecovery( History( documentPath ).ToList(), false );
}
static void ShowRecovery( List<PrismAutosaveEntry> entries, bool afterCrash )
{
PrismLog.Guard( "Showing the Prism recovery window", () =>
{
var window = new PrismRecoveryWindow( entries, afterCrash );
window.Show();
} );
}
// ---- shutdown ----------------------------------------------------------
/// <summary>
/// Take a final snapshot of anything still dirty and decide whether the next startup should offer
/// recovery. Exiting with unsaved work leaves the marker set on purpose.
/// </summary>
[Event( "app.exit" )]
static void OnExit()
{
PrismLog.Guard( "Finishing the Prism autosave on exit", () =>
{
Prune();
var dirty = false;
foreach ( var window in s_windows )
{
var session = PrismLog.Guard( "Reading a Prism session on exit", () => window.Session, null );
if ( session is null || !session.IsDirty ) continue;
dirty = true;
Snapshot( session );
}
PrismCookies.SessionMarker = dirty;
} );
}
// ---- paths -------------------------------------------------------------
static string KeyOf( PrismSession session )
{
if ( session is null ) return null;
if ( !string.IsNullOrWhiteSpace( session.FilePath ) ) return session.FilePath.ToLowerInvariant();
return "untitled:" + ( session.Graph?.DocumentId ?? "0" );
}
/// <summary>
/// The folder that holds one document's snapshots: a readable name plus a hash of the full path,
/// so two <c>base.prism</c> files in different folders never share a history.
/// </summary>
static string FolderFor( PrismGraph graph, string documentPath )
{
var root = Root;
if ( string.IsNullOrWhiteSpace( root ) ) return null;
string name;
string discriminator;
if ( string.IsNullOrWhiteSpace( documentPath ) )
{
name = "untitled";
discriminator = graph?.DocumentId ?? "unknown";
}
else
{
name = Path.GetFileNameWithoutExtension( documentPath );
discriminator = ShortHash( documentPath.ToLowerInvariant() );
}
return Path.Combine( root, $"{Sanitize( name )}-{Sanitize( discriminator )}" );
}
static string Sanitize( string value )
{
if ( string.IsNullOrWhiteSpace( value ) ) return "untitled";
var builder = new StringBuilder( value.Length );
foreach ( var character in value )
{
builder.Append( char.IsLetterOrDigit( character ) || character is '-' or '_' ? character : '_' );
}
return builder.ToString();
}
static string ShortHash( string value )
{
unchecked
{
var hash = 2166136261u;
foreach ( var character in value )
{
hash = ( hash ^ character ) * 16777619u;
}
return hash.ToString( "x8" );
}
}
}
/// <summary>
/// The window that offers autosaved work back.
/// <para>
/// It opens by itself exactly once, on the first startup after the editor went down with unsaved
/// changes. Every other time it is reached deliberately from <c>Prism ▸ Recover Autosave</c> or from
/// a document's context menu, where it doubles as a snapshot history browser.
/// </para>
/// </summary>
public sealed class PrismRecoveryWindow : BaseWindow
{
readonly List<PrismAutosaveEntry> _entries;
readonly bool _afterCrash;
Widget _list;
/// <summary>Build the window over a fixed set of snapshots.</summary>
public PrismRecoveryWindow( List<PrismAutosaveEntry> entries, bool afterCrash )
{
_entries = entries ?? new List<PrismAutosaveEntry>();
_afterCrash = afterCrash;
WindowTitle = afterCrash ? "Prism — Recover Unsaved Work" : "Prism Autosaves";
SetWindowIcon( afterCrash ? "restore_page" : "history" );
Size = new Vector2( 760f, 520f );
MinimumSize = new Vector2( 560f, 320f );
Layout = Layout.Column();
Layout.Margin = 20f;
Layout.Spacing = 12f;
BuildHeader();
BuildList();
BuildFooter();
}
void BuildHeader()
{
var title = Layout.Add( new Label.Title( _afterCrash
? "Prism closed with unsaved changes"
: "Autosave history" ) );
title.Color = PrismTheme.TextPrimary;
var body = Layout.Add( new Label.Body( _afterCrash
? "These documents have a snapshot newer than the file on disk. Restoring writes the snapshot "
+ "over the document and keeps a .bak of what was there."
: "Snapshots are taken while a document has unsaved changes, and the newest few are kept per "
+ "document. Restoring keeps a .bak of the file it replaces." ) );
body.Color = PrismTheme.TextSecondary;
body.WordWrap = true;
}
void BuildList()
{
var scroll = new ScrollArea( this );
scroll.Canvas = new Widget( scroll );
scroll.Canvas.Layout = Layout.Column();
scroll.Canvas.Layout.Spacing = 6f;
_list = scroll.Canvas;
Layout.Add( scroll, 1 );
Populate();
}
void Populate()
{
if ( _list is null ) return;
_list.Layout.Clear( true );
if ( _entries.Count == 0 )
{
var empty = _list.Layout.Add( new Label.Body(
"Nothing has been autosaved yet. Prism snapshots a document only while it has unsaved changes." ) );
empty.Color = PrismTheme.TextMuted;
empty.WordWrap = true;
_list.Layout.AddStretchCell();
return;
}
foreach ( var entry in _entries )
{
_list.Layout.Add( new PrismRecoveryRow( _list, entry, Refresh ) );
}
_list.Layout.AddStretchCell();
}
void Refresh()
{
_entries.RemoveAll( x => x is null || !System.IO.File.Exists( x.File ) );
Populate();
}
void BuildFooter()
{
var row = Layout.AddRow();
row.Spacing = 8f;
var discard = row.Add( new Button.Clear( "Discard All Autosaves", "delete_sweep", this ) );
discard.Clicked = () =>
{
PrismAutosave.DiscardAll();
_entries.Clear();
Populate();
};
row.AddStretchCell();
var close = row.Add( new Button.Primary( "Done", "check", this ) );
close.Clicked = Close;
}
}
/// <summary>One row of <see cref="PrismRecoveryWindow"/>: a snapshot and what can be done with it.</summary>
internal sealed class PrismRecoveryRow : Widget
{
readonly PrismAutosaveEntry _entry;
readonly Action _changed;
/// <summary>Build a row for one snapshot.</summary>
public PrismRecoveryRow( Widget parent, PrismAutosaveEntry entry, Action changed ) : base( parent )
{
_entry = entry;
_changed = changed;
MinimumSize = new Vector2( 0f, 62f );
Layout = Layout.Row();
Layout.Margin = 12f;
Layout.Spacing = 10f;
var text = Layout.AddColumn( 1 );
text.Spacing = 2f;
var title = text.Add( new Label( entry.Title ?? "Untitled", this ) );
title.Color = PrismTheme.TextPrimary;
var detail = text.Add( new Label( Detail(), this ) );
detail.Color = entry.OriginalMissing ? PrismTheme.Warning : PrismTheme.TextMuted;
Layout.AddStretchCell();
if ( !entry.OriginalMissing )
{
var restore = Layout.Add( new Button( "Restore", "restore_page", this ) );
restore.Clicked = () =>
{
var restored = PrismAutosave.Restore( _entry );
if ( !string.IsNullOrWhiteSpace( restored ) ) PrismAssetEditor.Open( restored );
_changed?.Invoke();
};
}
var open = Layout.Add( new Button( "Open Copy", "open_in_new", this ) );
open.Clicked = () => PrismLauncher.OpenDocument( _entry.File );
open.StatusTip = "Open the snapshot itself, leaving the document on disk untouched";
var discard = Layout.Add( new Button.Clear( "", "delete", this ) );
discard.Clicked = () =>
{
PrismAutosave.Discard( _entry );
_changed?.Invoke();
};
discard.StatusTip = "Delete every snapshot of this document";
}
string Detail()
{
if ( _entry.OriginalMissing )
{
return $"{_entry.Age} · the original document is gone — open the copy and save it somewhere";
}
return $"{_entry.Age} · {_entry.DocumentPath}";
}
/// <summary>Card background, so rows read as separate items rather than a wall of text.</summary>
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.ClearPen();
Paint.SetBrush( _entry.NewerThanOriginal ? PrismTheme.Elevated : PrismTheme.PanelAlt );
Paint.DrawRect( LocalRect, PrismTheme.RadiusPanel );
if ( !_entry.NewerThanOriginal ) return;
Paint.ClearPen();
Paint.SetBrush( PrismTheme.Accent );
Paint.DrawRect( new Rect( LocalRect.Left, LocalRect.Top, PrismTheme.AccentBarWidth, LocalRect.Height ),
PrismTheme.AccentBarWidth * 0.5f );
}
}