Editor/SuiPreviewCacheWriter.cs
using System;
using System.Collections.Generic;
using System.IO;
using Sandbox;
using SboxUiDesigner.Generation;
using SboxUiDesigner.Runtime;
namespace SboxUiDesigner.EditorUi;
/// <summary>
/// Writes generated Razor + SCSS for a .sui document into the preview cache
/// inside the consumer project's compilable code root, so the editor's hotload
/// pipeline picks them up and turns them into a real PanelComponent type.
///
/// Cache layout (per M1 inspection report):
/// <project root>/Code/_sui_preview/<ClassName>/<ClassName>.razor
/// <project root>/Code/_sui_preview/<ClassName>/<ClassName>.razor.scss
///
/// We write atomically (write-to-temp + replace) and skip the write entirely if
/// the file's sha256 matches what's already on disk — avoids a hotload churn for
/// noop regens (e.g. selection-only events that still fire DocumentChanged).
/// </summary>
public static class SuiPreviewCacheWriter
{
public sealed class WriteResult
{
public bool Ok { get; set; }
public string TypeFullName { get; set; } // <namespace>.<ClassName>
public string CacheFolder { get; set; } // absolute on-disk folder
public string RazorPath { get; set; } // absolute
public string ScssPath { get; set; } // absolute (may be null if no SCSS)
public bool RazorChanged { get; set; } // true iff content differed and we wrote
public bool ScssChanged { get; set; }
public List<string> Errors { get; } = new();
public List<string> Warnings { get; } = new();
}
/// <summary>
/// Generate + write to the preview cache for the given document. Returns
/// metadata including the fully-qualified type name the editor's compiler
/// will produce after hotload — caller polls TypeLibrary for it.
/// </summary>
public static WriteResult Write( SuiDocument document )
{
var result = new WriteResult();
if ( document == null )
{
result.Errors.Add( "document is null" );
return result;
}
var projectRoot = Sandbox.Project.Current?.RootDirectory?.FullName;
if ( string.IsNullOrEmpty( projectRoot ) )
{
result.Errors.Add( "Project.Current.RootDirectory is null — cannot resolve project root" );
return result;
}
// Drive the generator in Preview mode. ClassName/Namespace defaults come
// from the document's own Output settings; falls back to sanitized Name.
var ctx = new SuiGenerationContext
{
Document = document,
Mode = SuiGenerationMode.Preview,
OutputFolder = "", // intentionally blank — pipeline returns plain filenames
};
var gen = SuiGenerationPipeline.Run( ctx );
foreach ( var w in gen.Warnings ) result.Warnings.Add( w );
foreach ( var e in gen.Errors ) result.Errors.Add( e );
if ( !gen.Ok )
return result;
var className = ctx.ClassName;
var ns = ctx.Namespace;
if ( string.IsNullOrEmpty( className ) )
{
result.Errors.Add( "generator did not resolve a ClassName" );
return result;
}
var cacheFolder = Path.Combine( projectRoot, "Code", "_sui_preview", className );
try
{
Directory.CreateDirectory( cacheFolder );
}
catch ( Exception ex )
{
result.Errors.Add( $"failed to create cache folder '{cacheFolder}': {ex.Message}" );
return result;
}
// Razor
var razor = gen.FindByKind( SuiGeneratedFileKind.Razor );
if ( razor != null )
{
var path = Path.Combine( cacheFolder, $"{className}.razor" );
result.RazorPath = path;
try
{
result.RazorChanged = WriteIfChanged( path, razor.Content );
}
catch ( Exception ex )
{
result.Errors.Add( $"razor write failed: {ex.Message}" );
return result;
}
}
// SCSS
var scss = gen.FindByKind( SuiGeneratedFileKind.Scss );
if ( scss != null )
{
var path = Path.Combine( cacheFolder, $"{className}.razor.scss" );
result.ScssPath = path;
try
{
result.ScssChanged = WriteIfChanged( path, scss.Content );
}
catch ( Exception ex )
{
result.Errors.Add( $"scss write failed: {ex.Message}" );
return result;
}
}
result.CacheFolder = cacheFolder;
result.TypeFullName = string.IsNullOrEmpty( ns ) ? className : $"{ns}.{className}";
result.Ok = true;
return result;
}
/// <summary>
/// Compares the hash of the new content to whatever is already on disk; only
/// writes (atomically via .tmp + Replace) when the content actually changed.
/// Returns true iff a write happened.
/// </summary>
private static bool WriteIfChanged( string path, string content )
{
var newHash = SuiHashUtility.Sha256( content ?? "" );
if ( File.Exists( path ) )
{
try
{
var existing = File.ReadAllText( path );
if ( SuiHashUtility.Sha256( existing ) == newHash )
return false; // no-op
}
catch
{
// If we can't read, fall through and overwrite.
}
}
var tmp = path + ".tmp";
File.WriteAllText( tmp, content ?? "" );
try
{
if ( File.Exists( path ) )
File.Replace( tmp, path, null );
else
File.Move( tmp, path );
}
catch ( Exception )
{
// Replace can fail across volumes / on permission quirks. Fall back
// to plain overwrite so we don't leave the .tmp dangling.
if ( File.Exists( tmp ) )
{
File.Copy( tmp, path, overwrite: true );
try { File.Delete( tmp ); } catch { }
}
}
return true;
}
}