Imporving AssetDatabase

This commit is contained in:
2026-01-30 21:20:18 +09:00
parent 9f05944d81
commit d263f0c7e1
8 changed files with 158 additions and 201 deletions

View File

@@ -17,11 +17,8 @@ public static partial class AssetDatabase
private const int MAX_CACHED_ASSETS = 1000; private const int MAX_CACHED_ASSETS = 1000;
// Percentage of cache to evict when limit is reached (evict oldest 20%) // Percentage of cache to evict when limit is reached (evict oldest 20%)
private const float CACHE_EVICTION_PERCENTAGE = 0.2f; private const float _CACHE_EVICTION_PERCENTAGE = 0.2f;
/// <summary>
/// Get the path to the imported asset data directory.
/// </summary>
private static Result<string> GetImportedAssetsDirectory() private static Result<string> GetImportedAssetsDirectory()
{ {
if (AssetsDirectory == null) if (AssetsDirectory == null)
@@ -30,7 +27,6 @@ public static partial class AssetDatabase
} }
var cacheDir = Path.Combine(AssetsDirectory.Parent!.FullName, ProjectService.CACHE_FOLDER, "ImportedAssets"); var cacheDir = Path.Combine(AssetsDirectory.Parent!.FullName, ProjectService.CACHE_FOLDER, "ImportedAssets");
if (!Directory.Exists(cacheDir)) if (!Directory.Exists(cacheDir))
{ {
Directory.CreateDirectory(cacheDir); Directory.CreateDirectory(cacheDir);
@@ -39,11 +35,6 @@ public static partial class AssetDatabase
return cacheDir; return cacheDir;
} }
/// <summary>
/// Get the path where imported asset data is stored for a specific GUID.
/// </summary>
/// <param name="guid">GUID of the asset.</param>
/// <returns>Full path to the imported asset data file.</returns>
private static Result<string> GetImportedAssetPath(Guid guid) private static Result<string> GetImportedAssetPath(Guid guid)
{ {
var importedDirResult = GetImportedAssetsDirectory(); var importedDirResult = GetImportedAssetsDirectory();
@@ -57,12 +48,6 @@ public static partial class AssetDatabase
return assetDataPath; return assetDataPath;
} }
/// <summary>
/// Load asset by GUID with caching (internal implementation).
/// </summary>
/// <typeparam name="T">Type of asset to load.</typeparam>
/// <param name="guid">GUID of the asset.</param>
/// <returns>The loaded asset.</returns>
private static Result<T> LoadAssetInternal<T>(Guid guid) where T : Asset private static Result<T> LoadAssetInternal<T>(Guid guid) where T : Asset
{ {
// Check cache first // Check cache first
@@ -89,7 +74,6 @@ public static partial class AssetDatabase
} }
var assetDataPath = assetPathResult.Value; var assetDataPath = assetPathResult.Value;
if (!File.Exists(assetDataPath)) if (!File.Exists(assetDataPath))
{ {
return Result<T>.Failure($"Imported asset data not found at {assetDataPath}. Asset may not have been imported yet."); return Result<T>.Failure($"Imported asset data not found at {assetDataPath}. Asset may not have been imported yet.");
@@ -100,7 +84,6 @@ public static partial class AssetDatabase
// Read and deserialize asset data // Read and deserialize asset data
var json = File.ReadAllText(assetDataPath); var json = File.ReadAllText(assetDataPath);
var asset = JsonSerializer.Deserialize<T>(json); var asset = JsonSerializer.Deserialize<T>(json);
if (asset == null) if (asset == null)
{ {
return Result<T>.Failure("Failed to deserialize asset data"); return Result<T>.Failure("Failed to deserialize asset data");
@@ -108,7 +91,6 @@ public static partial class AssetDatabase
// Add to cache // Add to cache
CacheAsset(guid, asset); CacheAsset(guid, asset);
return asset; return asset;
} }
catch (Exception ex) catch (Exception ex)
@@ -117,12 +99,6 @@ public static partial class AssetDatabase
} }
} }
/// <summary>
/// Load asset by path with caching.
/// </summary>
/// <typeparam name="T">Type of asset to load.</typeparam>
/// <param name="assetPath">Full or relative path to the asset.</param>
/// <returns>The loaded asset.</returns>
public static Result<T> LoadAssetAtPath<T>(string assetPath) where T : Asset public static Result<T> LoadAssetAtPath<T>(string assetPath) where T : Asset
{ {
var guidResult = PathToGuid(assetPath); var guidResult = PathToGuid(assetPath);
@@ -134,9 +110,6 @@ public static partial class AssetDatabase
return LoadAsset<T>(guidResult.Value); return LoadAsset<T>(guidResult.Value);
} }
/// <summary>
/// Add an asset to the cache with LRU eviction if needed.
/// </summary>
private static void CacheAsset(Guid guid, Asset asset) private static void CacheAsset(Guid guid, Asset asset)
{ {
// Check if we need to evict old assets // Check if we need to evict old assets
@@ -149,12 +122,9 @@ public static partial class AssetDatabase
s_assetAccessTime[guid] = DateTime.UtcNow; s_assetAccessTime[guid] = DateTime.UtcNow;
} }
/// <summary>
/// Evict the oldest assets from cache based on LRU.
/// </summary>
private static void EvictOldestAssets() private static void EvictOldestAssets()
{ {
var evictionCount = (int)(MAX_CACHED_ASSETS * CACHE_EVICTION_PERCENTAGE); var evictionCount = (int)(MAX_CACHED_ASSETS * _CACHE_EVICTION_PERCENTAGE);
// Sort by access time and remove oldest entries // Sort by access time and remove oldest entries
var oldestAssets = s_assetAccessTime var oldestAssets = s_assetAccessTime
@@ -216,7 +186,8 @@ public static partial class AssetDatabase
/// <param name="guid">GUID of the asset.</param> /// <param name="guid">GUID of the asset.</param>
/// <param name="assetData">Processed asset data to save.</param> /// <param name="assetData">Processed asset data to save.</param>
/// <returns>Result indicating success or failure.</returns> /// <returns>Result indicating success or failure.</returns>
internal static Result SaveImportedAsset<T>(Guid guid, T assetData) where T : Asset public static Result SaveImportedAsset<T>(Guid guid, T assetData)
where T : Asset
{ {
var assetPathResult = GetImportedAssetPath(guid); var assetPathResult = GetImportedAssetPath(guid);
if (assetPathResult.IsFailure) if (assetPathResult.IsFailure)
@@ -231,7 +202,6 @@ public static partial class AssetDatabase
// Invalidate cache for this asset so it gets reloaded next time // Invalidate cache for this asset so it gets reloaded next time
UnloadAsset(guid); UnloadAsset(guid);
return Result.Success(); return Result.Success();
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -1,6 +1,7 @@
using Ghost.Core; using Ghost.Core;
using Ghost.Editor.Core.Utilities; using Ghost.Editor.Core.Utilities;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
@@ -27,10 +28,10 @@ public static partial class AssetDatabase
} }
} }
s_watcher.Created += OnAssetCreated; s_watcher.Created += OnFSEvent;
s_watcher.Deleted += OnAssetDeleted; s_watcher.Deleted += OnFSEvent;
s_watcher.Changed += OnFSEvent;
s_watcher.Renamed += OnAssetRenamed; s_watcher.Renamed += OnAssetRenamed;
s_watcher.Changed += OnAssetChanged;
} }
private static Result<string> GetMetaFilePath(string assetPath) private static Result<string> GetMetaFilePath(string assetPath)
@@ -194,48 +195,38 @@ public static partial class AssetDatabase
return r; return r;
} }
private static void OnAssetCreated(object sender, FileSystemEventArgs e) [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsMetaFile(string path)
{ {
// Skip meta files return Path.GetExtension(path).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase);
if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) }
private static async void OnFSEvent(object sender, FileSystemEventArgs e)
{
if (IsMetaFile(e.FullPath))
{ {
return; return;
} }
PostCommand(new AssetCommand(AssetCommandType.FileCreated, e.FullPath, Timestamp: DateTime.UtcNow)); var type = e.ChangeType switch
{
WatcherChangeTypes.Created => AssetCommandType.FileCreated,
WatcherChangeTypes.Deleted => AssetCommandType.FileDeleted,
WatcherChangeTypes.Changed => AssetCommandType.FileModified,
_ => throw new InvalidOperationException("Unsupported file system event type")
};
await PostCommandAsync(new AssetCommand(type, e.FullPath, Timestamp: DateTime.UtcNow));
} }
private static void OnAssetDeleted(object sender, FileSystemEventArgs e) private static async void OnAssetRenamed(object sender, RenamedEventArgs e)
{ {
// Skip meta files if (IsMetaFile(e.FullPath))
if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
{ {
return; return;
} }
PostCommand(new AssetCommand(AssetCommandType.FileDeleted, e.FullPath, Timestamp: DateTime.UtcNow)); await PostCommandAsync(new AssetCommand(AssetCommandType.FileRenamed, e.FullPath, e.OldFullPath, DateTime.UtcNow));
}
private static void OnAssetRenamed(object sender, RenamedEventArgs e)
{
// Skip meta files
if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
{
return;
}
PostCommand(new AssetCommand(AssetCommandType.FileRenamed, e.FullPath, e.OldFullPath, DateTime.UtcNow));
}
private static void OnAssetChanged(object sender, FileSystemEventArgs e)
{
// Skip meta files
if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
{
return;
}
PostCommand(new AssetCommand(AssetCommandType.FileModified, e.FullPath, Timestamp: DateTime.UtcNow));
} }
/// <summary> /// <summary>
@@ -243,6 +234,8 @@ public static partial class AssetDatabase
/// </summary> /// </summary>
private static async Task MarkDependentAssetsDirtyAsync(Guid assetGuid) private static async Task MarkDependentAssetsDirtyAsync(Guid assetGuid)
{ {
// TODO: We should have a reverse dependency lookup in the database to avoid scanning all assets.
// Query database for all assets and check their dependencies // Query database for all assets and check their dependencies
var allAssets = GetAllAssets(); var allAssets = GetAllAssets();

View File

@@ -50,6 +50,7 @@ public static partial class AssetDatabase
); );
CREATE INDEX IF NOT EXISTS idx_path ON Assets(Path); CREATE INDEX IF NOT EXISTS idx_path ON Assets(Path);
"; ";
await cmd.ExecuteNonQueryAsync(token); await cmd.ExecuteNonQueryAsync(token);
} }

View File

@@ -1,5 +1,6 @@
using Ghost.Core; using Ghost.Core;
using Ghost.Data.Services; using Ghost.Data.Services;
using System.Collections.Concurrent;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Threading.Channels; using System.Threading.Channels;
@@ -41,23 +42,22 @@ public static partial class AssetDatabase
private static readonly Dictionary<string, Guid> s_pathAssetLookup = new(); private static readonly Dictionary<string, Guid> s_pathAssetLookup = new();
// In-memory dirty asset tracking (for runtime modifications only) // In-memory dirty asset tracking (for runtime modifications only)
// TODO: We do not handle the reimporting of dirty assets yet
private static readonly HashSet<Guid> s_dirtyAssets = new(); private static readonly HashSet<Guid> s_dirtyAssets = new();
// Command buffer pattern - Channel for file system event commands // Command buffer pattern - Channel for file system event commands
private static Channel<AssetCommand>? s_commandChannel; private static Channel<AssetCommand>? s_commandChannel;
private static Timer? s_commandProcessorTimer; private static Timer? s_commandProcessorTimer;
private static readonly HashSet<string> s_pendingCommandPaths = new(); // Track paths with pending commands
private static readonly Lock s_commandLock = new(); private static readonly Lock s_commandLock = new();
private static readonly ConcurrentQueue<AssetCommand> s_waitingCommands = new(); // Commands waiting for manual refresh
private static bool s_autoRefreshEnabled = true; private static bool s_autoRefreshEnabled = true;
private static readonly Queue<AssetCommand> s_waitingCommands = new(); // Commands waiting for manual refresh
private static TaskCompletionSource<bool>? s_refreshTcs;
// Initialization guard // Initialization guard
private static readonly Lock s_initializationLock = new(); private static readonly Lock s_initializationLock = new();
private static bool s_initialized = false; private static bool s_initialized = false;
private static readonly TimeSpan s_debounceDelay = TimeSpan.FromMilliseconds(100); private static readonly TimeSpan s_debounceDelay = TimeSpan.FromMilliseconds(100);
private static ManualResetEventSlim s_resetEventSlim = new(false);
private static readonly JsonSerializerOptions s_defaultJsonOptions = new() private static readonly JsonSerializerOptions s_defaultJsonOptions = new()
{ {
@@ -79,14 +79,16 @@ public static partial class AssetDatabase
/// Initialize the asset database. /// Initialize the asset database.
/// Must be called after project is loaded. /// Must be called after project is loaded.
/// </summary> /// </summary>
internal static async Task Initialize(CancellationToken token = default) internal static async Task Initialize(CancellationToken token = default)
{ {
lock (s_initializationLock) lock (s_initializationLock)
{ {
if (s_initialized) if (s_initialized)
{ {
return; // Already initialized, skip return;
} }
s_initialized = true; s_initialized = true;
} }
@@ -99,8 +101,8 @@ public static partial class AssetDatabase
s_commandChannel = Channel.CreateUnbounded<AssetCommand>(new UnboundedChannelOptions s_commandChannel = Channel.CreateUnbounded<AssetCommand>(new UnboundedChannelOptions
{ {
SingleReader = false, // Timer callback reads SingleReader = false,
SingleWriter = false // Multiple FS events can write SingleWriter = false
}); });
// Initialize command processor timer (starts disabled, triggered by events) // Initialize command processor timer (starts disabled, triggered by events)
@@ -120,6 +122,7 @@ public static partial class AssetDatabase
InitializeAssetHandle(); InitializeAssetHandle();
InitializeMetaData(); InitializeMetaData();
// TODO: Timestamp fake instead of full scan.
await ValidateAndFixDatabaseAsync(token); await ValidateAndFixDatabaseAsync(token);
} }
@@ -137,7 +140,7 @@ public static partial class AssetDatabase
try try
{ {
// Scan all files in assets directory // Scan all files in assets directory
var allFiles = Directory.GetFiles(AssetsDirectory.FullName, "*.*", SearchOption.AllDirectories) var allFiles = Directory.EnumerateFiles(AssetsDirectory.FullName, "*.*", SearchOption.AllDirectories)
.Where(f => !f.EndsWith(Utilities.FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)); .Where(f => !f.EndsWith(Utilities.FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase));
// Ensure all files have metadata // Ensure all files have metadata
@@ -183,24 +186,16 @@ public static partial class AssetDatabase
public static async Task<Result> RefreshAsync(CancellationToken token = default) public static async Task<Result> RefreshAsync(CancellationToken token = default)
{ {
// Flush waiting commands to channel // Flush waiting commands to channel
lock (s_commandLock)
{
while (s_waitingCommands.TryDequeue(out var cmd)) while (s_waitingCommands.TryDequeue(out var cmd))
{ {
s_commandChannel?.Writer.TryWrite(cmd); s_commandChannel?.Writer.TryWrite(cmd);
} }
s_refreshTcs = new TaskCompletionSource<bool>(); s_resetEventSlim.Reset();
}
s_commandChannel?.Writer.TryWrite(new AssetCommand(AssetCommandType.ManualRefresh, string.Empty)); s_commandChannel?.Writer.TryWrite(new AssetCommand(AssetCommandType.ManualRefresh, string.Empty));
s_commandProcessorTimer?.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan); s_commandProcessorTimer?.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
if (!await s_refreshTcs.Task.WaitAsync(token)) await Task.Run(s_resetEventSlim.Wait, token);
{
return Result.Failure("Asset database refresh failed");
}
return Result.Success(); return Result.Success();
} }
@@ -269,9 +264,6 @@ public static partial class AssetDatabase
s_autoRefreshEnabled = enabled; s_autoRefreshEnabled = enabled;
} }
/// <summary>
/// Process all pending commands immediately (synchronous, for testing).
/// </summary>
internal static void FlushPendingCommands() internal static void FlushPendingCommands()
{ {
// Stop timer temporarily // Stop timer temporarily
@@ -284,10 +276,7 @@ public static partial class AssetDatabase
ProcessPendingCommands(null); ProcessPendingCommands(null);
} }
/// <summary> private static async ValueTask PostCommandAsync(AssetCommand command, CancellationToken token = default)
/// Post a command to the command channel for processing.
/// </summary>
private static void PostCommand(AssetCommand command)
{ {
if (s_commandChannel == null) if (s_commandChannel == null)
{ {
@@ -296,53 +285,38 @@ public static partial class AssetDatabase
if (s_autoRefreshEnabled) if (s_autoRefreshEnabled)
{ {
// Add to pending paths for temp file tracking await s_commandChannel.Writer.WriteAsync(command, token);
lock (s_commandLock)
{
s_pendingCommandPaths.Add(command.Path);
if (command.OldPath != null)
{
s_pendingCommandPaths.Add(command.OldPath);
}
}
// Write to channel (lock-free, async-safe)
s_commandChannel.Writer.TryWrite(command);
// Start or reset timer (safe even if not initialized yet)
s_commandProcessorTimer?.Change(s_debounceDelay, Timeout.InfiniteTimeSpan); s_commandProcessorTimer?.Change(s_debounceDelay, Timeout.InfiniteTimeSpan);
} }
else else
{
// Queue for manual refresh
lock (s_commandLock)
{ {
s_waitingCommands.Enqueue(command); s_waitingCommands.Enqueue(command);
} }
} }
}
/// <summary>
/// Timer callback to process pending commands.
/// </summary>
private static async void ProcessPendingCommands(object? state) private static async void ProcessPendingCommands(object? state)
{ {
if (s_commandChannel == null)
{
return;
}
try try
{ {
// Collect all pending commands // // Collect all pending commands
var commands = new List<AssetCommand>(); // var commands = new List<AssetCommand>();
//
// while (s_commandChannel.Reader.TryRead(out var cmd))
// {
// commands.Add(cmd);
// }
while (s_commandChannel?.Reader.TryRead(out var cmd) == true) // // Group commands by path (last command wins)
{ // var commandsByPath = new Dictionary<string, AssetCommand>();
commands.Add(cmd); // foreach (var cmd in commands)
} // {
// commandsByPath[cmd.Path] = cmd;
// Group commands by path (last command wins) // }
var commandsByPath = new Dictionary<string, AssetCommand>();
foreach (var cmd in commands)
{
commandsByPath[cmd.Path] = cmd;
}
// NOTE: We handle the temp file filtering in each command handler now // NOTE: We handle the temp file filtering in each command handler now
// We should able to remove this allocation heavy code // We should able to remove this allocation heavy code
@@ -365,24 +339,31 @@ public static partial class AssetDatabase
// } // }
// Execute commands // Execute commands
foreach (var cmd in commandsByPath.Values) // NOTE: We many don't need to collect all commands first, just process as we read.
// Channel in c# is thread-safe for multiple readers/writers.
//await foreach (var cmd in s_commandChannel.Reader.ReadAllAsync())
//{
// await ExecuteCommandAsync(cmd);
//}
while (s_commandChannel.Reader.TryRead(out var cmd))
{ {
await ExecuteCommandAsync(cmd); await ExecuteCommandAsync(cmd);
} }
s_refreshTcs?.SetResult(true); await ImportDirtyAssetsAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError($"Error processing commands: {ex.Message}"); Logger.LogError($"Error processing commands: {ex.Message}");
s_refreshTcs?.SetResult(false); }
finally
{
s_resetEventSlim.Set();
} }
} }
/// <summary> private static async ValueTask ExecuteCommandAsync(AssetCommand command)
/// Execute a single asset command.
/// </summary>
private static async Task ExecuteCommandAsync(AssetCommand command)
{ {
switch (command.Type) switch (command.Type)
{ {
@@ -411,10 +392,7 @@ public static partial class AssetDatabase
} }
} }
/// <summary> private static async ValueTask HandleFileCreatedAsync(string path)
/// Handle file created event.
/// </summary>
private static async Task HandleFileCreatedAsync(string path)
{ {
if (!File.Exists(path)) if (!File.Exists(path))
{ {
@@ -424,10 +402,7 @@ public static partial class AssetDatabase
await GenerateMetaFileAsync(path, CancellationToken.None); await GenerateMetaFileAsync(path, CancellationToken.None);
} }
/// <summary> private static async ValueTask HandleFileModifiedAsync(string path)
/// Handle file modified event.
/// </summary>
private static async Task HandleFileModifiedAsync(string path)
{ {
if (!File.Exists(path)) if (!File.Exists(path))
{ {
@@ -443,7 +418,6 @@ public static partial class AssetDatabase
return; return;
} }
// Calculate new hash and compare against database
var newHash = await CalculateFileHashAsync(path, CancellationToken.None); var newHash = await CalculateFileHashAsync(path, CancellationToken.None);
var oldHash = await GetFileHashAsync(metaResult.Value.Guid, CancellationToken.None); var oldHash = await GetFileHashAsync(metaResult.Value.Guid, CancellationToken.None);
@@ -455,16 +429,8 @@ public static partial class AssetDatabase
} }
} }
/// <summary> private static async ValueTask HandleFileDeletedAsync(string path)
/// Handle file deleted event.
/// </summary>
private static async Task HandleFileDeletedAsync(string path)
{ {
if (!File.Exists(path))
{
return;
}
var metaFileResult = GetMetaFilePath(path); var metaFileResult = GetMetaFilePath(path);
if (metaFileResult.IsSuccess && File.Exists(metaFileResult.Value)) if (metaFileResult.IsSuccess && File.Exists(metaFileResult.Value))
{ {
@@ -491,22 +457,8 @@ public static partial class AssetDatabase
} }
} }
/// <summary> private static async ValueTask HandleFileRenamedAsync(string oldPath, string newPath)
/// Handle file renamed event.
/// </summary>
private static async Task HandleFileRenamedAsync(string oldPath, string newPath)
{ {
if (!File.Exists(oldPath))
{
return;
}
if (File.Exists(newPath))
{
Logger.LogWarning($"Cannot rename asset from '{oldPath}' to '{newPath}': target file already exists.");
return;
}
var oldMetaPath = oldPath + Utilities.FileExtensions.META_FILE_EXTENSION; var oldMetaPath = oldPath + Utilities.FileExtensions.META_FILE_EXTENSION;
var newMetaPath = newPath + Utilities.FileExtensions.META_FILE_EXTENSION; var newMetaPath = newPath + Utilities.FileExtensions.META_FILE_EXTENSION;
@@ -543,22 +495,17 @@ public static partial class AssetDatabase
} }
catch catch
{ {
// Ignore
} }
} }
} }
/// <summary>
/// Shutdown the asset database.
/// Disposes resources and closes database connections.
/// </summary>
internal static void Shutdown() internal static void Shutdown()
{ {
lock (s_initializationLock) lock (s_initializationLock)
{ {
if (!s_initialized) if (!s_initialized)
{ {
return; // Not initialized, nothing to shutdown return;
} }
s_watcher?.Dispose(); s_watcher?.Dispose();
@@ -574,7 +521,6 @@ public static partial class AssetDatabase
s_assetPathLookup.Clear(); s_assetPathLookup.Clear();
s_pathAssetLookup.Clear(); s_pathAssetLookup.Clear();
s_dirtyAssets.Clear(); s_dirtyAssets.Clear();
s_pendingCommandPaths.Clear();
s_waitingCommands.Clear(); s_waitingCommands.Clear();
s_importerInstances.Clear(); s_importerInstances.Clear();
s_importerTypeLookup.Clear(); s_importerTypeLookup.Clear();

View File

@@ -35,7 +35,7 @@ public abstract class AssetImporter
/// </summary> /// </summary>
/// <param name="dependencies">List of dependency GUIDs extracted from the asset.</param> /// <param name="dependencies">List of dependency GUIDs extracted from the asset.</param>
/// <returns>Result indicating if all dependencies are valid.</returns> /// <returns>Result indicating if all dependencies are valid.</returns>
protected virtual ValueTask<Result> ValidateDependenciesAsync(List<Guid> dependencies) protected virtual ValueTask<Result> ValidateDependenciesAsync(List<Guid> dependencies, CancellationToken token = default)
{ {
foreach (var dependencyGuid in dependencies) foreach (var dependencyGuid in dependencies)
{ {

View File

@@ -27,7 +27,7 @@ internal class TextImporterSettings : ImporterSettings
[AssetImporter(".txt", ".md")] [AssetImporter(".txt", ".md")]
internal class TextImporter : AssetImporter<TextImporterSettings> internal class TextImporter : AssetImporter<TextImporterSettings>
{ {
public override async Task<Result> ImportAsync(string assetPath, AssetMeta meta) public override async ValueTask<Result> ImportAsync(string assetPath, AssetMeta meta, CancellationToken token = default)
{ {
var settings = GetSettings(meta); var settings = GetSettings(meta);
@@ -45,7 +45,7 @@ internal class TextImporter : AssetImporter<TextImporterSettings>
try try
{ {
// Read the file // Read the file
var content = await File.ReadAllTextAsync(assetPath); var content = await File.ReadAllTextAsync(assetPath, token);
if (settings.TrimWhitespace) if (settings.TrimWhitespace)
{ {

View File

@@ -73,7 +73,7 @@ internal class TextureImporterSettings : ImporterSettings
[AssetImporter(".png", ".jpg", ".jpeg", ".dds", ".tga", ".bmp")] [AssetImporter(".png", ".jpg", ".jpeg", ".dds", ".tga", ".bmp")]
internal class TextureImporter : AssetImporter<TextureImporterSettings> internal class TextureImporter : AssetImporter<TextureImporterSettings>
{ {
public override async Task<Result> ImportAsync(string assetPath, AssetMeta meta) public override async ValueTask<Result> ImportAsync(string assetPath, AssetMeta meta, CancellationToken token = default)
{ {
var settings = GetSettings(meta); var settings = GetSettings(meta);
@@ -82,7 +82,7 @@ internal class TextureImporter : AssetImporter<TextureImporterSettings>
var dependencies = new List<Guid>(); var dependencies = new List<Guid>();
// Validate dependencies // Validate dependencies
var depResult = await ValidateDependenciesAsync(dependencies); var depResult = await ValidateDependenciesAsync(dependencies, token);
if (depResult.IsFailure) if (depResult.IsFailure)
{ {
return depResult; return depResult;
@@ -97,7 +97,7 @@ internal class TextureImporter : AssetImporter<TextureImporterSettings>
} }
// Get image dimensions (simplified - in real implementation would use image library) // Get image dimensions (simplified - in real implementation would use image library)
var (width, height) = await GetImageDimensionsAsync(assetPath); var (width, height) = GetImageDimensions(assetPath);
if (width == 0 || height == 0) if (width == 0 || height == 0)
{ {
@@ -160,7 +160,7 @@ internal class TextureImporter : AssetImporter<TextureImporterSettings>
/// Get image dimensions from file. /// Get image dimensions from file.
/// Simplified implementation - in production, use an image library. /// Simplified implementation - in production, use an image library.
/// </summary> /// </summary>
private async Task<(uint width, uint height)> GetImageDimensionsAsync(string imagePath) private static (uint width, uint height) GetImageDimensions(string imagePath)
{ {
// This is a placeholder implementation // This is a placeholder implementation
// In a real implementation, you would use a library like: // In a real implementation, you would use a library like:
@@ -174,24 +174,24 @@ internal class TextureImporter : AssetImporter<TextureImporterSettings>
{ {
// For DDS files, read the header // For DDS files, read the header
// DDS header format: https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-header // DDS header format: https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-header
return await ReadDDSHeaderAsync(imagePath); return ReadDDSHeader(imagePath);
} }
else else
{ {
// For PNG/JPG/etc, we would use an image library // For PNG/JPG/etc, we would use an image library
// For now, return placeholder values // For now, return placeholder values
return await Task.FromResult<(uint, uint)>((1024, 1024)); return (1024, 1024);
} }
} }
/// <summary> /// <summary>
/// Read DDS file header to get dimensions. /// Read DDS file header to get dimensions.
/// </summary> /// </summary>
private async Task<(uint width, uint height)> ReadDDSHeaderAsync(string ddsPath) private static (uint width, uint height) ReadDDSHeader(string ddsPath)
{ {
try try
{ {
await using var stream = File.OpenRead(ddsPath); using var stream = File.OpenRead(ddsPath);
using var reader = new BinaryReader(stream); using var reader = new BinaryReader(stream);
// Read magic number (should be "DDS ") // Read magic number (should be "DDS ")
@@ -226,7 +226,7 @@ internal class TextureImporter : AssetImporter<TextureImporterSettings>
/// <summary> /// <summary>
/// Export a texture asset from memory to disk. /// Export a texture asset from memory to disk.
/// </summary> /// </summary>
public override async Task<Result> ExportAsync<T>(string assetPath, T assetData, AssetMeta meta) public override async ValueTask<Result> ExportAsync<T>(string assetPath, T assetData, AssetMeta meta, CancellationToken token = default)
{ {
if (assetData is not TextureAsset textureAsset) if (assetData is not TextureAsset textureAsset)
{ {
@@ -246,7 +246,7 @@ internal class TextureImporter : AssetImporter<TextureImporterSettings>
WriteIndented = true WriteIndented = true
}); });
await File.WriteAllTextAsync(assetPath, json); await File.WriteAllTextAsync(assetPath, json, token);
return Result.Success(); return Result.Success();
} }

View File

@@ -1,5 +1,6 @@
using Ghost.Editor.Core.AssetHandle; using Ghost.Editor.Core.AssetHandle;
using Ghost.Data.Services; using Ghost.Data.Services;
using Ghost.Core;
namespace Ghost.UnitTest; namespace Ghost.UnitTest;
@@ -94,6 +95,20 @@ public class AssetDatabaseIntegrationTest
await Task.Delay(50, TestContext.CancellationToken); await Task.Delay(50, TestContext.CancellationToken);
} }
private static void CheckInternalErrors()
{
if (Logger.Logs.Count > 0)
{
foreach (var log in Logger.Logs)
{
if (log.Level == LogLevel.Error)
{
Assert.Fail($"Internal error logged: {log.Message}");
}
}
}
}
[TestMethod] [TestMethod]
public async Task TestAutoMetaGeneration_WhenFileCreated() public async Task TestAutoMetaGeneration_WhenFileCreated()
{ {
@@ -111,6 +126,8 @@ public class AssetDatabaseIntegrationTest
// Verify meta file content // Verify meta file content
var metaContent = await File.ReadAllTextAsync(metaFile, TestContext.CancellationToken); var metaContent = await File.ReadAllTextAsync(metaFile, TestContext.CancellationToken);
Assert.Contains("Guid", metaContent, "Meta file should contain GUID"); Assert.Contains("Guid", metaContent, "Meta file should contain GUID");
CheckInternalErrors();
} }
[TestMethod] [TestMethod]
@@ -136,6 +153,8 @@ public class AssetDatabaseIntegrationTest
// Test exact match // Test exact match
results = await AssetDatabase.FindAssetsByNameAsync("enemy.txt", TestContext.CancellationToken); results = await AssetDatabase.FindAssetsByNameAsync("enemy.txt", TestContext.CancellationToken);
Assert.HasCount(1, results, "Should find 1 file matching 'enemy.txt'"); Assert.HasCount(1, results, "Should find 1 file matching 'enemy.txt'");
CheckInternalErrors();
} }
[TestMethod] [TestMethod]
@@ -164,6 +183,8 @@ public class AssetDatabaseIntegrationTest
var newGuidResult = AssetDatabase.PathToGuid(newPath); var newGuidResult = AssetDatabase.PathToGuid(newPath);
Assert.IsTrue(newGuidResult.IsSuccess, "Should be able to get GUID after rename"); Assert.IsTrue(newGuidResult.IsSuccess, "Should be able to get GUID after rename");
Assert.AreEqual(guid, newGuidResult.Value, "GUID should be preserved after rename"); Assert.AreEqual(guid, newGuidResult.Value, "GUID should be preserved after rename");
CheckInternalErrors();
} }
[TestMethod] [TestMethod]
@@ -182,6 +203,7 @@ public class AssetDatabaseIntegrationTest
File.Delete(filePath); File.Delete(filePath);
await WaitForFileSystemEvents(); await WaitForFileSystemEvents();
await Task.Delay(1000, TestContext.CancellationToken);
// Meta file should also be deleted // Meta file should also be deleted
var metaPath = filePath + ".gmeta"; var metaPath = filePath + ".gmeta";
Assert.IsFalse(File.Exists(metaPath), "Meta file should be deleted with asset"); Assert.IsFalse(File.Exists(metaPath), "Meta file should be deleted with asset");
@@ -189,6 +211,8 @@ public class AssetDatabaseIntegrationTest
// Asset should be removed from database // Asset should be removed from database
var pathResult = AssetDatabase.GuidToPath(guid); var pathResult = AssetDatabase.GuidToPath(guid);
Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database"); Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database");
CheckInternalErrors();
} }
[TestMethod] [TestMethod]
@@ -207,6 +231,8 @@ public class AssetDatabaseIntegrationTest
// Should be in database // Should be in database
var guidResult = AssetDatabase.PathToGuid(filePath); var guidResult = AssetDatabase.PathToGuid(filePath);
Assert.IsTrue(guidResult.IsSuccess, "Asset should be in database"); Assert.IsTrue(guidResult.IsSuccess, "Asset should be in database");
CheckInternalErrors();
} }
[TestMethod] [TestMethod]
@@ -240,6 +266,8 @@ public class AssetDatabaseIntegrationTest
// GUID should be preserved // GUID should be preserved
var newGuid = AssetDatabase.PathToGuid(destPath).Value; var newGuid = AssetDatabase.PathToGuid(destPath).Value;
Assert.AreEqual(guid, newGuid, "GUID should be preserved"); Assert.AreEqual(guid, newGuid, "GUID should be preserved");
CheckInternalErrors();
} }
[TestMethod] [TestMethod]
@@ -264,6 +292,8 @@ public class AssetDatabaseIntegrationTest
// Both should have different GUIDs // Both should have different GUIDs
var destGuid = AssetDatabase.PathToGuid(destPath).Value; var destGuid = AssetDatabase.PathToGuid(destPath).Value;
Assert.AreNotEqual(sourceGuid, destGuid, "Copied asset should have different GUID"); Assert.AreNotEqual(sourceGuid, destGuid, "Copied asset should have different GUID");
CheckInternalErrors();
} }
[TestMethod] [TestMethod]
@@ -287,6 +317,8 @@ public class AssetDatabaseIntegrationTest
// Should be removed from database // Should be removed from database
var pathResult = AssetDatabase.GuidToPath(guid); var pathResult = AssetDatabase.GuidToPath(guid);
Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database"); Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database");
CheckInternalErrors();
} }
[TestMethod] [TestMethod]
@@ -323,6 +355,8 @@ public class AssetDatabaseIntegrationTest
var metaContent = await File.ReadAllTextAsync(metaPath, TestContext.CancellationToken); var metaContent = await File.ReadAllTextAsync(metaPath, TestContext.CancellationToken);
Assert.Contains("Guid", metaContent, $"Meta file should be valid for {fileName}"); Assert.Contains("Guid", metaContent, $"Meta file should be valid for {fileName}");
} }
CheckInternalErrors();
} }
[TestMethod] [TestMethod]
@@ -351,6 +385,8 @@ public class AssetDatabaseIntegrationTest
var playerAssets = await AssetDatabase.FindAssetsByTagAsync("Player", TestContext.CancellationToken); var playerAssets = await AssetDatabase.FindAssetsByTagAsync("Player", TestContext.CancellationToken);
Assert.HasCount(1, playerAssets, "Should find 1 asset with 'Player' tag"); Assert.HasCount(1, playerAssets, "Should find 1 asset with 'Player' tag");
CheckInternalErrors();
} }
[TestMethod] [TestMethod]
@@ -375,13 +411,24 @@ public class AssetDatabaseIntegrationTest
// Only one meta file should exist // Only one meta file should exist
var metaFiles = Directory.GetFiles(_testAssetsDir, "refresh.txt.gmeta"); var metaFiles = Directory.GetFiles(_testAssetsDir, "refresh.txt.gmeta");
Assert.HasCount(1, metaFiles, "Should have exactly one meta file"); Assert.HasCount(1, metaFiles, "Should have exactly one meta file");
CheckInternalErrors();
} }
[TestMethod] [TestMethod]
public async Task ThreadSafetyTest() public async Task ThreadSafetyTest()
{
try
{ {
var testFile = Path.Combine(_testAssetsDir, "test.txt"); var testFile = Path.Combine(_testAssetsDir, "test.txt");
await File.WriteAllTextAsync(testFile, "Hello World", TestContext.CancellationToken); await File.WriteAllTextAsync(testFile, "Hello World", TestContext.CancellationToken);
await AssetDatabase.RefreshAsync(TestContext.CancellationToken); // This will cause race conditions if not handle properly because both AssetDatabase and FileSystemWatcher are involved await AssetDatabase.RefreshAsync(TestContext.CancellationToken); // This will cause race conditions if not handle properly because both AssetDatabase and FileSystemWatcher are involved
} }
catch (Exception ex)
{
Assert.Fail(ex.Message);
}
CheckInternalErrors();
}
} }