// MIT License - Copyright (c) 2026 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Editor.Utils { #if UNITY_EDITOR using System; using UnityEditor; using UnityEngine; /// /// A disposable scope that batches AssetDatabase operations for improved performance. /// Calls and /// on construction, and , , /// and optionally on disposal. /// /// /// /// Use this struct with a using statement to automatically batch asset operations: /// /// /// using (AssetDatabaseBatchHelper.BeginBatch()) /// { /// // Multiple asset operations batched together /// AssetDatabase.CreateAsset(obj, path); /// AssetDatabase.DeleteAsset(oldPath); /// } /// /// /// Nested scopes are supported. The actual AssetDatabase calls are only made at the outermost scope. /// /// public readonly struct AssetDatabaseBatchScope : IDisposable { /// /// Whether to call when this scope is disposed /// and is the scope that triggers cleanup. /// /// /// /// Out-of-order disposal: When scopes are disposed out of order /// (e.g., an inner scope is disposed after the outer scope), the cleanup (including /// the optional refresh) is performed by whichever scope's disposal brings the /// counter to zero. This means: /// /// /// /// If the inner scope (with refreshOnDispose=true) is disposed last, /// the refresh will still occur. /// /// /// If the outer scope (with refreshOnDispose=false) is disposed last /// and happens to bring the counter to zero, no refresh will occur even if /// the inner scope requested one. /// /// /// /// For correct behavior, always dispose scopes in the reverse order of creation /// (LIFO - Last In, First Out), which is automatic when using using statements. /// /// private readonly bool _shouldRefreshOnDispose; /// /// Whether this scope was the outermost scope when it was created. /// Used to detect out-of-order disposal. /// private readonly bool _isOutermostScope; /// /// Creates a new batch scope. Use instead of calling this directly. /// /// Whether to call when disposing. internal AssetDatabaseBatchScope(bool refreshOnDispose) { _shouldRefreshOnDispose = refreshOnDispose; _isOutermostScope = AssetDatabaseBatchHelper.IncrementBatchDepthWithUnityCall(); if (_isOutermostScope) { AssetDatabase.StartAssetEditing(); AssetDatabase.DisallowAutoRefresh(); } } /// /// Ends the batch scope. If this is the outermost scope, calls /// , , /// and optionally . /// /// /// /// This method is exception-safe: is guaranteed /// to be called even if throws an exception. /// This prevents Unity from being left in a stuck editing state. /// /// /// Any exceptions during cleanup are logged but not rethrown to ensure disposal completes. /// /// public void Dispose() { bool wasOutermost = AssetDatabaseBatchHelper.DecrementBatchDepthWithUnityCleanup(); if (wasOutermost != _isOutermostScope) { Debug.LogWarning( $"[{nameof(AssetDatabaseBatchScope)}] Scope disposal state mismatch: wasOutermost={wasOutermost}, _isOutermostScope={_isOutermostScope}. " + "This may indicate out-of-order disposal or manual counter manipulation." ); } // Perform cleanup when counter reaches 0 (wasOutermost is true). // Previously this required both wasOutermost AND _isOutermostScope to be true, // which caused Unity to be left in StartAssetEditing mode when scopes were // disposed out of order. The fix ensures cleanup happens whenever the counter // reaches 0, regardless of which scope is doing the disposing. if (wasOutermost) { bool allowAutoRefreshFailed = false; bool stopAssetEditingFailed = false; try { AssetDatabase.AllowAutoRefresh(); } catch (Exception allowAutoRefreshException) { allowAutoRefreshFailed = true; Debug.LogError( $"[{nameof(AssetDatabaseBatchScope)}] {nameof(AssetDatabase.AllowAutoRefresh)} threw during Dispose: {allowAutoRefreshException.Message}" ); } try { AssetDatabase.StopAssetEditing(); } catch (Exception stopAssetEditingException) { stopAssetEditingFailed = true; Debug.LogError( $"[{nameof(AssetDatabaseBatchScope)}] {nameof(AssetDatabase.StopAssetEditing)} threw during Dispose: {stopAssetEditingException.Message}" ); } if (allowAutoRefreshFailed || stopAssetEditingFailed) { Debug.LogWarning( $"[{nameof(AssetDatabaseBatchScope)}] Cleanup completed with errors. AllowAutoRefresh failed: {allowAutoRefreshFailed}, StopAssetEditing failed: {stopAssetEditingFailed}" ); } if (_shouldRefreshOnDispose) { try { AssetDatabase.Refresh(); } catch (Exception refreshException) { Debug.LogError( $"[{nameof(AssetDatabaseBatchScope)}] {nameof(AssetDatabase.Refresh)} threw during Dispose: {refreshException.Message}" ); } } } } } /// /// A disposable scope that temporarily pauses AssetDatabase batch operations. /// Calls and /// on construction (if batching was active), and resumes batching on disposal. /// /// /// /// Use this struct with a using statement to temporarily exit a batch scope: /// /// /// using (AssetDatabaseBatchHelper.BeginBatch()) /// { /// // ... batch operations ... /// using (AssetDatabaseBatchHelper.PauseBatch()) /// { /// // Operations here run outside of batch mode /// importer.SaveAndReimport(); /// } /// // ... more batch operations (batch mode resumed) ... /// } /// /// /// If no batch was active when was called, /// this struct does nothing on disposal. /// /// public struct AssetDatabasePauseScope : IDisposable { private readonly bool _wasBatching; private bool _disposed; /// /// Creates a new pause scope. Use instead of calling this directly. /// /// Whether a batch was active and has been paused. internal AssetDatabasePauseScope(bool wasBatching) { _wasBatching = wasBatching; _disposed = false; } /// /// Ends the pause scope. If a batch was paused, resumes batch mode by calling /// and . /// /// /// /// This method is exception-safe: any exceptions during batch resumption are logged but not rethrown. /// /// /// This method is idempotent: calling it multiple times has no effect after the first call. /// /// public void Dispose() { if (_disposed) { return; } _disposed = true; if (_wasBatching) { try { AssetDatabaseBatchHelper.ResumeBatch(); } catch (Exception resumeBatchException) { Debug.LogError( $"[{nameof(AssetDatabasePauseScope)}] {nameof(AssetDatabaseBatchHelper.ResumeBatch)} threw during Dispose: {resumeBatchException.Message}" ); } } } } /// /// Provides static helper methods for managing AssetDatabase batch operations. /// This is the single source of truth for all AssetDatabase batching in the codebase. /// public static class AssetDatabaseBatchHelper { private static readonly object Lock = new(); private static int _batchDepth; /// /// Tracks the number of actual Unity AssetDatabase API calls we've made. /// This can differ from _batchDepth if code manually calls IncrementBatchDepth. /// private static int _actualUnityBatchDepth; /// /// Gets a value indicating whether AssetDatabase operations are currently being batched. /// public static bool IsCurrentlyBatching { get { lock (Lock) { return _batchDepth > 0; } } } /// /// Gets the current nesting depth of batch scopes. /// public static int CurrentBatchDepth { get { lock (Lock) { return _batchDepth; } } } /// /// Gets the number of actual Unity AssetDatabase batch operations in progress. /// This tracks how many times StartAssetEditing/DisallowAutoRefresh were actually called. /// internal static int ActualUnityBatchDepth { get { lock (Lock) { return _actualUnityBatchDepth; } } } /// /// Begins a new AssetDatabase batch scope. All asset operations within the scope /// are batched together for improved performance. /// /// /// Whether to call when the scope is disposed. /// Defaults to true. /// /// A disposable scope that ends the batch when disposed. /// /// /// using (AssetDatabaseBatchHelper.BeginBatch()) /// { /// AssetDatabase.CreateAsset(obj1, path1); /// AssetDatabase.CreateAsset(obj2, path2); /// AssetDatabase.DeleteAsset(oldPath); /// } /// // Assets are now committed and database is refreshed /// /// public static AssetDatabaseBatchScope BeginBatch(bool refreshOnDispose = true) { return new AssetDatabaseBatchScope(refreshOnDispose); } /// /// Calls only if no batch scope is currently active. /// Use this when you need to ensure assets are refreshed but want to respect active batch scopes. /// /// /// This method is useful for code that may be called both inside and outside of batch scopes. /// When inside a batch scope, the refresh will be skipped (and handled by the scope disposal). /// When outside a batch scope, the refresh will be performed immediately. /// public static void RefreshIfNotBatching() { if (!IsCurrentlyBatching) { AssetDatabase.Refresh(); } } /// /// Calls only if no batch scope is currently active. /// Use this when you need to ensure assets are refreshed but want to respect active batch scopes. /// /// The import options to use if refresh is performed. /// /// This method is useful for code that may be called both inside and outside of batch scopes. /// When inside a batch scope, the refresh will be skipped (and handled by the scope disposal). /// When outside a batch scope, the refresh will be performed immediately. /// public static void RefreshIfNotBatching(ImportAssetOptions options) { if (!IsCurrentlyBatching) { AssetDatabase.Refresh(options); } } /// /// Calls followed by /// only if no batch scope is currently active. /// Use this for the common pattern of saving and refreshing assets together. /// /// /// /// This method combines the common SaveAssets() + Refresh() pattern into a single call /// that respects batch scopes. When inside a batch scope, both operations are skipped /// (and handled by the scope disposal). /// /// /// When outside a batch scope, this method calls /// followed by with . /// /// public static void SaveAndRefreshIfNotBatching() { if (!IsCurrentlyBatching) { AssetDatabase.SaveAssets(); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); } } /// /// Calls followed by /// only if no batch scope is currently active. /// Use this for the common pattern of saving and refreshing assets together. /// /// The import options to use if refresh is performed. /// /// /// This method combines the common SaveAssets() + Refresh() pattern into a single call /// that respects batch scopes. When inside a batch scope, both operations are skipped /// (and handled by the scope disposal). /// /// /// When outside a batch scope, this method calls /// followed by with the specified options. /// /// public static void SaveAndRefreshIfNotBatching(ImportAssetOptions options) { if (!IsCurrentlyBatching) { AssetDatabase.SaveAssets(); AssetDatabase.Refresh(options); } } /// /// Increments the batch depth counter and returns whether this is the outermost scope. /// This method ONLY increments the counter - it does NOT call Unity's AssetDatabase APIs. /// /// /// Warning: Using this method directly creates a mismatch between the /// tracked counter and Unity's actual state. Prefer using instead, /// which properly manages both the counter and Unity's state. /// This method exists primarily for testing the counter logic in isolation. /// /// true if this is the outermost (first) scope; otherwise, false. internal static bool IncrementBatchDepth() { lock (Lock) { int previousDepth = _batchDepth; _batchDepth++; return previousDepth == 0; } } /// /// Increments the batch depth counter and tracks that Unity APIs will be called. /// This is used internally by when it will /// call and . /// /// true if this is the outermost (first) scope; otherwise, false. internal static bool IncrementBatchDepthWithUnityCall() { lock (Lock) { int previousDepth = _batchDepth; _batchDepth++; bool isOutermost = previousDepth == 0; if (isOutermost) { _actualUnityBatchDepth++; } return isOutermost; } } /// /// Decrements the batch depth counter and returns whether this was the outermost scope. /// This method ONLY decrements the counter - use /// when Unity cleanup will be performed. /// /// true if this was the outermost scope (depth is now 0); otherwise, false. internal static bool DecrementBatchDepth() { lock (Lock) { _batchDepth--; if (_batchDepth < 0) { _batchDepth = 0; return false; } return _batchDepth == 0; } } /// /// Decrements both the batch depth counter and the Unity API call tracker. /// Returns whether this was the outermost scope (and thus Unity cleanup should be performed). /// /// true if this was the outermost scope (depth is now 0); otherwise, false. internal static bool DecrementBatchDepthWithUnityCleanup() { lock (Lock) { _batchDepth--; if (_batchDepth < 0) { _batchDepth = 0; return false; } if (_batchDepth == 0) { // Only decrement the Unity depth if we were tracking it if (_actualUnityBatchDepth > 0) { _actualUnityBatchDepth--; } return true; } return false; } } /// /// Resets the batch depth counter to zero and properly cleans up Unity's AssetDatabase state. /// This is the standard test cleanup method - use in SetUp/TearDown methods. /// /// /// /// When to use: Call this in test SetUp and TearDown methods to ensure /// clean state between tests. This is the preferred cleanup method for normal test scenarios. /// /// /// This method performs the following cleanup: /// /// /// Calls for each tracked batch level /// Calls for each tracked batch level /// Resets the internal counter to zero /// /// /// Important: This method only cleans up Unity state for the number of times /// that was called. If code manually incremented the batch depth /// counter without using , those "phantom" levels are NOT cleaned /// up because Unity's actual state doesn't need cleaning. /// /// /// This method is exception-safe: cleanup continues even if individual Unity API calls fail. /// /// internal static void ResetBatchDepth() { int depthToCleanup; lock (Lock) { int currentDepth = _batchDepth; int actualDepth = _actualUnityBatchDepth; _batchDepth = 0; _actualUnityBatchDepth = 0; depthToCleanup = currentDepth > 0 ? Math.Min(currentDepth, actualDepth) : 0; } int allowAutoRefreshFailures = 0; int stopAssetEditingFailures = 0; for (int i = 0; i < depthToCleanup; i++) { try { AssetDatabase.AllowAutoRefresh(); } catch (Exception allowAutoRefreshException) { allowAutoRefreshFailures++; Debug.LogError( $"[{nameof(AssetDatabaseBatchHelper)}] {nameof(AssetDatabase.AllowAutoRefresh)} threw during {nameof(ResetBatchDepth)} (iteration {i + 1}/{depthToCleanup}): {allowAutoRefreshException.Message}" ); } try { AssetDatabase.StopAssetEditing(); } catch (Exception stopAssetEditingException) { stopAssetEditingFailures++; Debug.LogError( $"[{nameof(AssetDatabaseBatchHelper)}] {nameof(AssetDatabase.StopAssetEditing)} threw during {nameof(ResetBatchDepth)} (iteration {i + 1}/{depthToCleanup}): {stopAssetEditingException.Message}" ); } } if (allowAutoRefreshFailures > 0 || stopAssetEditingFailures > 0) { Debug.LogWarning( $"[{nameof(AssetDatabaseBatchHelper)}] {nameof(ResetBatchDepth)} completed with {allowAutoRefreshFailures + stopAssetEditingFailures} errors out of {depthToCleanup * 2} calls. Unity AssetDatabase state may be inconsistent." ); } } /// /// Force-resets the AssetDatabase to a clean state by cleaning up all Unity API calls we've tracked. /// This method is equivalent to and exists for API compatibility. /// /// /// /// Important: This method only cleans up Unity's AssetDatabase state for /// the number of actual and /// calls that were made through this helper. /// /// /// It is NOT safe to call Unity's or /// more times than the corresponding start/disallow /// methods were called. Doing so causes Unity assertion failures and can leave the Editor /// in a broken state (hanging on "Hold on... Importing Assets"). /// /// /// If code has manually incremented the batch depth counter without using , /// those "phantom" levels are not cleaned up because Unity's actual state doesn't need cleaning. /// /// internal static void ForceResetAssetDatabase() { // This is now equivalent to ResetBatchDepth - there's no safe way to "force" reset // because we can't call Unity cleanup methods more times than we called start methods ResetBatchDepth(); } /// /// Resets only the internal counters without calling any Unity APIs. /// Use this at the start of a test fixture to clear any stale state from previous /// test runs or Editor sessions without risking Unity assertion failures. /// /// /// /// This is useful when Unity's internal AssetDatabase state may have been reset /// (e.g., by domain reload) but our static counters still hold values from /// previous sessions. Calling in this situation /// would cause Unity assertion failures because we'd be calling /// and /// when Unity's internal counters are already at zero. /// /// /// Warning: This method does NOT clean up Unity's AssetDatabase state. /// It only resets the internal tracking counters. If Unity's AssetDatabase is still /// in batch mode (e.g., was called but /// was not), the caller is responsible /// for ensuring Unity's state is properly cleaned up separately. Use this method only /// when you are certain Unity's state has already been reset (such as after a domain reload) /// or when you will handle Unity state cleanup through other means. /// /// /// If you need to clean up Unity state manually after calling this method, capture /// before calling , /// then call the cleanup APIs for that many levels: /// /// /// int unityDepth = AssetDatabaseBatchHelper.ActualUnityBatchDepth; /// AssetDatabaseBatchHelper.ResetCountersOnly(); /// for (int i = 0; i < unityDepth; i++) /// { /// AssetDatabase.AllowAutoRefresh(); /// AssetDatabase.StopAssetEditing(); /// } /// /// internal static void ResetCountersOnly() { lock (Lock) { _batchDepth = 0; _actualUnityBatchDepth = 0; } } /// /// Temporarily pauses the current batch scope to allow asset operations that require /// immediate processing (like or ). /// /// /// A disposable scope that resumes batch mode when disposed. /// If no batch was active, the returned scope does nothing on disposal. /// /// /// /// Use this method when you need to perform asset operations that require immediate processing /// while inside a batch scope. The typical pattern is: /// /// /// using (AssetDatabaseBatchHelper.BeginBatch()) /// { /// // ... batch operations ... /// using (AssetDatabaseBatchHelper.PauseBatch()) /// { /// // Operations here run outside of batch mode /// importer.SaveAndReimport(); /// } /// // ... more batch operations (batch mode resumed) ... /// } /// /// /// This method properly tracks the pause state so the counters remain in sync with Unity's state. /// /// public static AssetDatabasePauseScope PauseBatch() { bool wasBatching; lock (Lock) { wasBatching = _batchDepth > 0 && _actualUnityBatchDepth > 0; if (wasBatching) { _actualUnityBatchDepth--; } } if (wasBatching) { bool allowAutoRefreshFailed = false; try { AssetDatabase.AllowAutoRefresh(); } catch (Exception allowAutoRefreshException) { allowAutoRefreshFailed = true; Debug.LogError( $"[{nameof(AssetDatabaseBatchHelper)}] {nameof(AssetDatabase.AllowAutoRefresh)} threw during {nameof(PauseBatch)}: {allowAutoRefreshException.Message}" ); } try { AssetDatabase.StopAssetEditing(); } catch (Exception stopAssetEditingException) { Debug.LogError( $"[{nameof(AssetDatabaseBatchHelper)}] {nameof(AssetDatabase.StopAssetEditing)} threw during {nameof(PauseBatch)}: {stopAssetEditingException.Message}" ); if (allowAutoRefreshFailed) { Debug.LogWarning( $"[{nameof(AssetDatabaseBatchHelper)}] {nameof(PauseBatch)} completed with multiple errors. Unity AssetDatabase state may be inconsistent." ); } } } return new AssetDatabasePauseScope(wasBatching); } /// /// Resumes a previously paused batch scope. /// This is called automatically by . /// /// /// /// This method re-enters batch mode by calling /// and , and updates the internal counters accordingly. /// /// /// Important: Prefer using the using pattern with /// instead of calling this method directly, to ensure proper pairing. /// /// /// This method is exception-safe: if fails, /// is still attempted, and the counter is only /// incremented if at least one call succeeds (to maintain some level of state consistency). /// /// internal static void ResumeBatch() { bool startAssetEditingSucceeded = false; bool disallowAutoRefreshSucceeded = false; try { AssetDatabase.StartAssetEditing(); startAssetEditingSucceeded = true; } catch (Exception startAssetEditingException) { Debug.LogError( $"[{nameof(AssetDatabaseBatchHelper)}] {nameof(AssetDatabase.StartAssetEditing)} threw during {nameof(ResumeBatch)}: {startAssetEditingException.Message}" ); } try { AssetDatabase.DisallowAutoRefresh(); disallowAutoRefreshSucceeded = true; } catch (Exception disallowAutoRefreshException) { Debug.LogError( $"[{nameof(AssetDatabaseBatchHelper)}] {nameof(AssetDatabase.DisallowAutoRefresh)} threw during {nameof(ResumeBatch)}: {disallowAutoRefreshException.Message}" ); } if (!startAssetEditingSucceeded && !disallowAutoRefreshSucceeded) { Debug.LogWarning( $"[{nameof(AssetDatabaseBatchHelper)}] {nameof(ResumeBatch)} failed completely. Unity AssetDatabase state may be inconsistent." ); return; } lock (Lock) { _actualUnityBatchDepth++; } if (!startAssetEditingSucceeded || !disallowAutoRefreshSucceeded) { Debug.LogWarning( $"[{nameof(AssetDatabaseBatchHelper)}] {nameof(ResumeBatch)} completed with partial success. StartAssetEditing: {startAssetEditingSucceeded}, DisallowAutoRefresh: {disallowAutoRefreshSucceeded}" ); } } } #endif }