Update ContextFlyout
This commit is contained in:
@@ -20,3 +20,6 @@ public class CollectionPool<TCollection, TItem>
|
|||||||
}
|
}
|
||||||
|
|
||||||
public class ListPool<T> : CollectionPool<List<T>, T>;
|
public class ListPool<T> : CollectionPool<List<T>, T>;
|
||||||
|
public class HashSetPool<T> : CollectionPool<HashSet<T>, T>;
|
||||||
|
public class DictionaryPool<TKey, TValue> : CollectionPool<Dictionary<TKey, TValue>, KeyValuePair<TKey, TValue>>
|
||||||
|
where TKey : notnull;
|
||||||
@@ -2,7 +2,7 @@ using Ghost.Core;
|
|||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
public static partial class AssetDatabase
|
public static partial class AssetService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new asset at the specified path.
|
/// Create a new asset at the specified path.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using System.Reflection;
|
|||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
public static partial class AssetDatabase
|
public static partial class AssetService
|
||||||
{
|
{
|
||||||
private static readonly Dictionary<Type, AssetImporter> s_importerInstances = new();
|
private static readonly Dictionary<Type, AssetImporter> s_importerInstances = new();
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using System.Text.Json;
|
|||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
public static partial class AssetDatabase
|
public static partial class AssetService
|
||||||
{
|
{
|
||||||
// Asset cache - stores loaded assets by GUID
|
// Asset cache - stores loaded assets by GUID
|
||||||
private static readonly ConcurrentDictionary<Guid, Asset> s_assetCache = new();
|
private static readonly ConcurrentDictionary<Guid, Asset> s_assetCache = new();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using System.Text.Json;
|
|||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
public static partial class AssetDatabase
|
public static partial class AssetService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get the relative path from the assets directory.
|
/// Get the relative path from the assets directory.
|
||||||
@@ -96,7 +96,7 @@ public static partial class AssetDatabase
|
|||||||
/// <returns>The loaded asset.</returns>
|
/// <returns>The loaded asset.</returns>
|
||||||
public static Result<T> LoadAsset<T>(Guid guid) where T : Asset
|
public static Result<T> LoadAsset<T>(Guid guid) where T : Asset
|
||||||
{
|
{
|
||||||
// Implemented in AssetDatabase.Loader.cs
|
// Implemented in AssetService.Loader.cs
|
||||||
return LoadAssetInternal<T>(guid);
|
return LoadAssetInternal<T>(guid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using System.Text.Json;
|
|||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
public static partial class AssetDatabase
|
public static partial class AssetService
|
||||||
{
|
{
|
||||||
private static readonly Dictionary<string, Type> s_importerTypeLookup = new();
|
private static readonly Dictionary<string, Type> s_importerTypeLookup = new();
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ using System.Reflection;
|
|||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
public static partial class AssetDatabase
|
public static partial class AssetService
|
||||||
{
|
{
|
||||||
private static readonly Dictionary<string, Action<string>> s_assetOpenHandlers = new(StringComparer.OrdinalIgnoreCase);
|
private static readonly Dictionary<string, Action<string>> s_assetOpenHandlers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using System.Text.Json;
|
|||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
public static partial class AssetDatabase
|
public static partial class AssetService
|
||||||
{
|
{
|
||||||
private static SqliteConnection? s_dbConnection;
|
private static SqliteConnection? s_dbConnection;
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ internal readonly record struct AssetCommand(
|
|||||||
/// Handles asset registration, lookup, importing, and dependency management.
|
/// Handles asset registration, lookup, importing, and dependency management.
|
||||||
/// Uses SQLite for persistent storage and efficient querying.
|
/// Uses SQLite for persistent storage and efficient querying.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static partial class AssetDatabase
|
public static partial class AssetService
|
||||||
{
|
{
|
||||||
private static FileSystemWatcher? s_watcher;
|
private static FileSystemWatcher? s_watcher;
|
||||||
private static readonly Lock s_dbLock = new();
|
private static readonly Lock s_dbLock = new();
|
||||||
@@ -47,7 +47,6 @@ public static partial class AssetDatabase
|
|||||||
// 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 Lock s_commandLock = new();
|
|
||||||
private static readonly ConcurrentQueue<AssetCommand> s_waitingCommands = new(); // Commands waiting for manual refresh
|
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;
|
||||||
|
|
||||||
@@ -56,7 +55,7 @@ public static partial class AssetDatabase
|
|||||||
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 ManualResetEventSlim s_resetEventSlim = new(false);
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions s_defaultJsonOptions = new()
|
private static readonly JsonSerializerOptions s_defaultJsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -78,7 +77,6 @@ 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)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public abstract class AssetImporter
|
|||||||
{
|
{
|
||||||
foreach (var dependencyGuid in dependencies)
|
foreach (var dependencyGuid in dependencies)
|
||||||
{
|
{
|
||||||
var path = AssetDatabase.GuidToPath(dependencyGuid);
|
var path = AssetService.GuidToPath(dependencyGuid);
|
||||||
if (path.IsFailure)
|
if (path.IsFailure)
|
||||||
{
|
{
|
||||||
return ValueTask.FromResult(Result.Failure($"Missing dependency: {dependencyGuid}"));
|
return ValueTask.FromResult(Result.Failure($"Missing dependency: {dependencyGuid}"));
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ internal class TextureImporter : AssetImporter<TextureImporterSettings>
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Save the imported asset data
|
// Save the imported asset data
|
||||||
var saveResult = AssetDatabase.SaveImportedAsset(meta.Guid, textureAsset);
|
var saveResult = AssetService.SaveImportedAsset(meta.Guid, textureAsset);
|
||||||
if (saveResult.IsFailure)
|
if (saveResult.IsFailure)
|
||||||
{
|
{
|
||||||
return Result.Failure($"Failed to save texture asset: {saveResult.Message}");
|
return Result.Failure($"Failed to save texture asset: {saveResult.Message}");
|
||||||
|
|||||||
5
Ghost.Editor.Core/Contracts/IAssetService.cs
Normal file
5
Ghost.Editor.Core/Contracts/IAssetService.cs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
namespace Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
public interface IAssetService
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -8,6 +8,35 @@ namespace Ghost.Editor.Core.Controls;
|
|||||||
|
|
||||||
public sealed partial class ContextFlyout : MenuFlyout
|
public sealed partial class ContextFlyout : MenuFlyout
|
||||||
{
|
{
|
||||||
|
private class MenuNode
|
||||||
|
{
|
||||||
|
public required string Name
|
||||||
|
{
|
||||||
|
get; init;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MethodInfo? Method
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<MenuNode> Children
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
} = new();
|
||||||
|
|
||||||
|
public int RawGroup
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = int.MaxValue;
|
||||||
|
|
||||||
|
// The calculated group used for sorting (min of children for folders)
|
||||||
|
public int EffectiveGroup
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private bool _isPopulated;
|
private bool _isPopulated;
|
||||||
|
|
||||||
public string Tag
|
public string Tag
|
||||||
@@ -20,6 +49,89 @@ public sealed partial class ContextFlyout : MenuFlyout
|
|||||||
Opening += ContextFlyout_Opening;
|
Opening += ContextFlyout_Opening;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recursively sorts nodes and calculates folder groups
|
||||||
|
private static void PrepareNodes(List<MenuNode> nodes)
|
||||||
|
{
|
||||||
|
if (nodes.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var node in nodes)
|
||||||
|
{
|
||||||
|
if (node.Children.Count > 0)
|
||||||
|
{
|
||||||
|
// Go deep first
|
||||||
|
PrepareNodes(node.Children);
|
||||||
|
|
||||||
|
// A folder's group is determined by its highest priority child (lowest group number).
|
||||||
|
// This ensures a "File" folder (containing Group 0 items) sits at the top
|
||||||
|
// alongside other Group 0 leaf items.
|
||||||
|
node.EffectiveGroup = node.Children.Min(c => c.EffectiveGroup);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
node.EffectiveGroup = node.RawGroup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by Group, then by Name
|
||||||
|
nodes.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
var groupCompare = a.EffectiveGroup.CompareTo(b.EffectiveGroup);
|
||||||
|
return groupCompare != 0
|
||||||
|
? groupCompare
|
||||||
|
: string.CompareOrdinal(a.Name, b.Name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively builds the UI elements
|
||||||
|
private static void BuildNodes(List<MenuNode> nodes, IList<MenuFlyoutItemBase> targetCollection)
|
||||||
|
{
|
||||||
|
if (nodes.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int currentGroup = nodes[0].EffectiveGroup;
|
||||||
|
|
||||||
|
foreach (var node in nodes)
|
||||||
|
{
|
||||||
|
if (node.EffectiveGroup != currentGroup)
|
||||||
|
{
|
||||||
|
targetCollection.Add(new MenuFlyoutSeparator());
|
||||||
|
currentGroup = node.EffectiveGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.Children.Count > 0)
|
||||||
|
{
|
||||||
|
var subItem = new MenuFlyoutSubItem
|
||||||
|
{
|
||||||
|
Text = node.Name
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recursively render children into the subitem
|
||||||
|
BuildNodes(node.Children, subItem.Items);
|
||||||
|
targetCollection.Add(subItem);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var menuItem = new MenuFlyoutItem
|
||||||
|
{
|
||||||
|
Text = node.Name
|
||||||
|
};
|
||||||
|
|
||||||
|
var methodToInvoke = node.Method;
|
||||||
|
menuItem.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
methodToInvoke?.Invoke(null, null);
|
||||||
|
};
|
||||||
|
|
||||||
|
targetCollection.Add(menuItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void PopulateContextMenu()
|
private void PopulateContextMenu()
|
||||||
{
|
{
|
||||||
var methods = TypeCache.GetMethodsWithAttribute<ContextMenuItemAttribute>();
|
var methods = TypeCache.GetMethodsWithAttribute<ContextMenuItemAttribute>();
|
||||||
@@ -28,58 +140,66 @@ public sealed partial class ContextFlyout : MenuFlyout
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var list = new List<(ContextMenuItemAttribute attr, MethodInfo method)>();
|
// 1. Build the Tree
|
||||||
|
var rootNodes = new List<MenuNode>();
|
||||||
|
|
||||||
foreach (var method in methods)
|
foreach (var method in methods)
|
||||||
{
|
{
|
||||||
var attributes = method.GetCustomAttributes(typeof(ContextMenuItemAttribute), false);
|
var attr = method.GetCustomAttribute<ContextMenuItemAttribute>();
|
||||||
var attribute = (ContextMenuItemAttribute)attributes[0];
|
if (attr == null)
|
||||||
|
|
||||||
if (!string.Equals(attribute.Tag, Tag, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.Add((attribute, method));
|
// Filter tags
|
||||||
}
|
if (!string.Equals(attr.Tag, Tag, StringComparison.OrdinalIgnoreCase))
|
||||||
|
|
||||||
var span = CollectionsMarshal.AsSpan(list);
|
|
||||||
span.Sort((a, b) =>
|
|
||||||
{
|
|
||||||
var result = a.attr.Group.CompareTo(b.attr.Group);
|
|
||||||
if (result == 0)
|
|
||||||
{
|
{
|
||||||
result = string.CompareOrdinal(a.attr.Name, b.attr.Name);
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
var nameSpan = attr.Name.AsSpan();
|
||||||
});
|
var pathParts = nameSpan.Split('/');
|
||||||
|
|
||||||
// itemContainer may be a main thread only collection (for example, ObservableCollection), we run it on the UI thread
|
var currentLevel = rootNodes;
|
||||||
var i = 0;
|
MenuNode? currentNode = null;
|
||||||
var group = 0;
|
|
||||||
while (i < list.Count)
|
foreach (var range in pathParts)
|
||||||
{
|
|
||||||
var (attr, method) = list[i];
|
|
||||||
if (attr.Group != group)
|
|
||||||
{
|
{
|
||||||
Items.Add(new MenuFlyoutSeparator());
|
var part = nameSpan[range.Start..range.End];
|
||||||
group = attr.Group;
|
|
||||||
|
MenuNode? foundNode = null;
|
||||||
|
|
||||||
|
// Try to find existing node in the current level
|
||||||
|
foreach (var node in currentLevel)
|
||||||
|
{
|
||||||
|
if (part.Equals(node.Name.AsSpan(), StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
foundNode = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundNode == null)
|
||||||
|
{
|
||||||
|
foundNode = new MenuNode { Name = part.ToString() };
|
||||||
|
currentLevel.Add(foundNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = foundNode;
|
||||||
|
|
||||||
|
// If this is the last part, it's the executable item
|
||||||
|
if (range.End.Value == nameSpan.Length)
|
||||||
|
{
|
||||||
|
currentNode.Method = method;
|
||||||
|
currentNode.RawGroup = attr.Group;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLevel = currentNode.Children;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Group items with / in the name into submenus
|
|
||||||
var menuItem = new MenuFlyoutItem
|
|
||||||
{
|
|
||||||
Text = attr.Name
|
|
||||||
};
|
|
||||||
|
|
||||||
menuItem.Click += (_, _) =>
|
|
||||||
{
|
|
||||||
method.Invoke(null, null);
|
|
||||||
};
|
|
||||||
|
|
||||||
Items.Add(menuItem);
|
|
||||||
i++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PrepareNodes(rootNodes);
|
||||||
|
BuildNodes(rootNodes, Items);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void ContextFlyout_Opening(object? sender, object e)
|
private async void ContextFlyout_Opening(object? sender, object e)
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ public partial class App : Application
|
|||||||
services.AddSingleton<IProgressService, ProgressService>();
|
services.AddSingleton<IProgressService, ProgressService>();
|
||||||
services.AddSingleton<IInspectorService, InspectorService>();
|
services.AddSingleton<IInspectorService, InspectorService>();
|
||||||
services.AddSingleton<IPreviewService, PreviewService>();
|
services.AddSingleton<IPreviewService, PreviewService>();
|
||||||
|
services.AddSingleton<IAssetService, AssetService>();
|
||||||
|
|
||||||
services.AddSingleton<EngineEditorViewModel>();
|
services.AddSingleton<EngineEditorViewModel>();
|
||||||
|
|
||||||
|
|||||||
@@ -20,4 +20,34 @@ internal partial class ProjectBrowser
|
|||||||
Verb = "open"
|
Verb = "open"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[ContextMenuItem("project-browser", "Create/Folder")]
|
||||||
|
private static void CreateFolder()
|
||||||
|
{
|
||||||
|
// TODO: Use AssetService
|
||||||
|
|
||||||
|
var viewModel = LastFocused?.ViewModel;
|
||||||
|
if (viewModel is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentDir = viewModel.CurrentDirectoryPath;
|
||||||
|
if (!Directory.Exists(currentDir))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newFolderPath = Path.Combine(currentDir, "New Folder");
|
||||||
|
var folderIndex = 1;
|
||||||
|
while (Directory.Exists(newFolderPath))
|
||||||
|
{
|
||||||
|
newFolderPath = Path.Combine(currentDir, $"New Folder ({folderIndex})");
|
||||||
|
folderIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(newFolderPath);
|
||||||
|
// Refresh the view model to show the new folder
|
||||||
|
viewModel.NavigateToDirectory(currentDir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -150,7 +150,8 @@
|
|||||||
Padding="8"
|
Padding="8"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
RowSpacing="4">
|
RowSpacing="4"
|
||||||
|
ToolTipService.ToolTip="{x:Bind Name}">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
@@ -191,17 +192,7 @@
|
|||||||
MinRowSpacing="4" />
|
MinRowSpacing="4" />
|
||||||
</ItemsView.Layout>
|
</ItemsView.Layout>
|
||||||
<ItemsView.ContextFlyout>
|
<ItemsView.ContextFlyout>
|
||||||
<ghost:ContextFlyout Tag="project-browser">
|
<ghost:ContextFlyout Tag="project-browser" />
|
||||||
<MenuFlyoutSubItem Text="Create">
|
|
||||||
<MenuFlyoutItem Text="Folder" />
|
|
||||||
<MenuFlyoutItem Text="Script" />
|
|
||||||
<MenuFlyoutSubItem Text="Rendering">
|
|
||||||
<MenuFlyoutItem Text="Material" />
|
|
||||||
<MenuFlyoutItem Text="Volume Profile" />
|
|
||||||
</MenuFlyoutSubItem>
|
|
||||||
</MenuFlyoutSubItem>
|
|
||||||
<MenuFlyoutSeparator />
|
|
||||||
</ghost:ContextFlyout>
|
|
||||||
</ItemsView.ContextFlyout>
|
</ItemsView.ContextFlyout>
|
||||||
</ItemsView>
|
</ItemsView>
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ internal sealed partial class ProjectBrowser : UserControl
|
|||||||
private void ProjectBrowser_Unloaded(object sender, RoutedEventArgs e)
|
private void ProjectBrowser_Unloaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
_inspectorService.OnSelectionChanged -= _inspectorService_OnSelectionChanged;
|
_inspectorService.OnSelectionChanged -= _inspectorService_OnSelectionChanged;
|
||||||
|
|
||||||
|
if (LastFocused == this)
|
||||||
|
{
|
||||||
|
LastFocused = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void _inspectorService_OnSelectionChanged(object? sender, InspectorSelectionChangedEventArgs e)
|
private void _inspectorService_OnSelectionChanged(object? sender, InspectorSelectionChangedEventArgs e)
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ internal partial class ProjectBrowserViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
AssetDatabase.OpenAsset(SelectedItem.FullName);
|
AssetService.OpenAsset(SelectedItem.FullName);
|
||||||
return (null, 1);
|
return (null, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ internal partial class ProjectViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
AssetDatabase.OpenAsset(SelectedAsset.FullName);
|
AssetService.OpenAsset(SelectedAsset.FullName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ using Ghost.Core;
|
|||||||
namespace Ghost.UnitTest;
|
namespace Ghost.UnitTest;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Comprehensive integration tests for AssetDatabase.
|
/// Comprehensive integration tests for AssetService.
|
||||||
/// Tests database operations, file system watchers, searching, importing, and race conditions.
|
/// Tests database operations, file system watchers, searching, importing, and race conditions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[TestClass]
|
[TestClass]
|
||||||
[DoNotParallelize] // AssetDatabase is a singleton, tests must run sequentially
|
[DoNotParallelize] // AssetService is a singleton, tests must run sequentially
|
||||||
public class AssetDatabaseIntegrationTest
|
public class AssetDatabaseIntegrationTest
|
||||||
{
|
{
|
||||||
private string _tempPath = string.Empty;
|
private string _tempPath = string.Empty;
|
||||||
@@ -49,8 +49,8 @@ public class AssetDatabaseIntegrationTest
|
|||||||
var projectMetadataInfo = new Data.Models.ProjectMetadataInfo(projectPath, metadata);
|
var projectMetadataInfo = new Data.Models.ProjectMetadataInfo(projectPath, metadata);
|
||||||
ProjectService.CurrentProject = projectMetadataInfo;
|
ProjectService.CurrentProject = projectMetadataInfo;
|
||||||
|
|
||||||
// Initialize AssetDatabase
|
// Initialize AssetService
|
||||||
await AssetDatabase.Initialize(TestContext.CancellationToken);
|
await AssetService.Initialize(TestContext.CancellationToken);
|
||||||
|
|
||||||
// Give the file system watcher time to start
|
// Give the file system watcher time to start
|
||||||
await Task.Delay(100, TestContext.CancellationToken);
|
await Task.Delay(100, TestContext.CancellationToken);
|
||||||
@@ -59,10 +59,10 @@ public class AssetDatabaseIntegrationTest
|
|||||||
[TestCleanup]
|
[TestCleanup]
|
||||||
public void Cleanup()
|
public void Cleanup()
|
||||||
{
|
{
|
||||||
// Shutdown AssetDatabase to release file watchers
|
// Shutdown AssetService to release file watchers
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
AssetDatabase.Shutdown();
|
AssetService.Shutdown();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -91,7 +91,7 @@ public class AssetDatabaseIntegrationTest
|
|||||||
private async Task WaitForFileSystemEvents(int delayMs = 300)
|
private async Task WaitForFileSystemEvents(int delayMs = 300)
|
||||||
{
|
{
|
||||||
await Task.Delay(delayMs, TestContext.CancellationToken);
|
await Task.Delay(delayMs, TestContext.CancellationToken);
|
||||||
AssetDatabase.FlushPendingCommands();
|
AssetService.FlushPendingCommands();
|
||||||
|
|
||||||
// Give a bit more time after flush for any final processing
|
// Give a bit more time after flush for any final processing
|
||||||
await Task.Delay(50, TestContext.CancellationToken);
|
await Task.Delay(50, TestContext.CancellationToken);
|
||||||
@@ -145,15 +145,15 @@ public class AssetDatabaseIntegrationTest
|
|||||||
await WaitForFileSystemEvents();
|
await WaitForFileSystemEvents();
|
||||||
|
|
||||||
// Test wildcard search: player*
|
// Test wildcard search: player*
|
||||||
var results = await AssetDatabase.FindAssetsByNameAsync("player*", TestContext.CancellationToken);
|
var results = await AssetService.FindAssetsByNameAsync("player*", TestContext.CancellationToken);
|
||||||
Assert.HasCount(3, results, "Should find 3 files matching 'player*'");
|
Assert.HasCount(3, results, "Should find 3 files matching 'player*'");
|
||||||
|
|
||||||
// Test single character wildcard: player?
|
// Test single character wildcard: player?
|
||||||
results = await AssetDatabase.FindAssetsByNameAsync("player?.txt", TestContext.CancellationToken);
|
results = await AssetService.FindAssetsByNameAsync("player?.txt", TestContext.CancellationToken);
|
||||||
Assert.HasCount(2, results, "Should find 2 files matching 'player?.txt'");
|
Assert.HasCount(2, results, "Should find 2 files matching 'player?.txt'");
|
||||||
|
|
||||||
// Test exact match
|
// Test exact match
|
||||||
results = await AssetDatabase.FindAssetsByNameAsync("enemy.txt", TestContext.CancellationToken);
|
results = await AssetService.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();
|
CheckInternalErrors();
|
||||||
@@ -168,7 +168,7 @@ public class AssetDatabaseIntegrationTest
|
|||||||
await WaitForFileSystemEvents();
|
await WaitForFileSystemEvents();
|
||||||
|
|
||||||
// Get the GUID before rename
|
// Get the GUID before rename
|
||||||
var guidResult = AssetDatabase.PathToGuid(originalPath);
|
var guidResult = AssetService.PathToGuid(originalPath);
|
||||||
Assert.IsTrue(guidResult.IsSuccess, "Should be able to get GUID before rename");
|
Assert.IsTrue(guidResult.IsSuccess, "Should be able to get GUID before rename");
|
||||||
var guid = guidResult.Value;
|
var guid = guidResult.Value;
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ public class AssetDatabaseIntegrationTest
|
|||||||
Assert.IsTrue(File.Exists(newMetaPath), "Meta file should be moved with the asset");
|
Assert.IsTrue(File.Exists(newMetaPath), "Meta file should be moved with the asset");
|
||||||
|
|
||||||
// Verify GUID is preserved
|
// Verify GUID is preserved
|
||||||
var newGuidResult = AssetDatabase.PathToGuid(newPath);
|
var newGuidResult = AssetService.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");
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ public class AssetDatabaseIntegrationTest
|
|||||||
await File.WriteAllTextAsync(filePath, "data", TestContext.CancellationToken);
|
await File.WriteAllTextAsync(filePath, "data", TestContext.CancellationToken);
|
||||||
await WaitForFileSystemEvents();
|
await WaitForFileSystemEvents();
|
||||||
|
|
||||||
var guidResult = AssetDatabase.PathToGuid(filePath);
|
var guidResult = AssetService.PathToGuid(filePath);
|
||||||
Assert.IsTrue(guidResult.IsSuccess);
|
Assert.IsTrue(guidResult.IsSuccess);
|
||||||
var guid = guidResult.Value;
|
var guid = guidResult.Value;
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ public class AssetDatabaseIntegrationTest
|
|||||||
Assert.IsFalse(File.Exists(metaPath), "Meta file should be deleted with asset");
|
Assert.IsFalse(File.Exists(metaPath), "Meta file should be deleted with asset");
|
||||||
|
|
||||||
// Asset should be removed from database
|
// Asset should be removed from database
|
||||||
var pathResult = AssetDatabase.GuidToPath(guid);
|
var pathResult = AssetService.GuidToPath(guid);
|
||||||
Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database");
|
Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database");
|
||||||
|
|
||||||
CheckInternalErrors();
|
CheckInternalErrors();
|
||||||
@@ -223,7 +223,7 @@ public class AssetDatabaseIntegrationTest
|
|||||||
var filePath = Path.Combine(_testAssetsDir, "apiCreated.txt");
|
var filePath = Path.Combine(_testAssetsDir, "apiCreated.txt");
|
||||||
|
|
||||||
// Create via API
|
// Create via API
|
||||||
var result = await AssetDatabase.CreateAssetAsync(filePath, TestContext.CancellationToken);
|
var result = await AssetService.CreateAssetAsync(filePath, TestContext.CancellationToken);
|
||||||
Assert.IsTrue(result.IsSuccess, "Should create asset successfully");
|
Assert.IsTrue(result.IsSuccess, "Should create asset successfully");
|
||||||
|
|
||||||
// File and meta should exist
|
// File and meta should exist
|
||||||
@@ -231,7 +231,7 @@ public class AssetDatabaseIntegrationTest
|
|||||||
Assert.IsTrue(File.Exists(filePath + ".gmeta"), "Meta file should exist");
|
Assert.IsTrue(File.Exists(filePath + ".gmeta"), "Meta file should exist");
|
||||||
|
|
||||||
// Should be in database
|
// Should be in database
|
||||||
var guidResult = AssetDatabase.PathToGuid(filePath);
|
var guidResult = AssetService.PathToGuid(filePath);
|
||||||
Assert.IsTrue(guidResult.IsSuccess, "Asset should be in database");
|
Assert.IsTrue(guidResult.IsSuccess, "Asset should be in database");
|
||||||
|
|
||||||
CheckInternalErrors();
|
CheckInternalErrors();
|
||||||
@@ -245,7 +245,7 @@ public class AssetDatabaseIntegrationTest
|
|||||||
await File.WriteAllTextAsync(sourcePath, "data", TestContext.CancellationToken);
|
await File.WriteAllTextAsync(sourcePath, "data", TestContext.CancellationToken);
|
||||||
await WaitForFileSystemEvents();
|
await WaitForFileSystemEvents();
|
||||||
|
|
||||||
var guid = AssetDatabase.PathToGuid(sourcePath).Value;
|
var guid = AssetService.PathToGuid(sourcePath).Value;
|
||||||
|
|
||||||
// Create subdirectory
|
// Create subdirectory
|
||||||
var subDir = Path.Combine(_testAssetsDir, "SubFolder");
|
var subDir = Path.Combine(_testAssetsDir, "SubFolder");
|
||||||
@@ -254,7 +254,7 @@ public class AssetDatabaseIntegrationTest
|
|||||||
var destPath = Path.Combine(subDir, "source.txt");
|
var destPath = Path.Combine(subDir, "source.txt");
|
||||||
|
|
||||||
// Move via API
|
// Move via API
|
||||||
var result = await AssetDatabase.MoveAssetAsync(sourcePath, destPath, TestContext.CancellationToken);
|
var result = await AssetService.MoveAssetAsync(sourcePath, destPath, TestContext.CancellationToken);
|
||||||
Assert.IsTrue(result.IsSuccess, $"Should move asset successfully. Error: {result.Message}");
|
Assert.IsTrue(result.IsSuccess, $"Should move asset successfully. Error: {result.Message}");
|
||||||
|
|
||||||
// Old file should not exist
|
// Old file should not exist
|
||||||
@@ -266,7 +266,7 @@ public class AssetDatabaseIntegrationTest
|
|||||||
Assert.IsTrue(File.Exists(destPath + ".gmeta"), "Destination meta should exist");
|
Assert.IsTrue(File.Exists(destPath + ".gmeta"), "Destination meta should exist");
|
||||||
|
|
||||||
// GUID should be preserved
|
// GUID should be preserved
|
||||||
var newGuid = AssetDatabase.PathToGuid(destPath).Value;
|
var newGuid = AssetService.PathToGuid(destPath).Value;
|
||||||
Assert.AreEqual(guid, newGuid, "GUID should be preserved");
|
Assert.AreEqual(guid, newGuid, "GUID should be preserved");
|
||||||
|
|
||||||
CheckInternalErrors();
|
CheckInternalErrors();
|
||||||
@@ -280,11 +280,11 @@ public class AssetDatabaseIntegrationTest
|
|||||||
await File.WriteAllTextAsync(sourcePath, "data", TestContext.CancellationToken);
|
await File.WriteAllTextAsync(sourcePath, "data", TestContext.CancellationToken);
|
||||||
await WaitForFileSystemEvents();
|
await WaitForFileSystemEvents();
|
||||||
|
|
||||||
var sourceGuid = AssetDatabase.PathToGuid(sourcePath).Value;
|
var sourceGuid = AssetService.PathToGuid(sourcePath).Value;
|
||||||
var destPath = Path.Combine(_testAssetsDir, "copied.txt");
|
var destPath = Path.Combine(_testAssetsDir, "copied.txt");
|
||||||
|
|
||||||
// Copy via API
|
// Copy via API
|
||||||
var result = await AssetDatabase.CopyAssetAsync(sourcePath, destPath, TestContext.CancellationToken);
|
var result = await AssetService.CopyAssetAsync(sourcePath, destPath, TestContext.CancellationToken);
|
||||||
Assert.IsTrue(result.IsSuccess, "Should copy asset successfully");
|
Assert.IsTrue(result.IsSuccess, "Should copy asset successfully");
|
||||||
|
|
||||||
// Both files should exist
|
// Both files should exist
|
||||||
@@ -292,7 +292,7 @@ public class AssetDatabaseIntegrationTest
|
|||||||
Assert.IsTrue(File.Exists(destPath), "Destination file should exist");
|
Assert.IsTrue(File.Exists(destPath), "Destination file should exist");
|
||||||
|
|
||||||
// Both should have different GUIDs
|
// Both should have different GUIDs
|
||||||
var destGuid = AssetDatabase.PathToGuid(destPath).Value;
|
var destGuid = AssetService.PathToGuid(destPath).Value;
|
||||||
Assert.AreNotEqual(sourceGuid, destGuid, "Copied asset should have different GUID");
|
Assert.AreNotEqual(sourceGuid, destGuid, "Copied asset should have different GUID");
|
||||||
|
|
||||||
CheckInternalErrors();
|
CheckInternalErrors();
|
||||||
@@ -306,10 +306,10 @@ public class AssetDatabaseIntegrationTest
|
|||||||
await File.WriteAllTextAsync(filePath, "data", TestContext.CancellationToken);
|
await File.WriteAllTextAsync(filePath, "data", TestContext.CancellationToken);
|
||||||
await WaitForFileSystemEvents();
|
await WaitForFileSystemEvents();
|
||||||
|
|
||||||
var guid = AssetDatabase.PathToGuid(filePath).Value;
|
var guid = AssetService.PathToGuid(filePath).Value;
|
||||||
|
|
||||||
// Delete via API
|
// Delete via API
|
||||||
var result = await AssetDatabase.DeleteAssetAsync(filePath, TestContext.CancellationToken);
|
var result = await AssetService.DeleteAssetAsync(filePath, TestContext.CancellationToken);
|
||||||
Assert.IsTrue(result.IsSuccess, "Should delete asset successfully");
|
Assert.IsTrue(result.IsSuccess, "Should delete asset successfully");
|
||||||
|
|
||||||
// File and meta should not exist
|
// File and meta should not exist
|
||||||
@@ -317,7 +317,7 @@ public class AssetDatabaseIntegrationTest
|
|||||||
Assert.IsFalse(File.Exists(filePath + ".gmeta"), "Meta should be deleted");
|
Assert.IsFalse(File.Exists(filePath + ".gmeta"), "Meta should be deleted");
|
||||||
|
|
||||||
// Should be removed from database
|
// Should be removed from database
|
||||||
var pathResult = AssetDatabase.GuidToPath(guid);
|
var pathResult = AssetService.GuidToPath(guid);
|
||||||
Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database");
|
Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database");
|
||||||
|
|
||||||
CheckInternalErrors();
|
CheckInternalErrors();
|
||||||
@@ -374,18 +374,18 @@ public class AssetDatabaseIntegrationTest
|
|||||||
await File.WriteAllTextAsync(file3, "data", TestContext.CancellationToken);
|
await File.WriteAllTextAsync(file3, "data", TestContext.CancellationToken);
|
||||||
await WaitForFileSystemEvents();
|
await WaitForFileSystemEvents();
|
||||||
|
|
||||||
var guid1 = AssetDatabase.PathToGuid(file1).Value;
|
var guid1 = AssetService.PathToGuid(file1).Value;
|
||||||
var guid2 = AssetDatabase.PathToGuid(file2).Value;
|
var guid2 = AssetService.PathToGuid(file2).Value;
|
||||||
|
|
||||||
// Add tags
|
// Add tags
|
||||||
await AssetDatabase.SetAssetTagsAsync(guid1, new List<string> { "Test", "Player" }, TestContext.CancellationToken);
|
await AssetService.SetAssetTagsAsync(guid1, new List<string> { "Test", "Player" }, TestContext.CancellationToken);
|
||||||
await AssetDatabase.SetAssetTagsAsync(guid2, new List<string> { "Test", "Enemy" }, TestContext.CancellationToken);
|
await AssetService.SetAssetTagsAsync(guid2, new List<string> { "Test", "Enemy" }, TestContext.CancellationToken);
|
||||||
|
|
||||||
// Search by tag
|
// Search by tag
|
||||||
var testAssets = await AssetDatabase.FindAssetsByTagAsync("Test", TestContext.CancellationToken);
|
var testAssets = await AssetService.FindAssetsByTagAsync("Test", TestContext.CancellationToken);
|
||||||
Assert.HasCount(2, testAssets, "Should find 2 assets with 'Test' tag");
|
Assert.HasCount(2, testAssets, "Should find 2 assets with 'Test' tag");
|
||||||
|
|
||||||
var playerAssets = await AssetDatabase.FindAssetsByTagAsync("Player", TestContext.CancellationToken);
|
var playerAssets = await AssetService.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();
|
CheckInternalErrors();
|
||||||
@@ -399,15 +399,15 @@ public class AssetDatabaseIntegrationTest
|
|||||||
await File.WriteAllTextAsync(filePath, "data", TestContext.CancellationToken);
|
await File.WriteAllTextAsync(filePath, "data", TestContext.CancellationToken);
|
||||||
await WaitForFileSystemEvents();
|
await WaitForFileSystemEvents();
|
||||||
|
|
||||||
var guid1 = AssetDatabase.PathToGuid(filePath).Value;
|
var guid1 = AssetService.PathToGuid(filePath).Value;
|
||||||
|
|
||||||
// Call RefreshAsync multiple times
|
// Call RefreshAsync multiple times
|
||||||
await AssetDatabase.RefreshAsync(TestContext.CancellationToken);
|
await AssetService.RefreshAsync(TestContext.CancellationToken);
|
||||||
await AssetDatabase.RefreshAsync(TestContext.CancellationToken);
|
await AssetService.RefreshAsync(TestContext.CancellationToken);
|
||||||
await AssetDatabase.RefreshAsync(TestContext.CancellationToken);
|
await AssetService.RefreshAsync(TestContext.CancellationToken);
|
||||||
|
|
||||||
// GUID should remain the same
|
// GUID should remain the same
|
||||||
var guid2 = AssetDatabase.PathToGuid(filePath).Value;
|
var guid2 = AssetService.PathToGuid(filePath).Value;
|
||||||
Assert.AreEqual(guid1, guid2, "GUID should not change after refresh");
|
Assert.AreEqual(guid1, guid2, "GUID should not change after refresh");
|
||||||
|
|
||||||
// Only one meta file should exist
|
// Only one meta file should exist
|
||||||
@@ -424,7 +424,7 @@ public class AssetDatabaseIntegrationTest
|
|||||||
{
|
{
|
||||||
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 AssetService.RefreshAsync(TestContext.CancellationToken); // This will cause race conditions if not handle properly because both AssetService and FileSystemWatcher are involved
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user