diff --git a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs index fa370713c5..49045dd962 100644 --- a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs +++ b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using NUnit.Framework; +using UnityEditor.SceneManagement; +using UnityEngine; using io.github.hatayama.UnityCliLoop.FirstPartyTools; @@ -190,6 +192,204 @@ public void ResolveExternalSceneChanges_WhenSceneUnchanged_DoesNotSaveOrReload() Assert.That(reloadWasCalled, Is.False); } + [Test] + public void FocusReturnService_WhenFocusIsLost_HoldsAutoRefreshOnce() + { + // Verifies focus loss suspends Unity Auto Refresh only once per unfocused interval. + bool autoRefreshHeld = false; + int disallowCallCount = 0; + int allowCallCount = 0; + ExternalAssetFocusReturnService service = CreateFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => false, + () => disallowCallCount++, + () => allowCallCount++, + () => { }); + + service.HandleFocusChanged(false); + service.HandleFocusChanged(false); + + Assert.That(autoRefreshHeld, Is.True); + Assert.That(disallowCallCount, Is.EqualTo(1)); + Assert.That(allowCallCount, Is.EqualTo(0)); + } + + [Test] + public void FocusReturnService_WhenFocusReturns_RunsPreflightBeforeReleasingAutoRefresh() + { + // Verifies focus return resolves editor state before Unity Auto Refresh resumes. + bool autoRefreshHeld = true; + List events = new List(); + ExternalAssetFocusReturnService service = CreateFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => true, + () => events.Add("disallow"), + () => events.Add("allow"), + () => events.Add("preflight")); + + service.HandleFocusChanged(true); + + Assert.That(autoRefreshHeld, Is.False); + Assert.That(events, Is.EqualTo(new[] { "preflight", "allow" })); + } + + [Test] + public void FocusReturnService_WhenStartupFindsHeldAutoRefreshAndEditorIsFocused_RunsPreflightBeforeReleasingIt() + { + // Verifies focused startup recovery resolves editor state before releasing a reload-surviving hold. + bool autoRefreshHeld = true; + List events = new List(); + ExternalAssetFocusReturnService service = CreateFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => true, + () => events.Add("disallow"), + () => events.Add("allow"), + () => events.Add("preflight")); + + bool restoredHeldAutoRefresh = service.RestoreAutoRefreshIfHeld(); + + Assert.That(restoredHeldAutoRefresh, Is.True); + Assert.That(autoRefreshHeld, Is.False); + Assert.That(events, Is.EqualTo(new[] { "preflight", "allow" })); + } + + [Test] + public void FocusReturnService_WhenStartupFindsHeldAutoRefreshAndEditorIsUnfocused_KeepsAutoRefreshHeld() + { + // Verifies unfocused startup recovery keeps Auto Refresh suspended until focus returns. + bool autoRefreshHeld = true; + int allowCallCount = 0; + int preflightCallCount = 0; + ExternalAssetFocusReturnService service = CreateFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => false, + () => { }, + () => allowCallCount++, + () => preflightCallCount++); + + bool restoredHeldAutoRefresh = service.RestoreAutoRefreshIfHeld(); + + Assert.That(restoredHeldAutoRefresh, Is.False); + Assert.That(autoRefreshHeld, Is.True); + Assert.That(allowCallCount, Is.EqualTo(0)); + Assert.That(preflightCallCount, Is.EqualTo(0)); + } + + [Test] + public void FocusReturnService_WhenPreflightThrows_StillReleasesAutoRefresh() + { + // Verifies Auto Refresh is released even when focus-return preflight fails fast. + bool autoRefreshHeld = true; + int allowCallCount = 0; + ExternalAssetFocusReturnService service = CreateFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => true, + () => { }, + () => allowCallCount++, + () => throw new InvalidOperationException("preflight failed")); + + Assert.Throws(() => service.HandleFocusChanged(true)); + Assert.That(autoRefreshHeld, Is.False); + Assert.That(allowCallCount, Is.EqualTo(1)); + } + + [Test] + public void SnapshotSessionStore_WhenSnapshotsRoundTrip_PreservesFingerprints() + { + // Verifies focus-return snapshots survive a domain reload through JSON session storage. + Dictionary snapshots = + new Dictionary(StringComparer.Ordinal); + snapshots[ScenePath] = (true, SavedTime, 10); + const string MissingScenePath = "Assets/Scenes/MissingScene.unity"; + snapshots[MissingScenePath] = (false, DateTime.MinValue, 0); + + string json = ExternalAssetSnapshotSessionStore.SerializeSnapshots(snapshots); + Dictionary restored = + new Dictionary(StringComparer.Ordinal); + + ExternalAssetSnapshotSessionStore.RestoreSnapshots(restored, json); + + Assert.That(restored.Count, Is.EqualTo(2)); + Assert.That(restored[ScenePath].Exists, Is.True); + Assert.That(restored[ScenePath].LastWriteTimeUtc, Is.EqualTo(SavedTime)); + Assert.That(restored[ScenePath].Length, Is.EqualTo(10)); + Assert.That(restored[MissingScenePath].Exists, Is.False); + } + + [Test] + public void SnapshotSessionStore_WhenJsonIsEmpty_ClearsSnapshots() + { + // Verifies empty session data clears stale snapshots after normal startup. + Dictionary snapshots = CreateSnapshots(); + + ExternalAssetSnapshotSessionStore.RestoreSnapshots(snapshots, ""); + + Assert.That(snapshots, Is.Empty); + } + + [Test] + public void CreatePrefabStageReopenContext_WhenInstanceIsValid_PreservesContext() + { + // Verifies valid in-context Prefab Stage reopen data is preserved. + GameObject openedFromInstanceObject = new GameObject("OpenedFromInstanceObject"); + try + { + (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) context = + ExternalSceneChangeTracker.CreatePrefabStageReopenContext( + openedFromInstanceObject, + PrefabStage.Mode.InContext, + _ => true); + + Assert.That(context.OpenedFromInstanceObject, Is.SameAs(openedFromInstanceObject)); + Assert.That(context.Mode, Is.EqualTo(PrefabStage.Mode.InContext)); + } + finally + { + UnityEngine.Object.DestroyImmediate(openedFromInstanceObject); + } + } + + [Test] + public void CreatePrefabStageReopenContext_WhenInstanceIsInvalid_FallsBackToIsolation() + { + // Verifies invalid in-context Prefab Stage reopen data cannot reach OpenPrefab. + GameObject openedFromInstanceObject = new GameObject("OpenedFromInstanceObject"); + try + { + (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) context = + ExternalSceneChangeTracker.CreatePrefabStageReopenContext( + openedFromInstanceObject, + PrefabStage.Mode.InContext, + _ => false); + + Assert.That(context.OpenedFromInstanceObject, Is.Null); + Assert.That(context.Mode, Is.EqualTo(PrefabStage.Mode.InIsolation)); + } + finally + { + UnityEngine.Object.DestroyImmediate(openedFromInstanceObject); + } + } + + [Test] + public void CreatePrefabStageReopenContext_WhenInstanceIsMissing_UsesIsolation() + { + // Verifies missing Prefab Stage context reopens without invalid InContext arguments. + (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) context = + ExternalSceneChangeTracker.CreatePrefabStageReopenContext( + null, + PrefabStage.Mode.InContext, + _ => true); + + Assert.That(context.OpenedFromInstanceObject, Is.Null); + Assert.That(context.Mode, Is.EqualTo(PrefabStage.Mode.InIsolation)); + } + private static Dictionary CreateSnapshots() { Dictionary snapshots = @@ -197,5 +397,22 @@ public void ResolveExternalSceneChanges_WhenSceneUnchanged_DoesNotSaveOrReload() snapshots[ScenePath] = (true, SavedTime, 10); return snapshots; } + + private static ExternalAssetFocusReturnService CreateFocusReturnService( + Func getAutoRefreshHeld, + Action setAutoRefreshHeld, + Func isEditorFocused, + Action disallowAutoRefresh, + Action allowAutoRefresh, + Action resolveFocusReturnChanges) + { + return new ExternalAssetFocusReturnService( + getAutoRefreshHeld, + setAutoRefreshHeld, + isEditorFocused, + disallowAutoRefresh, + allowAutoRefresh, + resolveFocusReturnChanges); + } } } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs index 8913608b49..bcb8179204 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs @@ -15,17 +15,41 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class ExternalSceneChangeTracker { - private static readonly Dictionary Snapshots = + 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; @@ -33,20 +57,35 @@ public static void Initialize() EditorSceneManager.sceneSaved += HandleSceneSaved; EditorSceneManager.sceneClosed -= HandleSceneClosed; EditorSceneManager.sceneClosed += HandleSceneClosed; - RecordOpenSceneSnapshots(); + 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 = new ExternalSceneChangeResolver( - Snapshots, - GetOpenSceneStates, - ReadSceneFileFingerprint, - SaveDirtyOpenScenesBeforeReload, - ReloadOpenSceneSetup); - return resolver.ResolveExternalSceneChanges(reloadExternalSceneChanges); + 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) @@ -66,27 +105,107 @@ private static void HandleSceneClosed(Scene scene) return; } - Snapshots.Remove(NormalizeAssetPath(scene.path)); + 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++) { - Snapshots[scenes[i].AssetPath] = ReadSceneFileFingerprint(scenes[i].AssetPath); + 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; + return false; } string assetPath = NormalizeAssetPath(scene.path); - Snapshots[assetPath] = ReadSceneFileFingerprint(assetPath); + 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() @@ -114,7 +233,15 @@ private static bool IsTrackableScene(Scene scene) scene.path.EndsWith(".unity", StringComparison.OrdinalIgnoreCase); } - private static (bool Exists, DateTime LastWriteTimeUtc, long Length) ReadSceneFileFingerprint( + 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"); @@ -130,9 +257,101 @@ private static (bool Exists, DateTime LastWriteTimeUtc, long Length) ReadSceneFi 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); @@ -147,12 +366,121 @@ private static string[] SaveDirtyOpenScenesBeforeReload() continue; } - RecordSceneSnapshot(scene); + 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(); @@ -166,6 +494,50 @@ private static bool ReloadOpenSceneSetup() 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"); @@ -186,6 +558,211 @@ private static string GetSceneDisplayPath(Scene scene) 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); + } } ///