From 5b291bfb63a4fb7dd3d7fcbec515b12c0d49e9b5 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 30 Jun 2026 18:11:19 +0900 Subject: [PATCH 1/8] Prevent focus return external asset dialogs Hold Unity Auto Refresh while the editor is unfocused, then save dirty editor state and resolve clean external Scene or Prefab Stage changes before refresh resumes. This keeps common Git restore and checkout workflows from blocking on Unity confirmation dialogs. --- .../ExternalSceneChangeResolverTests.cs | 93 +++++ .../Compile/ExternalSceneChangeResolver.cs | 373 +++++++++++++++++- README.md | 2 + README_ja.md | 2 + 4 files changed, 463 insertions(+), 7 deletions(-) diff --git a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs index fa370713c5..ba420c430e 100644 --- a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs +++ b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs @@ -190,6 +190,84 @@ public void ResolveExternalSceneChanges_WhenSceneUnchanged_DoesNotSaveOrReload() Assert.That(reloadWasCalled, Is.False); } + [Test] + public void FocusReturnService_WhenFocusIsLost_HoldsAutoRefreshOnce() + { + // Verifies focus loss suspends Unity Auto Refresh only once per unfocused interval. + bool autoRefreshHeld = false; + int disallowCallCount = 0; + int allowCallCount = 0; + ExternalAssetFocusReturnService service = CreateFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => disallowCallCount++, + () => allowCallCount++, + () => { }); + + service.HandleFocusChanged(false); + service.HandleFocusChanged(false); + + Assert.That(autoRefreshHeld, Is.True); + Assert.That(disallowCallCount, Is.EqualTo(1)); + Assert.That(allowCallCount, Is.EqualTo(0)); + } + + [Test] + public void FocusReturnService_WhenFocusReturns_RunsPreflightBeforeReleasingAutoRefresh() + { + // Verifies focus return resolves editor state before Unity Auto Refresh resumes. + bool autoRefreshHeld = true; + List events = new List(); + ExternalAssetFocusReturnService service = CreateFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => events.Add("disallow"), + () => events.Add("allow"), + () => events.Add("preflight")); + + service.HandleFocusChanged(true); + + Assert.That(autoRefreshHeld, Is.False); + Assert.That(events, Is.EqualTo(new[] { "preflight", "allow" })); + } + + [Test] + public void FocusReturnService_WhenStartupFindsHeldAutoRefresh_ReleasesIt() + { + // Verifies startup recovery clears an Auto Refresh hold that survived a reload. + bool autoRefreshHeld = true; + int allowCallCount = 0; + ExternalAssetFocusReturnService service = CreateFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => { }, + () => allowCallCount++, + () => { }); + + service.RestoreAutoRefreshIfHeld(); + + Assert.That(autoRefreshHeld, Is.False); + Assert.That(allowCallCount, Is.EqualTo(1)); + } + + [Test] + public void FocusReturnService_WhenPreflightThrows_StillReleasesAutoRefresh() + { + // Verifies Auto Refresh is released even when focus-return preflight fails fast. + bool autoRefreshHeld = true; + int allowCallCount = 0; + ExternalAssetFocusReturnService service = CreateFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => { }, + () => allowCallCount++, + () => throw new InvalidOperationException("preflight failed")); + + Assert.Throws(() => service.HandleFocusChanged(true)); + Assert.That(autoRefreshHeld, Is.False); + Assert.That(allowCallCount, Is.EqualTo(1)); + } + private static Dictionary CreateSnapshots() { Dictionary snapshots = @@ -197,5 +275,20 @@ public void ResolveExternalSceneChanges_WhenSceneUnchanged_DoesNotSaveOrReload() snapshots[ScenePath] = (true, SavedTime, 10); return snapshots; } + + private static ExternalAssetFocusReturnService CreateFocusReturnService( + Func getAutoRefreshHeld, + Action setAutoRefreshHeld, + Action disallowAutoRefresh, + Action allowAutoRefresh, + Action resolveFocusReturnChanges) + { + return new ExternalAssetFocusReturnService( + getAutoRefreshHeld, + setAutoRefreshHeld, + disallowAutoRefresh, + allowAutoRefresh, + resolveFocusReturnChanges); + } } } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs index 8913608b49..27ff4aca45 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs @@ -15,12 +15,30 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class ExternalSceneChangeTracker { - private static readonly Dictionary Snapshots = + private const string AutoRefreshHeldSessionStateKey = + "io.github.hatayama.UnityCliLoop.ExternalSceneChangeTracker.AutoRefreshHeld"; + private static readonly Dictionary SceneSnapshots = new Dictionary(StringComparer.Ordinal); + private static readonly Dictionary PrefabStageSnapshots = + new Dictionary(StringComparer.Ordinal); + private static readonly ExternalAssetFocusReturnService FocusReturnService = + new ExternalAssetFocusReturnService( + () => SessionState.GetBool(AutoRefreshHeldSessionStateKey, false), + isHeld => SessionState.SetBool(AutoRefreshHeldSessionStateKey, isHeld), + AssetDatabase.DisallowAutoRefresh, + AssetDatabase.AllowAutoRefresh, + ResolveForFocusReturn); private static bool _initialized; public static void Initialize() { + if (AssetDatabase.IsAssetImportWorkerProcess()) + { + return; + } + + FocusReturnService.RestoreAutoRefreshIfHeld(); + if (_initialized) { return; @@ -33,7 +51,16 @@ public static void Initialize() 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; RecordOpenSceneSnapshots(); + RecordCurrentPrefabStageSnapshot(); } public static (bool CanProceed, string Message, string[] ScenePaths) ResolveForCompile( @@ -41,14 +68,19 @@ public static (bool CanProceed, string Message, string[] ScenePaths) ResolveForC { Initialize(); ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( - Snapshots, + SceneSnapshots, GetOpenSceneStates, - ReadSceneFileFingerprint, + ReadAssetFileFingerprint, SaveDirtyOpenScenesBeforeReload, ReloadOpenSceneSetup); return resolver.ResolveExternalSceneChanges(reloadExternalSceneChanges); } + private static void HandleFocusChanged(bool isFocused) + { + FocusReturnService.HandleFocusChanged(isFocused); + } + private static void HandleSceneOpened(Scene scene, OpenSceneMode mode) { RecordSceneSnapshot(scene); @@ -66,7 +98,45 @@ private static void HandleSceneClosed(Scene scene) return; } - Snapshots.Remove(NormalizeAssetPath(scene.path)); + SceneSnapshots.Remove(NormalizeAssetPath(scene.path)); + } + + private static void HandlePrefabStageOpened(PrefabStage prefabStage) + { + RecordPrefabStageSnapshot(prefabStage); + } + + private static void HandlePrefabStageClosing(PrefabStage prefabStage) + { + if (!IsTrackablePrefabStage(prefabStage)) + { + return; + } + + PrefabStageSnapshots.Remove(NormalizeAssetPath(prefabStage.assetPath)); + } + + private static void HandlePrefabSaved(GameObject prefabRoot) + { + RecordCurrentPrefabStageSnapshot(); + } + + private static void ResolveForFocusReturn() + { + 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(); + ResolveCurrentPrefabStageExternalChangeForFocusReturn(); } private static void RecordOpenSceneSnapshots() @@ -74,7 +144,7 @@ private static void RecordOpenSceneSnapshots() (string AssetPath, bool IsDirty)[] scenes = GetOpenSceneStates(); for (int i = 0; i < scenes.Length; i++) { - Snapshots[scenes[i].AssetPath] = ReadSceneFileFingerprint(scenes[i].AssetPath); + SceneSnapshots[scenes[i].AssetPath] = ReadAssetFileFingerprint(scenes[i].AssetPath); } } @@ -86,7 +156,23 @@ private static void RecordSceneSnapshot(Scene scene) } string assetPath = NormalizeAssetPath(scene.path); - Snapshots[assetPath] = ReadSceneFileFingerprint(assetPath); + SceneSnapshots[assetPath] = ReadAssetFileFingerprint(assetPath); + } + + 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); } private static (string AssetPath, bool IsDirty)[] GetOpenSceneStates() @@ -114,7 +200,15 @@ private static bool IsTrackableScene(Scene scene) scene.path.EndsWith(".unity", StringComparison.OrdinalIgnoreCase); } - private static (bool Exists, DateTime LastWriteTimeUtc, long Length) ReadSceneFileFingerprint( + private static bool IsTrackablePrefabStage(PrefabStage prefabStage) + { + return prefabStage != null && + prefabStage.scene.IsValid() && + !string.IsNullOrEmpty(prefabStage.assetPath) && + prefabStage.assetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase); + } + + private static (bool Exists, DateTime LastWriteTimeUtc, long Length) ReadAssetFileFingerprint( string assetPath) { Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty"); @@ -130,6 +224,66 @@ private static (bool Exists, DateTime LastWriteTimeUtc, long Length) ReadSceneFi return (true, fileInfo.LastWriteTimeUtc, fileInfo.Length); } + private static void ResolveSceneExternalChangesForFocusReturn() + { + ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( + SceneSnapshots, + GetOpenSceneStates, + ReadAssetFileFingerprint, + SaveDirtyOpenScenesBeforeReload, + ReloadOpenSceneSetup); + (bool CanProceed, string Message, string[] ScenePaths) result = + resolver.ResolveExternalSceneChanges(reloadExternalSceneChanges: true); + 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; + 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); + PrefabStage reopenedStage = PrefabStageUtility.OpenPrefab(assetPath); + if (reopenedStage == null) + { + Debug.LogWarning("Unity CLI Loop could not reopen externally changed Prefab asset on focus return. " + + "Prefab Stage: " + assetPath); + return; + } + + RecordPrefabStageSnapshot(reopenedStage); + } + private static string[] SaveDirtyOpenScenesBeforeReload() { List failedScenePaths = new List(); @@ -153,6 +307,98 @@ private static string[] SaveDirtyOpenScenesBeforeReload() return failedScenePaths.ToArray(); } + private static string[] SaveMissingOpenScenesFromUnity() + { + List failedScenePaths = new List(); + 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; + } + + RecordSceneSnapshot(scene); + } + + 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 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(); @@ -186,6 +432,119 @@ private static string GetSceneDisplayPath(Scene scene) return "Untitled scene"; } + + private static string GetPrefabStageDisplayPath(PrefabStage prefabStage) + { + Debug.Assert(prefabStage != null, "prefabStage must not be null"); + + if (!string.IsNullOrEmpty(prefabStage.assetPath)) + { + return NormalizeAssetPath(prefabStage.assetPath); + } + + return GetSceneDisplayPath(prefabStage.scene); + } + + private static void LogFocusReturnFailures(string action, string[] failedAssetPaths) + { + Debug.Assert(!string.IsNullOrEmpty(action), "action must not be empty"); + Debug.Assert(failedAssetPaths != null, "failedAssetPaths must not be null"); + + if (failedAssetPaths.Length == 0) + { + return; + } + + Debug.LogWarning("Unity CLI Loop could not " + action + " before Unity refreshes assets on focus return. " + + "Affected assets: " + string.Join(", ", failedAssetPaths)); + } + + private static bool HasSameFileState( + (bool Exists, DateTime LastWriteTimeUtc, long Length) previousFingerprint, + (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint) + { + return previousFingerprint.Exists == currentFingerprint.Exists && + previousFingerprint.LastWriteTimeUtc == currentFingerprint.LastWriteTimeUtc && + previousFingerprint.Length == currentFingerprint.Length; + } + } + + /// + /// Coordinates Auto Refresh suspension while Unity is unfocused. + /// + internal sealed class ExternalAssetFocusReturnService + { + private readonly Func _getAutoRefreshHeld; + private readonly Action _setAutoRefreshHeld; + private readonly Action _disallowAutoRefresh; + private readonly Action _allowAutoRefresh; + private readonly Action _resolveFocusReturnChanges; + + internal ExternalAssetFocusReturnService( + Func getAutoRefreshHeld, + Action setAutoRefreshHeld, + 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(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)); + _disallowAutoRefresh = disallowAutoRefresh ?? throw new ArgumentNullException(nameof(disallowAutoRefresh)); + _allowAutoRefresh = allowAutoRefresh ?? throw new ArgumentNullException(nameof(allowAutoRefresh)); + _resolveFocusReturnChanges = + resolveFocusReturnChanges ?? throw new ArgumentNullException(nameof(resolveFocusReturnChanges)); + } + + internal void RestoreAutoRefreshIfHeld() + { + ReleaseAutoRefreshIfHeld(); + } + + 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/README.md b/README.md index f52f3f8adf..6d87c53885 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,8 @@ Dedicated tools exist only for operations that dynamic code execution cannot han # Key Features ## Development Loop Tools +When Unity regains focus after another app changes project files, Unity CLI Loop briefly holds Auto Refresh, saves dirty loaded Scenes and the current Prefab Stage, then reloads clean externally changed open Scenes and the current Prefab Stage asset. This avoids Unity's external-change confirmation dialogs during common Git restore or checkout workflows while preserving the Unity-side editor state. + ### 1. compile - Execute Compilation Performs AssetDatabase.Refresh() and then compiles, returning the results after Domain Reload completes. Can detect errors and warnings that built-in linters cannot find. You can choose between incremental compilation and forced full compilation. diff --git a/README_ja.md b/README_ja.md index 95619b5455..c9e5b2724e 100644 --- a/README_ja.md +++ b/README_ja.md @@ -265,6 +265,8 @@ Unity CLI Loop はツールの数を追い求めません。C#コードの動的 # 主要機能 ## 自律開発ループ系ツール +別アプリでプロジェクトファイルが変更された後にUnityがフォーカスを取り戻すと、Unity CLI Loopは一時的にAuto Refreshを止め、dirtyなロード済みSceneと現在のPrefab Stageを保存してから、cleanな外部変更Scene/Prefabを再読み込みします。これにより、Git restoreやcheckout後の作業再開時にUnityの外部変更確認ダイアログで止まる状況を避けます。 + ### 1. compile - コンパイルの実行 AssetDatabase.Refresh()をした後、Domain Reload完了まで待ってコンパイル結果を返却します。内蔵のLinterでは発見できないエラー・警告を見つける事ができます。 差分コンパイルと強制全体コンパイルを選択できます。 From 09e18bbf0e65057c3e30350b4523315222ceb573 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 1 Jul 2026 16:18:11 +0900 Subject: [PATCH 2/8] Fix focus return preflight edge cases Keep active Auto Refresh holds in place during normal command initialization, and avoid reopening a Prefab Stage when dirty prefab state could not be saved. --- .../Compile/ExternalSceneChangeResolver.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs index 27ff4aca45..4e040b6335 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs @@ -37,13 +37,13 @@ public static void Initialize() return; } - FocusReturnService.RestoreAutoRefreshIfHeld(); - if (_initialized) { return; } + FocusReturnService.RestoreAutoRefreshIfHeld(); + _initialized = true; EditorSceneManager.sceneOpened -= HandleSceneOpened; EditorSceneManager.sceneOpened += HandleSceneOpened; @@ -136,6 +136,15 @@ private static void ResolveForFocusReturn() 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(); } @@ -378,6 +387,12 @@ private static string[] SaveMissingCurrentPrefabStageAsset() 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"); From 046490f88ecd73d4b59af18ade88c688b14421d3 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 1 Jul 2026 16:39:55 +0900 Subject: [PATCH 3/8] Keep Auto Refresh held across unfocused reloads Domain reload can reinitialize the focus-return guard while the editor is still in the background. Release a persisted Auto Refresh hold only when the editor is focused so later filesystem changes still wait for the focus-return preflight. --- .../ExternalSceneChangeResolverTests.cs | 38 ++++++++++++++++--- .../Compile/ExternalSceneChangeResolver.cs | 17 ++++++++- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs index ba420c430e..a561a205f7 100644 --- a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs +++ b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs @@ -200,6 +200,7 @@ public void FocusReturnService_WhenFocusIsLost_HoldsAutoRefreshOnce() ExternalAssetFocusReturnService service = CreateFocusReturnService( () => autoRefreshHeld, isHeld => autoRefreshHeld = isHeld, + () => false, () => disallowCallCount++, () => allowCallCount++, () => { }); @@ -221,6 +222,7 @@ public void FocusReturnService_WhenFocusReturns_RunsPreflightBeforeReleasingAuto ExternalAssetFocusReturnService service = CreateFocusReturnService( () => autoRefreshHeld, isHeld => autoRefreshHeld = isHeld, + () => true, () => events.Add("disallow"), () => events.Add("allow"), () => events.Add("preflight")); @@ -232,22 +234,45 @@ public void FocusReturnService_WhenFocusReturns_RunsPreflightBeforeReleasingAuto } [Test] - public void FocusReturnService_WhenStartupFindsHeldAutoRefresh_ReleasesIt() + public void FocusReturnService_WhenStartupFindsHeldAutoRefreshAndEditorIsFocused_RunsPreflightBeforeReleasingIt() { - // Verifies startup recovery clears an Auto Refresh hold that survived a reload. + // Verifies focused startup recovery resolves editor state before releasing a reload-surviving hold. + bool autoRefreshHeld = true; + List events = new List(); + ExternalAssetFocusReturnService service = CreateFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => true, + () => events.Add("disallow"), + () => events.Add("allow"), + () => events.Add("preflight")); + + service.RestoreAutoRefreshIfHeld(); + + Assert.That(autoRefreshHeld, Is.False); + Assert.That(events, Is.EqualTo(new[] { "preflight", "allow" })); + } + + [Test] + public void FocusReturnService_WhenStartupFindsHeldAutoRefreshAndEditorIsUnfocused_KeepsAutoRefreshHeld() + { + // Verifies unfocused startup recovery keeps Auto Refresh suspended until focus returns. bool autoRefreshHeld = true; int allowCallCount = 0; + int preflightCallCount = 0; ExternalAssetFocusReturnService service = CreateFocusReturnService( () => autoRefreshHeld, isHeld => autoRefreshHeld = isHeld, + () => false, () => { }, () => allowCallCount++, - () => { }); + () => preflightCallCount++); service.RestoreAutoRefreshIfHeld(); - Assert.That(autoRefreshHeld, Is.False); - Assert.That(allowCallCount, Is.EqualTo(1)); + Assert.That(autoRefreshHeld, Is.True); + Assert.That(allowCallCount, Is.EqualTo(0)); + Assert.That(preflightCallCount, Is.EqualTo(0)); } [Test] @@ -259,6 +284,7 @@ public void FocusReturnService_WhenPreflightThrows_StillReleasesAutoRefresh() ExternalAssetFocusReturnService service = CreateFocusReturnService( () => autoRefreshHeld, isHeld => autoRefreshHeld = isHeld, + () => true, () => { }, () => allowCallCount++, () => throw new InvalidOperationException("preflight failed")); @@ -279,6 +305,7 @@ public void FocusReturnService_WhenPreflightThrows_StillReleasesAutoRefresh() private static ExternalAssetFocusReturnService CreateFocusReturnService( Func getAutoRefreshHeld, Action setAutoRefreshHeld, + Func isEditorFocused, Action disallowAutoRefresh, Action allowAutoRefresh, Action resolveFocusReturnChanges) @@ -286,6 +313,7 @@ private static ExternalAssetFocusReturnService CreateFocusReturnService( return new ExternalAssetFocusReturnService( getAutoRefreshHeld, setAutoRefreshHeld, + isEditorFocused, disallowAutoRefresh, allowAutoRefresh, resolveFocusReturnChanges); diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs index 4e040b6335..eb1ae074f1 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs @@ -25,6 +25,7 @@ internal static class ExternalSceneChangeTracker new ExternalAssetFocusReturnService( () => SessionState.GetBool(AutoRefreshHeldSessionStateKey, false), isHeld => SessionState.SetBool(AutoRefreshHeldSessionStateKey, isHeld), + () => EditorApplication.isFocused, AssetDatabase.DisallowAutoRefresh, AssetDatabase.AllowAutoRefresh, ResolveForFocusReturn); @@ -491,6 +492,7 @@ 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; @@ -498,18 +500,21 @@ internal sealed class ExternalAssetFocusReturnService 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 = @@ -518,7 +523,17 @@ internal ExternalAssetFocusReturnService( internal void RestoreAutoRefreshIfHeld() { - ReleaseAutoRefreshIfHeld(); + if (!_getAutoRefreshHeld()) + { + return; + } + + if (!_isEditorFocused()) + { + return; + } + + HandleFocusChanged(true); } internal void HandleFocusChanged(bool isFocused) From dc6c09ccf5227c95e176272b98c17e2d7f2f2fc9 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 1 Jul 2026 16:44:46 +0900 Subject: [PATCH 4/8] Document focus-return save policy Focus-return handling intentionally persists Unity's in-memory editor state before refresh so source-control file replacement cannot trigger external reload dialogs. --- .../FirstPartyTools/Compile/ExternalSceneChangeResolver.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs index eb1ae074f1..673e654660 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs @@ -124,6 +124,8 @@ private static void HandlePrefabSaved(GameObject prefabRoot) 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); From ce4f20b7ff89e246c1cb80978a05e2c421d710b1 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 1 Jul 2026 17:02:13 +0900 Subject: [PATCH 5/8] Preserve Prefab Stage context on reopen Reopen externally changed Prefab Stages with the original opened instance and mode so focus-return refresh keeps in-context editing state intact. --- .../FirstPartyTools/Compile/ExternalSceneChangeResolver.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs index 673e654660..2098855e80 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs @@ -284,8 +284,11 @@ private static void ResolveCurrentPrefabStageExternalChangeForFocusReturn() return; } + GameObject openedFromInstanceObject = prefabStage.openedFromInstanceObject; + PrefabStage.Mode prefabStageMode = prefabStage.mode; AssetDatabase.ImportAsset(assetPath); - PrefabStage reopenedStage = PrefabStageUtility.OpenPrefab(assetPath); + PrefabStage reopenedStage = + PrefabStageUtility.OpenPrefab(assetPath, openedFromInstanceObject, prefabStageMode); if (reopenedStage == null) { Debug.LogWarning("Unity CLI Loop could not reopen externally changed Prefab asset on focus return. " + From 066ffd8eb7a4bee3e99164a27cb5189994ae53bd Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 1 Jul 2026 17:51:35 +0900 Subject: [PATCH 6/8] Harden focus-return recovery after reloads Persist tracked asset fingerprints across editor domain reloads so focus-return preflight can still detect external Scene and Prefab changes. Also validate Prefab Stage reopen inputs before calling Unity APIs that throw for invalid or missing Prefab assets. --- .../ExternalSceneChangeResolverTests.cs | 94 ++++++++ .../Compile/ExternalSceneChangeResolver.cs | 206 ++++++++++++++++-- 2 files changed, 280 insertions(+), 20 deletions(-) diff --git a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs index a561a205f7..6a8d479c32 100644 --- a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs +++ b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using NUnit.Framework; +using UnityEditor.SceneManagement; +using UnityEngine; using io.github.hatayama.UnityCliLoop.FirstPartyTools; @@ -294,6 +296,98 @@ public void FocusReturnService_WhenPreflightThrows_StillReleasesAutoRefresh() Assert.That(allowCallCount, Is.EqualTo(1)); } + [Test] + public void SnapshotSessionStore_WhenSnapshotsRoundTrip_PreservesFingerprints() + { + // Verifies focus-return snapshots survive a domain reload through JSON session storage. + Dictionary snapshots = + new Dictionary(StringComparer.Ordinal); + snapshots[ScenePath] = (true, SavedTime, 10); + const string MissingScenePath = "Assets/Scenes/MissingScene.unity"; + snapshots[MissingScenePath] = (false, DateTime.MinValue, 0); + + string json = ExternalAssetSnapshotSessionStore.SerializeSnapshots(snapshots); + Dictionary restored = + new Dictionary(StringComparer.Ordinal); + + ExternalAssetSnapshotSessionStore.RestoreSnapshots(restored, json); + + Assert.That(restored.Count, Is.EqualTo(2)); + Assert.That(restored[ScenePath].Exists, Is.True); + Assert.That(restored[ScenePath].LastWriteTimeUtc, Is.EqualTo(SavedTime)); + Assert.That(restored[ScenePath].Length, Is.EqualTo(10)); + Assert.That(restored[MissingScenePath].Exists, Is.False); + } + + [Test] + public void SnapshotSessionStore_WhenJsonIsEmpty_ClearsSnapshots() + { + // Verifies empty session data clears stale snapshots after normal startup. + Dictionary snapshots = CreateSnapshots(); + + ExternalAssetSnapshotSessionStore.RestoreSnapshots(snapshots, ""); + + Assert.That(snapshots, Is.Empty); + } + + [Test] + public void CreatePrefabStageReopenContext_WhenInstanceIsValid_PreservesContext() + { + // Verifies valid in-context Prefab Stage reopen data is preserved. + GameObject openedFromInstanceObject = new GameObject("OpenedFromInstanceObject"); + try + { + (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) context = + ExternalSceneChangeTracker.CreatePrefabStageReopenContext( + openedFromInstanceObject, + PrefabStage.Mode.InContext, + _ => true); + + Assert.That(context.OpenedFromInstanceObject, Is.SameAs(openedFromInstanceObject)); + Assert.That(context.Mode, Is.EqualTo(PrefabStage.Mode.InContext)); + } + finally + { + UnityEngine.Object.DestroyImmediate(openedFromInstanceObject); + } + } + + [Test] + public void CreatePrefabStageReopenContext_WhenInstanceIsInvalid_FallsBackToIsolation() + { + // Verifies invalid in-context Prefab Stage reopen data cannot reach OpenPrefab. + GameObject openedFromInstanceObject = new GameObject("OpenedFromInstanceObject"); + try + { + (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) context = + ExternalSceneChangeTracker.CreatePrefabStageReopenContext( + openedFromInstanceObject, + PrefabStage.Mode.InContext, + _ => false); + + Assert.That(context.OpenedFromInstanceObject, Is.Null); + Assert.That(context.Mode, Is.EqualTo(PrefabStage.Mode.InIsolation)); + } + finally + { + UnityEngine.Object.DestroyImmediate(openedFromInstanceObject); + } + } + + [Test] + public void CreatePrefabStageReopenContext_WhenInstanceIsMissing_UsesIsolation() + { + // Verifies missing Prefab Stage context reopens without invalid InContext arguments. + (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) context = + ExternalSceneChangeTracker.CreatePrefabStageReopenContext( + null, + PrefabStage.Mode.InContext, + _ => true); + + Assert.That(context.OpenedFromInstanceObject, Is.Null); + Assert.That(context.Mode, Is.EqualTo(PrefabStage.Mode.InIsolation)); + } + private static Dictionary CreateSnapshots() { Dictionary snapshots = diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs index 2098855e80..52e60a0401 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs @@ -17,14 +17,18 @@ 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( - () => SessionState.GetBool(AutoRefreshHeldSessionStateKey, false), - isHeld => SessionState.SetBool(AutoRefreshHeldSessionStateKey, isHeld), + IsAutoRefreshHeld, + SetAutoRefreshHeld, () => EditorApplication.isFocused, AssetDatabase.DisallowAutoRefresh, AssetDatabase.AllowAutoRefresh, @@ -43,6 +47,7 @@ public static void Initialize() return; } + RestoreSnapshotsFromSessionState(); FocusReturnService.RestoreAutoRefreshIfHeld(); _initialized = true; @@ -60,21 +65,22 @@ public static void Initialize() PrefabStage.prefabSaved += HandlePrefabSaved; EditorApplication.focusChanged -= HandleFocusChanged; EditorApplication.focusChanged += HandleFocusChanged; - RecordOpenSceneSnapshots(); - RecordCurrentPrefabStageSnapshot(); + if (!IsAutoRefreshHeld()) + { + RecordOpenSceneSnapshots(); + RecordCurrentPrefabStageSnapshot(); + } } public static (bool CanProceed, string Message, string[] ScenePaths) ResolveForCompile( bool reloadExternalSceneChanges) { Initialize(); - ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( - SceneSnapshots, - GetOpenSceneStates, - ReadAssetFileFingerprint, - SaveDirtyOpenScenesBeforeReload, - ReloadOpenSceneSetup); - return resolver.ResolveExternalSceneChanges(reloadExternalSceneChanges); + ExternalSceneChangeResolver resolver = CreateSceneChangeResolver(); + (bool CanProceed, string Message, string[] ScenePaths) result = + resolver.ResolveExternalSceneChanges(reloadExternalSceneChanges); + SaveSceneSnapshotsToSessionState(); + return result; } private static void HandleFocusChanged(bool isFocused) @@ -100,6 +106,7 @@ private static void HandleSceneClosed(Scene scene) } SceneSnapshots.Remove(NormalizeAssetPath(scene.path)); + SaveSceneSnapshotsToSessionState(); } private static void HandlePrefabStageOpened(PrefabStage prefabStage) @@ -115,6 +122,7 @@ private static void HandlePrefabStageClosing(PrefabStage prefabStage) } PrefabStageSnapshots.Remove(NormalizeAssetPath(prefabStage.assetPath)); + SavePrefabStageSnapshotsToSessionState(); } private static void HandlePrefabSaved(GameObject prefabRoot) @@ -153,11 +161,14 @@ private static void ResolveForFocusReturn() 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) @@ -169,6 +180,7 @@ private static void RecordSceneSnapshot(Scene scene) string assetPath = NormalizeAssetPath(scene.path); SceneSnapshots[assetPath] = ReadAssetFileFingerprint(assetPath); + SaveSceneSnapshotsToSessionState(); } private static void RecordCurrentPrefabStageSnapshot() @@ -185,6 +197,7 @@ private static void RecordPrefabStageSnapshot(PrefabStage prefabStage) string assetPath = NormalizeAssetPath(prefabStage.assetPath); PrefabStageSnapshots[assetPath] = ReadAssetFileFingerprint(assetPath); + SavePrefabStageSnapshotsToSessionState(); } private static (string AssetPath, bool IsDirty)[] GetOpenSceneStates() @@ -238,14 +251,10 @@ private static (bool Exists, DateTime LastWriteTimeUtc, long Length) ReadAssetFi private static void ResolveSceneExternalChangesForFocusReturn() { - ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver( - SceneSnapshots, - GetOpenSceneStates, - ReadAssetFileFingerprint, - SaveDirtyOpenScenesBeforeReload, - ReloadOpenSceneSetup); + ExternalSceneChangeResolver resolver = CreateSceneChangeResolver(); (bool CanProceed, string Message, string[] ScenePaths) result = resolver.ResolveExternalSceneChanges(reloadExternalSceneChanges: true); + SaveSceneSnapshotsToSessionState(); if (result.CanProceed) { return; @@ -269,6 +278,7 @@ private static void ResolveCurrentPrefabStageExternalChangeForFocusReturn() if (!PrefabStageSnapshots.ContainsKey(assetPath)) { PrefabStageSnapshots[assetPath] = currentFingerprint; + SavePrefabStageSnapshotsToSessionState(); return; } @@ -284,21 +294,56 @@ private static void ResolveCurrentPrefabStageExternalChangeForFocusReturn() return; } - GameObject openedFromInstanceObject = prefabStage.openedFromInstanceObject; - PrefabStage.Mode prefabStageMode = prefabStage.mode; 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); + PrefabStageSnapshots[assetPath] = currentFingerprint; + SavePrefabStageSnapshotsToSessionState(); + return; + } + + (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) reopenContext = + CreatePrefabStageReopenContext( + prefabStage.openedFromInstanceObject, + prefabStage.mode, + PrefabUtility.IsPartOfPrefabInstance); PrefabStage reopenedStage = - PrefabStageUtility.OpenPrefab(assetPath, openedFromInstanceObject, prefabStageMode); + 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); + PrefabStageSnapshots[assetPath] = currentFingerprint; + SavePrefabStageSnapshotsToSessionState(); 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(); @@ -433,6 +478,50 @@ private static bool ReloadOpenSceneSetup() return true; } + private static ExternalSceneChangeResolver CreateSceneChangeResolver() + { + return new ExternalSceneChangeResolver( + SceneSnapshots, + GetOpenSceneStates, + ReadAssetFileFingerprint, + SaveDirtyOpenScenesBeforeReload, + ReloadOpenSceneSetup); + } + + private static bool IsAutoRefreshHeld() + { + return SessionState.GetBool(AutoRefreshHeldSessionStateKey, false); + } + + private static void SetAutoRefreshHeld(bool isHeld) + { + SessionState.SetBool(AutoRefreshHeldSessionStateKey, isHeld); + } + + private static void RestoreSnapshotsFromSessionState() + { + ExternalAssetSnapshotSessionStore.RestoreSnapshots( + SceneSnapshots, + SessionState.GetString(SceneSnapshotsSessionStateKey, "")); + ExternalAssetSnapshotSessionStore.RestoreSnapshots( + PrefabStageSnapshots, + SessionState.GetString(PrefabStageSnapshotsSessionStateKey, "")); + } + + private static void SaveSceneSnapshotsToSessionState() + { + SessionState.SetString( + SceneSnapshotsSessionStateKey, + ExternalAssetSnapshotSessionStore.SerializeSnapshots(SceneSnapshots)); + } + + private static void SavePrefabStageSnapshotsToSessionState() + { + SessionState.SetString( + PrefabStageSnapshotsSessionStateKey, + ExternalAssetSnapshotSessionStore.SerializeSnapshots(PrefabStageSnapshots)); + } + private static string NormalizeAssetPath(string assetPath) { Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty"); @@ -490,6 +579,83 @@ private static bool HasSameFileState( } } + /// + /// 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. /// From 7f3690a91311408ebc219febde9d5943f2a71a05 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 1 Jul 2026 17:54:02 +0900 Subject: [PATCH 7/8] Revert README focus-return notes Remove the README additions for the focus-return behavior so the PR keeps the documentation unchanged. --- README.md | 2 -- README_ja.md | 2 -- 2 files changed, 4 deletions(-) diff --git a/README.md b/README.md index 6d87c53885..f52f3f8adf 100644 --- a/README.md +++ b/README.md @@ -266,8 +266,6 @@ Dedicated tools exist only for operations that dynamic code execution cannot han # Key Features ## Development Loop Tools -When Unity regains focus after another app changes project files, Unity CLI Loop briefly holds Auto Refresh, saves dirty loaded Scenes and the current Prefab Stage, then reloads clean externally changed open Scenes and the current Prefab Stage asset. This avoids Unity's external-change confirmation dialogs during common Git restore or checkout workflows while preserving the Unity-side editor state. - ### 1. compile - Execute Compilation Performs AssetDatabase.Refresh() and then compiles, returning the results after Domain Reload completes. Can detect errors and warnings that built-in linters cannot find. You can choose between incremental compilation and forced full compilation. diff --git a/README_ja.md b/README_ja.md index c9e5b2724e..95619b5455 100644 --- a/README_ja.md +++ b/README_ja.md @@ -265,8 +265,6 @@ Unity CLI Loop はツールの数を追い求めません。C#コードの動的 # 主要機能 ## 自律開発ループ系ツール -別アプリでプロジェクトファイルが変更された後にUnityがフォーカスを取り戻すと、Unity CLI Loopは一時的にAuto Refreshを止め、dirtyなロード済みSceneと現在のPrefab Stageを保存してから、cleanな外部変更Scene/Prefabを再読み込みします。これにより、Git restoreやcheckout後の作業再開時にUnityの外部変更確認ダイアログで止まる状況を避けます。 - ### 1. compile - コンパイルの実行 AssetDatabase.Refresh()をした後、Domain Reload完了まで待ってコンパイル結果を返却します。内蔵のLinterでは発見できないエラー・警告を見つける事ができます。 差分コンパイルと強制全体コンパイルを選択できます。 From 0ff763b4c8da2d5f5221496d43610db6f2c8aae4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 1 Jul 2026 18:13:52 +0900 Subject: [PATCH 8/8] Keep unresolved focus-return snapshots stale Do not advance Prefab Stage baselines when reopening fails, because the editor can still be showing the previous Prefab contents. Also skip initial snapshot recording after held Auto Refresh recovery has already run preflight, and batch Scene snapshot persistence inside focus-return save loops. --- .../ExternalSceneChangeResolverTests.cs | 6 ++- .../Compile/ExternalSceneChangeResolver.cs | 43 +++++++++++++------ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs index 6a8d479c32..49045dd962 100644 --- a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs +++ b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs @@ -249,8 +249,9 @@ public void FocusReturnService_WhenStartupFindsHeldAutoRefreshAndEditorIsFocused () => events.Add("allow"), () => events.Add("preflight")); - service.RestoreAutoRefreshIfHeld(); + bool restoredHeldAutoRefresh = service.RestoreAutoRefreshIfHeld(); + Assert.That(restoredHeldAutoRefresh, Is.True); Assert.That(autoRefreshHeld, Is.False); Assert.That(events, Is.EqualTo(new[] { "preflight", "allow" })); } @@ -270,8 +271,9 @@ public void FocusReturnService_WhenStartupFindsHeldAutoRefreshAndEditorIsUnfocus () => allowCallCount++, () => preflightCallCount++); - service.RestoreAutoRefreshIfHeld(); + bool restoredHeldAutoRefresh = service.RestoreAutoRefreshIfHeld(); + Assert.That(restoredHeldAutoRefresh, Is.False); Assert.That(autoRefreshHeld, Is.True); Assert.That(allowCallCount, Is.EqualTo(0)); Assert.That(preflightCallCount, Is.EqualTo(0)); diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs index 52e60a0401..bcb8179204 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeResolver.cs @@ -48,7 +48,7 @@ public static void Initialize() } RestoreSnapshotsFromSessionState(); - FocusReturnService.RestoreAutoRefreshIfHeld(); + bool restoredHeldAutoRefresh = FocusReturnService.RestoreAutoRefreshIfHeld(); _initialized = true; EditorSceneManager.sceneOpened -= HandleSceneOpened; @@ -65,7 +65,7 @@ public static void Initialize() PrefabStage.prefabSaved += HandlePrefabSaved; EditorApplication.focusChanged -= HandleFocusChanged; EditorApplication.focusChanged += HandleFocusChanged; - if (!IsAutoRefreshHeld()) + if (!restoredHeldAutoRefresh && !IsAutoRefreshHeld()) { RecordOpenSceneSnapshots(); RecordCurrentPrefabStageSnapshot(); @@ -172,15 +172,23 @@ private static void RecordOpenSceneSnapshots() } private static void RecordSceneSnapshot(Scene scene) + { + if (RecordSceneSnapshotIfTrackable(scene)) + { + SaveSceneSnapshotsToSessionState(); + } + } + + private static bool RecordSceneSnapshotIfTrackable(Scene scene) { if (!IsTrackableScene(scene)) { - return; + return false; } string assetPath = NormalizeAssetPath(scene.path); SceneSnapshots[assetPath] = ReadAssetFileFingerprint(assetPath); - SaveSceneSnapshotsToSessionState(); + return true; } private static void RecordCurrentPrefabStageSnapshot() @@ -300,8 +308,6 @@ private static void ResolveCurrentPrefabStageExternalChangeForFocusReturn() { Debug.LogWarning("Unity CLI Loop could not reopen externally changed Prefab asset on focus return. " + "Prefab Stage: " + assetPath); - PrefabStageSnapshots[assetPath] = currentFingerprint; - SavePrefabStageSnapshotsToSessionState(); return; } @@ -316,8 +322,6 @@ private static void ResolveCurrentPrefabStageExternalChangeForFocusReturn() { Debug.LogWarning("Unity CLI Loop could not reopen externally changed Prefab asset on focus return. " + "Prefab Stage: " + assetPath); - PrefabStageSnapshots[assetPath] = currentFingerprint; - SavePrefabStageSnapshotsToSessionState(); return; } @@ -347,6 +351,7 @@ internal static (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) Cre private static string[] SaveDirtyOpenScenesBeforeReload() { List failedScenePaths = new List(); + bool hasRecordedSceneSnapshot = false; for (int i = 0; i < SceneManager.sceneCount; i++) { Scene scene = SceneManager.GetSceneAt(i); @@ -361,7 +366,12 @@ private static string[] SaveDirtyOpenScenesBeforeReload() continue; } - RecordSceneSnapshot(scene); + hasRecordedSceneSnapshot = RecordSceneSnapshotIfTrackable(scene) || hasRecordedSceneSnapshot; + } + + if (hasRecordedSceneSnapshot) + { + SaveSceneSnapshotsToSessionState(); } return failedScenePaths.ToArray(); @@ -370,6 +380,7 @@ private static string[] SaveDirtyOpenScenesBeforeReload() private static string[] SaveMissingOpenScenesFromUnity() { List failedScenePaths = new List(); + bool hasRecordedSceneSnapshot = false; for (int i = 0; i < SceneManager.sceneCount; i++) { Scene scene = SceneManager.GetSceneAt(i); @@ -392,7 +403,12 @@ private static string[] SaveMissingOpenScenesFromUnity() continue; } - RecordSceneSnapshot(scene); + hasRecordedSceneSnapshot = RecordSceneSnapshotIfTrackable(scene) || hasRecordedSceneSnapshot; + } + + if (hasRecordedSceneSnapshot) + { + SaveSceneSnapshotsToSessionState(); } return failedScenePaths.ToArray(); @@ -692,19 +708,20 @@ internal ExternalAssetFocusReturnService( resolveFocusReturnChanges ?? throw new ArgumentNullException(nameof(resolveFocusReturnChanges)); } - internal void RestoreAutoRefreshIfHeld() + internal bool RestoreAutoRefreshIfHeld() { if (!_getAutoRefreshHeld()) { - return; + return false; } if (!_isEditorFocused()) { - return; + return false; } HandleFocusChanged(true); + return true; } internal void HandleFocusChanged(bool isFocused)