From 776fefb3f02b01bc54e9e9f75be0f76f576836a5 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 9 Jul 2026 07:05:34 +0900 Subject: [PATCH 1/2] Split external scene change responsibilities by file Give the tracker, snapshot session store, focus-return coordinator, and compile resolver explicit file ownership while preserving every class body, visibility, event hook, and mutable state owner. --- .../ExternalAssetFocusReturnService.cs | 98 +++ .../ExternalAssetFocusReturnService.cs.meta | 11 + .../ExternalAssetSnapshotSessionStore.cs | 83 ++ .../ExternalAssetSnapshotSessionStore.cs.meta | 11 + .../Compile/ExternalSceneChangeResolver.cs | 761 ------------------ .../Compile/ExternalSceneChangeTracker.cs | 597 ++++++++++++++ .../ExternalSceneChangeTracker.cs.meta | 11 + 7 files changed, 811 insertions(+), 761 deletions(-) create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFocusReturnService.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFocusReturnService.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetSnapshotSessionStore.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetSnapshotSessionStore.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs.meta 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..9c9460c570 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. /// diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs new file mode 100644 index 0000000000..d529f764ed --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs @@ -0,0 +1,597 @@ +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; + } + } +} 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: From 1f530a6ae0eef8d3d09fdacf1dcca8721db2140a Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 9 Jul 2026 07:08:15 +0900 Subject: [PATCH 2/2] Unify external asset file-state comparison Use one stateless comparer for Scene and Prefab fingerprint identity so both reload paths share the same existence, timestamp, and length contract. Cover each comparison field independently to prevent future drift. --- .../ExternalAssetFileStateComparerTests.cs | 68 +++++++++++++++++++ ...xternalAssetFileStateComparerTests.cs.meta | 11 +++ .../Compile/ExternalAssetFileStateComparer.cs | 19 ++++++ .../ExternalAssetFileStateComparer.cs.meta | 11 +++ .../Compile/ExternalSceneChangeResolver.cs | 12 +--- .../Compile/ExternalSceneChangeTracker.cs | 12 +--- 6 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 Assets/Tests/Editor/ExternalAssetFileStateComparerTests.cs create mode 100644 Assets/Tests/Editor/ExternalAssetFileStateComparerTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFileStateComparer.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFileStateComparer.cs.meta 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/ExternalSceneChangeResolver.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs index 9c9460c570..bd21cece5f 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs @@ -56,7 +56,8 @@ public ExternalSceneChangeResolver( continue; } - if (HasSameFileState(_snapshots[scene.AssetPath], currentFingerprint)) + if (ExternalAssetFileStateComparer.HasSameFileState( + _snapshots[scene.AssetPath], currentFingerprint)) { continue; } @@ -110,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 index d529f764ed..1e9ddf9346 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs @@ -290,7 +290,8 @@ private static void ResolveCurrentPrefabStageExternalChangeForFocusReturn() return; } - if (HasSameFileState(PrefabStageSnapshots[assetPath], currentFingerprint)) + if (ExternalAssetFileStateComparer.HasSameFileState( + PrefabStageSnapshots[assetPath], currentFingerprint)) { return; } @@ -584,14 +585,5 @@ private static void LogFocusReturnFailures(string action, string[] failedAssetPa 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; - } } }