Editor/Prism/Integration/PrismCookies.cs

Static helper that centralises Prism editor and per-project persistent settings (cookies). It defines typed properties for theme, UI and editor preferences, recent/open documents and session state, caches reads, writes through cookie containers, and raises a Changed event when settings change.

File Access
using Editor.Prism.Core;
using Editor.Prism.Ui;

namespace Editor.Prism.Integration;

/// <summary>
/// Every persisted Prism setting, in one place and strongly typed.
/// <para>
/// Two stores are in play and the split is deliberate. <c>EditorCookie</c> is machine-global
/// (<c>&lt;sbox&gt;/config/tools.json</c>) and holds preferences that describe the *person* — theme,
/// wire style, whether Prism claims shader files. <c>ProjectCookie</c> is per project
/// (<c>&lt;project&gt;/.sbox/project.json</c>) and holds facts about the *work* — recent documents,
/// the last folder a dialog landed in, the unclean-shutdown marker.
/// </para>
/// <para>
/// Everything here degrades to its default rather than throwing: a cookie container is always
/// present (it falls back to an in-memory one), but the value inside may be anything a previous
/// version wrote, so every read is guarded.
/// </para>
/// </summary>
public static class PrismCookies
{
	// ---- keys ------------------------------------------------------------

	const string KeyTheme = "prism.theme";
	const string KeyAutosaveEnabled = "prism.autosave.enabled";
	const string KeyAutosaveInterval = "prism.autosave.intervalSeconds";
	const string KeyAutosaveRetained = "prism.autosave.retained";
	const string KeyCompileOnSave = "prism.compileOnSave";
	const string KeyValidateSlang = "prism.validateSlang";
	const string KeyRouteCodeFiles = "prism.routeCodeFiles";
	const string KeyFallbackCodeEditor = "prism.fallbackCodeEditor";
	const string KeyWelcomeShown = "prism.welcomeShown";
	const string KeyVerboseLogging = "prism.verboseLogging";
	const string KeyRecentFiles = "prism.recentFiles";
	const string KeyLastDirectory = "prism.lastDirectory";
	const string KeyOpenDocuments = "prism.session.openDocuments";
	const string KeySessionMarker = "prism.session.dirty";

	/// <summary>How many documents the recent list keeps.</summary>
	public const int RecentLimit = 12;

	/// <summary>
	/// Raised after any setting changes, so open windows and the preferences page stay in step.
	/// Handlers must be cheap and must never throw.
	/// </summary>
	public static event Action Changed;

	// ---- appearance ------------------------------------------------------

	/// <summary>
	/// Colour palette used by the graph, panels and code editor. <c>"Prism"</c> is the built-in dark
	/// palette; anything else is resolved against <c>.sbox/prism/theme.json</c> and falls back to the
	/// built-in when missing.
	/// </summary>
	[Title( "Theme" )]
	public static string Theme
	{
		get => GetString( KeyTheme, "Prism" );
		set => SetString( KeyTheme, string.IsNullOrWhiteSpace( value ) ? "Prism" : value );
	}

	/// <summary>
	/// How connections are routed across the canvas. Curved wires read faster; orthogonal wires make
	/// dense graphs easier to follow.
	/// </summary>
	[Title( "Wire Style" )]
	public static PrismWireStyle WireStyle
	{
		get
		{
			var raw = GetString( PrismConstants.CookieWireStyle, null );

			return Enum.TryParse<PrismWireStyle>( raw, true, out var style ) ? style : PrismWireStyle.Bezier;
		}

		set => SetString( PrismConstants.CookieWireStyle, value.ToString() );
	}

	/// <summary>
	/// Render a live thumbnail on node cards. Costs one small render per visible node.
	/// </summary>
	[Title( "Node Previews" )]
	public static bool NodePreviews
	{
		get => Get( PrismConstants.CookieNodePreviews, true );
		set => Set( PrismConstants.CookieNodePreviews, value );
	}

	// ---- editing ---------------------------------------------------------

	/// <summary>
	/// Periodically snapshot unsaved documents so a crash cannot lose work.
	/// </summary>
	[Title( "Autosave" )]
	public static bool AutosaveEnabled
	{
		get => Get( KeyAutosaveEnabled, true );
		set => Set( KeyAutosaveEnabled, value );
	}

	/// <summary>
	/// Seconds between snapshots while a document has unsaved changes. Clamped to 15..1800.
	/// </summary>
	[Title( "Autosave Interval" )]
	[Range( 15, 600 )]
	public static int AutosaveIntervalSeconds
	{
		get => Math.Clamp( Get( KeyAutosaveInterval, PrismConstants.AutosaveIntervalSeconds ), 15, 1800 );
		set => Set( KeyAutosaveInterval, Math.Clamp( value, 15, 1800 ) );
	}

	/// <summary>
	/// Snapshot generations kept per document before the oldest is discarded. Clamped to 1..50.
	/// </summary>
	[Title( "Autosave History" )]
	[Range( 1, 30 )]
	public static int AutosaveRetained
	{
		get => Math.Clamp( Get( KeyAutosaveRetained, PrismConstants.AutosaveRetained ), 1, 50 );
		set => Set( KeyAutosaveRetained, Math.Clamp( value, 1, 50 ) );
	}

	/// <summary>
	/// Run the engine shader compiler after every save.
	/// </summary>
	[Title( "Compile On Save" )]
	public static bool CompileOnSave
	{
		get => Get( KeyCompileOnSave, true );
		set => Set( KeyCompileOnSave, value );
	}

	/// <summary>
	/// Cross-check generated code with slangc. Informational only — it never blocks rendering.
	/// </summary>
	[Title( "Validate With Slang" )]
	public static bool ValidateWithSlang
	{
		get => Get( KeyValidateSlang, false );
		set => Set( KeyValidateSlang, value );
	}

	// ---- integration -----------------------------------------------------

	/// <summary>
	/// Double-clicking a <c>.shader</c> opens it in Prism's code editor. The engine also notifies any
	/// external editor — that cannot be cancelled. On by default: answering <c>open.shader</c> is the
	/// only way to make double-click land in Prism, because that path never reaches <c>IAssetEditor</c>.
	/// </summary>
	[Title( "Open Shader Files In Prism" )]
	public static bool ClaimShaderFiles
	{
		get => Get( PrismConstants.CookieClaimShaderFiles, true );
		set => Set( PrismConstants.CookieClaimShaderFiles, value );
	}

	/// <summary>
	/// Route shader sources to Prism editor-wide. C#, Razor and SCSS are handed straight to the
	/// previously selected editor, so this never takes over your IDE.
	/// </summary>
	[Title( "Use Prism As The Code Editor" )]
	public static bool RouteCodeFiles
	{
		get => Get( KeyRouteCodeFiles, false );
		set => Set( KeyRouteCodeFiles, value );
	}

	/// <summary>
	/// Type name of the editor Prism delegates non-shader files to. Empty means "pick the best
	/// installed one".
	/// </summary>
	public static string FallbackCodeEditor
	{
		get => GetString( KeyFallbackCodeEditor, null );
		set => SetString( KeyFallbackCodeEditor, value );
	}

	/// <summary>Mirrors <c>PrismLog.Verbose</c> across sessions.</summary>
	[Title( "Verbose Logging" ), Description( "Log every compile stage and toolchain probe to the console." )]
	public static bool VerboseLogging
	{
		get => Get( KeyVerboseLogging, false );
		set
		{
			Set( KeyVerboseLogging, value );
			PrismLog.Verbose = value;
		}
	}

	/// <summary>Whether the "What is Prism" panel has already been shown once.</summary>
	public static bool WelcomeShown
	{
		get => Get( KeyWelcomeShown, false );
		set => Set( KeyWelcomeShown, value );
	}

	// ---- per-project state -----------------------------------------------

	/// <summary>Most recently opened documents, newest first. Absolute paths.</summary>
	public static IReadOnlyList<string> RecentFiles
	{
		get => PrismLog.Guard( "Reading the recent document list",
			() => (IReadOnlyList<string>)(ProjectCookie?.Get( KeyRecentFiles, Array.Empty<string>() ) ?? Array.Empty<string>()),
			Array.Empty<string>() );
	}

	/// <summary>Move a document to the front of the recent list, trimming to <see cref="RecentLimit"/>.</summary>
	public static void PushRecent( string absolutePath )
	{
		if ( string.IsNullOrWhiteSpace( absolutePath ) ) return;

		PrismLog.Guard( "Updating the recent document list", () =>
		{
			var list = new List<string>();
			list.Add( absolutePath );

			foreach ( var existing in RecentFiles )
			{
				if ( string.IsNullOrWhiteSpace( existing ) ) continue;
				if ( string.Equals( existing, absolutePath, StringComparison.OrdinalIgnoreCase ) ) continue;
				if ( list.Count >= RecentLimit ) break;

				list.Add( existing );
			}

			ProjectCookie?.Set( KeyRecentFiles, list.ToArray() );
		} );

		Notify();
	}

	/// <summary>Drop a document from the recent list — used when a file turns out to be gone.</summary>
	public static void ForgetRecent( string absolutePath )
	{
		if ( string.IsNullOrWhiteSpace( absolutePath ) ) return;

		PrismLog.Guard( "Pruning the recent document list", () =>
		{
			var list = RecentFiles
				.Where( x => !string.IsNullOrWhiteSpace( x ) )
				.Where( x => !string.Equals( x, absolutePath, StringComparison.OrdinalIgnoreCase ) )
				.ToArray();

			ProjectCookie?.Set( KeyRecentFiles, list );
		} );

		Notify();
	}

	/// <summary>Empty the recent list.</summary>
	public static void ClearRecent()
	{
		PrismLog.Guard( "Clearing the recent document list",
			() => ProjectCookie?.Set( KeyRecentFiles, Array.Empty<string>() ) );

		Notify();
	}

	/// <summary>Directory the last open/save dialog finished in, so the next one starts there.</summary>
	public static string LastDirectory
	{
		get => PrismLog.Guard( "Reading the last Prism directory",
			() => ProjectCookie?.GetString( KeyLastDirectory, null ), null );

		set => PrismLog.Guard( "Writing the last Prism directory",
			() => ProjectCookie?.SetString( KeyLastDirectory, value ?? string.Empty ) );
	}

	/// <summary>
	/// Absolute paths of the documents that were open when the editor was last running. Written by
	/// the autosave service and read by crash recovery.
	/// </summary>
	public static IReadOnlyList<string> OpenDocuments
	{
		get => PrismLog.Guard( "Reading the open document list",
			() => (IReadOnlyList<string>)(ProjectCookie?.Get( KeyOpenDocuments, Array.Empty<string>() ) ?? Array.Empty<string>()),
			Array.Empty<string>() );

		set => PrismLog.Guard( "Writing the open document list",
			() => ProjectCookie?.Set( KeyOpenDocuments, value?.ToArray() ?? Array.Empty<string>() ) );
	}

	/// <summary>
	/// True between "a document became dirty" and "the editor exited cleanly". If it is still true on
	/// startup the previous session did not shut down, so recovery offers the autosaves.
	/// </summary>
	public static bool SessionMarker
	{
		get => PrismLog.Guard( "Reading the Prism session marker",
			() => ProjectCookie?.Get( KeySessionMarker, false ) ?? false, false );

		set => PrismLog.Guard( "Writing the Prism session marker",
			() => ProjectCookie?.Set( KeySessionMarker, value ) );
	}

	// ---- plumbing --------------------------------------------------------

	/// <summary>Raise <see cref="Changed"/> without letting a handler take the caller down.</summary>
	public static void Notify()
	{
		s_cache.Clear();

		PrismLog.Guard( "Raising PrismCookies.Changed", () => Changed?.Invoke() );
	}

	/// <summary>
	/// Drop the memoised values. Only needed when something outside this class writes one of our keys —
	/// <c>SlangToolchain</c> owns its own, and a hotload resets the field anyway.
	/// </summary>
	public static void FlushCache()
	{
		s_cache.Clear();
	}

	/// <summary>
	/// Drop every <see cref="Changed"/> handler. A static event outlives the assembly that subscribed
	/// to it, so after a hotload the invocation list is full of methods on types that no longer exist.
	/// </summary>
	public static void ClearSubscribers()
	{
		Changed = null;
	}

	/// <summary>Restore every Prism setting to its shipped default. Per-project state is left alone.</summary>
	public static void ResetToDefaults()
	{
		PrismLog.Guard( "Resetting Prism preferences", () =>
		{
			foreach ( var key in new[]
			{
				KeyTheme, PrismConstants.CookieWireStyle, PrismConstants.CookieNodePreviews,
				KeyAutosaveEnabled, KeyAutosaveInterval, KeyAutosaveRetained,
				KeyCompileOnSave, KeyValidateSlang,
				PrismConstants.CookieClaimShaderFiles, KeyRouteCodeFiles, KeyFallbackCodeEditor,
				KeyVerboseLogging
			} )
			{
				EditorCookie?.Remove( key );
			}
		} );

		PrismLog.Verbose = false;
		Notify();
	}

	/// <summary>
	/// Memoised reads. <c>CookieContainer.Get&lt;T&gt;</c> runs a JSON deserialize on every call, and
	/// several of these are read from the per-frame tick, so the values are cached until something
	/// writes one. Every write goes through <see cref="Notify"/>, which drops the cache.
	/// </summary>
	static readonly Dictionary<string, object> s_cache = new();

	static T Get<T>( string key, T fallback )
	{
		if ( s_cache.TryGetValue( key, out var cached ) && cached is T typed ) return typed;

		var value = PrismLog.Guard( $"Reading the cookie '{key}'",
			() => EditorCookie is null ? fallback : EditorCookie.Get( key, fallback ), fallback );

		s_cache[key] = value;

		return value;
	}

	static void Set<T>( string key, T value )
	{
		PrismLog.Guard( $"Writing the cookie '{key}'", () => EditorCookie?.Set( key, value ) );
		Notify();
	}

	static string GetString( string key, string fallback )
	{
		if ( s_cache.TryGetValue( key, out var cached ) ) return cached as string;

		var value = PrismLog.Guard( $"Reading the cookie '{key}'",
			() =>
			{
				if ( EditorCookie is null ) return fallback;

				var stored = EditorCookie.GetString( key, fallback );

				return string.IsNullOrEmpty( stored ) ? fallback : stored;
			}, fallback );

		s_cache[key] = value;

		return value;
	}

	static void SetString( string key, string value )
	{
		PrismLog.Guard( $"Writing the cookie '{key}'", () => EditorCookie?.SetString( key, value ?? string.Empty ) );
		Notify();
	}
}