diff --git a/Assets/Tests/Editor/ExternalAssetFileStateComparerTests.cs b/Assets/Tests/Editor/ExternalAssetFileStateComparerTests.cs
new file mode 100644
index 0000000000..2bf2e507aa
--- /dev/null
+++ b/Assets/Tests/Editor/ExternalAssetFileStateComparerTests.cs
@@ -0,0 +1,68 @@
+using System;
+using NUnit.Framework;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Tests the shared external asset file-state identity contract.
+ ///
+ public sealed class ExternalAssetFileStateComparerTests
+ {
+ private static readonly DateTime SavedTime =
+ new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ ///
+ /// Verifies file states match when every fingerprint field is equal.
+ ///
+ [Test]
+ public void HasSameFileState_WhenAllFieldsMatch_ReturnsTrue()
+ {
+ bool hasSameFileState = ExternalAssetFileStateComparer.HasSameFileState(
+ (true, SavedTime, 10),
+ (true, SavedTime, 10));
+
+ Assert.That(hasSameFileState, Is.True);
+ }
+
+ ///
+ /// Verifies file states differ when only file existence changes.
+ ///
+ [Test]
+ public void HasSameFileState_WhenExistenceDiffers_ReturnsFalse()
+ {
+ bool hasSameFileState = ExternalAssetFileStateComparer.HasSameFileState(
+ (true, SavedTime, 10),
+ (false, SavedTime, 10));
+
+ Assert.That(hasSameFileState, Is.False);
+ }
+
+ ///
+ /// Verifies file states differ when only the last write time changes.
+ ///
+ [Test]
+ public void HasSameFileState_WhenLastWriteTimeDiffers_ReturnsFalse()
+ {
+ bool hasSameFileState = ExternalAssetFileStateComparer.HasSameFileState(
+ (true, SavedTime, 10),
+ (true, SavedTime.AddMinutes(1), 10));
+
+ Assert.That(hasSameFileState, Is.False);
+ }
+
+ ///
+ /// Verifies file states differ when only the file length changes.
+ ///
+ [Test]
+ public void HasSameFileState_WhenLengthDiffers_ReturnsFalse()
+ {
+ bool hasSameFileState = ExternalAssetFileStateComparer.HasSameFileState(
+ (true, SavedTime, 10),
+ (true, SavedTime, 20));
+
+ Assert.That(hasSameFileState, Is.False);
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/ExternalAssetFileStateComparerTests.cs.meta b/Assets/Tests/Editor/ExternalAssetFileStateComparerTests.cs.meta
new file mode 100644
index 0000000000..ca2d277f4a
--- /dev/null
+++ b/Assets/Tests/Editor/ExternalAssetFileStateComparerTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 21f4bf99a1df4e2687fe927ce29d4127
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFileStateComparer.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFileStateComparer.cs
new file mode 100644
index 0000000000..478a3550cf
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFileStateComparer.cs
@@ -0,0 +1,19 @@
+using System;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// Compares external asset fingerprints using the shared file-state identity contract.
+ ///
+ internal static class ExternalAssetFileStateComparer
+ {
+ internal static bool HasSameFileState(
+ (bool Exists, DateTime LastWriteTimeUtc, long Length) previousFingerprint,
+ (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint)
+ {
+ return previousFingerprint.Exists == currentFingerprint.Exists &&
+ previousFingerprint.LastWriteTimeUtc == currentFingerprint.LastWriteTimeUtc &&
+ previousFingerprint.Length == currentFingerprint.Length;
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFileStateComparer.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFileStateComparer.cs.meta
new file mode 100644
index 0000000000..d190cb15b9
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFileStateComparer.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 59ae9a0c097c437aa682a7f82d7bfaae
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFocusReturnService.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFocusReturnService.cs
new file mode 100644
index 0000000000..94d283709d
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFocusReturnService.cs
@@ -0,0 +1,98 @@
+using System;
+using UnityEngine;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// Coordinates Auto Refresh suspension while Unity is unfocused.
+ ///
+ internal sealed class ExternalAssetFocusReturnService
+ {
+ private readonly Func _getAutoRefreshHeld;
+ private readonly Action _setAutoRefreshHeld;
+ private readonly Func _isEditorFocused;
+ private readonly Action _disallowAutoRefresh;
+ private readonly Action _allowAutoRefresh;
+ private readonly Action _resolveFocusReturnChanges;
+
+ internal ExternalAssetFocusReturnService(
+ Func getAutoRefreshHeld,
+ Action setAutoRefreshHeld,
+ Func isEditorFocused,
+ Action disallowAutoRefresh,
+ Action allowAutoRefresh,
+ Action resolveFocusReturnChanges)
+ {
+ Debug.Assert(getAutoRefreshHeld != null, "getAutoRefreshHeld must not be null");
+ Debug.Assert(setAutoRefreshHeld != null, "setAutoRefreshHeld must not be null");
+ Debug.Assert(isEditorFocused != null, "isEditorFocused must not be null");
+ Debug.Assert(disallowAutoRefresh != null, "disallowAutoRefresh must not be null");
+ Debug.Assert(allowAutoRefresh != null, "allowAutoRefresh must not be null");
+ Debug.Assert(resolveFocusReturnChanges != null, "resolveFocusReturnChanges must not be null");
+
+ _getAutoRefreshHeld = getAutoRefreshHeld ?? throw new ArgumentNullException(nameof(getAutoRefreshHeld));
+ _setAutoRefreshHeld = setAutoRefreshHeld ?? throw new ArgumentNullException(nameof(setAutoRefreshHeld));
+ _isEditorFocused = isEditorFocused ?? throw new ArgumentNullException(nameof(isEditorFocused));
+ _disallowAutoRefresh = disallowAutoRefresh ?? throw new ArgumentNullException(nameof(disallowAutoRefresh));
+ _allowAutoRefresh = allowAutoRefresh ?? throw new ArgumentNullException(nameof(allowAutoRefresh));
+ _resolveFocusReturnChanges =
+ resolveFocusReturnChanges ?? throw new ArgumentNullException(nameof(resolveFocusReturnChanges));
+ }
+
+ internal bool RestoreAutoRefreshIfHeld()
+ {
+ if (!_getAutoRefreshHeld())
+ {
+ return false;
+ }
+
+ if (!_isEditorFocused())
+ {
+ return false;
+ }
+
+ HandleFocusChanged(true);
+ return true;
+ }
+
+ internal void HandleFocusChanged(bool isFocused)
+ {
+ if (!isFocused)
+ {
+ HoldAutoRefreshIfNeeded();
+ return;
+ }
+
+ try
+ {
+ _resolveFocusReturnChanges();
+ }
+ finally
+ {
+ ReleaseAutoRefreshIfHeld();
+ }
+ }
+
+ private void HoldAutoRefreshIfNeeded()
+ {
+ if (_getAutoRefreshHeld())
+ {
+ return;
+ }
+
+ _disallowAutoRefresh();
+ _setAutoRefreshHeld(true);
+ }
+
+ private void ReleaseAutoRefreshIfHeld()
+ {
+ if (!_getAutoRefreshHeld())
+ {
+ return;
+ }
+
+ _allowAutoRefresh();
+ _setAutoRefreshHeld(false);
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFocusReturnService.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFocusReturnService.cs.meta
new file mode 100644
index 0000000000..b3719acbce
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFocusReturnService.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0e756216d6984843a9c45be220a9c78f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetSnapshotSessionStore.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetSnapshotSessionStore.cs
new file mode 100644
index 0000000000..3543ded449
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetSnapshotSessionStore.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// Serializes tracked asset fingerprints so focus-return recovery survives editor domain reloads.
+ ///
+ internal static class ExternalAssetSnapshotSessionStore
+ {
+ internal static string SerializeSnapshots(
+ Dictionary snapshots)
+ {
+ Debug.Assert(snapshots != null, "snapshots must not be null");
+
+ AssetSnapshotSessionData data = new AssetSnapshotSessionData();
+ data.Entries = new AssetSnapshotEntry[snapshots.Count];
+ int index = 0;
+ foreach (KeyValuePair snapshot in snapshots)
+ {
+ data.Entries[index] = new AssetSnapshotEntry
+ {
+ AssetPath = snapshot.Key,
+ Exists = snapshot.Value.Exists,
+ LastWriteTimeUtcTicks = snapshot.Value.LastWriteTimeUtc.Ticks,
+ Length = snapshot.Value.Length
+ };
+ index++;
+ }
+
+ return JsonUtility.ToJson(data);
+ }
+
+ internal static void RestoreSnapshots(
+ Dictionary snapshots,
+ string json)
+ {
+ Debug.Assert(snapshots != null, "snapshots must not be null");
+
+ snapshots.Clear();
+ if (string.IsNullOrEmpty(json))
+ {
+ return;
+ }
+
+ AssetSnapshotSessionData data = JsonUtility.FromJson(json);
+ if (data == null || data.Entries == null)
+ {
+ return;
+ }
+
+ for (int i = 0; i < data.Entries.Length; i++)
+ {
+ AssetSnapshotEntry entry = data.Entries[i];
+ if (entry == null || string.IsNullOrEmpty(entry.AssetPath))
+ {
+ continue;
+ }
+
+ snapshots[entry.AssetPath] = (
+ entry.Exists,
+ new DateTime(entry.LastWriteTimeUtcTicks, DateTimeKind.Utc),
+ entry.Length);
+ }
+ }
+
+ [Serializable]
+ private sealed class AssetSnapshotSessionData
+ {
+ public AssetSnapshotEntry[] Entries = new AssetSnapshotEntry[0];
+ }
+
+ [Serializable]
+ private sealed class AssetSnapshotEntry
+ {
+ public string AssetPath;
+ public bool Exists;
+ public long LastWriteTimeUtcTicks;
+ public long Length;
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetSnapshotSessionStore.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetSnapshotSessionStore.cs.meta
new file mode 100644
index 0000000000..f913917e84
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetSnapshotSessionStore.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b9c8a83b2ad24cb7800665ae367e6e14
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs
index bcb8179204..bd21cece5f 100644
--- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs
+++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs
@@ -1,770 +1,9 @@
using System;
using System.Collections.Generic;
-using System.IO;
-using UnityEditor;
-using UnityEditor.SceneManagement;
using UnityEngine;
-using UnityEngine.SceneManagement;
-
-using io.github.hatayama.UnityCliLoop.ToolContracts;
namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
- ///
- /// Tracks open Scene file snapshots so compile can resolve external disk changes before asset refresh.
- ///
- internal static class ExternalSceneChangeTracker
- {
- private const string AutoRefreshHeldSessionStateKey =
- "io.github.hatayama.UnityCliLoop.ExternalSceneChangeTracker.AutoRefreshHeld";
- private const string SceneSnapshotsSessionStateKey =
- "io.github.hatayama.UnityCliLoop.ExternalSceneChangeTracker.SceneSnapshots";
- private const string PrefabStageSnapshotsSessionStateKey =
- "io.github.hatayama.UnityCliLoop.ExternalSceneChangeTracker.PrefabStageSnapshots";
- private static readonly Dictionary SceneSnapshots =
- new Dictionary(StringComparer.Ordinal);
- private static readonly Dictionary PrefabStageSnapshots =
- new Dictionary(StringComparer.Ordinal);
- private static readonly ExternalAssetFocusReturnService FocusReturnService =
- new ExternalAssetFocusReturnService(
- IsAutoRefreshHeld,
- SetAutoRefreshHeld,
- () => EditorApplication.isFocused,
- AssetDatabase.DisallowAutoRefresh,
- AssetDatabase.AllowAutoRefresh,
- ResolveForFocusReturn);
- private static bool _initialized;
-
- public static void Initialize()
- {
- if (AssetDatabase.IsAssetImportWorkerProcess())
- {
- return;
- }
-
- if (_initialized)
- {
- return;
- }
-
- RestoreSnapshotsFromSessionState();
- bool restoredHeldAutoRefresh = FocusReturnService.RestoreAutoRefreshIfHeld();
-
- _initialized = true;
- EditorSceneManager.sceneOpened -= HandleSceneOpened;
- EditorSceneManager.sceneOpened += HandleSceneOpened;
- EditorSceneManager.sceneSaved -= HandleSceneSaved;
- EditorSceneManager.sceneSaved += HandleSceneSaved;
- EditorSceneManager.sceneClosed -= HandleSceneClosed;
- EditorSceneManager.sceneClosed += HandleSceneClosed;
- PrefabStage.prefabStageOpened -= HandlePrefabStageOpened;
- PrefabStage.prefabStageOpened += HandlePrefabStageOpened;
- PrefabStage.prefabStageClosing -= HandlePrefabStageClosing;
- PrefabStage.prefabStageClosing += HandlePrefabStageClosing;
- PrefabStage.prefabSaved -= HandlePrefabSaved;
- PrefabStage.prefabSaved += HandlePrefabSaved;
- EditorApplication.focusChanged -= HandleFocusChanged;
- EditorApplication.focusChanged += HandleFocusChanged;
- if (!restoredHeldAutoRefresh && !IsAutoRefreshHeld())
- {
- RecordOpenSceneSnapshots();
- RecordCurrentPrefabStageSnapshot();
- }
- }
-
- public static (bool CanProceed, string Message, string[] ScenePaths) ResolveForCompile(
- bool reloadExternalSceneChanges)
- {
- Initialize();
- ExternalSceneChangeResolver resolver = CreateSceneChangeResolver();
- (bool CanProceed, string Message, string[] ScenePaths) result =
- resolver.ResolveExternalSceneChanges(reloadExternalSceneChanges);
- SaveSceneSnapshotsToSessionState();
- return result;
- }
-
- private static void HandleFocusChanged(bool isFocused)
- {
- FocusReturnService.HandleFocusChanged(isFocused);
- }
-
- private static void HandleSceneOpened(Scene scene, OpenSceneMode mode)
- {
- RecordSceneSnapshot(scene);
- }
-
- private static void HandleSceneSaved(Scene scene)
- {
- RecordSceneSnapshot(scene);
- }
-
- private static void HandleSceneClosed(Scene scene)
- {
- if (!IsTrackableScene(scene))
- {
- return;
- }
-
- SceneSnapshots.Remove(NormalizeAssetPath(scene.path));
- SaveSceneSnapshotsToSessionState();
- }
-
- private static void HandlePrefabStageOpened(PrefabStage prefabStage)
- {
- RecordPrefabStageSnapshot(prefabStage);
- }
-
- private static void HandlePrefabStageClosing(PrefabStage prefabStage)
- {
- if (!IsTrackablePrefabStage(prefabStage))
- {
- return;
- }
-
- PrefabStageSnapshots.Remove(NormalizeAssetPath(prefabStage.assetPath));
- SavePrefabStageSnapshotsToSessionState();
- }
-
- private static void HandlePrefabSaved(GameObject prefabRoot)
- {
- RecordCurrentPrefabStageSnapshot();
- }
-
- private static void ResolveForFocusReturn()
- {
- // Focus return treats Unity's in-memory editor state as authoritative because source-control
- // operations can replace files while Unity is unfocused and would otherwise trigger reload dialogs.
- string[] dirtySceneSaveFailures = SaveDirtyOpenScenesBeforeReload();
- LogFocusReturnFailures("save dirty Scene files", dirtySceneSaveFailures);
-
- string[] missingSceneSaveFailures = SaveMissingOpenScenesFromUnity();
- LogFocusReturnFailures("restore missing Scene files from the Unity state", missingSceneSaveFailures);
-
- string[] dirtyPrefabSaveFailures = SaveDirtyCurrentPrefabStage();
- LogFocusReturnFailures("save the dirty Prefab Stage", dirtyPrefabSaveFailures);
-
- string[] missingPrefabSaveFailures = SaveMissingCurrentPrefabStageAsset();
- LogFocusReturnFailures("restore the missing Prefab asset from the Unity state", missingPrefabSaveFailures);
-
- ResolveSceneExternalChangesForFocusReturn();
- if (dirtyPrefabSaveFailures.Length > 0 ||
- missingPrefabSaveFailures.Length > 0 ||
- IsCurrentPrefabStageDirty())
- {
- Debug.LogWarning(
- "Unity CLI Loop skipped Prefab Stage external-change reload because the current Prefab Stage is still dirty or could not be saved.");
- return;
- }
-
- ResolveCurrentPrefabStageExternalChangeForFocusReturn();
- }
-
- private static void RecordOpenSceneSnapshots()
- {
- SceneSnapshots.Clear();
- (string AssetPath, bool IsDirty)[] scenes = GetOpenSceneStates();
- for (int i = 0; i < scenes.Length; i++)
- {
- SceneSnapshots[scenes[i].AssetPath] = ReadAssetFileFingerprint(scenes[i].AssetPath);
- }
-
- SaveSceneSnapshotsToSessionState();
- }
-
- private static void RecordSceneSnapshot(Scene scene)
- {
- if (RecordSceneSnapshotIfTrackable(scene))
- {
- SaveSceneSnapshotsToSessionState();
- }
- }
-
- private static bool RecordSceneSnapshotIfTrackable(Scene scene)
- {
- if (!IsTrackableScene(scene))
- {
- return false;
- }
-
- string assetPath = NormalizeAssetPath(scene.path);
- SceneSnapshots[assetPath] = ReadAssetFileFingerprint(assetPath);
- return true;
- }
-
- private static void RecordCurrentPrefabStageSnapshot()
- {
- RecordPrefabStageSnapshot(PrefabStageUtility.GetCurrentPrefabStage());
- }
-
- private static void RecordPrefabStageSnapshot(PrefabStage prefabStage)
- {
- if (!IsTrackablePrefabStage(prefabStage))
- {
- return;
- }
-
- string assetPath = NormalizeAssetPath(prefabStage.assetPath);
- PrefabStageSnapshots[assetPath] = ReadAssetFileFingerprint(assetPath);
- SavePrefabStageSnapshotsToSessionState();
- }
-
- private static (string AssetPath, bool IsDirty)[] GetOpenSceneStates()
- {
- List<(string AssetPath, bool IsDirty)> scenes = new List<(string AssetPath, bool IsDirty)>();
- for (int i = 0; i < SceneManager.sceneCount; i++)
- {
- Scene scene = SceneManager.GetSceneAt(i);
- if (!IsTrackableScene(scene))
- {
- continue;
- }
-
- scenes.Add((NormalizeAssetPath(scene.path), scene.isDirty));
- }
-
- return scenes.ToArray();
- }
-
- private static bool IsTrackableScene(Scene scene)
- {
- return scene.IsValid() &&
- scene.isLoaded &&
- !string.IsNullOrEmpty(scene.path) &&
- scene.path.EndsWith(".unity", StringComparison.OrdinalIgnoreCase);
- }
-
- private static bool IsTrackablePrefabStage(PrefabStage prefabStage)
- {
- return prefabStage != null &&
- prefabStage.scene.IsValid() &&
- !string.IsNullOrEmpty(prefabStage.assetPath) &&
- prefabStage.assetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase);
- }
-
- private static (bool Exists, DateTime LastWriteTimeUtc, long Length) ReadAssetFileFingerprint(
- string assetPath)
- {
- Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty");
-
- string projectRoot = UnityCliLoopPathResolver.GetProjectRoot();
- string fullPath = Path.GetFullPath(Path.Combine(projectRoot, assetPath));
- FileInfo fileInfo = new FileInfo(fullPath);
- if (!fileInfo.Exists)
- {
- return (false, DateTime.MinValue, 0);
- }
-
- return (true, fileInfo.LastWriteTimeUtc, fileInfo.Length);
- }
-
- private static void ResolveSceneExternalChangesForFocusReturn()
- {
- ExternalSceneChangeResolver resolver = CreateSceneChangeResolver();
- (bool CanProceed, string Message, string[] ScenePaths) result =
- resolver.ResolveExternalSceneChanges(reloadExternalSceneChanges: true);
- SaveSceneSnapshotsToSessionState();
- if (result.CanProceed)
- {
- return;
- }
-
- Debug.LogWarning("Unity CLI Loop could not resolve external Scene changes on focus return. " +
- result.Message);
- }
-
- private static void ResolveCurrentPrefabStageExternalChangeForFocusReturn()
- {
- PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
- if (!IsTrackablePrefabStage(prefabStage))
- {
- return;
- }
-
- string assetPath = NormalizeAssetPath(prefabStage.assetPath);
- (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint =
- ReadAssetFileFingerprint(assetPath);
- if (!PrefabStageSnapshots.ContainsKey(assetPath))
- {
- PrefabStageSnapshots[assetPath] = currentFingerprint;
- SavePrefabStageSnapshotsToSessionState();
- return;
- }
-
- if (HasSameFileState(PrefabStageSnapshots[assetPath], currentFingerprint))
- {
- return;
- }
-
- if (!currentFingerprint.Exists)
- {
- string[] saveFailures = SaveMissingCurrentPrefabStageAsset();
- LogFocusReturnFailures("restore the missing Prefab asset from the Unity state", saveFailures);
- return;
- }
-
- AssetDatabase.ImportAsset(assetPath);
- UnityEngine.Object prefabAsset = AssetDatabase.LoadMainAssetAtPath(assetPath);
- if (prefabAsset == null)
- {
- Debug.LogWarning("Unity CLI Loop could not reopen externally changed Prefab asset on focus return. " +
- "Prefab Stage: " + assetPath);
- return;
- }
-
- (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) reopenContext =
- CreatePrefabStageReopenContext(
- prefabStage.openedFromInstanceObject,
- prefabStage.mode,
- PrefabUtility.IsPartOfPrefabInstance);
- PrefabStage reopenedStage =
- PrefabStageUtility.OpenPrefab(assetPath, reopenContext.OpenedFromInstanceObject, reopenContext.Mode);
- if (reopenedStage == null)
- {
- Debug.LogWarning("Unity CLI Loop could not reopen externally changed Prefab asset on focus return. " +
- "Prefab Stage: " + assetPath);
- return;
- }
-
- RecordPrefabStageSnapshot(reopenedStage);
- }
-
- internal static (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) CreatePrefabStageReopenContext(
- GameObject openedFromInstanceObject,
- PrefabStage.Mode prefabStageMode,
- Func isPartOfPrefabInstance)
- {
- Debug.Assert(isPartOfPrefabInstance != null, "isPartOfPrefabInstance must not be null");
-
- if (openedFromInstanceObject == null)
- {
- return (null, PrefabStage.Mode.InIsolation);
- }
-
- if (isPartOfPrefabInstance(openedFromInstanceObject))
- {
- return (openedFromInstanceObject, prefabStageMode);
- }
-
- return (null, PrefabStage.Mode.InIsolation);
- }
-
- private static string[] SaveDirtyOpenScenesBeforeReload()
- {
- List failedScenePaths = new List();
- bool hasRecordedSceneSnapshot = false;
- for (int i = 0; i < SceneManager.sceneCount; i++)
- {
- Scene scene = SceneManager.GetSceneAt(i);
- if (!scene.IsValid() || !scene.isLoaded || !scene.isDirty)
- {
- continue;
- }
-
- if (string.IsNullOrEmpty(scene.path) || !EditorSceneManager.SaveScene(scene))
- {
- failedScenePaths.Add(GetSceneDisplayPath(scene));
- continue;
- }
-
- hasRecordedSceneSnapshot = RecordSceneSnapshotIfTrackable(scene) || hasRecordedSceneSnapshot;
- }
-
- if (hasRecordedSceneSnapshot)
- {
- SaveSceneSnapshotsToSessionState();
- }
-
- return failedScenePaths.ToArray();
- }
-
- private static string[] SaveMissingOpenScenesFromUnity()
- {
- List failedScenePaths = new List();
- bool hasRecordedSceneSnapshot = false;
- for (int i = 0; i < SceneManager.sceneCount; i++)
- {
- Scene scene = SceneManager.GetSceneAt(i);
- if (!IsTrackableScene(scene))
- {
- continue;
- }
-
- string assetPath = NormalizeAssetPath(scene.path);
- (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint =
- ReadAssetFileFingerprint(assetPath);
- if (currentFingerprint.Exists)
- {
- continue;
- }
-
- if (!EditorSceneManager.SaveScene(scene))
- {
- failedScenePaths.Add(GetSceneDisplayPath(scene));
- continue;
- }
-
- hasRecordedSceneSnapshot = RecordSceneSnapshotIfTrackable(scene) || hasRecordedSceneSnapshot;
- }
-
- if (hasRecordedSceneSnapshot)
- {
- SaveSceneSnapshotsToSessionState();
- }
-
- return failedScenePaths.ToArray();
- }
-
- private static string[] SaveDirtyCurrentPrefabStage()
- {
- PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
- if (!IsTrackablePrefabStage(prefabStage) || !prefabStage.scene.isDirty)
- {
- return Array.Empty();
- }
-
- if (TrySavePrefabStage(prefabStage))
- {
- return Array.Empty();
- }
-
- return new[] { GetPrefabStageDisplayPath(prefabStage) };
- }
-
- private static string[] SaveMissingCurrentPrefabStageAsset()
- {
- PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
- if (!IsTrackablePrefabStage(prefabStage))
- {
- return Array.Empty();
- }
-
- string assetPath = NormalizeAssetPath(prefabStage.assetPath);
- (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint =
- ReadAssetFileFingerprint(assetPath);
- if (currentFingerprint.Exists)
- {
- return Array.Empty();
- }
-
- if (TrySavePrefabStage(prefabStage))
- {
- return Array.Empty();
- }
-
- return new[] { GetPrefabStageDisplayPath(prefabStage) };
- }
-
- private static bool IsCurrentPrefabStageDirty()
- {
- PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
- return IsTrackablePrefabStage(prefabStage) && prefabStage.scene.isDirty;
- }
-
- private static bool TrySavePrefabStage(PrefabStage prefabStage)
- {
- Debug.Assert(prefabStage != null, "prefabStage must not be null");
-
- if (string.IsNullOrEmpty(prefabStage.assetPath))
- {
- return false;
- }
-
- bool success;
- PrefabUtility.SaveAsPrefabAsset(prefabStage.prefabContentsRoot, prefabStage.assetPath, out success);
- if (!success)
- {
- return false;
- }
-
- prefabStage.ClearDirtiness();
- RecordPrefabStageSnapshot(prefabStage);
- return true;
- }
-
- private static bool ReloadOpenSceneSetup()
- {
- SceneSetup[] sceneSetup = EditorSceneManager.GetSceneManagerSetup();
- if (sceneSetup == null || sceneSetup.Length == 0)
- {
- return true;
- }
-
- EditorSceneManager.RestoreSceneManagerSetup(sceneSetup);
- RecordOpenSceneSnapshots();
- return true;
- }
-
- private static ExternalSceneChangeResolver CreateSceneChangeResolver()
- {
- return new ExternalSceneChangeResolver(
- SceneSnapshots,
- GetOpenSceneStates,
- ReadAssetFileFingerprint,
- SaveDirtyOpenScenesBeforeReload,
- ReloadOpenSceneSetup);
- }
-
- private static bool IsAutoRefreshHeld()
- {
- return SessionState.GetBool(AutoRefreshHeldSessionStateKey, false);
- }
-
- private static void SetAutoRefreshHeld(bool isHeld)
- {
- SessionState.SetBool(AutoRefreshHeldSessionStateKey, isHeld);
- }
-
- private static void RestoreSnapshotsFromSessionState()
- {
- ExternalAssetSnapshotSessionStore.RestoreSnapshots(
- SceneSnapshots,
- SessionState.GetString(SceneSnapshotsSessionStateKey, ""));
- ExternalAssetSnapshotSessionStore.RestoreSnapshots(
- PrefabStageSnapshots,
- SessionState.GetString(PrefabStageSnapshotsSessionStateKey, ""));
- }
-
- private static void SaveSceneSnapshotsToSessionState()
- {
- SessionState.SetString(
- SceneSnapshotsSessionStateKey,
- ExternalAssetSnapshotSessionStore.SerializeSnapshots(SceneSnapshots));
- }
-
- private static void SavePrefabStageSnapshotsToSessionState()
- {
- SessionState.SetString(
- PrefabStageSnapshotsSessionStateKey,
- ExternalAssetSnapshotSessionStore.SerializeSnapshots(PrefabStageSnapshots));
- }
-
- private static string NormalizeAssetPath(string assetPath)
- {
- Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty");
- return assetPath.Replace('\\', '/');
- }
-
- private static string GetSceneDisplayPath(Scene scene)
- {
- if (!string.IsNullOrEmpty(scene.path))
- {
- return NormalizeAssetPath(scene.path);
- }
-
- if (!string.IsNullOrEmpty(scene.name))
- {
- return scene.name;
- }
-
- return "Untitled scene";
- }
-
- private static string GetPrefabStageDisplayPath(PrefabStage prefabStage)
- {
- Debug.Assert(prefabStage != null, "prefabStage must not be null");
-
- if (!string.IsNullOrEmpty(prefabStage.assetPath))
- {
- return NormalizeAssetPath(prefabStage.assetPath);
- }
-
- return GetSceneDisplayPath(prefabStage.scene);
- }
-
- private static void LogFocusReturnFailures(string action, string[] failedAssetPaths)
- {
- Debug.Assert(!string.IsNullOrEmpty(action), "action must not be empty");
- Debug.Assert(failedAssetPaths != null, "failedAssetPaths must not be null");
-
- if (failedAssetPaths.Length == 0)
- {
- return;
- }
-
- Debug.LogWarning("Unity CLI Loop could not " + action + " before Unity refreshes assets on focus return. " +
- "Affected assets: " + string.Join(", ", failedAssetPaths));
- }
-
- private static bool HasSameFileState(
- (bool Exists, DateTime LastWriteTimeUtc, long Length) previousFingerprint,
- (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint)
- {
- return previousFingerprint.Exists == currentFingerprint.Exists &&
- previousFingerprint.LastWriteTimeUtc == currentFingerprint.LastWriteTimeUtc &&
- previousFingerprint.Length == currentFingerprint.Length;
- }
- }
-
- ///
- /// Serializes tracked asset fingerprints so focus-return recovery survives editor domain reloads.
- ///
- internal static class ExternalAssetSnapshotSessionStore
- {
- internal static string SerializeSnapshots(
- Dictionary snapshots)
- {
- Debug.Assert(snapshots != null, "snapshots must not be null");
-
- AssetSnapshotSessionData data = new AssetSnapshotSessionData();
- data.Entries = new AssetSnapshotEntry[snapshots.Count];
- int index = 0;
- foreach (KeyValuePair snapshot in snapshots)
- {
- data.Entries[index] = new AssetSnapshotEntry
- {
- AssetPath = snapshot.Key,
- Exists = snapshot.Value.Exists,
- LastWriteTimeUtcTicks = snapshot.Value.LastWriteTimeUtc.Ticks,
- Length = snapshot.Value.Length
- };
- index++;
- }
-
- return JsonUtility.ToJson(data);
- }
-
- internal static void RestoreSnapshots(
- Dictionary snapshots,
- string json)
- {
- Debug.Assert(snapshots != null, "snapshots must not be null");
-
- snapshots.Clear();
- if (string.IsNullOrEmpty(json))
- {
- return;
- }
-
- AssetSnapshotSessionData data = JsonUtility.FromJson(json);
- if (data == null || data.Entries == null)
- {
- return;
- }
-
- for (int i = 0; i < data.Entries.Length; i++)
- {
- AssetSnapshotEntry entry = data.Entries[i];
- if (entry == null || string.IsNullOrEmpty(entry.AssetPath))
- {
- continue;
- }
-
- snapshots[entry.AssetPath] = (
- entry.Exists,
- new DateTime(entry.LastWriteTimeUtcTicks, DateTimeKind.Utc),
- entry.Length);
- }
- }
-
- [Serializable]
- private sealed class AssetSnapshotSessionData
- {
- public AssetSnapshotEntry[] Entries = new AssetSnapshotEntry[0];
- }
-
- [Serializable]
- private sealed class AssetSnapshotEntry
- {
- public string AssetPath;
- public bool Exists;
- public long LastWriteTimeUtcTicks;
- public long Length;
- }
- }
-
- ///
- /// Coordinates Auto Refresh suspension while Unity is unfocused.
- ///
- internal sealed class ExternalAssetFocusReturnService
- {
- private readonly Func _getAutoRefreshHeld;
- private readonly Action _setAutoRefreshHeld;
- private readonly Func _isEditorFocused;
- private readonly Action _disallowAutoRefresh;
- private readonly Action _allowAutoRefresh;
- private readonly Action _resolveFocusReturnChanges;
-
- internal ExternalAssetFocusReturnService(
- Func getAutoRefreshHeld,
- Action setAutoRefreshHeld,
- Func isEditorFocused,
- Action disallowAutoRefresh,
- Action allowAutoRefresh,
- Action resolveFocusReturnChanges)
- {
- Debug.Assert(getAutoRefreshHeld != null, "getAutoRefreshHeld must not be null");
- Debug.Assert(setAutoRefreshHeld != null, "setAutoRefreshHeld must not be null");
- Debug.Assert(isEditorFocused != null, "isEditorFocused must not be null");
- Debug.Assert(disallowAutoRefresh != null, "disallowAutoRefresh must not be null");
- Debug.Assert(allowAutoRefresh != null, "allowAutoRefresh must not be null");
- Debug.Assert(resolveFocusReturnChanges != null, "resolveFocusReturnChanges must not be null");
-
- _getAutoRefreshHeld = getAutoRefreshHeld ?? throw new ArgumentNullException(nameof(getAutoRefreshHeld));
- _setAutoRefreshHeld = setAutoRefreshHeld ?? throw new ArgumentNullException(nameof(setAutoRefreshHeld));
- _isEditorFocused = isEditorFocused ?? throw new ArgumentNullException(nameof(isEditorFocused));
- _disallowAutoRefresh = disallowAutoRefresh ?? throw new ArgumentNullException(nameof(disallowAutoRefresh));
- _allowAutoRefresh = allowAutoRefresh ?? throw new ArgumentNullException(nameof(allowAutoRefresh));
- _resolveFocusReturnChanges =
- resolveFocusReturnChanges ?? throw new ArgumentNullException(nameof(resolveFocusReturnChanges));
- }
-
- internal bool RestoreAutoRefreshIfHeld()
- {
- if (!_getAutoRefreshHeld())
- {
- return false;
- }
-
- if (!_isEditorFocused())
- {
- return false;
- }
-
- HandleFocusChanged(true);
- return true;
- }
-
- internal void HandleFocusChanged(bool isFocused)
- {
- if (!isFocused)
- {
- HoldAutoRefreshIfNeeded();
- return;
- }
-
- try
- {
- _resolveFocusReturnChanges();
- }
- finally
- {
- ReleaseAutoRefreshIfHeld();
- }
- }
-
- private void HoldAutoRefreshIfNeeded()
- {
- if (_getAutoRefreshHeld())
- {
- return;
- }
-
- _disallowAutoRefresh();
- _setAutoRefreshHeld(true);
- }
-
- private void ReleaseAutoRefreshIfHeld()
- {
- if (!_getAutoRefreshHeld())
- {
- return;
- }
-
- _allowAutoRefresh();
- _setAutoRefreshHeld(false);
- }
- }
-
///
/// Resolves external Scene file changes according to the compile request policy.
///
@@ -817,7 +56,8 @@ public ExternalSceneChangeResolver(
continue;
}
- if (HasSameFileState(_snapshots[scene.AssetPath], currentFingerprint))
+ if (ExternalAssetFileStateComparer.HasSameFileState(
+ _snapshots[scene.AssetPath], currentFingerprint))
{
continue;
}
@@ -871,15 +111,6 @@ public ExternalSceneChangeResolver(
return (true, null, Array.Empty());
}
- private static bool HasSameFileState(
- (bool Exists, DateTime LastWriteTimeUtc, long Length) previousFingerprint,
- (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint)
- {
- return previousFingerprint.Exists == currentFingerprint.Exists &&
- previousFingerprint.LastWriteTimeUtc == currentFingerprint.LastWriteTimeUtc &&
- previousFingerprint.Length == currentFingerprint.Length;
- }
-
private static string CreateStoppedMessage(string[] scenePaths)
{
return "Compilation stopped because open Scene files changed externally. " +
diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs
new file mode 100644
index 0000000000..1e9ddf9346
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs
@@ -0,0 +1,589 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// Tracks open Scene file snapshots so compile can resolve external disk changes before asset refresh.
+ ///
+ internal static class ExternalSceneChangeTracker
+ {
+ private const string AutoRefreshHeldSessionStateKey =
+ "io.github.hatayama.UnityCliLoop.ExternalSceneChangeTracker.AutoRefreshHeld";
+ private const string SceneSnapshotsSessionStateKey =
+ "io.github.hatayama.UnityCliLoop.ExternalSceneChangeTracker.SceneSnapshots";
+ private const string PrefabStageSnapshotsSessionStateKey =
+ "io.github.hatayama.UnityCliLoop.ExternalSceneChangeTracker.PrefabStageSnapshots";
+ private static readonly Dictionary SceneSnapshots =
+ new Dictionary(StringComparer.Ordinal);
+ private static readonly Dictionary PrefabStageSnapshots =
+ new Dictionary(StringComparer.Ordinal);
+ private static readonly ExternalAssetFocusReturnService FocusReturnService =
+ new ExternalAssetFocusReturnService(
+ IsAutoRefreshHeld,
+ SetAutoRefreshHeld,
+ () => EditorApplication.isFocused,
+ AssetDatabase.DisallowAutoRefresh,
+ AssetDatabase.AllowAutoRefresh,
+ ResolveForFocusReturn);
+ private static bool _initialized;
+
+ public static void Initialize()
+ {
+ if (AssetDatabase.IsAssetImportWorkerProcess())
+ {
+ return;
+ }
+
+ if (_initialized)
+ {
+ return;
+ }
+
+ RestoreSnapshotsFromSessionState();
+ bool restoredHeldAutoRefresh = FocusReturnService.RestoreAutoRefreshIfHeld();
+
+ _initialized = true;
+ EditorSceneManager.sceneOpened -= HandleSceneOpened;
+ EditorSceneManager.sceneOpened += HandleSceneOpened;
+ EditorSceneManager.sceneSaved -= HandleSceneSaved;
+ EditorSceneManager.sceneSaved += HandleSceneSaved;
+ EditorSceneManager.sceneClosed -= HandleSceneClosed;
+ EditorSceneManager.sceneClosed += HandleSceneClosed;
+ PrefabStage.prefabStageOpened -= HandlePrefabStageOpened;
+ PrefabStage.prefabStageOpened += HandlePrefabStageOpened;
+ PrefabStage.prefabStageClosing -= HandlePrefabStageClosing;
+ PrefabStage.prefabStageClosing += HandlePrefabStageClosing;
+ PrefabStage.prefabSaved -= HandlePrefabSaved;
+ PrefabStage.prefabSaved += HandlePrefabSaved;
+ EditorApplication.focusChanged -= HandleFocusChanged;
+ EditorApplication.focusChanged += HandleFocusChanged;
+ if (!restoredHeldAutoRefresh && !IsAutoRefreshHeld())
+ {
+ RecordOpenSceneSnapshots();
+ RecordCurrentPrefabStageSnapshot();
+ }
+ }
+
+ public static (bool CanProceed, string Message, string[] ScenePaths) ResolveForCompile(
+ bool reloadExternalSceneChanges)
+ {
+ Initialize();
+ ExternalSceneChangeResolver resolver = CreateSceneChangeResolver();
+ (bool CanProceed, string Message, string[] ScenePaths) result =
+ resolver.ResolveExternalSceneChanges(reloadExternalSceneChanges);
+ SaveSceneSnapshotsToSessionState();
+ return result;
+ }
+
+ private static void HandleFocusChanged(bool isFocused)
+ {
+ FocusReturnService.HandleFocusChanged(isFocused);
+ }
+
+ private static void HandleSceneOpened(Scene scene, OpenSceneMode mode)
+ {
+ RecordSceneSnapshot(scene);
+ }
+
+ private static void HandleSceneSaved(Scene scene)
+ {
+ RecordSceneSnapshot(scene);
+ }
+
+ private static void HandleSceneClosed(Scene scene)
+ {
+ if (!IsTrackableScene(scene))
+ {
+ return;
+ }
+
+ SceneSnapshots.Remove(NormalizeAssetPath(scene.path));
+ SaveSceneSnapshotsToSessionState();
+ }
+
+ private static void HandlePrefabStageOpened(PrefabStage prefabStage)
+ {
+ RecordPrefabStageSnapshot(prefabStage);
+ }
+
+ private static void HandlePrefabStageClosing(PrefabStage prefabStage)
+ {
+ if (!IsTrackablePrefabStage(prefabStage))
+ {
+ return;
+ }
+
+ PrefabStageSnapshots.Remove(NormalizeAssetPath(prefabStage.assetPath));
+ SavePrefabStageSnapshotsToSessionState();
+ }
+
+ private static void HandlePrefabSaved(GameObject prefabRoot)
+ {
+ RecordCurrentPrefabStageSnapshot();
+ }
+
+ private static void ResolveForFocusReturn()
+ {
+ // Focus return treats Unity's in-memory editor state as authoritative because source-control
+ // operations can replace files while Unity is unfocused and would otherwise trigger reload dialogs.
+ string[] dirtySceneSaveFailures = SaveDirtyOpenScenesBeforeReload();
+ LogFocusReturnFailures("save dirty Scene files", dirtySceneSaveFailures);
+
+ string[] missingSceneSaveFailures = SaveMissingOpenScenesFromUnity();
+ LogFocusReturnFailures("restore missing Scene files from the Unity state", missingSceneSaveFailures);
+
+ string[] dirtyPrefabSaveFailures = SaveDirtyCurrentPrefabStage();
+ LogFocusReturnFailures("save the dirty Prefab Stage", dirtyPrefabSaveFailures);
+
+ string[] missingPrefabSaveFailures = SaveMissingCurrentPrefabStageAsset();
+ LogFocusReturnFailures("restore the missing Prefab asset from the Unity state", missingPrefabSaveFailures);
+
+ ResolveSceneExternalChangesForFocusReturn();
+ if (dirtyPrefabSaveFailures.Length > 0 ||
+ missingPrefabSaveFailures.Length > 0 ||
+ IsCurrentPrefabStageDirty())
+ {
+ Debug.LogWarning(
+ "Unity CLI Loop skipped Prefab Stage external-change reload because the current Prefab Stage is still dirty or could not be saved.");
+ return;
+ }
+
+ ResolveCurrentPrefabStageExternalChangeForFocusReturn();
+ }
+
+ private static void RecordOpenSceneSnapshots()
+ {
+ SceneSnapshots.Clear();
+ (string AssetPath, bool IsDirty)[] scenes = GetOpenSceneStates();
+ for (int i = 0; i < scenes.Length; i++)
+ {
+ SceneSnapshots[scenes[i].AssetPath] = ReadAssetFileFingerprint(scenes[i].AssetPath);
+ }
+
+ SaveSceneSnapshotsToSessionState();
+ }
+
+ private static void RecordSceneSnapshot(Scene scene)
+ {
+ if (RecordSceneSnapshotIfTrackable(scene))
+ {
+ SaveSceneSnapshotsToSessionState();
+ }
+ }
+
+ private static bool RecordSceneSnapshotIfTrackable(Scene scene)
+ {
+ if (!IsTrackableScene(scene))
+ {
+ return false;
+ }
+
+ string assetPath = NormalizeAssetPath(scene.path);
+ SceneSnapshots[assetPath] = ReadAssetFileFingerprint(assetPath);
+ return true;
+ }
+
+ private static void RecordCurrentPrefabStageSnapshot()
+ {
+ RecordPrefabStageSnapshot(PrefabStageUtility.GetCurrentPrefabStage());
+ }
+
+ private static void RecordPrefabStageSnapshot(PrefabStage prefabStage)
+ {
+ if (!IsTrackablePrefabStage(prefabStage))
+ {
+ return;
+ }
+
+ string assetPath = NormalizeAssetPath(prefabStage.assetPath);
+ PrefabStageSnapshots[assetPath] = ReadAssetFileFingerprint(assetPath);
+ SavePrefabStageSnapshotsToSessionState();
+ }
+
+ private static (string AssetPath, bool IsDirty)[] GetOpenSceneStates()
+ {
+ List<(string AssetPath, bool IsDirty)> scenes = new List<(string AssetPath, bool IsDirty)>();
+ for (int i = 0; i < SceneManager.sceneCount; i++)
+ {
+ Scene scene = SceneManager.GetSceneAt(i);
+ if (!IsTrackableScene(scene))
+ {
+ continue;
+ }
+
+ scenes.Add((NormalizeAssetPath(scene.path), scene.isDirty));
+ }
+
+ return scenes.ToArray();
+ }
+
+ private static bool IsTrackableScene(Scene scene)
+ {
+ return scene.IsValid() &&
+ scene.isLoaded &&
+ !string.IsNullOrEmpty(scene.path) &&
+ scene.path.EndsWith(".unity", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsTrackablePrefabStage(PrefabStage prefabStage)
+ {
+ return prefabStage != null &&
+ prefabStage.scene.IsValid() &&
+ !string.IsNullOrEmpty(prefabStage.assetPath) &&
+ prefabStage.assetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static (bool Exists, DateTime LastWriteTimeUtc, long Length) ReadAssetFileFingerprint(
+ string assetPath)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty");
+
+ string projectRoot = UnityCliLoopPathResolver.GetProjectRoot();
+ string fullPath = Path.GetFullPath(Path.Combine(projectRoot, assetPath));
+ FileInfo fileInfo = new FileInfo(fullPath);
+ if (!fileInfo.Exists)
+ {
+ return (false, DateTime.MinValue, 0);
+ }
+
+ return (true, fileInfo.LastWriteTimeUtc, fileInfo.Length);
+ }
+
+ private static void ResolveSceneExternalChangesForFocusReturn()
+ {
+ ExternalSceneChangeResolver resolver = CreateSceneChangeResolver();
+ (bool CanProceed, string Message, string[] ScenePaths) result =
+ resolver.ResolveExternalSceneChanges(reloadExternalSceneChanges: true);
+ SaveSceneSnapshotsToSessionState();
+ if (result.CanProceed)
+ {
+ return;
+ }
+
+ Debug.LogWarning("Unity CLI Loop could not resolve external Scene changes on focus return. " +
+ result.Message);
+ }
+
+ private static void ResolveCurrentPrefabStageExternalChangeForFocusReturn()
+ {
+ PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
+ if (!IsTrackablePrefabStage(prefabStage))
+ {
+ return;
+ }
+
+ string assetPath = NormalizeAssetPath(prefabStage.assetPath);
+ (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint =
+ ReadAssetFileFingerprint(assetPath);
+ if (!PrefabStageSnapshots.ContainsKey(assetPath))
+ {
+ PrefabStageSnapshots[assetPath] = currentFingerprint;
+ SavePrefabStageSnapshotsToSessionState();
+ return;
+ }
+
+ if (ExternalAssetFileStateComparer.HasSameFileState(
+ PrefabStageSnapshots[assetPath], currentFingerprint))
+ {
+ return;
+ }
+
+ if (!currentFingerprint.Exists)
+ {
+ string[] saveFailures = SaveMissingCurrentPrefabStageAsset();
+ LogFocusReturnFailures("restore the missing Prefab asset from the Unity state", saveFailures);
+ return;
+ }
+
+ AssetDatabase.ImportAsset(assetPath);
+ UnityEngine.Object prefabAsset = AssetDatabase.LoadMainAssetAtPath(assetPath);
+ if (prefabAsset == null)
+ {
+ Debug.LogWarning("Unity CLI Loop could not reopen externally changed Prefab asset on focus return. " +
+ "Prefab Stage: " + assetPath);
+ return;
+ }
+
+ (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) reopenContext =
+ CreatePrefabStageReopenContext(
+ prefabStage.openedFromInstanceObject,
+ prefabStage.mode,
+ PrefabUtility.IsPartOfPrefabInstance);
+ PrefabStage reopenedStage =
+ PrefabStageUtility.OpenPrefab(assetPath, reopenContext.OpenedFromInstanceObject, reopenContext.Mode);
+ if (reopenedStage == null)
+ {
+ Debug.LogWarning("Unity CLI Loop could not reopen externally changed Prefab asset on focus return. " +
+ "Prefab Stage: " + assetPath);
+ return;
+ }
+
+ RecordPrefabStageSnapshot(reopenedStage);
+ }
+
+ internal static (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) CreatePrefabStageReopenContext(
+ GameObject openedFromInstanceObject,
+ PrefabStage.Mode prefabStageMode,
+ Func isPartOfPrefabInstance)
+ {
+ Debug.Assert(isPartOfPrefabInstance != null, "isPartOfPrefabInstance must not be null");
+
+ if (openedFromInstanceObject == null)
+ {
+ return (null, PrefabStage.Mode.InIsolation);
+ }
+
+ if (isPartOfPrefabInstance(openedFromInstanceObject))
+ {
+ return (openedFromInstanceObject, prefabStageMode);
+ }
+
+ return (null, PrefabStage.Mode.InIsolation);
+ }
+
+ private static string[] SaveDirtyOpenScenesBeforeReload()
+ {
+ List failedScenePaths = new List();
+ bool hasRecordedSceneSnapshot = false;
+ for (int i = 0; i < SceneManager.sceneCount; i++)
+ {
+ Scene scene = SceneManager.GetSceneAt(i);
+ if (!scene.IsValid() || !scene.isLoaded || !scene.isDirty)
+ {
+ continue;
+ }
+
+ if (string.IsNullOrEmpty(scene.path) || !EditorSceneManager.SaveScene(scene))
+ {
+ failedScenePaths.Add(GetSceneDisplayPath(scene));
+ continue;
+ }
+
+ hasRecordedSceneSnapshot = RecordSceneSnapshotIfTrackable(scene) || hasRecordedSceneSnapshot;
+ }
+
+ if (hasRecordedSceneSnapshot)
+ {
+ SaveSceneSnapshotsToSessionState();
+ }
+
+ return failedScenePaths.ToArray();
+ }
+
+ private static string[] SaveMissingOpenScenesFromUnity()
+ {
+ List failedScenePaths = new List();
+ bool hasRecordedSceneSnapshot = false;
+ for (int i = 0; i < SceneManager.sceneCount; i++)
+ {
+ Scene scene = SceneManager.GetSceneAt(i);
+ if (!IsTrackableScene(scene))
+ {
+ continue;
+ }
+
+ string assetPath = NormalizeAssetPath(scene.path);
+ (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint =
+ ReadAssetFileFingerprint(assetPath);
+ if (currentFingerprint.Exists)
+ {
+ continue;
+ }
+
+ if (!EditorSceneManager.SaveScene(scene))
+ {
+ failedScenePaths.Add(GetSceneDisplayPath(scene));
+ continue;
+ }
+
+ hasRecordedSceneSnapshot = RecordSceneSnapshotIfTrackable(scene) || hasRecordedSceneSnapshot;
+ }
+
+ if (hasRecordedSceneSnapshot)
+ {
+ SaveSceneSnapshotsToSessionState();
+ }
+
+ return failedScenePaths.ToArray();
+ }
+
+ private static string[] SaveDirtyCurrentPrefabStage()
+ {
+ PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
+ if (!IsTrackablePrefabStage(prefabStage) || !prefabStage.scene.isDirty)
+ {
+ return Array.Empty();
+ }
+
+ if (TrySavePrefabStage(prefabStage))
+ {
+ return Array.Empty();
+ }
+
+ return new[] { GetPrefabStageDisplayPath(prefabStage) };
+ }
+
+ private static string[] SaveMissingCurrentPrefabStageAsset()
+ {
+ PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
+ if (!IsTrackablePrefabStage(prefabStage))
+ {
+ return Array.Empty();
+ }
+
+ string assetPath = NormalizeAssetPath(prefabStage.assetPath);
+ (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint =
+ ReadAssetFileFingerprint(assetPath);
+ if (currentFingerprint.Exists)
+ {
+ return Array.Empty();
+ }
+
+ if (TrySavePrefabStage(prefabStage))
+ {
+ return Array.Empty();
+ }
+
+ return new[] { GetPrefabStageDisplayPath(prefabStage) };
+ }
+
+ private static bool IsCurrentPrefabStageDirty()
+ {
+ PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
+ return IsTrackablePrefabStage(prefabStage) && prefabStage.scene.isDirty;
+ }
+
+ private static bool TrySavePrefabStage(PrefabStage prefabStage)
+ {
+ Debug.Assert(prefabStage != null, "prefabStage must not be null");
+
+ if (string.IsNullOrEmpty(prefabStage.assetPath))
+ {
+ return false;
+ }
+
+ bool success;
+ PrefabUtility.SaveAsPrefabAsset(prefabStage.prefabContentsRoot, prefabStage.assetPath, out success);
+ if (!success)
+ {
+ return false;
+ }
+
+ prefabStage.ClearDirtiness();
+ RecordPrefabStageSnapshot(prefabStage);
+ return true;
+ }
+
+ private static bool ReloadOpenSceneSetup()
+ {
+ SceneSetup[] sceneSetup = EditorSceneManager.GetSceneManagerSetup();
+ if (sceneSetup == null || sceneSetup.Length == 0)
+ {
+ return true;
+ }
+
+ EditorSceneManager.RestoreSceneManagerSetup(sceneSetup);
+ RecordOpenSceneSnapshots();
+ return true;
+ }
+
+ private static ExternalSceneChangeResolver CreateSceneChangeResolver()
+ {
+ return new ExternalSceneChangeResolver(
+ SceneSnapshots,
+ GetOpenSceneStates,
+ ReadAssetFileFingerprint,
+ SaveDirtyOpenScenesBeforeReload,
+ ReloadOpenSceneSetup);
+ }
+
+ private static bool IsAutoRefreshHeld()
+ {
+ return SessionState.GetBool(AutoRefreshHeldSessionStateKey, false);
+ }
+
+ private static void SetAutoRefreshHeld(bool isHeld)
+ {
+ SessionState.SetBool(AutoRefreshHeldSessionStateKey, isHeld);
+ }
+
+ private static void RestoreSnapshotsFromSessionState()
+ {
+ ExternalAssetSnapshotSessionStore.RestoreSnapshots(
+ SceneSnapshots,
+ SessionState.GetString(SceneSnapshotsSessionStateKey, ""));
+ ExternalAssetSnapshotSessionStore.RestoreSnapshots(
+ PrefabStageSnapshots,
+ SessionState.GetString(PrefabStageSnapshotsSessionStateKey, ""));
+ }
+
+ private static void SaveSceneSnapshotsToSessionState()
+ {
+ SessionState.SetString(
+ SceneSnapshotsSessionStateKey,
+ ExternalAssetSnapshotSessionStore.SerializeSnapshots(SceneSnapshots));
+ }
+
+ private static void SavePrefabStageSnapshotsToSessionState()
+ {
+ SessionState.SetString(
+ PrefabStageSnapshotsSessionStateKey,
+ ExternalAssetSnapshotSessionStore.SerializeSnapshots(PrefabStageSnapshots));
+ }
+
+ private static string NormalizeAssetPath(string assetPath)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty");
+ return assetPath.Replace('\\', '/');
+ }
+
+ private static string GetSceneDisplayPath(Scene scene)
+ {
+ if (!string.IsNullOrEmpty(scene.path))
+ {
+ return NormalizeAssetPath(scene.path);
+ }
+
+ if (!string.IsNullOrEmpty(scene.name))
+ {
+ return scene.name;
+ }
+
+ return "Untitled scene";
+ }
+
+ private static string GetPrefabStageDisplayPath(PrefabStage prefabStage)
+ {
+ Debug.Assert(prefabStage != null, "prefabStage must not be null");
+
+ if (!string.IsNullOrEmpty(prefabStage.assetPath))
+ {
+ return NormalizeAssetPath(prefabStage.assetPath);
+ }
+
+ return GetSceneDisplayPath(prefabStage.scene);
+ }
+
+ private static void LogFocusReturnFailures(string action, string[] failedAssetPaths)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(action), "action must not be empty");
+ Debug.Assert(failedAssetPaths != null, "failedAssetPaths must not be null");
+
+ if (failedAssetPaths.Length == 0)
+ {
+ return;
+ }
+
+ Debug.LogWarning("Unity CLI Loop could not " + action + " before Unity refreshes assets on focus return. " +
+ "Affected assets: " + string.Join(", ", failedAssetPaths));
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs.meta
new file mode 100644
index 0000000000..3ad43b67a0
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 6d5c1b8149e64d61b4dc57d174a8d1c8
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: