// 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
}