using System; using System.Collections.Generic; using System.Linq; using UnityEditor; using UnityEngine; namespace UnityTcp.Editor.Helpers { /// /// Hook mechanism for external tools to notify Unity when they've modified files /// that may affect Unity's state (scripts, assets, scenes, etc.). /// This is NOT exposed as a tool to LLM - it's an internal notification system. /// public static class UnityStateDirtyHook { /// /// File change types that can affect Unity state. /// public enum FileChangeType { ScriptModified, // .cs files modified AssetModified, // Asset files (.prefab, .mat, .asset, etc.) modified SceneModified, // .unity scene files modified ShaderModified, // .shader files modified ConfigModified, // Project settings, package.json, etc. modified UIModified, // .uxml, .uss files modified Unknown // Other file types } private static readonly Queue _pendingNotifications = new Queue(); private static readonly object _notificationLock = new object(); private static bool _refreshScheduled = false; /// /// Notification record for dirty file changes. /// public class DirtyNotification { public DateTime Timestamp { get; set; } public FileChangeType ChangeType { get; set; } public string FilePath { get; set; } public string ToolName { get; set; } public bool RequiresReimport { get; set; } public bool RequiresCompilation { get; set; } } /// /// Called by external agentic tools (edit, write, etc.) to notify Unity of file changes. /// This is the main entry point for the hook system. /// /// Path to the file that was modified /// Name of the tool that made the change (for logging) public static void NotifyFileChanged(string filePath, string toolName = "unknown") { if (string.IsNullOrEmpty(filePath)) return; try { // Normalize path filePath = filePath.Replace('\\', '/'); // Determine change type and required actions var changeType = DetermineChangeType(filePath); bool requiresReimport = ShouldReimport(filePath, changeType); bool requiresCompilation = RequiresCompilation(filePath, changeType); var notification = new DirtyNotification { Timestamp = DateTime.UtcNow, ChangeType = changeType, FilePath = filePath, ToolName = toolName, RequiresReimport = requiresReimport, RequiresCompilation = requiresCompilation }; lock (_notificationLock) { _pendingNotifications.Enqueue(notification); // Schedule refresh on next editor update if (!_refreshScheduled) { _refreshScheduled = true; EditorApplication.delayCall += ProcessPendingNotifications; } } Debug.Log($"[UnityStateDirtyHook] Notified: {changeType} - {filePath} (from {toolName})"); } catch (Exception e) { Debug.LogWarning($"[UnityStateDirtyHook] Failed to process notification for {filePath}: {e.Message}"); } } /// /// Process all pending dirty notifications and trigger appropriate Unity actions. /// private static void ProcessPendingNotifications() { List toProcess; lock (_notificationLock) { if (_pendingNotifications.Count == 0) { _refreshScheduled = false; return; } toProcess = new List(_pendingNotifications); _pendingNotifications.Clear(); _refreshScheduled = false; } // Group by action type var needsReimport = toProcess.Where(n => n.RequiresReimport).Select(n => n.FilePath).Distinct().ToList(); var needsCompilation = toProcess.Any(n => n.RequiresCompilation); // Process reimports if (needsReimport.Count > 0) { Debug.Log($"[UnityStateDirtyHook] Reimporting {needsReimport.Count} assets..."); foreach (var path in needsReimport) { // Convert to Unity-relative path if needed string unityPath = ConvertToUnityPath(path); if (!string.IsNullOrEmpty(unityPath)) { AssetDatabase.ImportAsset(unityPath, ImportAssetOptions.ForceUpdate); } } AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate); } // Increment state revision StateComposer.IncrementRevision(); // Log summary var summary = new { processed = toProcess.Count, reimported = needsReimport.Count, needsCompilation = needsCompilation, byType = toProcess.GroupBy(n => n.ChangeType).ToDictionary(g => g.Key.ToString(), g => g.Count()) }; Debug.Log($"[UnityStateDirtyHook] Processed notifications: {Codely.Newtonsoft.Json.JsonConvert.SerializeObject(summary)}"); // If compilation is needed, it will happen automatically via Unity's asset pipeline if (needsCompilation) { Debug.Log("[UnityStateDirtyHook] Script changes detected - Unity will trigger compilation automatically"); } } /// /// Determine the type of change based on file extension. /// private static FileChangeType DetermineChangeType(string filePath) { string ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant(); switch (ext) { case ".cs": return FileChangeType.ScriptModified; case ".unity": case ".scene": return FileChangeType.SceneModified; case ".shader": case ".shadergraph": case ".shadersubgraph": return FileChangeType.ShaderModified; case ".prefab": case ".mat": case ".asset": case ".png": case ".jpg": case ".jpeg": case ".psd": case ".fbx": case ".obj": case ".mp3": case ".wav": case ".anim": case ".controller": return FileChangeType.AssetModified; case ".uxml": case ".uss": return FileChangeType.UIModified; case ".json": case ".asmdef": case ".asmref": return FileChangeType.ConfigModified; default: return FileChangeType.Unknown; } } /// /// Determine if a file change requires reimporting in Unity. /// private static bool ShouldReimport(string filePath, FileChangeType changeType) { // Check if file is in Assets/ folder if (!IsInAssetsFolder(filePath)) return false; switch (changeType) { case FileChangeType.ScriptModified: case FileChangeType.ShaderModified: case FileChangeType.AssetModified: case FileChangeType.SceneModified: case FileChangeType.UIModified: return true; case FileChangeType.ConfigModified: return filePath.Contains("package.json") || filePath.Contains(".asmdef"); default: return false; } } /// /// Determine if a file change requires script compilation. /// private static bool RequiresCompilation(string filePath, FileChangeType changeType) { return changeType == FileChangeType.ScriptModified && IsInAssetsFolder(filePath); } /// /// Check if a file path is within the Unity Assets folder. /// private static bool IsInAssetsFolder(string filePath) { string normalizedPath = filePath.Replace('\\', '/'); return normalizedPath.Contains("/Assets/") || normalizedPath.StartsWith("Assets/"); } /// /// Convert an absolute or relative path to Unity-relative path (Assets/...). /// private static string ConvertToUnityPath(string filePath) { string normalized = filePath.Replace('\\', '/'); // Already Unity-relative if (normalized.StartsWith("Assets/")) return normalized; // Extract Assets/... portion int assetsIndex = normalized.IndexOf("/Assets/"); if (assetsIndex >= 0) return normalized.Substring(assetsIndex + 1); // Skip the leading / // Check if it's relative to project root string projectRoot = Application.dataPath.Replace("/Assets", "").Replace('\\', '/'); if (normalized.StartsWith(projectRoot)) { string relativePath = normalized.Substring(projectRoot.Length).TrimStart('/'); if (relativePath.StartsWith("Assets/")) return relativePath; } return null; } /// /// Get statistics about recent dirty notifications (for debugging). /// public static object GetStatistics() { lock (_notificationLock) { return new { pending = _pendingNotifications.Count, refreshScheduled = _refreshScheduled }; } } /// /// Clear all pending notifications (for testing/debugging). /// public static void ClearPendingNotifications() { lock (_notificationLock) { _pendingNotifications.Clear(); _refreshScheduled = false; } Debug.Log("[UnityStateDirtyHook] Cleared all pending notifications"); } } }