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: