diff --git a/AssetEditor/DependencyInjectionContainer.cs b/AssetEditor/DependencyInjectionContainer.cs index bfcff145b..fe7785e28 100644 --- a/AssetEditor/DependencyInjectionContainer.cs +++ b/AssetEditor/DependencyInjectionContainer.cs @@ -19,7 +19,6 @@ public override void Register(IServiceCollection serviceCollection) { serviceCollection.AddScoped(); serviceCollection.AddScoped(); - serviceCollection.AddSingleton( x=> x.GetRequiredService()); serviceCollection.AddSingleton(); serviceCollection.AddTransient(); diff --git a/AssetEditor/ViewModels/SettingsViewModel.cs b/AssetEditor/ViewModels/SettingsViewModel.cs index c3b7a2120..850b442c7 100644 --- a/AssetEditor/ViewModels/SettingsViewModel.cs +++ b/AssetEditor/ViewModels/SettingsViewModel.cs @@ -94,7 +94,7 @@ private void Save() _settingsService.CurrentSettings.CameraControlMode = SelectedCameraMode; foreach (var item in GameDirectores) - _settingsService.CurrentSettings.GameDirectories.Add(new ApplicationSettings.GamePathPair() { Game = item.GameType, Path = item.Path }); + _settingsService.CurrentSettings.GameDirectories.Add(new ApplicationSettings.GamePathPair(item.GameType, item.Path)); _settingsService.CurrentSettings.WwisePath = WwisePath; _localizationManager.LoadLanguage(SelectedLanguage); diff --git a/Editors/AnimationEditor/CampaignAnimationCreator/DevConfig/CampaignAnimation_Damsel.cs b/Editors/AnimationEditor/CampaignAnimationCreator/DevConfig/CampaignAnimation_Damsel.cs index 55b02a0f7..11b10ba71 100644 --- a/Editors/AnimationEditor/CampaignAnimationCreator/DevConfig/CampaignAnimation_Damsel.cs +++ b/Editors/AnimationEditor/CampaignAnimationCreator/DevConfig/CampaignAnimation_Damsel.cs @@ -9,10 +9,10 @@ namespace Editors.AnimationVisualEditors.CampaignAnimationCreator.DevConfig { internal class CampaignAnimation_Damsel : IDeveloperConfiguration { - private readonly IEditorCreator _editorCreator; + private readonly IEditorManager _editorCreator; private readonly IPackFileService _packFileService; - public CampaignAnimation_Damsel(IEditorCreator editorCreator, IPackFileService packFileService) + public CampaignAnimation_Damsel(IEditorManager editorCreator, IPackFileService packFileService) { _editorCreator = editorCreator; _packFileService = packFileService; diff --git a/Editors/AnimationEditor/MountAnimationCreator/DevConfig/MountTool.cs b/Editors/AnimationEditor/MountAnimationCreator/DevConfig/MountTool.cs index 24a98c173..3818c7eac 100644 --- a/Editors/AnimationEditor/MountAnimationCreator/DevConfig/MountTool.cs +++ b/Editors/AnimationEditor/MountAnimationCreator/DevConfig/MountTool.cs @@ -9,10 +9,10 @@ namespace Editors.AnimationVisualEditors.MountAnimationCreator.DevConfig { internal class MountTool : IDeveloperConfiguration { - private readonly IEditorCreator _editorCreator; + private readonly IEditorManager _editorCreator; private readonly IPackFileService _packFileService; - public MountTool(IEditorCreator editorCreator, IPackFileService packFileService) + public MountTool(IEditorManager editorCreator, IPackFileService packFileService) { _editorCreator = editorCreator; _packFileService = packFileService; @@ -30,7 +30,7 @@ public void OpenFileOnLoad() CreateLionAndHu01b(_editorCreator, _packFileService); } - static void CreateLionAndHu01b(IEditorCreator creator, IPackFileService packfileService) + static void CreateLionAndHu01b(IEditorManager creator, IPackFileService packfileService) { var riderInput = new AnimationToolInput() { diff --git a/Editors/AnimationReTarget/Editors.AnimatioReTarget/DevConfig/AnimTransfer_DwarfArcher.cs b/Editors/AnimationReTarget/Editors.AnimatioReTarget/DevConfig/AnimTransfer_DwarfArcher.cs index 8ffd7e1c3..9e9e99c35 100644 --- a/Editors/AnimationReTarget/Editors.AnimatioReTarget/DevConfig/AnimTransfer_DwarfArcher.cs +++ b/Editors/AnimationReTarget/Editors.AnimatioReTarget/DevConfig/AnimTransfer_DwarfArcher.cs @@ -9,10 +9,10 @@ namespace Editors.AnimatioReTarget.DevConfig { internal class AnimTransfer_DwarfArcher : IDeveloperConfiguration { - private readonly IEditorCreator _editorCreator; + private readonly IEditorManager _editorCreator; private readonly IPackFileService _packFileService; - public AnimTransfer_DwarfArcher(IEditorCreator editorCreator, IPackFileService packFileService) + public AnimTransfer_DwarfArcher(IEditorManager editorCreator, IPackFileService packFileService) { _editorCreator = editorCreator; _packFileService = packFileService; @@ -30,7 +30,7 @@ public void OpenFileOnLoad() CreateDwarfAndEmpArcher(_editorCreator, _packFileService); } - static void CreateDwarfAndEmpArcher(IEditorCreator creator, IPackFileService packfileService) + static void CreateDwarfAndEmpArcher(IEditorManager creator, IPackFileService packfileService) { var targetInput = new AnimationToolInput() { diff --git a/Editors/BmdEditor/DependencyInjectionContainer.cs b/Editors/BmdEditor/DependencyInjectionContainer.cs index 802dcd302..38bc98850 100644 --- a/Editors/BmdEditor/DependencyInjectionContainer.cs +++ b/Editors/BmdEditor/DependencyInjectionContainer.cs @@ -1,4 +1,4 @@ -using Editors.BmdEditor.ViewModels; +using Editors.BmdEditor.ViewModels; using Editors.BmdEditor.Views; using Editors.BmdEditor.Services; using Microsoft.Extensions.DependencyInjection; diff --git a/Editors/BmdEditor/ViewModels/BmdSceneViewModel.cs b/Editors/BmdEditor/ViewModels/BmdSceneViewModel.cs index 51ad26100..21a27ce36 100644 --- a/Editors/BmdEditor/ViewModels/BmdSceneViewModel.cs +++ b/Editors/BmdEditor/ViewModels/BmdSceneViewModel.cs @@ -24,7 +24,7 @@ public partial class BmdSceneViewModel : ObservableObject, IDisposable { private readonly ILogger _logger = Serilog.Log.ForContext(); private readonly IPackFileService _packFileService; - private readonly IEditorCreator _editorCreator; + private readonly IEditorManager _editorCreator; private readonly ResourceLibrary _resourceLibrary; private readonly IGraphicsResourceCreator _graphicsResourceCreator; private readonly MeshBuilderService _meshBuilderService; @@ -56,7 +56,7 @@ public IWpfGame? Scene3D public BmdSceneViewModel( IPackFileService packFileService, - IEditorCreator editorCreator, + IEditorManager editorCreator, ResourceLibrary resourceLibrary, IGraphicsResourceCreator graphicsResourceCreator, MeshBuilderService meshBuilderService, diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/External/IExportFileContextMenuHelper.cs b/Editors/ImportExportEditor/Editors.ImportExport/ContextMenu/IExportFileContextMenuHelper.cs similarity index 59% rename from Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/External/IExportFileContextMenuHelper.cs rename to Editors/ImportExportEditor/Editors.ImportExport/ContextMenu/IExportFileContextMenuHelper.cs index df26eb697..b76e73779 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/External/IExportFileContextMenuHelper.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/ContextMenu/IExportFileContextMenuHelper.cs @@ -1,10 +1,10 @@ -using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models; -namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.External +namespace Editors.ImportExport.ContextMenu { public interface IExportFileContextMenuHelper { bool CanExportFile(PackFile packFile); void ShowDialog(PackFile packFile); } -} +} \ No newline at end of file diff --git a/Editors/ImportExportEditor/Editors.ImportExport/ContextMenu/IImportFileContextMenuHelper.cs b/Editors/ImportExportEditor/Editors.ImportExport/ContextMenu/IImportFileContextMenuHelper.cs new file mode 100644 index 000000000..45b840eca --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/ContextMenu/IImportFileContextMenuHelper.cs @@ -0,0 +1,11 @@ +using Shared.Core.PackFiles.Models; +using TreeNode = Shared.Ui.BaseDialogs.PackFileTree.TreeNode; + +namespace Editors.ImportExport.ContextMenu +{ + public interface IImportFileContextMenuHelper + { + bool CanImportFile(PackFile file); + void ShowDialog(TreeNode node); + } +} \ No newline at end of file diff --git a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs index 5951f30bc..5e768febc 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs @@ -10,6 +10,7 @@ using Editors.ImportExport.Exporting.Presentation.DdsToNormalPng; using Editors.ImportExport.Exporting.Presentation.DdsToPng; using Editors.ImportExport.Exporting.Presentation.RmvToGltf; +using Editors.ImportExport.ContextMenu; using Editors.ImportExport.Importing; using Editors.ImportExport.Importing.Importers.GltfToRmv; using Editors.ImportExport.Importing.Importers.GltfToRmv.Helper; @@ -18,7 +19,7 @@ using Shared.Core.DependencyInjection; using Shared.Core.DevConfig; using Editors.ImportImport.Importing.Presentation.RmvToGltf; -using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.External; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu; namespace Editors.ImportExport { @@ -55,6 +56,8 @@ public override void Register(IServiceCollection services) services.AddScoped(); // Helpers to ensure we can hook up to the UI + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -69,7 +72,18 @@ public override void Register(IServiceCollection services) services.AddTransient(); services.AddTransient(); + services.AddSingleton(); + RegisterAllAsInterface(services, ServiceLifetime.Transient); } } + + public class ImportExportPackFileContextMenuRegistration : IPackFileContextMenuRegistration + { + public void Register(PackFileContextMenuRegistry registry) + { + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "Export", priority: 10, ContextMenuCluster.Export); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "Import", priority: 20, ContextMenuCluster.FolderOperation); + } + } } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/AdvancedExportCommand.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/AdvancedExportCommand.cs new file mode 100644 index 000000000..c9ba6e577 --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/AdvancedExportCommand.cs @@ -0,0 +1,15 @@ +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; +using Shared.Ui.BaseDialogs.PackFileTree; +using Editors.ImportExport.ContextMenu; + +namespace Editors.ImportExport.Exporting +{ + public class AdvancedExportCommand(IExportFileContextMenuHelper exportFileContextMenuHelper) : IContextMenuCommand + { + public string GetDisplayName(TreeNode node) => "Advanced Export"; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && node.Item != null; + public bool IsEnabled(TreeNode node) => exportFileContextMenuHelper.CanExportFile(node.Item); + + public void Execute(TreeNode selectedNode) => exportFileContextMenuHelper.ShowDialog(selectedNode.Item); + } +} diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/ExportFileContextMenuHelper.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/ExportFileContextMenuHelper.cs index 458514904..6773ff96a 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/ExportFileContextMenuHelper.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/ExportFileContextMenuHelper.cs @@ -1,9 +1,9 @@ using Editors.ImportExport.Misc; using Editors.ImportExport.Exporting.Exporters; +using Editors.ImportExport.ContextMenu; using Shared.Core.Events; using Shared.Core.PackFiles.Models; using Shared.Core.Settings; -using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.External; namespace Editors.ImportExport.Exporting { diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Importing/AdvancedImportCommand.cs b/Editors/ImportExportEditor/Editors.ImportExport/Importing/AdvancedImportCommand.cs new file mode 100644 index 000000000..79c5c8e48 --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Importing/AdvancedImportCommand.cs @@ -0,0 +1,15 @@ +using Editors.ImportExport.ContextMenu; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Editors.ImportExport.Importing +{ + public class AdvancedImportCommand(IImportFileContextMenuHelper importFileContextMenuHelper) : IContextMenuCommand + { + public string GetDisplayName(TreeNode node) => "Advanced Import"; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.Directory && !node.FileOwner.IsCaPackFile; + public bool IsEnabled(TreeNode node) => true; + + public void Execute(TreeNode selectedNode) => importFileContextMenuHelper.ShowDialog(selectedNode); + } +} diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Importing/ImportFIleContextMenuHelper.cs b/Editors/ImportExportEditor/Editors.ImportExport/Importing/ImportFIleContextMenuHelper.cs index 01c671571..600fcab1e 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Importing/ImportFIleContextMenuHelper.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Importing/ImportFIleContextMenuHelper.cs @@ -1,10 +1,10 @@ using System.IO; +using Editors.ImportExport.ContextMenu; using Editors.ImportExport.Exporting.Exporters; using Editors.ImportExport.Misc; using Shared.Core.Events; using Shared.Core.PackFiles.Models; using Shared.Core.Settings; -using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.External; using TreeNode = Shared.Ui.BaseDialogs.PackFileTree.TreeNode; namespace Editors.ImportExport.Importing diff --git a/Editors/Kitbashing/KitbasherEditor/Core/SceneExplorer/SceneExplorerContextMenuHandler.cs b/Editors/Kitbashing/KitbasherEditor/Core/SceneExplorer/SceneExplorerContextMenuHandler.cs index 5b4753fa1..b6ec35d9c 100644 --- a/Editors/Kitbashing/KitbasherEditor/Core/SceneExplorer/SceneExplorerContextMenuHandler.cs +++ b/Editors/Kitbashing/KitbasherEditor/Core/SceneExplorer/SceneExplorerContextMenuHandler.cs @@ -22,7 +22,7 @@ public partial class SceneExplorerContextMenuHandler : ObservableObject, IDispos ISceneNode _activeNode; IEnumerable _activeNodes; - [ObservableProperty] ObservableCollection _items = []; + [ObservableProperty] ObservableCollection _items = []; public SceneExplorerContextMenuHandler(IEventHub eventHub, SceneManager sceneManager, CommandFactory commandFactory, SelectionManager selectionManager) { @@ -49,26 +49,26 @@ void OnSelectionChanged(SceneNodeSelectedEvent selectionChangedEvent) return; if (CanMakeEditable(_activeNode)) - Items.Add(new ContextMenuItem("Make Editable", new RelayCommand(MakeEditable))); + Items.Add(new ContextMenuItem("Make Editable", MakeEditable)); if (IsUngroupable(_activeNode)) - Items.Add(new ContextMenuItem("Ungroup", new RelayCommand(Ungroup))); + Items.Add(new ContextMenuItem("Ungroup", Ungroup)); if (IsLockable(_activeNode)) - Items.Add(new ContextMenuItem("Lock", new RelayCommand(ToggleLock))); + Items.Add(new ContextMenuItem("Lock", ToggleLock)); else if (IsUnlockable(_activeNode)) - Items.Add(new ContextMenuItem("Unlock", new RelayCommand(ToggleLock))); + Items.Add(new ContextMenuItem("Unlock", ToggleLock)); if (Items.Count != 0) Items.Add(null); - Items.Add(new ContextMenuItem("Invert Selection", new RelayCommand(InvertSelection))); - Items.Add(new ContextMenuItem("Select Similarly Named", new RelayCommand(SelectSimilar))); + Items.Add(new ContextMenuItem("Invert Selection", InvertSelection)); + Items.Add(new ContextMenuItem("Select Similarly Named", SelectSimilar)); if (IsRemovable(_activeNode)) { if (Items.Count != 0) Items.Add(null); - Items.Add(new ContextMenuItem("Remove", new RelayCommand(RemoveNode))); + Items.Add(new ContextMenuItem("Remove", RemoveNode)); } } diff --git a/Editors/Kitbashing/KitbasherEditor/Core/SceneNodeEditor/Nodes/MeshNode/Mesh.Material/AdvancedRmvMaterial/AdvancedRmvMaterialView.xaml b/Editors/Kitbashing/KitbasherEditor/Core/SceneNodeEditor/Nodes/MeshNode/Mesh.Material/AdvancedRmvMaterial/AdvancedRmvMaterialView.xaml index 37b25b24c..76a7717a4 100644 --- a/Editors/Kitbashing/KitbasherEditor/Core/SceneNodeEditor/Nodes/MeshNode/Mesh.Material/AdvancedRmvMaterial/AdvancedRmvMaterialView.xaml +++ b/Editors/Kitbashing/KitbasherEditor/Core/SceneNodeEditor/Nodes/MeshNode/Mesh.Material/AdvancedRmvMaterial/AdvancedRmvMaterialView.xaml @@ -41,27 +41,27 @@ - + - + - + - + - + - + diff --git a/Editors/Kitbashing/KitbasherEditor/Core/SceneNodeEditor/Nodes/MeshNode/Mesh.Material/Emissive/EmissiveView.xaml b/Editors/Kitbashing/KitbasherEditor/Core/SceneNodeEditor/Nodes/MeshNode/Mesh.Material/Emissive/EmissiveView.xaml index 559571afb..f8e17a2f7 100644 --- a/Editors/Kitbashing/KitbasherEditor/Core/SceneNodeEditor/Nodes/MeshNode/Mesh.Material/Emissive/EmissiveView.xaml +++ b/Editors/Kitbashing/KitbasherEditor/Core/SceneNodeEditor/Nodes/MeshNode/Mesh.Material/Emissive/EmissiveView.xaml @@ -28,31 +28,31 @@ - + - + - + - + - + - + - + - + @@ -70,7 +70,7 @@ - + - + - + - + - + - + - + diff --git a/Editors/Kitbashing/KitbasherEditor/Core/SceneNodeEditor/Nodes/MeshNode/Mesh.Material/Tint/TintView.xaml b/Editors/Kitbashing/KitbasherEditor/Core/SceneNodeEditor/Nodes/MeshNode/Mesh.Material/Tint/TintView.xaml index 1be4d67d5..3018c8b7f 100644 --- a/Editors/Kitbashing/KitbasherEditor/Core/SceneNodeEditor/Nodes/MeshNode/Mesh.Material/Tint/TintView.xaml +++ b/Editors/Kitbashing/KitbasherEditor/Core/SceneNodeEditor/Nodes/MeshNode/Mesh.Material/Tint/TintView.xaml @@ -26,17 +26,17 @@ - + - + - + @@ -53,7 +53,7 @@ - + diff --git a/Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs b/Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs index 879432ade..7ee4fa44e 100644 --- a/Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs +++ b/Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs @@ -13,9 +13,9 @@ using Shared.Core.Misc; using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; +using Editors.ImportExport.ContextMenu; using Shared.Ui.Common.MenuSystem; using Shared.GameFormats.RigidModel; -using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.External; // <- added using MessageBox = System.Windows.MessageBox; namespace Editors.KitbasherEditor.UiCommands diff --git a/Editors/MetaDataEditor/AnimationMeta/DevConfig/AnimMetaTool.cs b/Editors/MetaDataEditor/AnimationMeta/DevConfig/AnimMetaTool.cs index c819c1279..48a0f57d2 100644 --- a/Editors/MetaDataEditor/AnimationMeta/DevConfig/AnimMetaTool.cs +++ b/Editors/MetaDataEditor/AnimationMeta/DevConfig/AnimMetaTool.cs @@ -9,11 +9,11 @@ namespace Editors.AnimationMeta.DevConfig { internal class AnimMetaTool : IDeveloperConfiguration { - private readonly IEditorCreator _editorCreator; + private readonly IEditorManager _editorCreator; private readonly IPackFileContainerLoader _packFileContainerLoader; private readonly IPackFileService _packFileService; - public AnimMetaTool(IEditorCreator editorCreator, IPackFileContainerLoader packFileContainerLoader, IPackFileService packFileService) + public AnimMetaTool(IEditorManager editorCreator, IPackFileContainerLoader packFileContainerLoader, IPackFileService packFileService) { _editorCreator = editorCreator; _packFileContainerLoader = packFileContainerLoader; diff --git a/Editors/MetaDataEditor/AnimationMeta/DevConfig/SuperView_Rat.cs b/Editors/MetaDataEditor/AnimationMeta/DevConfig/SuperView_Rat.cs index 921d590be..5ea470a3f 100644 --- a/Editors/MetaDataEditor/AnimationMeta/DevConfig/SuperView_Rat.cs +++ b/Editors/MetaDataEditor/AnimationMeta/DevConfig/SuperView_Rat.cs @@ -12,11 +12,11 @@ namespace Editors.AnimationMeta.DevConfig { internal class SuperView_Rat : IDeveloperConfiguration { - private readonly IEditorCreator _editorCreator; + private readonly IEditorManager _editorCreator; private readonly IPackFileContainerLoader _packFileContainerLoader; private readonly IPackFileService _packFileService; - public SuperView_Rat(IEditorCreator editorCreator, IPackFileContainerLoader packFileContainerLoader, IPackFileService packFileService) + public SuperView_Rat(IEditorManager editorCreator, IPackFileContainerLoader packFileContainerLoader, IPackFileService packFileService) { _editorCreator = editorCreator; _packFileContainerLoader = packFileContainerLoader; @@ -38,7 +38,7 @@ public void OpenFileOnLoad() CreateThrot(_editorCreator, _packFileService); } - void CreateThrot(IEditorCreator creator, IPackFileService packfileService) + void CreateThrot(IEditorManager creator, IPackFileService packfileService) { var debugInput = new AnimationToolInput() { diff --git a/Editors/Reports/DependencyInjectionContainer.cs b/Editors/Reports/DependencyInjectionContainer.cs index 43cce9f32..aa0b2882d 100644 --- a/Editors/Reports/DependencyInjectionContainer.cs +++ b/Editors/Reports/DependencyInjectionContainer.cs @@ -6,7 +6,7 @@ using Editors.Reports.Geometry; using Microsoft.Extensions.DependencyInjection; using Shared.Core.DependencyInjection; -using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu; namespace Editors.Reports { @@ -40,8 +40,8 @@ public override void Register(IServiceCollection serviceCollection) serviceCollection.AddTransient(); serviceCollection.AddTransient(); - serviceCollection.AddScoped(); serviceCollection.AddTransient(); + serviceCollection.AddSingleton(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); @@ -53,4 +53,12 @@ public override void Register(IServiceCollection serviceCollection) serviceCollection.AddTransient(); } } + + public class ReportsPackFileContextMenuRegistration : IPackFileContextMenuRegistration + { + public void Register(PackFileContextMenuRegistry registry) + { + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "Reports", priority: 50, ContextMenuCluster.Reports); + } + } } diff --git a/Editors/Reports/Geometry/RmvToTextReport.cs b/Editors/Reports/Geometry/RmvToTextReport.cs index d238bdc4a..969ec7ca0 100644 --- a/Editors/Reports/Geometry/RmvToTextReport.cs +++ b/Editors/Reports/Geometry/RmvToTextReport.cs @@ -11,10 +11,12 @@ namespace Editors.Reports.Geometry { - public class RmvToTextCommand(RmvToTextReport report) : IRmvToTextCommand + public class RmvToTextCommand(RmvToTextReport report) : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Generate Rmv to Text"; + public bool ShouldAdd(TreeNode node) => IsEnabled(node); + public bool IsEnabled(TreeNode node) => node.NodeType == NodeType.File && node.Item != null && node.Name.EndsWith(".rigid_model_v2", StringComparison.OrdinalIgnoreCase); public void Execute(TreeNode node) diff --git a/Editors/SkeletonEditor/Editor.VisualSkeletonEditor/DevConfig/SkeletonTool.cs b/Editors/SkeletonEditor/Editor.VisualSkeletonEditor/DevConfig/SkeletonTool.cs index 0bba4a435..3bac1adc4 100644 --- a/Editors/SkeletonEditor/Editor.VisualSkeletonEditor/DevConfig/SkeletonTool.cs +++ b/Editors/SkeletonEditor/Editor.VisualSkeletonEditor/DevConfig/SkeletonTool.cs @@ -9,11 +9,11 @@ namespace Editor.VisualSkeletonEditor.DevConfig { internal class SkeletonTool : IDeveloperConfiguration { - private readonly IEditorCreator _editorCreator; + private readonly IEditorManager _editorCreator; private readonly IPackFileContainerLoader _packFileContainerLoader; private readonly IPackFileService _packFileService; - public SkeletonTool(IEditorCreator editorCreator, IPackFileContainerLoader packFileContainerLoader, IPackFileService packFileService) + public SkeletonTool(IEditorManager editorCreator, IPackFileContainerLoader packFileContainerLoader, IPackFileService packFileService) { _editorCreator = editorCreator; _packFileContainerLoader = packFileContainerLoader; diff --git a/Editors/TwuiEditor/Editor.Twui/DevConfig/TwuiToolEditor.cs b/Editors/TwuiEditor/Editor.Twui/DevConfig/TwuiToolEditor.cs index 28100d9ad..f2834efef 100644 --- a/Editors/TwuiEditor/Editor.Twui/DevConfig/TwuiToolEditor.cs +++ b/Editors/TwuiEditor/Editor.Twui/DevConfig/TwuiToolEditor.cs @@ -9,11 +9,11 @@ namespace Editors.Twui.DevConfig { internal class TwuiToolEditor : IDeveloperConfiguration { - private readonly IEditorCreator _editorCreator; + private readonly IEditorManager _editorCreator; private readonly IPackFileContainerLoader _packFileContainerLoader; private readonly IPackFileService _packFileService; - public TwuiToolEditor(IEditorCreator editorCreator, IPackFileContainerLoader packFileContainerLoader, IPackFileService packFileService) + public TwuiToolEditor(IEditorManager editorCreator, IPackFileContainerLoader packFileContainerLoader, IPackFileService packFileService) { _editorCreator = editorCreator; _packFileContainerLoader = packFileContainerLoader; diff --git a/Shared/SharedCore/Shared.Core/DependencyInjection/ScopeRepository.cs b/Shared/SharedCore/Shared.Core/DependencyInjection/ScopeRepository.cs index 8e409ffe0..f1fa8f73d 100644 --- a/Shared/SharedCore/Shared.Core/DependencyInjection/ScopeRepository.cs +++ b/Shared/SharedCore/Shared.Core/DependencyInjection/ScopeRepository.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Reflection; using System.Text; +using CommunityToolkit.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Shared.Core.ErrorHandling; using Shared.Core.ToolCreation; @@ -14,8 +15,8 @@ public interface IScopeRepository IEditorInterface CreateScope(Type owner); void RemoveScope(IEditorInterface owner); - T GetRequiredServiceRootScope(); - T GetRequiredService(IEditorInterface editorHandle); + T GetRequiredServiceRootScope() where T : notnull; + T GetRequiredService(IEditorInterface editorHandle) where T : notnull; void Print(); IEditorInterface? GetEditorFromToken(ScopeToken token); @@ -131,16 +132,22 @@ void GenerateDebugString(IServiceProvider provider, string scopeName, StringBuil // Get resolved services var serviceProviderType = provider.GetType(); var resolvedServicesProperty = serviceProviderType.GetProperty("ResolvedServices", BindingFlags.NonPublic | BindingFlags.Instance); + + Guard.IsNotNull(resolvedServicesProperty, "Failed get property ResolvedServices"); var resolved = resolvedServicesProperty.GetValue(provider); + Guard.IsNotNull(resolved, "Failed to get value from provider"); var resolveList = CastToObjectDictionary(resolved); + stringBuilder.AppendLine($"\tResolvedServices[{resolveList.Count}]"); foreach (var service in resolveList) stringBuilder.AppendLine($"\t\t{ service.Value.GetType().FullName}"); var disposablesServicesProperty = serviceProviderType.GetProperty("Disposables", BindingFlags.NonPublic | BindingFlags.Instance); + Guard.IsNotNull(disposablesServicesProperty, "Unable to find Disposables property on ServiceProvider"); var disposables = disposablesServicesProperty.GetValue(provider) as IList; - + Guard.IsNotNull(disposables, "Unable to cast Disposables to IList"); + stringBuilder.AppendLine($"\tDisposables[{disposables.Count}]"); foreach (var service in disposables) stringBuilder.AppendLine($"\t\t{service.GetType().FullName}"); @@ -167,7 +174,7 @@ static Dictionary CastToObjectDictionary(object input) foreach (var key in dictionary.Keys) { var value = dictionary[key]; - objectDictionary[key] = value; + objectDictionary[key] = value!; } return objectDictionary; diff --git a/Shared/SharedCore/Shared.Core/DevConfig/DevelopmentConfigurationManager.cs b/Shared/SharedCore/Shared.Core/DevConfig/DevelopmentConfigurationManager.cs index 4879fc855..f8593a37c 100644 --- a/Shared/SharedCore/Shared.Core/DevConfig/DevelopmentConfigurationManager.cs +++ b/Shared/SharedCore/Shared.Core/DevConfig/DevelopmentConfigurationManager.cs @@ -13,7 +13,7 @@ public class DevelopmentConfigurationManager private readonly IPackFileService _packFileService; private readonly ApplicationSettingsService _settingsService; private readonly IEnumerable _developerConfigurations; - private IDeveloperConfiguration _activeConfig; + private IDeveloperConfiguration? _activeConfig; public DevelopmentConfigurationManager(IEnumerable developerConfigurations, IPackFileService packFileService, ApplicationSettingsService settingsService) { diff --git a/Shared/SharedCore/Shared.Core/ErrorHandling/ApplicationStateRecorder.cs b/Shared/SharedCore/Shared.Core/ErrorHandling/ApplicationStateRecorder.cs index dfb35a19a..92352210c 100644 --- a/Shared/SharedCore/Shared.Core/ErrorHandling/ApplicationStateRecorder.cs +++ b/Shared/SharedCore/Shared.Core/ErrorHandling/ApplicationStateRecorder.cs @@ -4,7 +4,7 @@ namespace Shared.Core.ErrorHandling { public static class ApplicationStateRecorder { - static Stopwatch s_applicationTimer; + static Stopwatch? s_applicationTimer; static uint s_numberOfEditorsOpened = 0; public static void Initialize() @@ -18,7 +18,7 @@ public static void EditorOpened() s_numberOfEditorsOpened++; } - public static double GetApplicationRunTimeInSec() => s_applicationTimer.Elapsed.TotalSeconds; + public static double GetApplicationRunTimeInSec() => s_applicationTimer?.Elapsed.TotalSeconds ?? 0; public static uint GetNumberOfOpenedEditors() => s_numberOfEditorsOpened; } } diff --git a/Shared/SharedCore/Shared.Core/ErrorHandling/ErrorList.cs b/Shared/SharedCore/Shared.Core/ErrorHandling/ErrorList.cs index 31499954b..6f1d76a36 100644 --- a/Shared/SharedCore/Shared.Core/ErrorHandling/ErrorList.cs +++ b/Shared/SharedCore/Shared.Core/ErrorHandling/ErrorList.cs @@ -5,9 +5,9 @@ namespace Shared.Core.ErrorHandling [DebuggerDisplay("{ErrorType}:{ItemName}-{Description}")] public class ErrorListDataItem { - public string ErrorType { get; set; } - public string ItemName { get; set; } - public string Description { get; set; } + public string ErrorType { get; set; } = ""; + public string ItemName { get; set; } = ""; + public string Description { get; set; } = ""; public bool IsError { get; set; } = false; public bool IsWarning { get; set; } = false; diff --git a/Shared/SharedCore/Shared.Core/ErrorHandling/Exceptions/ExceptionInformation.cs b/Shared/SharedCore/Shared.Core/ErrorHandling/Exceptions/ExceptionInformation.cs index d9e2c0e58..0be5a1ec7 100644 --- a/Shared/SharedCore/Shared.Core/ErrorHandling/Exceptions/ExceptionInformation.cs +++ b/Shared/SharedCore/Shared.Core/ErrorHandling/Exceptions/ExceptionInformation.cs @@ -2,7 +2,7 @@ namespace Shared.Core.ErrorHandling.Exceptions { - public record ExceptionPackFileContainerInfo(bool IsMainEditable, bool IsCa, string Name, string SystemPath); + public record ExceptionPackFileContainerInfo(bool IsMainEditable, bool IsCa, string Name, string? SystemPath); public record ExceptionInstance(string Message, string[] StackTrace); public record GraphicsResourceScopeInfo(string ScopeOwner, bool IsCurrentScope, int ResourceCount, List Resources); diff --git a/Shared/SharedCore/Shared.Core/ErrorHandling/Logging.cs b/Shared/SharedCore/Shared.Core/ErrorHandling/Logging.cs index df7d75ae5..095517ec0 100644 --- a/Shared/SharedCore/Shared.Core/ErrorHandling/Logging.cs +++ b/Shared/SharedCore/Shared.Core/ErrorHandling/Logging.cs @@ -25,7 +25,7 @@ public class Logging public static ILogger CreateStatic(Type type) => Log.ForContext(type); static bool IsConfigured = false; - public static string LogName { get; private set; } + public static string LogName { get; private set; } = string.Empty; public static CustomLoggingSink? CustomSink; public static void Configure(LogEventLevel logEventLevel) diff --git a/Shared/SharedCore/Shared.Core/ErrorHandling/Result.cs b/Shared/SharedCore/Shared.Core/ErrorHandling/Result.cs index b95c6b437..9d4d76473 100644 --- a/Shared/SharedCore/Shared.Core/ErrorHandling/Result.cs +++ b/Shared/SharedCore/Shared.Core/ErrorHandling/Result.cs @@ -4,17 +4,16 @@ namespace Shared.Core.ErrorHandling { public class Result { - public ErrorList LogItems { get; private set; } + public ErrorList LogItems { get; private set; } = new(); public bool IsSuccess { get; private set; } public bool Failed { get => !IsSuccess; } - public T Item { get; private set; } + public T? Item { get; private set; } public static Result FromError(string errorGroup, string description) { var item = new Result() { IsSuccess = false, - LogItems = new ErrorList() }; item.LogItems.Error(errorGroup, description); return item; @@ -25,7 +24,6 @@ public static Result FromError(string errorGroup, ValidationResult result) var item = new Result() { IsSuccess = false, - LogItems = new ErrorList() }; foreach (var error in result.Errors) item.LogItems.Error(errorGroup, $"{error.PropertyName} - {error.ErrorMessage}"); @@ -37,7 +35,6 @@ public static Result FromError(ErrorList errorList) var item = new Result() { IsSuccess = false, - LogItems = new ErrorList() }; foreach (var error in errorList.Errors) item.LogItems.Error(error.ErrorType, error.Description); diff --git a/Shared/SharedCore/Shared.Core/ErrorHandling/UnkownXmlDataThrower.cs b/Shared/SharedCore/Shared.Core/ErrorHandling/UnkownXmlDataThrower.cs index d7eaf5adc..cb33f1e35 100644 --- a/Shared/SharedCore/Shared.Core/ErrorHandling/UnkownXmlDataThrower.cs +++ b/Shared/SharedCore/Shared.Core/ErrorHandling/UnkownXmlDataThrower.cs @@ -15,18 +15,17 @@ public UnknownXmlDataThrower() OnUnknownElement = Element, }; } - - void Attribute(object sender, XmlAttributeEventArgs e) + void Attribute(object? sender, XmlAttributeEventArgs e) { throw new XmlException("Unsuported xml attribute : " + e.Attr.LocalName + $" at line {e.LineNumber} and position {e.LinePosition}", null, e.LineNumber, e.LinePosition); } - void Node(object sender, XmlNodeEventArgs e) + void Node(object? sender, XmlNodeEventArgs e) { throw new XmlException("Unsuported xml node : " + e.LocalName + $" at line {e.LineNumber} and position {e.LinePosition}", null, e.LineNumber, e.LinePosition); } - void Element(object sender, XmlElementEventArgs e) + void Element(object? sender, XmlElementEventArgs e) { throw new XmlException("Unsuported xml element : " + e.Element.LocalName + $" at line {e.LineNumber} and position {e.LinePosition}", null, e.LineNumber, e.LinePosition); } diff --git a/Shared/SharedCore/Shared.Core/Events/EventHub.cs b/Shared/SharedCore/Shared.Core/Events/EventHub.cs index 3fb54fd7f..0d5e8f3de 100644 --- a/Shared/SharedCore/Shared.Core/Events/EventHub.cs +++ b/Shared/SharedCore/Shared.Core/Events/EventHub.cs @@ -1,5 +1,4 @@ -using Serilog; -using Shared.Core.DependencyInjection; +using Shared.Core.DependencyInjection; using Shared.Core.ErrorHandling; namespace Shared.Core.Events @@ -40,7 +39,7 @@ abstract class EventHub : IDisposable private readonly ILogger _logger = Logging.Create(); bool _isDisposed = false; - Dictionary> _callbackList = new(); + readonly Dictionary> _callbackList = new(); private readonly IScopeRepository _scopeRepository; private readonly string _hubName; private readonly bool _isGlobal; @@ -57,7 +56,7 @@ public void PublishGlobalEvent(T e) // If gloabl, send to self and all children if (_isGlobal) { - _logger.Here().Information($"Publshing global event {e.GetType().Name} on {_hubName}"); + _logger.Here().Information($"Publshing global event {e!.GetType().Name} on {_hubName}"); // Send to global subscribers Publish(e); @@ -135,6 +134,9 @@ public void Register(object owner, Action action) public void Dispose() { + if (_isDisposed == true) + return; + _isDisposed = true; _callbackList?.Clear(); } diff --git a/Shared/SharedCore/Shared.Core/Events/Global/OpenEditorCommand.cs b/Shared/SharedCore/Shared.Core/Events/Global/OpenEditorCommand.cs index 1f08fdbeb..08d8582fb 100644 --- a/Shared/SharedCore/Shared.Core/Events/Global/OpenEditorCommand.cs +++ b/Shared/SharedCore/Shared.Core/Events/Global/OpenEditorCommand.cs @@ -8,10 +8,10 @@ namespace Shared.Core.Events.Global { public class OpenEditorCommand : IUiCommand { - private readonly IEditorCreator _editorCreator; + private readonly IEditorManager _editorCreator; private readonly IPackFileService _packFileService; - public OpenEditorCommand(IEditorCreator editorCreator, IPackFileService packFileService) + public OpenEditorCommand(IEditorManager editorCreator, IPackFileService packFileService) { _packFileService = packFileService; _editorCreator = editorCreator; @@ -49,6 +49,7 @@ public IEditorInterface Execute(EditorEnums editorEnum) public void ExecuteAsWindow(string fileName, int width, int heigh) { var file = _packFileService.FindFile(fileName); + Guard.IsNotNull(file, "File not found: " + fileName); var window = _editorCreator.CreateWindow(file); window.Width = width; @@ -56,12 +57,12 @@ public void ExecuteAsWindow(string fileName, int width, int heigh) window.ShowDialog(); } - public void ExecuteAsWindow(EditorEnums preferedEditor, int width, int heigh) - { - var window = _editorCreator.CreateWindow(null, preferedEditor); - window.Width = width; - window.Height = heigh; - window.ShowDialog(); - } + //public void ExecuteAsWindow(EditorEnums preferedEditor, int width, int heigh) + //{ + // var window = _editorCreator.CreateWindow(null, preferedEditor); + // window.Width = width; + // window.Height = heigh; + // window.ShowDialog(); + //} } } diff --git a/Shared/SharedCore/Shared.Core/Misc/DirectoryHelper.cs b/Shared/SharedCore/Shared.Core/Misc/DirectoryHelper.cs index 8c71f84e9..4310d3884 100644 --- a/Shared/SharedCore/Shared.Core/Misc/DirectoryHelper.cs +++ b/Shared/SharedCore/Shared.Core/Misc/DirectoryHelper.cs @@ -6,8 +6,6 @@ namespace Shared.Core.Misc { public class DirectoryHelper { - - private const string ExplorerWindowClass = "CabinetWClass"; private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); @@ -51,7 +49,7 @@ public static void EnsureCreated() EnsureCreated(CacheDirectory); } - public static void EnsureCreated(string? path) + public static void EnsureCreated(string path) { if (!Directory.Exists(path)) Directory.CreateDirectory(path); @@ -60,7 +58,7 @@ public static void EnsureCreated(string? path) public static void EnsureFileFolderCreated(string filePath) { var folder = Path.GetDirectoryName(filePath); - if (!Directory.Exists(folder)) + if (!Directory.Exists(folder) && string.IsNullOrWhiteSpace(folder) == false) Directory.CreateDirectory(folder); } @@ -68,9 +66,7 @@ public static bool IsFileLocked(string path) { try { - using (Stream stream = new FileStream(path, FileMode.Open)) - { - } + using Stream stream = new FileStream(path, FileMode.Open); } catch (IOException) { @@ -105,8 +101,7 @@ public static void OpenOrFocusFolder(string folderPath) return; } - IntPtr foundHandle = IntPtr.Zero; - + var foundHandle = IntPtr.Zero; EnumWindows((hWnd, lParam) => { if (!IsWindowVisible(hWnd)) @@ -122,8 +117,7 @@ public static void OpenOrFocusFolder(string folderPath) GetWindowText(hWnd, windowTitle, windowTitle.Capacity); // Explorer title usually ends with folder name - string folderName = Path.GetFileName(folderPath); - + var folderName = Path.GetFileName(folderPath); if (!string.IsNullOrEmpty(folderName) && windowTitle.ToString().Contains(folderName, StringComparison.OrdinalIgnoreCase)) { diff --git a/Shared/SharedCore/Shared.Core/Misc/FileWatcher.cs b/Shared/SharedCore/Shared.Core/Misc/FileWatcher.cs index fbae3db5d..bec2e4788 100644 --- a/Shared/SharedCore/Shared.Core/Misc/FileWatcher.cs +++ b/Shared/SharedCore/Shared.Core/Misc/FileWatcher.cs @@ -1,4 +1,4 @@ -namespace Shared.Core.Misc +/*namespace Shared.Core.Misc { public class FileWatcher { @@ -61,4 +61,4 @@ public string Path set => _path = value; } } -} +}*/ diff --git a/Shared/SharedCore/Shared.Core/Misc/JsonHelpers.cs b/Shared/SharedCore/Shared.Core/Misc/JsonHelpers.cs index a7567aed0..74b396442 100644 --- a/Shared/SharedCore/Shared.Core/Misc/JsonHelpers.cs +++ b/Shared/SharedCore/Shared.Core/Misc/JsonHelpers.cs @@ -10,15 +10,15 @@ public override bool CanConvert(Type objecType) return objecType == typeof(List); } - public override object ReadJson(JsonReader reader, Type objecType, object existingValue, - JsonSerializer serializer) + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { var token = JToken.Load(reader); if (token.Type == JTokenType.Array) { return token.ToObject>(); } - return new List { token.ToObject() }; + var tokenT = token.ToObject(); + return new List { tokenT! }; } public override bool CanWrite @@ -26,9 +26,6 @@ public override bool CanWrite get { return false; } } - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) - { - throw new NotImplementedException(); - } + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) => throw new NotImplementedException(); } } diff --git a/Shared/SharedCore/Shared.Core/Misc/MathUtil.cs b/Shared/SharedCore/Shared.Core/Misc/MathUtil.cs index 413dedcf3..9700b4459 100644 --- a/Shared/SharedCore/Shared.Core/Misc/MathUtil.cs +++ b/Shared/SharedCore/Shared.Core/Misc/MathUtil.cs @@ -141,7 +141,7 @@ public static Matrix CreateRotation(Vector3 angles_degrees) return Matrix.CreateRotationX(x) * Matrix.CreateRotationY(y) * Matrix.CreateRotationZ(z); } - public static Matrix CreateRotation(Vector3[] vectors, Vector3[] basis = null) + public static Matrix CreateRotation(Vector3[] vectors, Vector3[]? basis = null) { if (vectors.Length != 3) { diff --git a/Shared/SharedCore/Shared.Core/Misc/NotifyAttr.cs b/Shared/SharedCore/Shared.Core/Misc/NotifyAttr.cs index c3cd57a80..7ec1aa1f5 100644 --- a/Shared/SharedCore/Shared.Core/Misc/NotifyAttr.cs +++ b/Shared/SharedCore/Shared.Core/Misc/NotifyAttr.cs @@ -11,16 +11,18 @@ public class NotifyAttr : NotifyPropertyChangedImpl public NotifyAttr(T value) { + _value = value; Value = value; } public NotifyAttr() { - Value = default; + _value = default!; } public NotifyAttr(T value, ValueChangedDelegate onValueChanged) { + _value = value; Value = value; _onValueChanged = onValueChanged; } diff --git a/Shared/SharedCore/Shared.Core/Misc/NotifyPropertyChangedImpl.cs b/Shared/SharedCore/Shared.Core/Misc/NotifyPropertyChangedImpl.cs index 77934f342..939837498 100644 --- a/Shared/SharedCore/Shared.Core/Misc/NotifyPropertyChangedImpl.cs +++ b/Shared/SharedCore/Shared.Core/Misc/NotifyPropertyChangedImpl.cs @@ -26,7 +26,7 @@ protected virtual void NotifyPropertyChanged(T value, ValueChangedDelegate valueChangedDelegate?.Invoke(value); } - protected virtual void SetAndNotify(ref T variable, T newValue, ValueChangedDelegate valueChangedDelegate = null, [CallerMemberName] string propertyName = "") + protected virtual void SetAndNotify(ref T variable, T newValue, ValueChangedDelegate? valueChangedDelegate = null, [CallerMemberName] string propertyName = "") { variable = newValue; NotifyPropertyChanged(propertyName); @@ -51,7 +51,7 @@ protected virtual void SetAndNotifyWithSender(T value, ValueAndSenderChangedD valueChangedDelegate?.Invoke(this, value); } - protected virtual void SetAndNotifyWithSender(ref T variable, T newValue, ValueAndSenderChangedDelegate valueChangedDelegate = null, [CallerMemberName] string propertyName = "") + protected virtual void SetAndNotifyWithSender(ref T variable, T newValue, ValueAndSenderChangedDelegate? valueChangedDelegate = null, [CallerMemberName] string propertyName = "") { variable = newValue; NotifyPropertyChanged(propertyName); diff --git a/Shared/SharedCore/Shared.Core/Misc/ReflectionHelper.cs b/Shared/SharedCore/Shared.Core/Misc/ReflectionHelper.cs index cbd9f4c9e..ae0e54930 100644 --- a/Shared/SharedCore/Shared.Core/Misc/ReflectionHelper.cs +++ b/Shared/SharedCore/Shared.Core/Misc/ReflectionHelper.cs @@ -15,17 +15,17 @@ public static object GetMemberValue(object obj, string memberName) // Try to get as a Property var propInfo = type.GetProperty(memberName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); if (propInfo != null) - return propInfo.GetValue(obj, null); + return propInfo.GetValue(obj, null)!; // Try to get as a Field var fieldInfo = type.GetField(memberName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); if (fieldInfo != null) - return fieldInfo.GetValue(obj); + return fieldInfo.GetValue(obj)!; throw new MemberAccessException($"Member '{memberName}' not found in type '{type.Name}'."); } - public static T CreateShallowCopy(T original) where T : class + public static T? CreateShallowCopy(T original) where T : class { if (original == null) { @@ -34,7 +34,7 @@ public static T CreateShallowCopy(T original) where T : class // Create a new instance of the same type as the original object. // Activator.CreateInstance() uses reflection to instantiate the type dynamically. - T copy = (T)Activator.CreateInstance(original.GetType()); + T copy = (T)Activator.CreateInstance(original.GetType())!; // Get all public, instance properties of the class. PropertyInfo[] properties = original.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance); @@ -45,7 +45,7 @@ public static T CreateShallowCopy(T original) where T : class if (property.CanRead && property.CanWrite) { // Get the value from the original object and set it on the new copy. - object value = property.GetValue(original); + object value = property.GetValue(original)!; property.SetValue(copy, value); } } diff --git a/Shared/SharedCore/Shared.Core/Misc/SaveUtility.cs b/Shared/SharedCore/Shared.Core/Misc/SaveUtility.cs index c2056266d..2c42aa2a4 100644 --- a/Shared/SharedCore/Shared.Core/Misc/SaveUtility.cs +++ b/Shared/SharedCore/Shared.Core/Misc/SaveUtility.cs @@ -36,9 +36,9 @@ public static void CreateFileBackup(string originalFileName) var dirName = Path.GetDirectoryName(originalFileName); var fileName = Path.GetFileNameWithoutExtension(originalFileName); var extention = Path.GetExtension(originalFileName); - var uniqeFileName = IndexedFilename(Path.Combine(dirName, s_backupFolderPath, fileName), extention); + var uniqeFileName = IndexedFilename(Path.Combine(dirName!, s_backupFolderPath, fileName), extention); - Directory.CreateDirectory(Path.Combine(dirName, s_backupFolderPath)); + Directory.CreateDirectory(Path.Combine(dirName!, s_backupFolderPath)); File.Copy(originalFileName, uniqeFileName); } } @@ -46,7 +46,7 @@ public static void CreateFileBackup(string originalFileName) static string IndexedFilename(string stub, string extension) { var ix = 0; - string filename = null; + string? filename = null; do { ix++; diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/CachedPackFileContainer.cs b/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/CachedPackFileContainer.cs index ee3bafa20..d6f4a634b 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/CachedPackFileContainer.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/CachedPackFileContainer.cs @@ -8,7 +8,7 @@ namespace Shared.Core.PackFiles.Models.Containers { internal class CachedPackFileContainer : IPackFileContainerInternal { - private readonly DbContextOptions _dbOptions; + private readonly CacheDbContext _db; public string Name { get; set; } public bool IsCaPackFile { get => true; set { } } @@ -19,52 +19,89 @@ public CachedPackFileContainer(string name, DbContextOptions dbO { Name = name; SystemFilePath = string.Empty; - _dbOptions = dbOptions; + _db = new CacheDbContext(dbOptions); + _db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; } public int GetFileCount() { - using var db = new CacheDbContext(_dbOptions); - return db.Files.Count(); + return _db.Files.Count(); } public PackFile? FindFile(string path) { var lowerPath = PathNormalization.NormalizeFileName(path); - using var db = new CacheDbContext(_dbOptions); - var entry = db.Files.AsNoTracking().FirstOrDefault(f => f.RelativePath == lowerPath); + var entry = _db.Files.FirstOrDefault(f => f.RelativePath == lowerPath); return entry != null ? ToPackFile(entry) : null; } public bool ContainsFile(string path) { var lowerPath = PathNormalization.NormalizeFileName(path); - using var db = new CacheDbContext(_dbOptions); - return db.Files.Any(f => f.RelativePath == lowerPath); + return _db.Files.Any(f => f.RelativePath == lowerPath); } public string? GetFullPath(PackFile file) { - using var db = new CacheDbContext(_dbOptions); - var entry = db.Files.AsNoTracking().FirstOrDefault(f => f.FileName == file.Name); - return entry?.RelativePath; + if (file.DataSource is PackedFileSource source) + { + var entry = _db.Files.FirstOrDefault(f => + f.SourcePackFilePath == source.Parent.FilePath && + f.Offset == source.Offset && + f.Size == source.Size && + f.IsEncrypted == source.IsEncrypted && + f.IsCompressed == source.IsCompressed && + f.CompressionFormat == (int)source.CompressionFormat && + f.UncompressedSize == source.UncompressedSize && + f.FileName.ToLower() == file.Name.ToLower()); + + if (entry != null) + return entry.RelativePath; + } + + var matchingPaths = _db.Files + .Where(f => f.FileName.ToLower() == file.Name.ToLower()) + .Select(f => f.RelativePath) + .Take(2) + .ToList(); + + return matchingPaths.Count == 1 ? matchingPaths[0] : null; } public List<(string FileName, PackFile Pack)> FindAllWithExtention(string extention) { extention = extention.ToLower(); - using var db = new CacheDbContext(_dbOptions); - var entries = db.Files.AsNoTracking() + var entries = _db.Files .Where(f => f.Extension == extention) .ToList(); return entries.Select(e => (e.RelativePath, ToPackFile(e))).ToList(); } + public List<(string Path, PackFile File)> SearchFiles(string? textFilter, IReadOnlyList? extensions) + { + IQueryable query = _db.Files; + + if (extensions != null && extensions.Count > 0) + { + var extList = extensions.Select(e => e.ToLowerInvariant()).ToList(); + query = query.Where(f => extList.Any(ext => f.FileName.Contains(ext))); + } + + if (!string.IsNullOrWhiteSpace(textFilter)) + { + var pattern = $"%{textFilter}%"; + query = query.Where(f => EF.Functions.Like(f.FileName, pattern)); + } + + var entries = query.OrderBy(f => f.RelativePath).ToList(); + + return entries.Select(e => (e.RelativePath, ToPackFile(e))).ToList(); + } + public Dictionary GetAllFiles() { - using var db = new CacheDbContext(_dbOptions); - var entries = db.Files.AsNoTracking().ToList(); + var entries = _db.Files.ToList(); var parentCache = new Dictionary(StringComparer.OrdinalIgnoreCase); var result = new Dictionary(entries.Count); @@ -87,77 +124,63 @@ public Dictionary GetAllFiles() return result; } - public void AddOrUpdateFile(string path, PackFile file) => - throw new InvalidOperationException("Cannot modify a cached CA pack file container."); - public DirectoryContent GetDirectoryContent(string directoryPath) - { - using var db = new CacheDbContext(_dbOptions); - // Get files directly in this folder - var filesInDir = db.Files.AsNoTracking() + public List<(string Path, PackFile File)> GetDirectoryContent(string directoryPath) + { + var directFileRows = _db.Files .Where(f => f.FolderPath == directoryPath) - .Select(f => new { f.FileName, f.SourcePackFilePath, f.Offset, f.Size, f.IsEncrypted, f.IsCompressed, f.CompressionFormat, f.UncompressedSize }) + .Select(f => new { f.FolderPath, f.FileName, f.SourcePackFilePath, f.Offset, f.Size, f.IsEncrypted, f.IsCompressed, f.CompressionFormat, f.UncompressedSize }) .ToList(); - var files = filesInDir + var packedFileSourceParentCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + var files = directFileRows .Select(f => { - var parent = new PackedFileSourceParent { FilePath = f.SourcePackFilePath }; + if (!packedFileSourceParentCache.TryGetValue(f.SourcePackFilePath, out var parent)) + { + parent = new PackedFileSourceParent { FilePath = f.SourcePackFilePath }; + packedFileSourceParentCache[f.SourcePackFilePath] = parent; + } + var source = new PackedFileSource(parent, f.Offset, f.Size, f.IsEncrypted, f.IsCompressed, (Utility.CompressionFormat)f.CompressionFormat, f.UncompressedSize); - return (f.FileName, File: new PackFile(f.FileName, source)); + var relativePath = string.IsNullOrEmpty(f.FolderPath) ? f.FileName : $"{f.FolderPath}\\{f.FileName}"; + return (Path: relativePath, File: new PackFile(f.FileName, source)); }) - .OrderBy(x => x.FileName, StringComparer.CurrentCultureIgnoreCase) + .OrderBy(x => x.Path, StringComparer.CurrentCultureIgnoreCase) .ToList(); - // Get immediate subfolders - var prefix = string.IsNullOrEmpty(directoryPath) ? "" : directoryPath + "\\"; - var prefixLen = prefix.Length; - - List subFolders; - if (string.IsNullOrEmpty(directoryPath)) - { - // Root level: get distinct first path segment from all FolderPaths - subFolders = db.Files.AsNoTracking() - .Where(f => f.FolderPath != "") - .Select(f => f.FolderPath) - .Distinct() - .AsEnumerable() - .Select(fp => - { - var sepIdx = fp.IndexOf('\\'); - return sepIdx == -1 ? fp : fp.Substring(0, sepIdx); - }) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase) - .ToList(); - } - else - { - // Non-root: get distinct next segment from FolderPaths that start with prefix - subFolders = db.Files.AsNoTracking() - .Where(f => f.FolderPath.StartsWith(prefix)) - .Select(f => f.FolderPath) - .Distinct() - .AsEnumerable() - .Select(fp => - { - var remainder = fp.Substring(prefixLen); - var sepIdx = remainder.IndexOf('\\'); - return sepIdx == -1 ? remainder : remainder.Substring(0, sepIdx); - }) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase) - .ToList(); - } + return files; + } - return new DirectoryContent - { - SubFolders = subFolders, - Files = files - }; + public List GetSubDirectories(string directoryPath) + { + var prefix = string.IsNullOrEmpty(directoryPath) ? "" : directoryPath + "\\"; + var prefixLength = prefix.Length; + + return _db.Files + .Where(f => string.IsNullOrEmpty(directoryPath) + ? f.FolderPath != "" + : f.FolderPath.StartsWith(prefix)) + .Select(f => f.FolderPath) + .AsEnumerable() + .Select(folderPath => string.IsNullOrEmpty(directoryPath) + ? folderPath + : folderPath.Substring(prefixLength)) + .Select(candidate => + { + var separatorIndex = candidate.IndexOf('\\'); + return separatorIndex == -1 ? candidate : candidate.Substring(0, separatorIndex); + }) + .Where(folderName => !string.IsNullOrWhiteSpace(folderName)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase) + .ToList(); } + public void AddOrUpdateFile(string path, PackFile file) => + throw new InvalidOperationException("Cannot modify a cached CA pack file container."); + public List AddFiles(List newFiles) => throw new InvalidOperationException("Cannot modify a cached CA pack file container."); diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/PackFileContainer.cs b/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/PackFileContainer.cs index 093fa3087..e6f81c3ea 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/PackFileContainer.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/PackFileContainer.cs @@ -1,4 +1,5 @@ -using Shared.Core.Misc; +using CommunityToolkit.Diagnostics; +using Shared.Core.Misc; using Shared.Core.PackFiles.Models.FileSources; using Shared.Core.PackFiles.Serialization; using Shared.Core.PackFiles.Utility; @@ -11,12 +12,20 @@ internal class PackFileContainer : IPackFileContainerInternal public string Name { get; set; } public PFHeader Header { get; set; } public bool IsCaPackFile { get; set; } = false; - public string SystemFilePath { get; set; } + public string? SystemFilePath { get; set; } public long OriginalLoadByteSize { get; set; } = -1; public HashSet SourcePackFilePaths { get; set; } = []; public Dictionary FileList { get; set; } = []; + public PackFileContainer(string name) + { + Name = name; + var v = PackFileVersionConverter.ToString(PackFileVersion.PFH5); + Header = new PFHeader(v, PackFileCAType.MOD); + } + + public int GetFileCount() => FileList.Count; public void AddOrUpdateFile(string path, PackFile file) @@ -27,39 +36,40 @@ public void AddOrUpdateFile(string path, PackFile file) public Dictionary GetAllFiles() => FileList; - public DirectoryContent GetDirectoryContent(string directoryPath) + public List<(string Path, PackFile File)> GetDirectoryContent(string directoryPath) { var prefix = string.IsNullOrEmpty(directoryPath) ? "" : directoryPath + "\\"; - var prefixLength = prefix.Length; - var subFolders = new HashSet(StringComparer.OrdinalIgnoreCase); - var files = new List<(string FileName, PackFile File)>(); + var results = new List<(string Path, PackFile File)>(); + var directFileSlashCount = string.IsNullOrEmpty(directoryPath) ? 0 : directoryPath.Count(c => c == '\\') + 1; foreach (var (path, packFile) in FileList) { - if (prefixLength > 0 && !path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - continue; - if (prefixLength == 0 && path.Length == 0) + if ((string.IsNullOrEmpty(prefix) || path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + && path.Count(c => c == '\\') == directFileSlashCount) + results.Add((path, packFile)); + } + + results.Sort((a, b) => StringComparer.CurrentCultureIgnoreCase.Compare(a.Path, b.Path)); + return results; + } + + public List GetSubDirectories(string directoryPath) + { + var prefix = string.IsNullOrEmpty(directoryPath) ? "" : directoryPath + "\\"; + var subFolders = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var path in FileList.Keys) + { + if (!string.IsNullOrEmpty(prefix) && !path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; - var remainder = path.AsSpan(prefixLength); + var remainder = string.IsNullOrEmpty(prefix) ? path : path.Substring(prefix.Length); var separatorIndex = remainder.IndexOf(Path.DirectorySeparatorChar); - - if (separatorIndex == -1) - { - files.Add((packFile.Name, packFile)); - } - else - { - var folderName = remainder.Slice(0, separatorIndex).ToString(); - subFolders.Add(folderName); - } + if (separatorIndex > 0) + subFolders.Add(remainder.Substring(0, separatorIndex)); } - return new DirectoryContent - { - SubFolders = subFolders.OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase).ToList(), - Files = files.OrderBy(x => x.FileName, StringComparer.CurrentCultureIgnoreCase).ToList() - }; + return subFolders.OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase).ToList(); } public List<(string FileName, PackFile Pack)> FindAllWithExtention(string extention) @@ -74,11 +84,42 @@ public DirectoryContent GetDirectoryContent(string directoryPath) return output; } - public PackFileContainer(string name) + public List<(string Path, PackFile File)> SearchFiles(string? textFilter, IReadOnlyList? extensions) { - Name = name; + var results = new List<(string Path, PackFile File)>(); + + foreach (var (path, packFile) in FileList) + { + if (extensions != null && extensions.Count > 0) + { + var matchesExtension = false; + foreach (var ext in extensions) + { + if (packFile.Name.Contains(ext, StringComparison.OrdinalIgnoreCase)) + { + matchesExtension = true; + break; + } + } + if (!matchesExtension) + continue; + } + + if (!string.IsNullOrWhiteSpace(textFilter)) + { + if (!packFile.Name.Contains(textFilter, StringComparison.OrdinalIgnoreCase)) + continue; + } + + results.Add((path, packFile)); + } + + results.Sort((a, b) => StringComparer.OrdinalIgnoreCase.Compare(a.Path, b.Path)); + return results; } + + public void MergePackFileContainer(PackFileContainer other) { foreach (var item in other.GetAllFiles()) @@ -221,6 +262,7 @@ public virtual void SaveToDisk(string path, bool createBackup, GameInformation g if (OriginalLoadByteSize != -1) { + Guard.IsNotNull(SystemFilePath, "SystemFilePath must be set if saving to disk"); var fileInfo = new FileInfo(SystemFilePath); var byteSize = fileInfo.Length; if (byteSize != OriginalLoadByteSize) diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Models/IPackFileContainer.cs b/Shared/SharedCore/Shared.Core/PackFiles/Models/IPackFileContainer.cs index e20975ffc..04ec72cf6 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Models/IPackFileContainer.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Models/IPackFileContainer.cs @@ -1,33 +1,20 @@ -using System.Collections.Generic; - -namespace Shared.Core.PackFiles.Models +namespace Shared.Core.PackFiles.Models { - public readonly struct DirectoryContent - { - public IReadOnlyList SubFolders { get; init; } - public IReadOnlyList<(string FileName, PackFile File)> Files { get; init; } - - public static DirectoryContent Empty => new() - { - SubFolders = [], - Files = [] - }; - } - public interface IPackFileContainer { string Name { get; } bool IsCaPackFile { get; set; } - string SystemFilePath { get; } + string? SystemFilePath { get; } int GetFileCount(); PackFile? FindFile(string path); bool ContainsFile(string path); string? GetFullPath(PackFile file); - void AddOrUpdateFile(string path, PackFile file); - List<(string FileName, PackFile Pack)> FindAllWithExtention(string extention); + Dictionary GetAllFiles(); - DirectoryContent GetDirectoryContent(string directoryPath); + + + List<(string Path, PackFile File)> SearchFiles(string? textFilter, IReadOnlyList? extensions); } } diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Models/IPackFileContainerInternal.cs b/Shared/SharedCore/Shared.Core/PackFiles/Models/IPackFileContainerInternal.cs index c48cf3de6..e1cc13812 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Models/IPackFileContainerInternal.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Models/IPackFileContainerInternal.cs @@ -1,9 +1,10 @@ -using Shared.Core.Settings; +using Shared.Core.Settings; namespace Shared.Core.PackFiles.Models { internal interface IPackFileContainerInternal : IPackFileContainer { + void AddOrUpdateFile(string path, PackFile file); List AddFiles(List newFiles); PackFile? DeleteFile(PackFile file); void DeleteFolder(string folder); @@ -12,5 +13,9 @@ internal interface IPackFileContainerInternal : IPackFileContainer void RenameFile(PackFile file, string newName); void SaveFileData(PackFile file, byte[] data); void SaveToDisk(string path, bool createBackup, GameInformation gameInformation); + + List<(string FileName, PackFile Pack)> FindAllWithExtention(string extention); + List GetSubDirectories(string directoryPath); + List<(string Path, PackFile File)> GetDirectoryContent(string directoryPath); } -} \ No newline at end of file +} diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Models/PFHeader.cs b/Shared/SharedCore/Shared.Core/PackFiles/Models/PFHeader.cs index bea1bd5e0..6575817d0 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Models/PFHeader.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Models/PFHeader.cs @@ -55,7 +55,13 @@ public class PFHeader public List DependantFiles { get; set; } = []; - public PFHeader() { } + public PFHeader(string strVersion, int type, uint referenceFileCount) + { + StrVersion = strVersion; + ByteMask = type; + ReferenceFileCount = referenceFileCount; + Buffer = DefaultTimeStamp; + } public PFHeader(string version, PackFileCAType type) { diff --git a/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs b/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs index 6b6ac60e9..4ef75a356 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs @@ -29,7 +29,7 @@ public PackFileService(IGlobalEventHub? globalEventHub) _globalEventHub = globalEventHub; } - private static IPackFileContainerInternal CastContainer(IPackFileContainer container) => (IPackFileContainerInternal)container; + internal static IPackFileContainerInternal CastContainer(IPackFileContainer container) => (IPackFileContainerInternal)container; public List GetAllPackfileContainers() => _packFileContainers.Cast().ToList(); @@ -142,14 +142,17 @@ public void UnloadPackContainer(IPackFileContainer pf) _globalEventHub?.PublishGlobalEvent(new PackFileContainerRemovedEvent(container)); } - public List<(string FileName, PackFile Pack)> FindAllWithExtention(string extention, IPackFileContainer? container = null) + public List<(string FileName, PackFile Pack)> FindAllWithExtention(string extention, IPackFileContainer? pf = null) { - if (container != null) + if (pf != null) + { + var container = CastContainer(pf); return container.FindAllWithExtention(extention); + } var output = new List<(string, PackFile)>(); - foreach (var pf in _packFileContainers) - output.AddRange(pf.FindAllWithExtention(extention)); + foreach (var instance in _packFileContainers) + output.AddRange(instance.FindAllWithExtention(extention)); return output; } diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Serialization/CacheDatabase/CacheDbContext.cs b/Shared/SharedCore/Shared.Core/PackFiles/Serialization/CacheDatabase/CacheDbContext.cs index 03ef10bba..12b072a69 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Serialization/CacheDatabase/CacheDbContext.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Serialization/CacheDatabase/CacheDbContext.cs @@ -26,6 +26,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(e => e.RelativePath).IsUnique(); entity.HasIndex(e => e.Extension); entity.HasIndex(e => e.FolderPath); + entity.HasIndex(e => e.FileName); }); } } diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Serialization/CacheDatabase/PackFileContainerCacheHelper.cs b/Shared/SharedCore/Shared.Core/PackFiles/Serialization/CacheDatabase/PackFileContainerCacheHelper.cs index 950656807..53059ee8d 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Serialization/CacheDatabase/PackFileContainerCacheHelper.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Serialization/CacheDatabase/PackFileContainerCacheHelper.cs @@ -59,6 +59,23 @@ public static DbContextOptions CreateDbOptions(string dbFilePath .Options; } + public static DbContextOptions CreateDbOptionsFromConnectionString(string connectionString) + { + return new DbContextOptionsBuilder() + .UseSqlite(connectionString) + .Options; + } + + public static DbContextOptions CreateDbOptions(SqliteConnection connection) + { + if (connection.State != System.Data.ConnectionState.Open) + connection.Open(); + + return new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + } + private const int CurrentSchemaVersion = 3; public static void SaveCache(string fingerprint, PackFileContainer container, DbContextOptions dbOptions) @@ -72,84 +89,99 @@ public static void SaveCache(string fingerprint, PackFileContainer container, Db db.Database.EnsureCreated(); } - // Use raw SQLite for bulk insert - avoids EF change tracker memory and perf overhead - var connectionString = $"Data Source={GetDbFilePath(dbOptions)};Pooling=False"; - using var connection = new SqliteConnection(connectionString); - connection.Open(); - - using var transaction = connection.BeginTransaction(); + // Use raw SQLite for bulk insert - avoids EF change tracker memory and perf overhead. + // If dbOptions carries an explicit SqliteConnection (for tests/in-memory), reuse it. + var (connection, shouldDisposeConnection) = GetSqliteConnection(dbOptions); + if (connection.State != System.Data.ConnectionState.Open) + connection.Open(); - // Insert CacheInfo - using (var cmd = connection.CreateCommand()) + try { - cmd.Transaction = transaction; - cmd.CommandText = @"INSERT INTO CacheInfo (SchemaVersion, Fingerprint, ContainerName, SystemFilePath, SourcePackFilePaths) - VALUES ($schemaVersion, $fingerprint, $containerName, $systemFilePath, $sourcePackFilePaths)"; - cmd.Parameters.AddWithValue("$schemaVersion", CurrentSchemaVersion); - cmd.Parameters.AddWithValue("$fingerprint", fingerprint); - cmd.Parameters.AddWithValue("$containerName", container.Name); - cmd.Parameters.AddWithValue("$systemFilePath", container.SystemFilePath); - cmd.Parameters.AddWithValue("$sourcePackFilePaths", string.Join("|", container.SourcePackFilePaths)); - cmd.ExecuteNonQuery(); - } + using var transaction = connection.BeginTransaction(); - // Bulk insert files using a prepared statement - using (var cmd = connection.CreateCommand()) - { - cmd.Transaction = transaction; - cmd.CommandText = @"INSERT INTO FileList (RelativePath, FileName, Extension, FolderPath, SourcePackFilePath, Offset, Size, IsEncrypted, IsCompressed, CompressionFormat, UncompressedSize) - VALUES ($relativePath, $fileName, $extension, $folderPath, $sourcePackFilePath, $offset, $size, $isEncrypted, $isCompressed, $compressionFormat, $uncompressedSize)"; + // Insert CacheInfo + using (var cmd = connection.CreateCommand()) + { + cmd.Transaction = transaction; + cmd.CommandText = @"INSERT INTO CacheInfo (SchemaVersion, Fingerprint, ContainerName, SystemFilePath, SourcePackFilePaths) + VALUES ($schemaVersion, $fingerprint, $containerName, $systemFilePath, $sourcePackFilePaths)"; + cmd.Parameters.AddWithValue("$schemaVersion", CurrentSchemaVersion); + cmd.Parameters.AddWithValue("$fingerprint", fingerprint); + cmd.Parameters.AddWithValue("$containerName", container.Name); + cmd.Parameters.AddWithValue("$systemFilePath", container.SystemFilePath); + cmd.Parameters.AddWithValue("$sourcePackFilePaths", string.Join("|", container.SourcePackFilePaths)); + cmd.ExecuteNonQuery(); + } - var pRelativePath = cmd.Parameters.Add("$relativePath", SqliteType.Text); - var pFileName = cmd.Parameters.Add("$fileName", SqliteType.Text); - var pExtension = cmd.Parameters.Add("$extension", SqliteType.Text); - var pFolderPath = cmd.Parameters.Add("$folderPath", SqliteType.Text); - var pSourcePackFilePath = cmd.Parameters.Add("$sourcePackFilePath", SqliteType.Text); - var pOffset = cmd.Parameters.Add("$offset", SqliteType.Integer); - var pSize = cmd.Parameters.Add("$size", SqliteType.Integer); - var pIsEncrypted = cmd.Parameters.Add("$isEncrypted", SqliteType.Integer); - var pIsCompressed = cmd.Parameters.Add("$isCompressed", SqliteType.Integer); - var pCompressionFormat = cmd.Parameters.Add("$compressionFormat", SqliteType.Integer); - var pUncompressedSize = cmd.Parameters.Add("$uncompressedSize", SqliteType.Integer); - - cmd.Prepare(); - - foreach (var (relativePath, packFile) in container.GetAllFiles()) + // Bulk insert files using a prepared statement + using (var cmd = connection.CreateCommand()) { - if (packFile.DataSource is not PackedFileSource source) - continue; - - var lastSep = relativePath.LastIndexOf(Path.DirectorySeparatorChar); - var folderPath = lastSep == -1 ? "" : relativePath.Substring(0, lastSep); - - pRelativePath.Value = relativePath; - pFileName.Value = packFile.Name; - pExtension.Value = Path.GetExtension(relativePath).ToLower(); - pFolderPath.Value = folderPath; - pSourcePackFilePath.Value = source.Parent.FilePath; - pOffset.Value = source.Offset; - pSize.Value = source.Size; - pIsEncrypted.Value = source.IsEncrypted ? 1 : 0; - pIsCompressed.Value = source.IsCompressed ? 1 : 0; - pCompressionFormat.Value = (int)source.CompressionFormat; - pUncompressedSize.Value = (long)source.UncompressedSize; + cmd.Transaction = transaction; + cmd.CommandText = @"INSERT INTO FileList (RelativePath, FileName, Extension, FolderPath, SourcePackFilePath, Offset, Size, IsEncrypted, IsCompressed, CompressionFormat, UncompressedSize) + VALUES ($relativePath, $fileName, $extension, $folderPath, $sourcePackFilePath, $offset, $size, $isEncrypted, $isCompressed, $compressionFormat, $uncompressedSize)"; - cmd.ExecuteNonQuery(); + var pRelativePath = cmd.Parameters.Add("$relativePath", SqliteType.Text); + var pFileName = cmd.Parameters.Add("$fileName", SqliteType.Text); + var pExtension = cmd.Parameters.Add("$extension", SqliteType.Text); + var pFolderPath = cmd.Parameters.Add("$folderPath", SqliteType.Text); + var pSourcePackFilePath = cmd.Parameters.Add("$sourcePackFilePath", SqliteType.Text); + var pOffset = cmd.Parameters.Add("$offset", SqliteType.Integer); + var pSize = cmd.Parameters.Add("$size", SqliteType.Integer); + var pIsEncrypted = cmd.Parameters.Add("$isEncrypted", SqliteType.Integer); + var pIsCompressed = cmd.Parameters.Add("$isCompressed", SqliteType.Integer); + var pCompressionFormat = cmd.Parameters.Add("$compressionFormat", SqliteType.Integer); + var pUncompressedSize = cmd.Parameters.Add("$uncompressedSize", SqliteType.Integer); + + cmd.Prepare(); + + foreach (var (relativePath, packFile) in container.GetAllFiles()) + { + if (packFile.DataSource is not PackedFileSource source) + continue; + + var lastSep = relativePath.LastIndexOf(Path.DirectorySeparatorChar); + var folderPath = lastSep == -1 ? "" : relativePath.Substring(0, lastSep); + + pRelativePath.Value = relativePath; + pFileName.Value = packFile.Name; + pExtension.Value = Path.GetExtension(relativePath).ToLower(); + pFolderPath.Value = folderPath; + pSourcePackFilePath.Value = source.Parent.FilePath; + pOffset.Value = source.Offset; + pSize.Value = source.Size; + pIsEncrypted.Value = source.IsEncrypted ? 1 : 0; + pIsCompressed.Value = source.IsCompressed ? 1 : 0; + pCompressionFormat.Value = (int)source.CompressionFormat; + pUncompressedSize.Value = (long)source.UncompressedSize; + + cmd.ExecuteNonQuery(); + } } - } - transaction.Commit(); - _logger.Here().Information($"Cache saved successfully for '{container.Name}'"); + transaction.Commit(); + _logger.Here().Information($"Cache saved successfully for '{container.Name}'"); + } + finally + { + if (shouldDisposeConnection) + connection.Dispose(); + } } - private static string GetDbFilePath(DbContextOptions dbOptions) + private static (SqliteConnection Connection, bool ShouldDisposeConnection) GetSqliteConnection(DbContextOptions dbOptions) { var relationalOptions = dbOptions.Extensions .OfType() .FirstOrDefault(); - var connStr = relationalOptions?.ConnectionString ?? ""; - var builder = new SqliteConnectionStringBuilder(connStr); - return builder.DataSource; + + if (relationalOptions?.Connection is SqliteConnection sqliteConnection) + return (sqliteConnection, false); + + var connectionString = relationalOptions?.ConnectionString; + if (string.IsNullOrWhiteSpace(connectionString)) + throw new InvalidOperationException("Unable to resolve SQLite connection from DbContextOptions."); + + return (new SqliteConnection(connectionString), true); } public static CachedPackFileContainer? LoadContainerFromCache(string dbFilePath, string expectedFingerprint) diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Serialization/PackFileSerializerLoader.cs b/Shared/SharedCore/Shared.Core/PackFiles/Serialization/PackFileSerializerLoader.cs index fa835a4d8..dc8e7ed8d 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Serialization/PackFileSerializerLoader.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Serialization/PackFileSerializerLoader.cs @@ -10,7 +10,7 @@ namespace Shared.Core.PackFiles.Serialization { public static class PackFileVersionConverter { - static List<(PackFileVersion EnumValue, string StringValue)> s_values = new List<(PackFileVersion EnumValue, string StringValue)>() + readonly static List<(PackFileVersion EnumValue, string StringValue)> s_values = new List<(PackFileVersion EnumValue, string StringValue)>() { (PackFileVersion.PFH0, "PFH0"), (PackFileVersion.PFH2, "PFH2"), @@ -121,12 +121,11 @@ public static PackFileContainer Load(string packFileSystemPath, long packFileSiz static PFHeader ReadHeader(BinaryReader reader) { var fileNameBuffer = new byte[1024]; - var header = new PFHeader() - { - StrVersion = new string(reader.ReadChars(4)), // 4 - ByteMask = reader.ReadInt32(), // 8 - ReferenceFileCount = reader.ReadUInt32(), // 12 - }; + + var header_strVersion = new string(reader.ReadChars(4)); // 4 + var header_byteMask = reader.ReadInt32(); // 8 + var header_referenceFileCount = reader.ReadUInt32(); // 12 + var header = new PFHeader(header_strVersion, header_byteMask, header_referenceFileCount); var pack_file_index_size = reader.ReadUInt32(); // 16 var pack_file_count = reader.ReadUInt32(); // 20 diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Utility/PackFileServiceUtility.cs b/Shared/SharedCore/Shared.Core/PackFiles/Utility/PackFileServiceUtility.cs index c27799653..4b6c0c5a3 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Utility/PackFileServiceUtility.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Utility/PackFileServiceUtility.cs @@ -2,8 +2,34 @@ namespace Shared.Core.PackFiles.Utility { + public sealed record DirectoryFileEntry(string FileName, PackFile File); + + public sealed record DirectoryEntriesSplitResult( + string DirectoryPath, + IReadOnlyList SubFolders, + IReadOnlyList Files); + public static class PackFileServiceUtility { + public static DirectoryEntriesSplitResult SplitDirectoryEntries(IPackFileContainer container, string directoryPath) + { + var packFileContainerInternal = PackFileService.CastContainer(container); + + var files = packFileContainerInternal.GetDirectoryContent(directoryPath) + .Select(entry => new DirectoryFileEntry(entry.File.Name, entry.File)) + .OrderBy(x => x.FileName, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + var subFolders = packFileContainerInternal.GetSubDirectories(directoryPath) + .OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + return new DirectoryEntriesSplitResult( + directoryPath, + subFolders, + files); + } + public static List GetAllAnimPacks(IPackFileService pfs) { var animPacks = FindAllWithExtention(pfs, @".animpack"); @@ -22,11 +48,17 @@ public static List FindAllWithExtention(IPackFileService pfs, string e public static List<(string FileName, PackFile Pack)> FindAllWithExtentionIncludePaths(IPackFileService pfs, string extention, IPackFileContainer? packFileContainer = null) { if (packFileContainer != null) - return packFileContainer.FindAllWithExtention(extention); + { + var container = PackFileService.CastContainer(packFileContainer); + return container.FindAllWithExtention(extention); + } var output = new List<(string, PackFile)>(); foreach (var pf in pfs.GetAllPackfileContainers()) - output.AddRange(pf.FindAllWithExtention(extention)); + { + var container = PackFileService.CastContainer(pf); + output.AddRange(container.FindAllWithExtention(extention)); + } return output; } diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Utility/PackFileUtil.cs b/Shared/SharedCore/Shared.Core/PackFiles/Utility/PackFileUtil.cs index 6beb0d742..bc5d2192c 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Utility/PackFileUtil.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Utility/PackFileUtil.cs @@ -1,4 +1,5 @@ -using Shared.Core.PackFiles.Models; +using CommunityToolkit.Diagnostics; +using Shared.Core.PackFiles.Models; using Shared.Core.PackFiles.Models.FileSources; namespace Shared.Core.PackFiles.Utility @@ -70,7 +71,9 @@ public static List LoadFilesFromDisk(IPackFileService pfs, IEnumerable packFileList.Add( new NewPackFileEntry(fileRef.PackFilePath, packfile)); } - pfs.AddFilesToPack(pfs.GetEditablePack(), packFileList); + var editablePack = pfs.GetEditablePack(); + Guard.IsNotNull(editablePack, "EditablePack returns null"); + pfs.AddFilesToPack(editablePack, packFileList); return packFileList.Select(x=>x.PackFile).ToList(); } @@ -80,7 +83,7 @@ public class FileRef { public string SystemPath { get; set; } public string PackFilePath { get; set; } - public string OverrideName { get; set; } = null; + public string? OverrideName { get; set; } = null; public FileRef(string systemPath, string packFilePath) { diff --git a/Shared/SharedCore/Shared.Core/Services/FileSystemAccess.cs b/Shared/SharedCore/Shared.Core/Services/FileSystemAccess.cs new file mode 100644 index 000000000..487a45615 --- /dev/null +++ b/Shared/SharedCore/Shared.Core/Services/FileSystemAccess.cs @@ -0,0 +1,22 @@ +using System.Diagnostics; +using System.IO; + +namespace Shared.Core.Services +{ + public class FileSystemAccess : IFileSystemAccess + { + public string? PathGetDirectoryName(string path) => Path.GetDirectoryName(path); + public string PathGetFileName(string path) => Path.GetFileName(path); + + public void FileWriteAllBytes(string path, byte[] bytes) => File.WriteAllBytes(path, bytes); + public byte[] FileReadAllBytes(string path) => File.ReadAllBytes(path); + public bool FileExists(string path) => File.Exists(path); + + public bool DirectoryExists(string path) => Directory.Exists(path); + public string[] DirectoryGetFiles(string path, string searchPattern, SearchOption searchOption) => Directory.GetFiles(path, searchPattern, searchOption); + public DirectoryInfo CreateDirectoryInfo(string path) => new(path); + + public Process? ProcessStart(string fileName, string arguments) => Process.Start(fileName, arguments); + public Process? ProcessStart(ProcessStartInfo startInfo) => Process.Start(startInfo); + } +} diff --git a/Shared/SharedCore/Shared.Core/Services/IFileSystemAccess.cs b/Shared/SharedCore/Shared.Core/Services/IFileSystemAccess.cs new file mode 100644 index 000000000..066d94640 --- /dev/null +++ b/Shared/SharedCore/Shared.Core/Services/IFileSystemAccess.cs @@ -0,0 +1,21 @@ +using System.Diagnostics; + +namespace Shared.Core.Services +{ + public interface IFileSystemAccess + { + string? PathGetDirectoryName(string path); + string PathGetFileName(string path); + + void FileWriteAllBytes(string path, byte[] bytes); + byte[] FileReadAllBytes(string path); + bool FileExists(string path); + + bool DirectoryExists(string path); + string[] DirectoryGetFiles(string path, string searchPattern, SearchOption searchOption); + DirectoryInfo CreateDirectoryInfo(string path); + + Process? ProcessStart(string fileName, string arguments); + Process? ProcessStart(ProcessStartInfo startInfo); + } +} diff --git a/Shared/SharedCore/Shared.Core/Services/IStandardDialogs.cs b/Shared/SharedCore/Shared.Core/Services/IStandardDialogs.cs index b9683c105..648c420c2 100644 --- a/Shared/SharedCore/Shared.Core/Services/IStandardDialogs.cs +++ b/Shared/SharedCore/Shared.Core/Services/IStandardDialogs.cs @@ -1,6 +1,7 @@ using Shared.Core.ErrorHandling; using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; +using System.Collections.Generic; namespace Shared.Core.Services { @@ -9,6 +10,10 @@ public interface IStandardDialogs SaveDialogResult DisplaySaveDialog(IPackFileService pfs, List extensions); BrowseDialogResultFile DisplayBrowseDialog(List extensions); BrowseDialogResultFolder DisplayBrowseFolderDialog(); + SystemOpenFileDialogResult ShowSystemOpenFileDialog(bool multiselect = false, string filter = "All files|*.*"); + SystemSaveFileDialogResult ShowSystemSaveFileDialog(string initialFileName, string filter, string defaultExt); + SystemBrowseFolderDialogResult ShowSystemFolderBrowserDialog(); + string ShowFolderNameDialog(IEnumerable existingNames, string currentValue = ""); void ShowExceptionWindow(Exception e, string userInfo = ""); void ShowErrorViewDialog(string title, ErrorList errorItems, bool modal = true); @@ -22,6 +27,9 @@ public interface IStandardDialogs public record SaveDialogResult(bool Result, PackFile? SelectedPackFile, string? SelectedFilePath); public record BrowseDialogResultFile(bool Result, PackFile File); public record BrowseDialogResultFolder(bool Result, string Folder); + public record SystemOpenFileDialogResult(bool Result, IReadOnlyList FilePaths); + public record SystemSaveFileDialogResult(bool Result, string? FilePath); + public record SystemBrowseFolderDialogResult(bool Result, string? FolderPath); public record TextInputDialogResult(bool Result, string Text); public enum ShowMessageBoxResult diff --git a/Shared/SharedCore/Shared.Core/Settings/ApplicationSettingsService.cs b/Shared/SharedCore/Shared.Core/Settings/ApplicationSettingsService.cs index 25fb5125d..d837b795e 100644 --- a/Shared/SharedCore/Shared.Core/Settings/ApplicationSettingsService.cs +++ b/Shared/SharedCore/Shared.Core/Settings/ApplicationSettingsService.cs @@ -14,11 +14,7 @@ public enum CameraControlMode public class ApplicationSettings { - public class GamePathPair - { - public GameTypeEnum Game { get; set; } - public string Path { get; set; } - } + public record GamePathPair(GameTypeEnum Game, string Path); public ObservableCollection RecentPackFilePaths { get; set; } = []; public ThemeType Theme { get; set; } = ThemeType.DarkTheme; @@ -30,7 +26,7 @@ public class GamePathPair public bool ShowCAWemFiles { get; set; } = false; public bool IsFirstTimeStartingApplication { get; set; } = true; public bool IsDeveloperRun { get; set; } = false; - public string WwisePath { get; set; } + public string WwisePath { get; set; } = string.Empty; public bool OnlyLoadLod0ForReferenceMeshes { get; set; } = true; public int VisualEditorsGridSize { get; set; } = 10; public Vector3 VertexSelectionColour { get; set; } = new Vector3(1.0f, 0.47f, 0.0f); @@ -39,7 +35,6 @@ public class GamePathPair public ApplicationSettings() { - // TODO: Need a way to get the right wwise path according to the game or instead just have several settings for the wwise CLI according tot he version var wwiseRoot = Environment.GetEnvironmentVariable("WWISEROOT") ?? ""; if (!string.IsNullOrEmpty(wwiseRoot)) diff --git a/Shared/SharedCore/Shared.Core/Settings/ThemesController.cs b/Shared/SharedCore/Shared.Core/Settings/ThemesController.cs index fb31defba..09610d628 100644 --- a/Shared/SharedCore/Shared.Core/Settings/ThemesController.cs +++ b/Shared/SharedCore/Shared.Core/Settings/ThemesController.cs @@ -59,7 +59,8 @@ public static string GetEnumAsString(ThemeType theme) return theme switch { ThemeType.DarkTheme => "Dark", - ThemeType.LightTheme => "Light" + ThemeType.LightTheme => "Light", + _ => "Dark" }; } } diff --git a/Shared/SharedCore/Shared.Core/Shared.Core.csproj b/Shared/SharedCore/Shared.Core/Shared.Core.csproj index 58e3a872a..13f699d45 100644 --- a/Shared/SharedCore/Shared.Core/Shared.Core.csproj +++ b/Shared/SharedCore/Shared.Core/Shared.Core.csproj @@ -4,6 +4,7 @@ net10.0-windows enable enable + true diff --git a/Shared/SharedCore/Shared.Core/ToolCreation/EditorDatabase.cs b/Shared/SharedCore/Shared.Core/ToolCreation/EditorDatabase.cs index be7703e0d..0ee51d78e 100644 --- a/Shared/SharedCore/Shared.Core/ToolCreation/EditorDatabase.cs +++ b/Shared/SharedCore/Shared.Core/ToolCreation/EditorDatabase.cs @@ -71,7 +71,7 @@ public IEditorInterface Create(EditorEnums editorEnum) return null; } - Type selectedEditor = null; + Type? selectedEditor = null; if (allEditors.Count == 1) { selectedEditor = allEditors.First().ViewModel; diff --git a/Shared/SharedCore/Shared.Core/ToolCreation/EditorInfoBuilder.cs b/Shared/SharedCore/Shared.Core/ToolCreation/EditorInfoBuilder.cs index 3beeb2ee9..7e64fefa4 100644 --- a/Shared/SharedCore/Shared.Core/ToolCreation/EditorInfoBuilder.cs +++ b/Shared/SharedCore/Shared.Core/ToolCreation/EditorInfoBuilder.cs @@ -30,12 +30,15 @@ public static class EditorPriorites public class EditorInfoBuilder { protected EditorInfo _instance; + + protected EditorInfoBuilder(EditorEnums editorType, Type tView, Type tViewModel) + { + _instance = new EditorInfo(editorType, tView, tViewModel); + } + public static EditorInfoBuilder Create(EditorEnums editorType) where TViewModel : IEditorInterface { - return new EditorInfoBuilder() - { - _instance = new EditorInfo(editorType, typeof(TView), typeof(TViewModel)) - }; + return new EditorInfoBuilder(editorType, typeof(TView), typeof(TViewModel)); } public EditorInfoBuilder AddToToolbar(string toolbarLabel, bool enabled = true) diff --git a/Shared/SharedCore/Shared.Core/ToolCreation/IEditorInterface.cs b/Shared/SharedCore/Shared.Core/ToolCreation/IEditorInterface.cs index 720652b4b..ba0e0546e 100644 --- a/Shared/SharedCore/Shared.Core/ToolCreation/IEditorInterface.cs +++ b/Shared/SharedCore/Shared.Core/ToolCreation/IEditorInterface.cs @@ -22,15 +22,12 @@ public interface IFileEditor public void LoadFile(PackFile file); } - public interface IEditorCreator + public interface IEditorManager { IEditorInterface CreateFromFile(PackFile file, EditorEnums? preferedEditor = null); IEditorInterface Create(EditorEnums editor, Action? onInitializeCallback = null); Window CreateWindow(PackFile packFile, EditorEnums? preferedEditor = null); - } - public interface IEditorManager : IEditorCreator - { IList GetAllEditors(); int GetCurrentEditor(); void SetEditorAsCurrent(IEditorInterface editor); @@ -42,9 +39,6 @@ public interface IEditorManager : IEditorCreator public void CloseAllTools(IEditorInterface tool); public void CloseToolsToLeft(IEditorInterface tool); public void CloseToolsToRight(IEditorInterface tool); - public bool Drop(IEditorInterface node, IEditorInterface targetNode = default, bool insertAfterTargetNode = default); - - - + public bool Drop(IEditorInterface node, IEditorInterface? targetNode = default, bool insertAfterTargetNode = default); } } diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/CachedPackFileContainerTests.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/CachedPackFileContainerTests.cs deleted file mode 100644 index 12dc99df7..000000000 --- a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/CachedPackFileContainerTests.cs +++ /dev/null @@ -1,490 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Shared.Core.PackFiles; -using Shared.Core.PackFiles.Models; -using Shared.Core.PackFiles.Models.Containers; -using Shared.Core.PackFiles.Models.FileSources; -using Shared.Core.PackFiles.Serialization.CacheDatabase; -using Shared.Core.PackFiles.Utility; -using Shared.Core.Settings; - -namespace Shared.CoreTest.PackFiles.Models.Containers -{ - internal class CachedPackFileContainer_ReadOnly - { - private string _tempDir; - private string _dbFilePath; - private CachedPackFileContainer _container; - - [SetUp] - public void Setup() - { - _tempDir = Path.Combine(Path.GetTempPath(), "CachedContainerTests_" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_tempDir); - _dbFilePath = Path.Combine(_tempDir, "test.db"); - - // Build a container with test data and save to DB - var sourceContainer = new PackFileContainer("TestCache") - { - IsCaPackFile = true, - SystemFilePath = @"c:\game\data" - }; - sourceContainer.SourcePackFilePaths.Add(@"c:\game\data\pack1.pack"); - - var parent = new PackedFileSourceParent { FilePath = @"c:\game\data\pack1.pack" }; - sourceContainer.AddOrUpdateFile("folder\\file.txt", new PackFile("file.txt", - new PackedFileSource(parent, 100, 200, false, false, CompressionFormat.None, 0))); - sourceContainer.AddOrUpdateFile("other\\data.bin", new PackFile("data.bin", - new PackedFileSource(parent, 300, 400, false, true, CompressionFormat.Lz4, 800))); - sourceContainer.AddOrUpdateFile("audio\\sound.wem", new PackFile("sound.wem", - new PackedFileSource(parent, 700, 500, false, false, CompressionFormat.None, 0))); - - var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); - PackFileContainerCacheHelper.SaveCache("test_fp", sourceContainer, dbOptions); - - // Load the cached container (lazy, no files in memory) - _container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "test_fp")!; - } - - [TearDown] - public void TearDown() - { - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, true); - } - - [Test] - public void IsCaPackFile_AlwaysTrue() - { - Assert.That(_container.IsCaPackFile, Is.True); - } - - [Test] - public void IsCaPackFile_SetterDoesNotChangeValue() - { - _container.IsCaPackFile = false; - Assert.That(_container.IsCaPackFile, Is.True); - } - - [Test] - public void GetFileCount_ReturnsCorrectCount() - { - Assert.That(_container.GetFileCount(), Is.EqualTo(3)); - } - - [Test] - public void FindFile_ReturnsFile() - { - var result = _container.FindFile("folder\\file.txt"); - Assert.That(result, Is.Not.Null); - Assert.That(result.Name, Is.EqualTo("file.txt")); - } - - [Test] - public void FindFile_ReturnsNullForMissing() - { - var result = _container.FindFile("missing\\path.txt"); - Assert.That(result, Is.Null); - } - - [Test] - public void FindFile_NormalizesPath() - { - var result = _container.FindFile("FOLDER/FILE.TXT"); - Assert.That(result, Is.Not.Null); - } - - [Test] - public void FindFile_ReturnsCorrectDataSource() - { - var result = _container.FindFile("folder\\file.txt"); - var source = result!.DataSource as PackedFileSource; - Assert.That(source, Is.Not.Null); - Assert.That(source.Offset, Is.EqualTo(100)); - Assert.That(source.Size, Is.EqualTo(200)); - } - - [Test] - public void ContainsFile_ReturnsTrueForExisting() - { - Assert.That(_container.ContainsFile("folder\\file.txt"), Is.True); - } - - [Test] - public void ContainsFile_ReturnsFalseForMissing() - { - Assert.That(_container.ContainsFile("missing.txt"), Is.False); - } - - [Test] - public void GetFullPath_ReturnsPath() - { - var file = _container.FindFile("folder\\file.txt")!; - var path = _container.GetFullPath(file); - Assert.That(path, Is.EqualTo("folder\\file.txt")); - } - - [Test] - public void GetFullPath_ReturnsNullForMissing() - { - var unknownFile = new PackFile("unknown.txt", null); - var path = _container.GetFullPath(unknownFile); - Assert.That(path, Is.Null); - } - - [Test] - public void FindAllWithExtention_ReturnsMatching() - { - var results = _container.FindAllWithExtention(".wem"); - Assert.That(results.Count, Is.EqualTo(1)); - Assert.That(results[0].FileName, Does.EndWith("sound.wem")); - } - - [Test] - public void FindAllWithExtention_ReturnsEmptyForNoMatch() - { - var results = _container.FindAllWithExtention(".xyz"); - Assert.That(results, Is.Empty); - } - - [Test] - public void GetAllFiles_ReturnsAllFiles() - { - var all = _container.GetAllFiles(); - Assert.That(all.Count, Is.EqualTo(3)); - Assert.That(all.ContainsKey("folder\\file.txt"), Is.True); - Assert.That(all.ContainsKey("other\\data.bin"), Is.True); - Assert.That(all.ContainsKey("audio\\sound.wem"), Is.True); - } - - [Test] - public void AddFiles_Throws() - { - var newFiles = new List - { - new("folder", new PackFile("new.txt", null)) - }; - Assert.Throws(() => _container.AddFiles(newFiles)); - } - - [Test] - public void DeleteFile_Throws() - { - var file = _container.FindFile("folder\\file.txt")!; - Assert.Throws(() => _container.DeleteFile(file)); - } - - [Test] - public void DeleteFolder_Throws() - { - Assert.Throws(() => _container.DeleteFolder("folder")); - } - - [Test] - public void MoveFile_Throws() - { - var file = _container.FindFile("folder\\file.txt")!; - Assert.Throws(() => _container.MoveFile(file, "other")); - } - - [Test] - public void RenameDirectory_Throws() - { - Assert.Throws(() => _container.RenameDirectory("folder", "renamed")); - } - - [Test] - public void RenameFile_Throws() - { - var file = _container.FindFile("folder\\file.txt")!; - Assert.Throws(() => _container.RenameFile(file, "renamed.txt")); - } - - [Test] - public void SaveFileData_Throws() - { - var file = _container.FindFile("folder\\file.txt")!; - Assert.Throws(() => _container.SaveFileData(file, [1, 2, 3])); - } - - [Test] - public void SaveToDisk_Throws() - { - var gameInfo = GameInformationDatabase.GetGameById(GameTypeEnum.Warhammer3); - Assert.Throws(() => _container.SaveToDisk("path", false, gameInfo)); - } - - [Test] - public void AddOrUpdateFile_Throws() - { - Assert.Throws(() => - _container.AddOrUpdateFile("test\\new.txt", new PackFile("new.txt", null))); - } - } - - internal class CachedPackFileContainer_GetDirectoryContent - { - private string _tempDir; - private string _dbFilePath; - private CachedPackFileContainer _container; - - [SetUp] - public void Setup() - { - _tempDir = Path.Combine(Path.GetTempPath(), "CachedDirContentTests_" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_tempDir); - _dbFilePath = Path.Combine(_tempDir, "test.db"); - - var sourceContainer = new PackFileContainer("DirTest") - { - IsCaPackFile = true, - SystemFilePath = @"c:\game\data" - }; - sourceContainer.SourcePackFilePaths.Add(@"c:\game\data\pack1.pack"); - - var parent = new PackedFileSourceParent { FilePath = @"c:\game\data\pack1.pack" }; - - // Root-level files - sourceContainer.AddOrUpdateFile("root_file.txt", new PackFile("root_file.txt", - new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); - - // Single-level folders - sourceContainer.AddOrUpdateFile("models\\unit.model", new PackFile("unit.model", - new PackedFileSource(parent, 10, 20, false, false, CompressionFormat.None, 0))); - sourceContainer.AddOrUpdateFile("models\\vehicle.model", new PackFile("vehicle.model", - new PackedFileSource(parent, 30, 40, false, false, CompressionFormat.None, 0))); - - // Nested folders - sourceContainer.AddOrUpdateFile("models\\textures\\diffuse.dds", new PackFile("diffuse.dds", - new PackedFileSource(parent, 70, 50, false, false, CompressionFormat.None, 0))); - sourceContainer.AddOrUpdateFile("models\\textures\\normal.dds", new PackFile("normal.dds", - new PackedFileSource(parent, 120, 60, false, false, CompressionFormat.None, 0))); - sourceContainer.AddOrUpdateFile("models\\textures\\specular\\gloss.dds", new PackFile("gloss.dds", - new PackedFileSource(parent, 180, 30, false, false, CompressionFormat.None, 0))); - - // Another top-level folder - sourceContainer.AddOrUpdateFile("audio\\music.wem", new PackFile("music.wem", - new PackedFileSource(parent, 210, 100, false, false, CompressionFormat.None, 0))); - - var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); - PackFileContainerCacheHelper.SaveCache("fp", sourceContainer, dbOptions); - _container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp")!; - } - - [TearDown] - public void TearDown() - { - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, true); - } - - [Test] - public void Root_ReturnsRootFilesAndTopFolders() - { - var content = _container.GetDirectoryContent(""); - - Assert.That(content.Files.Any(f => f.FileName == "root_file.txt"), Is.True); - Assert.That(content.SubFolders, Does.Contain("models")); - Assert.That(content.SubFolders, Does.Contain("audio")); - // Should NOT contain nested folders at root level - Assert.That(content.SubFolders, Does.Not.Contain("textures")); - } - - [Test] - public void Root_DoesNotIncludeFilesFromSubfolders() - { - var content = _container.GetDirectoryContent(""); - - Assert.That(content.Files.Count, Is.EqualTo(1)); - Assert.That(content.Files[0].FileName, Is.EqualTo("root_file.txt")); - } - - [Test] - public void Subfolder_ReturnsDirectFilesAndImmediateSubfolders() - { - var content = _container.GetDirectoryContent("models"); - - Assert.That(content.Files.Any(f => f.FileName == "unit.model"), Is.True); - Assert.That(content.Files.Any(f => f.FileName == "vehicle.model"), Is.True); - Assert.That(content.SubFolders, Does.Contain("textures")); - // Should NOT include files from deeper levels - Assert.That(content.Files.Any(f => f.FileName == "diffuse.dds"), Is.False); - } - - [Test] - public void NestedSubfolder_ReturnsCorrectContent() - { - var content = _container.GetDirectoryContent("models\\textures"); - - Assert.That(content.Files.Any(f => f.FileName == "diffuse.dds"), Is.True); - Assert.That(content.Files.Any(f => f.FileName == "normal.dds"), Is.True); - Assert.That(content.SubFolders, Does.Contain("specular")); - Assert.That(content.Files.Count, Is.EqualTo(2)); - } - - [Test] - public void DeepNestedFolder_ReturnsOnlyItsFiles() - { - var content = _container.GetDirectoryContent("models\\textures\\specular"); - - Assert.That(content.Files.Count, Is.EqualTo(1)); - Assert.That(content.Files[0].FileName, Is.EqualTo("gloss.dds")); - Assert.That(content.SubFolders, Is.Empty); - } - - [Test] - public void NonexistentFolder_ReturnsEmpty() - { - var content = _container.GetDirectoryContent("nonexistent\\path"); - - Assert.That(content.Files, Is.Empty); - Assert.That(content.SubFolders, Is.Empty); - } - - [Test] - public void SubfoldersAreSorted() - { - var content = _container.GetDirectoryContent(""); - - var sorted = content.SubFolders.OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase).ToList(); - Assert.That(content.SubFolders, Is.EqualTo(sorted)); - } - - [Test] - public void FilesAreSorted() - { - var content = _container.GetDirectoryContent("models"); - - var sorted = content.Files.OrderBy(x => x.FileName, StringComparer.CurrentCultureIgnoreCase).ToList(); - Assert.That(content.Files.Select(f => f.FileName), Is.EqualTo(sorted.Select(f => f.FileName))); - } - - [Test] - public void Files_HaveCorrectDataSource() - { - var content = _container.GetDirectoryContent("models"); - var unitFile = content.Files.First(f => f.FileName == "unit.model"); - var source = unitFile.File.DataSource as PackedFileSource; - - Assert.That(source, Is.Not.Null); - Assert.That(source.Offset, Is.EqualTo(10)); - Assert.That(source.Size, Is.EqualTo(20)); - Assert.That(source.Parent.FilePath, Is.EqualTo(@"c:\game\data\pack1.pack")); - } - } - - internal class CachedPackFileContainer_EdgeCases - { - private string _tempDir; - private string _dbFilePath; - - [SetUp] - public void Setup() - { - _tempDir = Path.Combine(Path.GetTempPath(), "CachedEdgeCaseTests_" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_tempDir); - _dbFilePath = Path.Combine(_tempDir, "test.db"); - } - - [TearDown] - public void TearDown() - { - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, true); - } - - [Test] - public void FindAllWithExtention_IsCaseInsensitive() - { - var sourceContainer = new PackFileContainer("Test") { SystemFilePath = @"c:\game" }; - var parent = new PackedFileSourceParent { FilePath = @"c:\game\pack.pack" }; - sourceContainer.AddOrUpdateFile("sound.wem", new PackFile("sound.wem", - new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); - - var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); - PackFileContainerCacheHelper.SaveCache("fp", sourceContainer, dbOptions); - var container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp")!; - - var upper = container.FindAllWithExtention(".WEM"); - var lower = container.FindAllWithExtention(".wem"); - Assert.That(upper.Count, Is.EqualTo(1)); - Assert.That(lower.Count, Is.EqualTo(1)); - } - - [Test] - public void ContainsFile_NormalizesForwardSlashes() - { - var sourceContainer = new PackFileContainer("Test") { SystemFilePath = @"c:\game" }; - var parent = new PackedFileSourceParent { FilePath = @"c:\game\pack.pack" }; - sourceContainer.AddOrUpdateFile("folder\\file.txt", new PackFile("file.txt", - new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); - - var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); - PackFileContainerCacheHelper.SaveCache("fp", sourceContainer, dbOptions); - var container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp")!; - - Assert.That(container.ContainsFile("folder/file.txt"), Is.True); - Assert.That(container.ContainsFile("FOLDER/FILE.TXT"), Is.True); - Assert.That(container.ContainsFile(" folder\\file.txt "), Is.True); - } - - [Test] - public void GetAllFiles_PreservesCompressionMetadata() - { - var sourceContainer = new PackFileContainer("Test") { SystemFilePath = @"c:\game" }; - var parent = new PackedFileSourceParent { FilePath = @"c:\game\pack.pack" }; - sourceContainer.AddOrUpdateFile("data.bin", new PackFile("data.bin", - new PackedFileSource(parent, 100, 500, true, true, CompressionFormat.Lz4, 2000))); - - var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); - PackFileContainerCacheHelper.SaveCache("fp", sourceContainer, dbOptions); - var container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp")!; - - var all = container.GetAllFiles(); - var source = (PackedFileSource)all["data.bin"].DataSource; - - Assert.That(source.IsEncrypted, Is.True); - Assert.That(source.IsCompressed, Is.True); - Assert.That(source.CompressionFormat, Is.EqualTo(CompressionFormat.Lz4)); - Assert.That(source.UncompressedSize, Is.EqualTo(2000)); - Assert.That(source.Offset, Is.EqualTo(100)); - Assert.That(source.Size, Is.EqualTo(500)); - } - - [Test] - public void GetFullPath_WithDuplicateFileNames_ReturnsFirstMatch() - { - var sourceContainer = new PackFileContainer("Test") { SystemFilePath = @"c:\game" }; - var parent = new PackedFileSourceParent { FilePath = @"c:\game\pack.pack" }; - sourceContainer.AddOrUpdateFile("folder_a\\shared.txt", new PackFile("shared.txt", - new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); - sourceContainer.AddOrUpdateFile("folder_b\\shared.txt", new PackFile("shared.txt", - new PackedFileSource(parent, 10, 20, false, false, CompressionFormat.None, 0))); - - var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); - PackFileContainerCacheHelper.SaveCache("fp", sourceContainer, dbOptions); - var container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp")!; - - // GetFullPath matches by FileName - with duplicates it returns the first DB row - var file = container.FindFile("folder_a\\shared.txt")!; - var path = container.GetFullPath(file); - - // Should return a valid path (either folder_a or folder_b) - Assert.That(path, Does.Contain("shared.txt")); - Assert.That(path, Does.Contain("\\")); - } - - [Test] - public void GetDirectoryContent_EmptyContainer_ReturnsEmpty() - { - var sourceContainer = new PackFileContainer("Empty") { SystemFilePath = @"c:\game" }; - - var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); - PackFileContainerCacheHelper.SaveCache("fp", sourceContainer, dbOptions); - var container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp")!; - - var content = container.GetDirectoryContent(""); - Assert.That(content.Files, Is.Empty); - Assert.That(content.SubFolders, Is.Empty); - } - } -} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests.cs deleted file mode 100644 index 6e0f02f38..000000000 --- a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests.cs +++ /dev/null @@ -1,495 +0,0 @@ -using Shared.Core.PackFiles; -using Shared.Core.PackFiles.Models; -using Shared.Core.PackFiles.Models.Containers; -using Shared.Core.PackFiles.Models.FileSources; - -namespace Shared.CoreTest.PackFiles.Models.Containers -{ - internal class PackFileContainer_AddFiles - { - [Test] - public void AddFiles_MultipleFiles() - { - var container = new PackFileContainer("Test"); - var newFiles = new List - { - new("Directory_0", new PackFile("file0.txt", null)), - new("Directory_0", new PackFile("file1.txt", null)), - new("Directory_1", new PackFile("file0.txt", null)), - new("", new PackFile("rootFile.txt", null)) - }; - - var result = container.AddFiles(newFiles); - - Assert.That(container.GetFileCount(), Is.EqualTo(4)); - Assert.That(result.Count, Is.EqualTo(4)); - } - - [Test] - public void AddFiles_AddToRoot() - { - var container = new PackFileContainer("Test"); - var newFiles = new List - { - new("", new PackFile("rootFile.txt", null)) - }; - - container.AddFiles(newFiles); - - Assert.That(container.GetFileCount(), Is.EqualTo(1)); - Assert.That(container.ContainsFile("rootfile.txt"), Is.True); - } - - [Test] - public void AddFiles_AddToFolder() - { - var container = new PackFileContainer("Test"); - var newFiles = new List - { - new("Directory_0", new PackFile("file0.txt", null)), - new("Directory_0", new PackFile("file1.txt", null)), - new("Directory_1", new PackFile("file0.txt", null)), - }; - - container.AddFiles(newFiles); - - Assert.That(container.GetFileCount(), Is.EqualTo(3)); - } - - [Test] - public void AddFiles_FileNameConflict_Override() - { - var container = new PackFileContainer("Test"); - var newFiles = new List - { - new("Directory_0", new PackFile("file0.txt", null)), - }; - - container.AddFiles(newFiles); - container.AddFiles(newFiles); - - Assert.That(container.GetFileCount(), Is.EqualTo(1)); - } - - [Test] - public void AddFiles_WhiteSpaceInName() - { - var container = new PackFileContainer("Test"); - var newFiles = new List - { - new("Directory_0 ", new PackFile("file0.txt ", null)), - }; - - container.AddFiles(newFiles); - - Assert.That(container.GetFileCount(), Is.EqualTo(1)); - Assert.That(container.GetAllFiles().First().Key.Any(char.IsWhiteSpace), Is.False); - Assert.That(container.GetAllFiles().First().Value.Name.Any(char.IsWhiteSpace), Is.False); - } - - [Test] - public void AddFiles_EmptyFileName_Throws() - { - var container = new PackFileContainer("Test"); - var newFiles = new List - { - new("Directory_0", new PackFile("", null)), - }; - - Assert.Throws(() => container.AddFiles(newFiles)); - } - - [Test] - public void AddFiles_ReturnsAddedFiles() - { - var container = new PackFileContainer("Test"); - var file0 = new PackFile("file0.txt", null); - var file1 = new PackFile("file1.txt", null); - var newFiles = new List - { - new("Dir", file0), - new("Dir", file1), - }; - - var result = container.AddFiles(newFiles); - - Assert.That(result, Contains.Item(file0)); - Assert.That(result, Contains.Item(file1)); - } - } - - internal class PackFileContainer_DeleteFile - { - [Test] - public void DeleteFile_RemovesFile() - { - var container = CreateContainerWithFiles(); - var file = container.GetAllFiles().Values.First(); - - container.DeleteFile(file); - - Assert.That(container.GetFileCount(), Is.EqualTo(3)); - Assert.That(container.GetAllFiles().Values, Does.Not.Contain(file)); - } - - [Test] - public void DeleteFile_ReturnsDeletedFile() - { - var container = CreateContainerWithFiles(); - var file = container.GetAllFiles().Values.First(); - - var result = container.DeleteFile(file); - - Assert.That(result, Is.EqualTo(file)); - } - - static PackFileContainer CreateContainerWithFiles() - { - var container = new PackFileContainer("Test"); - container.AddFiles(new List - { - new("Dir_0", new PackFile("file0.txt", null)), - new("Dir_0", new PackFile("file1.txt", null)), - new("Dir_1", new PackFile("file0.txt", null)), - new("", new PackFile("rootFile.txt", null)), - }); - return container; - } - - [Test] - public void DeleteFile_FileNotInContainer_ReturnsNull() - { - var container = CreateContainerWithFiles(); - var orphanFile = new PackFile("orphan.txt", null); - - var result = container.DeleteFile(orphanFile); - - Assert.That(result, Is.Null); - Assert.That(container.GetFileCount(), Is.EqualTo(4)); - } - } - - internal class PackFileContainer_DeleteFolder - { - [Test] - public void DeleteFolder_RemovesFilesInFolder() - { - var container = CreateTestContainer(); - - container.DeleteFolder("directory_0"); - - Assert.That(container.GetFileCount(), Is.EqualTo(2)); - } - - [Test] - public void DeleteFolder_MissingFolder_NoEffect() - { - var container = CreateTestContainer(); - - container.DeleteFolder("nonexistent"); - - Assert.That(container.GetFileCount(), Is.EqualTo(8)); - } - - [Test] - public void DeleteFolder_WithSubFolder() - { - var container = CreateTestContainer(); - - container.DeleteFolder("directory_0\\subfolder"); - - Assert.That(container.GetFileCount(), Is.EqualTo(4)); - } - - [Test] - public void DeleteFolder_SimilarPrefixFolder_DoesNotDeleteWrongFiles() - { - var container = new PackFileContainer("Test"); - container.AddFiles(new List - { - new("dir", new PackFile("file0.txt", null)), - new("directory", new PackFile("file1.txt", null)), - }); - - container.DeleteFolder("dir"); - - Assert.That(container.GetFileCount(), Is.EqualTo(1)); - Assert.That(container.FindFile("directory\\file1.txt"), Is.Not.Null); - } - - static PackFileContainer CreateTestContainer() - { - var container = new PackFileContainer("Test"); - container.AddFiles(new List - { - new("Directory_0", new PackFile("file0.txt", null)), - new("Directory_0", new PackFile("file1.txt", null)), - new("Directory_0\\subfolder", new PackFile("subfile0.txt", null)), - new("Directory_0\\subfolder", new PackFile("subfile1.txt", null)), - new("Directory_0\\subfolder\\child", new PackFile("childFile0.txt", null)), - new("Directory_0\\subfolder\\child", new PackFile("childFile1.txt", null)), - new("Directory_1", new PackFile("file0.txt", null)), - new("", new PackFile("rootFile.txt", null)) - }); - Assert.That(container.GetFileCount(), Is.EqualTo(8)); - return container; - } - } - - internal class PackFileContainer_FindFile - { - [Test] - public void FindFile_ExistingFile() - { - var container = new PackFileContainer("Test"); - var file = new PackFile("file0.txt", null); - container.AddFiles(new List { new("Dir", file) }); - - var result = container.FindFile("Dir\\file0.txt"); - - Assert.That(result, Is.EqualTo(file)); - } - - [Test] - public void FindFile_CaseInsensitive() - { - var container = new PackFileContainer("Test"); - var file = new PackFile("file0.txt", null); - container.AddFiles(new List { new("Dir", file) }); - - var result = container.FindFile("DIR\\FILE0.TXT"); - - Assert.That(result, Is.EqualTo(file)); - } - - [Test] - public void FindFile_ForwardSlash() - { - var container = new PackFileContainer("Test"); - var file = new PackFile("file0.txt", null); - container.AddFiles(new List { new("Dir", file) }); - - var result = container.FindFile("Dir/file0.txt"); - - Assert.That(result, Is.EqualTo(file)); - } - - [Test] - public void FindFile_NotFound_ReturnsNull() - { - var container = new PackFileContainer("Test"); - - var result = container.FindFile("nonexistent.txt"); - - Assert.That(result, Is.Null); - } - - [Test] - public void FindFile_TrimWhitespace() - { - var container = new PackFileContainer("Test"); - var file = new PackFile("file0.txt", null); - container.AddFiles(new List { new("Dir", file) }); - - var result = container.FindFile(" Dir\\file0.txt "); - - Assert.That(result, Is.EqualTo(file)); - } - } - - internal class PackFileContainer_GetFullPath - { - [Test] - public void GetFullPath_ByReference() - { - var container = new PackFileContainer("Test"); - var file = new PackFile("file0.txt", null); - container.AddFiles(new List { new("Dir", file) }); - - var result = container.GetFullPath(file); - - Assert.That(result, Is.EqualTo("dir\\file0.txt")); - } - - [Test] - public void GetFullPath_ByName() - { - var container = new PackFileContainer("Test"); - var file = new PackFile("file0.txt", null); - container.AddFiles(new List { new("Dir", file) }); - - var otherRef = new PackFile("file0.txt", null); - var result = container.GetFullPath(otherRef); - - Assert.That(result, Is.EqualTo("dir\\file0.txt")); - } - - [Test] - public void GetFullPath_NotFound_ReturnsNull() - { - var container = new PackFileContainer("Test"); - - var result = container.GetFullPath(new PackFile("nonexistent.txt", null)); - - Assert.That(result, Is.Null); - } - } - - internal class PackFileContainer_MoveFile - { - [Test] - public void MoveFile_MovesToNewFolder() - { - var container = new PackFileContainer("Test"); - var file = new PackFile("file0.txt", null); - container.AddFiles(new List { new("OldDir", file) }); - - container.MoveFile(file, "NewDir"); - - Assert.That(container.GetFileCount(), Is.EqualTo(1)); - Assert.That(container.ContainsFile("newdir\\file0.txt"), Is.True); - Assert.That(container.ContainsFile("olddir\\file0.txt"), Is.False); - } - - [Test] - public void MoveFile_NormalizesPathCasing() - { - var container = new PackFileContainer("Test"); - var file = new PackFile("file0.txt", null); - container.AddFiles(new List { new("OldDir", file) }); - - container.MoveFile(file, "NewDir"); - - var found = container.FindFile("NewDir\\file0.txt"); - Assert.That(found, Is.Not.Null); - } - - } - - internal class PackFileContainer_RenameDirectory - { - [Test] - public void RenameDirectory_TopLevel() - { - var container = new PackFileContainer("Test"); - container.AddFiles(new List - { - new("OldName", new PackFile("file0.txt", null)), - new("OldName", new PackFile("file1.txt", null)), - }); - - var newNodePath = container.RenameDirectory("oldname", "NewName"); - - Assert.That(newNodePath, Is.EqualTo("NewName")); - Assert.That(container.GetFileCount(), Is.EqualTo(2)); - Assert.That(container.GetAllFiles().Keys.All(k => k.StartsWith("newname", StringComparison.OrdinalIgnoreCase)), Is.True); - } - - [Test] - public void RenameDirectory_Nested() - { - var container = new PackFileContainer("Test"); - container.AddFiles(new List - { - new("Parent\\OldChild", new PackFile("file0.txt", null)), - new("Parent\\OldChild\\Sub", new PackFile("file1.txt", null)), - }); - - var newNodePath = container.RenameDirectory("parent\\oldchild", "NewChild"); - - Assert.That(newNodePath, Is.EqualTo("parent\\NewChild")); - Assert.That(container.ContainsFile("parent\\newchild\\file0.txt"), Is.True); - Assert.That(container.ContainsFile("parent\\newchild\\sub\\file1.txt"), Is.True); - } - - [Test] - public void RenameDirectory_ReturnsNewNodePath() - { - var container = new PackFileContainer("Test"); - container.AddFiles(new List - { - new("Parent\\Child", new PackFile("file0.txt", null)), - }); - - var result = container.RenameDirectory("parent\\child", "RenamedChild"); - - Assert.That(result, Is.EqualTo("parent\\RenamedChild")); - } - - [Test] - public void RenameDirectory_NormalizesPathCasing() - { - var container = new PackFileContainer("Test"); - container.AddFiles(new List - { - new("OldDir", new PackFile("file.txt", null)), - }); - - container.RenameDirectory("olddir", "NewDir"); - - var found = container.FindFile("NewDir\\file.txt"); - Assert.That(found, Is.Not.Null); - } - } - - internal class PackFileContainer_RenameFile - { - [Test] - public void RenameFile_UpdatesNameAndPath() - { - var container = new PackFileContainer("Test"); - var file = new PackFile("old.txt", null); - container.AddFiles(new List { new("Dir", file) }); - - container.RenameFile(file, "new.txt"); - - Assert.That(file.Name, Is.EqualTo("new.txt")); - Assert.That(container.GetFileCount(), Is.EqualTo(1)); - Assert.That(container.ContainsFile("dir\\new.txt"), Is.True); - } - - [Test] - public void RenameFile_RootFile_ProducesValidPath() - { - var container = new PackFileContainer("Test"); - var file = new PackFile("old.txt", null); - container.AddFiles(new List { new("", file) }); - - container.RenameFile(file, "new.txt"); - - Assert.That(container.ContainsFile("new.txt"), Is.True); - Assert.That(container.GetAllFiles().Keys.Any(k => k.StartsWith("\\")), Is.False); - } - - [Test] - public void RenameFile_NormalizesPathCasing() - { - var container = new PackFileContainer("Test"); - var file = new PackFile("old.txt", null); - container.AddFiles(new List { new("Dir", file) }); - - container.RenameFile(file, "New.txt"); - - var found = container.FindFile("Dir\\New.txt"); - Assert.That(found, Is.Not.Null); - } - - } - - internal class PackFileContainer_SaveFileData - { - [Test] - public void SaveFileData_UpdatesDataSource() - { - var container = new PackFileContainer("Test"); - var file = new PackFile("file.txt", new MemorySource([1, 2, 3])); - container.AddFiles(new List { new("Dir", file) }); - - var newData = new byte[] { 4, 5, 6, 7 }; - container.SaveFileData(file, newData); - - Assert.That(file.DataSource.ReadData(), Is.EqualTo(newData)); - } - - } -} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_AddFiles.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_AddFiles.cs new file mode 100644 index 000000000..9a2daeb49 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_AddFiles.cs @@ -0,0 +1,48 @@ +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_AddFiles : PackFileContainerTests_TestBase + { + public PackFileContainerTests_AddFiles(Type containerType) : base(containerType) { } + + [Test] + public void AddFiles_AddsMultipleFilesOrThrowsOnCached() + { + var newFiles = new List + { + new("dir", new PackFile("a.txt", null)), + new("", new PackFile("root.txt", null)) + }; + + if (IsCachedContainer) + { + Assert.Throws(() => _container.AddFiles(newFiles)); + return; + } + + var added = _container.AddFiles(newFiles); + Assert.That(added.Count, Is.EqualTo(2)); + Assert.That(_container.ContainsFile("dir\\a.txt"), Is.True); + Assert.That(_container.ContainsFile("root.txt"), Is.True); + } + + [Test] + public void AddFiles_EmptyFileName_Throws() + { + if (IsCachedContainer) + { + Assert.Throws(() => + _container.AddFiles([new NewPackFileEntry("dir", new PackFile("", null))])); + return; + } + + Assert.Throws(() => + _container.AddFiles([new NewPackFileEntry("dir", new PackFile("", null))])); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_AddOrUpdateFile.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_AddOrUpdateFile.cs new file mode 100644 index 000000000..83e3d5950 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_AddOrUpdateFile.cs @@ -0,0 +1,41 @@ +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_AddOrUpdateFile : PackFileContainerTests_TestBase + { + public PackFileContainerTests_AddOrUpdateFile(Type containerType) : base(containerType) { } + + [Test] + public void AddOrUpdateFile_NewPath_AddsFileOrThrowsOnCached() + { + if (IsCachedContainer) + { + Assert.Throws(() => + _container.AddOrUpdateFile("new\\file.txt", new PackFile("file.txt", null))); + return; + } + + _container.AddOrUpdateFile("new\\file.txt", new PackFile("file.txt", null)); + Assert.That(_container.ContainsFile("new\\file.txt"), Is.True); + } + + [Test] + public void AddOrUpdateFile_ExistingPath_UpdatesWithoutIncreasingCount() + { + if (IsCachedContainer) + { + Assert.Throws(() => + _container.AddOrUpdateFile("folder\\file.txt", new PackFile("file.txt", null))); + return; + } + + var before = _container.GetFileCount(); + _container.AddOrUpdateFile("folder\\file.txt", new PackFile("file.txt", null)); + Assert.That(_container.GetFileCount(), Is.EqualTo(before)); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_ContainsFile.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_ContainsFile.cs new file mode 100644 index 000000000..fafa504c4 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_ContainsFile.cs @@ -0,0 +1,23 @@ +using Shared.Core.PackFiles.Models.Containers; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_ContainsFile : PackFileContainerTests_TestBase + { + public PackFileContainerTests_ContainsFile(Type containerType) : base(containerType) { } + + [Test] + public void ContainsFile_TrueForNormalizedInput() + { + Assert.That(_container.ContainsFile(" folder/file.txt "), Is.True); + } + + [Test] + public void ContainsFile_FalseForMissing() + { + Assert.That(_container.ContainsFile("missing.txt"), Is.False); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_DeleteFile.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_DeleteFile.cs new file mode 100644 index 000000000..6bfd379fa --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_DeleteFile.cs @@ -0,0 +1,41 @@ +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_DeleteFile : PackFileContainerTests_TestBase + { + public PackFileContainerTests_DeleteFile(Type containerType) : base(containerType) { } + + [Test] + public void DeleteFile_RemovesFileOrThrowsOnCached() + { + var file = _container.FindFile("folder\\file.txt")!; + + if (IsCachedContainer) + { + Assert.Throws(() => _container.DeleteFile(file)); + return; + } + + var deleted = _container.DeleteFile(file); + Assert.That(deleted, Is.EqualTo(file)); + Assert.That(_container.ContainsFile("folder\\file.txt"), Is.False); + } + + [Test] + public void DeleteFile_UnknownFile_ReturnsNullForWritableContainer() + { + if (IsCachedContainer) + { + var known = _container.FindFile("folder\\file.txt")!; + Assert.Throws(() => _container.DeleteFile(known)); + return; + } + + Assert.That(_container.DeleteFile(new PackFile("x.txt", null)), Is.Null); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_DeleteFolder.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_DeleteFolder.cs new file mode 100644 index 000000000..5ee657f27 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_DeleteFolder.cs @@ -0,0 +1,25 @@ +using Shared.Core.PackFiles.Models.Containers; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_DeleteFolder : PackFileContainerTests_TestBase + { + public PackFileContainerTests_DeleteFolder(Type containerType) : base(containerType) { } + + [Test] + public void DeleteFolder_RemovesFolderTreeOrThrowsOnCached() + { + if (IsCachedContainer) + { + Assert.Throws(() => _container.DeleteFolder("models")); + return; + } + + _container.DeleteFolder("models"); + Assert.That(_container.ContainsFile("models\\unit.model"), Is.False); + Assert.That(_container.ContainsFile("models\\textures\\diffuse.dds"), Is.False); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_FindAllWithExtention.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_FindAllWithExtention.cs new file mode 100644 index 000000000..4273c082a --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_FindAllWithExtention.cs @@ -0,0 +1,24 @@ +using Shared.Core.PackFiles.Models.Containers; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_FindAllWithExtention : PackFileContainerTests_TestBase + { + public PackFileContainerTests_FindAllWithExtention(Type containerType) : base(containerType) { } + + [Test] + public void FindAllWithExtention_IsCaseInsensitive() + { + Assert.That(_container.FindAllWithExtention(".wem").Count, Is.EqualTo(3)); + Assert.That(_container.FindAllWithExtention(".WEM").Count, Is.EqualTo(3)); + } + + [Test] + public void FindAllWithExtention_NoMatch_ReturnsEmpty() + { + Assert.That(_container.FindAllWithExtention(".xyz"), Is.Empty); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_FindFile.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_FindFile.cs new file mode 100644 index 000000000..05fbefc7b --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_FindFile.cs @@ -0,0 +1,32 @@ +using Shared.Core.PackFiles.Models.Containers; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_FindFile : PackFileContainerTests_TestBase + { + public PackFileContainerTests_FindFile(Type containerType) : base(containerType) { } + + [Test] + public void FindFile_FindsExistingPath() + { + var file = _container.FindFile("folder\\file.txt"); + Assert.That(file, Is.Not.Null); + Assert.That(file!.Name, Is.EqualTo("file.txt")); + } + + [Test] + public void FindFile_NormalizesCaseAndSlashes() + { + var file = _container.FindFile("FOLDER/FILE.TXT"); + Assert.That(file, Is.Not.Null); + } + + [Test] + public void FindFile_MissingPath_ReturnsNull() + { + Assert.That(_container.FindFile("missing\\file.txt"), Is.Null); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_GetAllFiles.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_GetAllFiles.cs new file mode 100644 index 000000000..69b345954 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_GetAllFiles.cs @@ -0,0 +1,30 @@ +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.PackFiles.Utility; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_GetAllFiles : PackFileContainerTests_TestBase + { + public PackFileContainerTests_GetAllFiles(Type containerType) : base(containerType) { } + + [Test] + public void GetAllFiles_ReturnsExpectedCountAndKeys() + { + var files = _container.GetAllFiles(); + Assert.That(files.Count, Is.EqualTo(15)); + Assert.That(files.ContainsKey("folder\\file.txt"), Is.True); + Assert.That(files.ContainsKey("scripts\\campaign_script.lua"), Is.True); + } + + [Test] + public void GetAllFiles_PreservesCompressionMetadata() + { + var source = (PackedFileSource)_container.GetAllFiles()["compressed\\data.bin"].DataSource; + Assert.That(source.CompressionFormat, Is.EqualTo(CompressionFormat.Lz4)); + Assert.That(source.UncompressedSize, Is.EqualTo(2000)); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_GetDirectoryContent.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_GetDirectoryContent.cs new file mode 100644 index 000000000..8a2ccb56a --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_GetDirectoryContent.cs @@ -0,0 +1,45 @@ +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.PackFiles.Utility; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_GetDirectoryContent : PackFileContainerTests_TestBase + { + public PackFileContainerTests_GetDirectoryContent(Type containerType) : base(containerType) { } + + [Test] + public void GetDirectoryContent_Root_ReturnsDirectFilesOnly() + { + var rows = _container.GetDirectoryContent(""); + Assert.That(rows.Count, Is.EqualTo(1)); + Assert.That(rows[0].Path, Is.EqualTo("root_file.txt")); + } + + [Test] + public void GetDirectoryContent_NestedFolder_ReturnsOnlyItsFiles() + { + var rows = _container.GetDirectoryContent(@"models\textures"); + Assert.That(rows.Select(x => x.Path), Is.EquivalentTo(new[] + { + @"models\textures\diffuse.dds", + @"models\textures\normal.dds" + })); + } + + [Test] + public void GetDirectoryContent_UnknownFolder_ReturnsEmpty() + { + Assert.That(_container.GetDirectoryContent("missing\\folder"), Is.Empty); + } + + [Test] + public void GetDirectoryContent_ComposesWithUtility() + { + var split = PackFileServiceUtility.SplitDirectoryEntries(_container, "models"); + Assert.That(split.Files.Any(x => x.FileName == "unit.model"), Is.True); + Assert.That(split.SubFolders, Does.Contain("textures")); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_GetFileCount.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_GetFileCount.cs new file mode 100644 index 000000000..8b5e2355b --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_GetFileCount.cs @@ -0,0 +1,17 @@ +using Shared.Core.PackFiles.Models.Containers; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_GetFileCount : PackFileContainerTests_TestBase + { + public PackFileContainerTests_GetFileCount(Type containerType) : base(containerType) { } + + [Test] + public void GetFileCount_ReturnsMasterDatasetCount() + { + Assert.That(_container.GetFileCount(), Is.EqualTo(15)); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_GetFullPath.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_GetFullPath.cs new file mode 100644 index 000000000..ba174af50 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_GetFullPath.cs @@ -0,0 +1,60 @@ +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.PackFiles.Models.FileSources; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_GetFullPath : PackFileContainerTests_TestBase + { + public PackFileContainerTests_GetFullPath(Type containerType) : base(containerType) { } + + [Test] + public void GetFullPath_ReturnsStoredPath() + { + var file = _container.FindFile("folder\\file.txt")!; + var path = _container.GetFullPath(file); + Assert.That(path, Is.EqualTo("folder\\file.txt")); + } + + [Test] + public void GetFullPath_UnknownFile_ReturnsNull() + { + var unknown = new PackFile("none.txt", null); + Assert.That(_container.GetFullPath(unknown), Is.Null); + } + + [Test] + public void GetFullPath_DuplicateFileName_ReturnsPathForExactSource() + { + var file = _container.FindFile("folder_b\\shared.txt")!; + + var path = _container.GetFullPath(file); + + Assert.That(path, Is.EqualTo("folder_b\\shared.txt")); + } + + [Test] + public void GetFullPath_DuplicateFileName_WithUnknownSource_ReturnsNullForCachedContainer() + { + IgnoreIfNotCached("Ambiguous filename fallback"); + + var unknownSourceFile = PackFile.CreateFromBytes("shared.txt", [1, 2, 3]); + + var path = _container.GetFullPath(unknownSourceFile); + + Assert.That(path, Is.Null); + } + + [Test] + public void GetFullPath_UniqueFileName_WithUnknownSource_UsesNameFallback() + { + var unknownSourceFile = PackFile.CreateFromBytes("file.txt", [1, 2, 3]); + + var path = _container.GetFullPath(unknownSourceFile); + + Assert.That(path, Is.EqualTo("folder\\file.txt")); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_GetSubDirectories.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_GetSubDirectories.cs new file mode 100644 index 000000000..2aa8aa55c --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_GetSubDirectories.cs @@ -0,0 +1,35 @@ +using Shared.Core.PackFiles.Models.Containers; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_GetSubDirectories : PackFileContainerTests_TestBase + { + public PackFileContainerTests_GetSubDirectories(Type containerType) : base(containerType) { } + + [Test] + public void GetSubDirectories_Root_ReturnsImmediateSortedFolders() + { + var folders = _container.GetSubDirectories(""); + Assert.That(folders, Does.Contain("audio")); + Assert.That(folders, Does.Contain("models")); + + var sorted = folders.OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase).ToList(); + Assert.That(folders, Is.EqualTo(sorted)); + } + + [Test] + public void GetSubDirectories_NestedFolder_ReturnsOnlyChildren() + { + var folders = _container.GetSubDirectories(@"models\textures"); + Assert.That(folders, Is.EqualTo(new[] { "specular" })); + } + + [Test] + public void GetSubDirectories_UnknownFolder_ReturnsEmpty() + { + Assert.That(_container.GetSubDirectories("missing\\folder"), Is.Empty); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_IsCaPackFile.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_IsCaPackFile.cs new file mode 100644 index 000000000..48627d47a --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_IsCaPackFile.cs @@ -0,0 +1,24 @@ +using Shared.Core.PackFiles.Models.Containers; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_IsCaPackFile : PackFileContainerTests_TestBase + { + public PackFileContainerTests_IsCaPackFile(Type containerType) : base(containerType) { } + + [Test] + public void IsCaPackFile_AlwaysStartsTrue() + { + Assert.That(_container.IsCaPackFile, Is.True); + } + + [Test] + public void IsCaPackFile_SetterBehavior_MatchesContainerType() + { + _container.IsCaPackFile = false; + Assert.That(_container.IsCaPackFile, Is.EqualTo(IsCachedContainer)); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_MoveFile.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_MoveFile.cs new file mode 100644 index 000000000..a55191d0f --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_MoveFile.cs @@ -0,0 +1,27 @@ +using Shared.Core.PackFiles.Models.Containers; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_MoveFile : PackFileContainerTests_TestBase + { + public PackFileContainerTests_MoveFile(Type containerType) : base(containerType) { } + + [Test] + public void MoveFile_MovesFileOrThrowsOnCached() + { + var file = _container.FindFile("folder\\file.txt")!; + + if (IsCachedContainer) + { + Assert.Throws(() => _container.MoveFile(file, "other")); + return; + } + + _container.MoveFile(file, "other"); + Assert.That(_container.ContainsFile("other\\file.txt"), Is.True); + Assert.That(_container.ContainsFile("folder\\file.txt"), Is.False); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_Name.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_Name.cs new file mode 100644 index 000000000..9e158ff34 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_Name.cs @@ -0,0 +1,17 @@ +using Shared.Core.PackFiles.Models.Containers; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_Name : PackFileContainerTests_TestBase + { + public PackFileContainerTests_Name(Type containerType) : base(containerType) { } + + [Test] + public void Name_IsSetFromContainerCreation() + { + Assert.That(_container.Name, Is.EqualTo("TestCache")); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_RenameDirectory.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_RenameDirectory.cs new file mode 100644 index 000000000..0852a193f --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_RenameDirectory.cs @@ -0,0 +1,25 @@ +using Shared.Core.PackFiles.Models.Containers; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_RenameDirectory : PackFileContainerTests_TestBase + { + public PackFileContainerTests_RenameDirectory(Type containerType) : base(containerType) { } + + [Test] + public void RenameDirectory_RenamesNodeOrThrowsOnCached() + { + if (IsCachedContainer) + { + Assert.Throws(() => _container.RenameDirectory("models", "newmodels")); + return; + } + + var newPath = _container.RenameDirectory("models", "newmodels"); + Assert.That(newPath, Is.EqualTo("newmodels")); + Assert.That(_container.ContainsFile("newmodels\\unit.model"), Is.True); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_RenameFile.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_RenameFile.cs new file mode 100644 index 000000000..dadfa08c4 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_RenameFile.cs @@ -0,0 +1,27 @@ +using Shared.Core.PackFiles.Models.Containers; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_RenameFile : PackFileContainerTests_TestBase + { + public PackFileContainerTests_RenameFile(Type containerType) : base(containerType) { } + + [Test] + public void RenameFile_RenamesFileOrThrowsOnCached() + { + var file = _container.FindFile("folder\\file.txt")!; + + if (IsCachedContainer) + { + Assert.Throws(() => _container.RenameFile(file, "renamed.txt")); + return; + } + + _container.RenameFile(file, "renamed.txt"); + Assert.That(_container.ContainsFile("folder\\renamed.txt"), Is.True); + Assert.That(_container.ContainsFile("folder\\file.txt"), Is.False); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_SaveFileData.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_SaveFileData.cs new file mode 100644 index 000000000..6673b0dce --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_SaveFileData.cs @@ -0,0 +1,30 @@ +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.PackFiles.Models.FileSources; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_SaveFileData : PackFileContainerTests_TestBase + { + public PackFileContainerTests_SaveFileData(Type containerType) : base(containerType) { } + + [Test] + public void SaveFileData_UpdatesMemorySourceOrThrowsOnCached() + { + if (IsCachedContainer) + { + var cachedFile = _container.FindFile("folder\\file.txt")!; + Assert.Throws(() => _container.SaveFileData(cachedFile, [1, 2, 3])); + return; + } + + var file = new PackFile("new.txt", new MemorySource([1])); + _container.AddOrUpdateFile("new.txt", file); + _container.SaveFileData(file, [9, 8, 7]); + + Assert.That(file.DataSource.ReadData(), Is.EqualTo(new byte[] { 9, 8, 7 })); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_SaveToDisk.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_SaveToDisk.cs new file mode 100644 index 000000000..332a2a23c --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_SaveToDisk.cs @@ -0,0 +1,22 @@ +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.Settings; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_SaveToDisk : PackFileContainerTests_TestBase + { + public PackFileContainerTests_SaveToDisk(Type containerType) : base(containerType) { } + + [Test] + public void SaveToDisk_CachedContainer_Throws() + { + if (!IsCachedContainer) + Assert.Ignore("Writable pack SaveToDisk behavior is validated in serializer/integration tests."); + + var gameInfo = GameInformationDatabase.GetGameById(GameTypeEnum.Warhammer3); + Assert.Throws(() => _container.SaveToDisk("path", false, gameInfo)); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_SearchFiles.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_SearchFiles.cs new file mode 100644 index 000000000..b8278c865 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_SearchFiles.cs @@ -0,0 +1,104 @@ +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.PackFiles.Serialization.CacheDatabase; +using Shared.Core.PackFiles.Utility; +using Microsoft.Data.Sqlite; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_SearchFiles : PackFileContainerTests_TestBase + { + public PackFileContainerTests_SearchFiles(Type containerType) : base(containerType) { } + + [Test] + public void SearchFiles_NullFilters_ReturnsAll() + { + var results = _container.SearchFiles(null, null); + Assert.That(results.Count, Is.EqualTo(15)); + } + + [Test] + public void SearchFiles_TextAndExtension_FiltersCorrectly() + { + var results = _container.SearchFiles("battle", [".wem"]); + Assert.That(results.Count, Is.EqualTo(1)); + Assert.That(results[0].Path, Is.EqualTo(@"audio\battle_sound.wem")); + } + + [Test] + public void SearchFiles_ResultsArePathSorted() + { + var paths = _container.SearchFiles(null, null).Select(x => x.Path).ToList(); + var sorted = paths.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); + Assert.That(paths, Is.EqualTo(sorted)); + } + + [Test] + public void SearchFiles_EmptyExtensionList_TreatedAsNoFilter() + { + var results = _container.SearchFiles(null, []); + Assert.That(results.Count, Is.EqualTo(15)); + } + + [Test] + public void SearchFiles_ExtensionFilter_MatchesFilenameSubstrings_ForLegacyWemVariants() + { + var sourceContainer = new PackFileContainer("SearchFilesVariants") + { + IsCaPackFile = true, + SystemFilePath = @"c:\game\data" + }; + sourceContainer.SourcePackFilePaths.Add(@"c:\game\data\pack1.pack"); + + var parent = new PackedFileSourceParent { FilePath = @"c:\game\data\pack1.pack" }; + sourceContainer.AddOrUpdateFile("audio\\voice.wem", new PackFile("voice.wem", new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("audio\\voice.wem_temp", new PackFile("voice.wem_temp", new PackedFileSource(parent, 10, 10, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("audio\\voice.wem.{sdf}", new PackFile("voice.wem.{sdf}", new PackedFileSource(parent, 20, 10, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("audio\\voice.txt", new PackFile("voice.txt", new PackedFileSource(parent, 30, 10, false, false, CompressionFormat.None, 0))); + + SqliteConnection? keepAliveConnection = null; + IPackFileContainer container; + try + { + if (IsCachedContainer) + { + var dbName = "SearchFilesVariants_" + Guid.NewGuid().ToString("N"); + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = dbName, + Mode = SqliteOpenMode.Memory, + Cache = SqliteCacheMode.Shared + }.ToString(); + + // Keep one connection alive so the shared in-memory DB remains available + // while SaveCache and LoadContainerFromCache use separate EF/raw connections. + keepAliveConnection = new SqliteConnection(connectionString); + keepAliveConnection.Open(); + + var dbOptions = PackFileContainerCacheHelper.CreateDbOptionsFromConnectionString(connectionString); + PackFileContainerCacheHelper.SaveCache("variants_fp", sourceContainer, dbOptions); + container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "variants_fp")!; + } + else + { + container = sourceContainer; + } + + var results = container.SearchFiles(null, [".wem"]); + var paths = results.Select(x => x.Path).ToList(); + + Assert.That(paths, Does.Contain(@"audio\voice.wem")); + Assert.That(paths, Does.Contain(@"audio\voice.wem_temp")); + Assert.That(paths, Does.Contain(@"audio\voice.wem.{sdf}")); + Assert.That(paths, Does.Not.Contain(@"audio\voice.txt")); + } + finally + { + keepAliveConnection?.Dispose(); + } + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_SystemFilePath.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_SystemFilePath.cs new file mode 100644 index 000000000..9a2a83c28 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_SystemFilePath.cs @@ -0,0 +1,17 @@ +using Shared.Core.PackFiles.Models.Containers; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture(typeof(CachedPackFileContainer))] + [TestFixture(typeof(PackFileContainer))] + internal class PackFileContainerTests_SystemFilePath : PackFileContainerTests_TestBase + { + public PackFileContainerTests_SystemFilePath(Type containerType) : base(containerType) { } + + [Test] + public void SystemFilePath_MatchesSeededPath() + { + Assert.That(_container.SystemFilePath, Is.EqualTo(@"c:\game\data")); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_TestBase.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_TestBase.cs new file mode 100644 index 000000000..c85d3769d --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/PackFileContainerTests_TestBase.cs @@ -0,0 +1,110 @@ +using Microsoft.Data.Sqlite; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.PackFiles.Serialization.CacheDatabase; +using Shared.Core.PackFiles.Utility; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + internal abstract class PackFileContainerTests_TestBase + { + private readonly bool _useCachedContainer; + + private SqliteConnection? _cacheKeepAliveConnection; + protected IPackFileContainerInternal _container = null!; + protected bool IsCachedContainer => _useCachedContainer; + + protected PackFileContainerTests_TestBase(Type containerType) + { + _useCachedContainer = containerType == typeof(CachedPackFileContainer); + } + + [SetUp] + public void Setup() + { + var sourceContainer = new PackFileContainer("TestCache") + { + IsCaPackFile = true, + SystemFilePath = @"c:\game\data" + }; + sourceContainer.SourcePackFilePaths.Add(@"c:\game\data\pack1.pack"); + + var parent = new PackedFileSourceParent { FilePath = @"c:\game\data\pack1.pack" }; + sourceContainer.AddOrUpdateFile("folder\\file.txt", new PackFile("file.txt", + new PackedFileSource(parent, 100, 200, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("other\\data.bin", new PackFile("data.bin", + new PackedFileSource(parent, 300, 400, false, true, CompressionFormat.Lz4, 800))); + sourceContainer.AddOrUpdateFile("audio\\sound.wem", new PackFile("sound.wem", + new PackedFileSource(parent, 700, 500, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("root_file.txt", new PackFile("root_file.txt", + new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("models\\unit.model", new PackFile("unit.model", + new PackedFileSource(parent, 10, 20, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("models\\vehicle.model", new PackFile("vehicle.model", + new PackedFileSource(parent, 30, 40, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("models\\textures\\diffuse.dds", new PackFile("diffuse.dds", + new PackedFileSource(parent, 70, 50, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("models\\textures\\normal.dds", new PackFile("normal.dds", + new PackedFileSource(parent, 120, 60, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("models\\textures\\specular\\gloss.dds", new PackFile("gloss.dds", + new PackedFileSource(parent, 180, 30, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("audio\\music.wem", new PackFile("music.wem", + new PackedFileSource(parent, 210, 100, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("audio\\battle_sound.wem", new PackFile("battle_sound.wem", + new PackedFileSource(parent, 400, 300, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("scripts\\campaign_script.lua", new PackFile("campaign_script.lua", + new PackedFileSource(parent, 850, 80, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("folder_a\\shared.txt", new PackFile("shared.txt", + new PackedFileSource(parent, 900, 10, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("folder_b\\shared.txt", new PackFile("shared.txt", + new PackedFileSource(parent, 910, 20, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("compressed\\data.bin", new PackFile("data.bin", + new PackedFileSource(parent, 1000, 500, true, true, CompressionFormat.Lz4, 2000))); + + if (_useCachedContainer) + { + var dbName = "CachedContainerTests_" + Guid.NewGuid().ToString("N"); + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = dbName, + Mode = SqliteOpenMode.Memory, + Cache = SqliteCacheMode.Shared + }.ToString(); + + _cacheKeepAliveConnection = new SqliteConnection(connectionString); + _cacheKeepAliveConnection.Open(); + + var dbOptions = PackFileContainerCacheHelper.CreateDbOptionsFromConnectionString(connectionString); + PackFileContainerCacheHelper.SaveCache("test_fp", sourceContainer, dbOptions); + _container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "test_fp")!; + } + else + { + _container = sourceContainer; + } + } + + [TearDown] + public void TearDown() + { + _cacheKeepAliveConnection?.Dispose(); + _cacheKeepAliveConnection = null; + } + + protected void IgnoreIfNotCached(string scenario) + { + if (!IsCachedContainer) + Assert.Ignore($"{scenario} is currently validated only for CachedPackFileContainer."); + } + + protected CachedPackFileContainer GetCachedContainerOrIgnore(string scenario) + { + if (_container is CachedPackFileContainer cachedContainer) + return cachedContainer; + + Assert.Ignore($"{scenario} requires CachedPackFileContainer-specific API not present on IPackFileContainerInternal."); + return null!; + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Serialization/PackFileContainerCacheHelperTests.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Serialization/PackFileContainerCacheHelperTests.cs index 0b1023af0..a8503b95c 100644 --- a/Shared/SharedCore/Shared.CoreTest/PackFiles/Serialization/PackFileContainerCacheHelperTests.cs +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Serialization/PackFileContainerCacheHelperTests.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; using Shared.Core.PackFiles.Models; using Shared.Core.PackFiles.Models.Containers; using Shared.Core.PackFiles.Models.FileSources; @@ -11,6 +12,7 @@ internal class PackFileContainerCacheHelperTests { private string _tempDir; private string _dbFilePath; + private List _inMemoryKeepAliveConnections; [SetUp] public void Setup() @@ -18,16 +20,37 @@ public void Setup() _tempDir = Path.Combine(Path.GetTempPath(), "AssetEditorCacheTests_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); _dbFilePath = Path.Combine(_tempDir, "test_cache.db"); + _inMemoryKeepAliveConnections = []; } [TearDown] public void TearDown() { + foreach (var connection in _inMemoryKeepAliveConnections) + connection.Dispose(); + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } private DbContextOptions CreateTestDbOptions() + { + var dbName = "CacheHelperTests_" + Guid.NewGuid().ToString("N"); + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = dbName, + Mode = SqliteOpenMode.Memory, + Cache = SqliteCacheMode.Shared + }.ToString(); + + var keepAliveConnection = new SqliteConnection(connectionString); + keepAliveConnection.Open(); + _inMemoryKeepAliveConnections.Add(keepAliveConnection); + + return PackFileContainerCacheHelper.CreateDbOptionsFromConnectionString(connectionString); + } + + private DbContextOptions CreateFileDbOptions() { return PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); } @@ -247,7 +270,7 @@ public void TryLoadFromCache_ReturnsContainerWhenValid() container.AddOrUpdateFile("test\\file.txt", new PackFile("file.txt", new PackedFileSource(parent, 0, 100, false, false, CompressionFormat.None, 0))); - var dbOptions = CreateTestDbOptions(); + var dbOptions = CreateFileDbOptions(); PackFileContainerCacheHelper.SaveCache("fp_try", container, dbOptions); var result = PackFileContainerCacheHelper.TryLoadFromCache(_dbFilePath, "fp_try"); @@ -312,7 +335,7 @@ public void SaveCache_EmptyContainer_RoundTrips() public void SaveCache_OverwritesExistingCache() { var parent = new PackedFileSourceParent { FilePath = @"c:\game\pack.pack" }; - var dbOptions = CreateTestDbOptions(); + var dbOptions = CreateFileDbOptions(); // Save first version var container1 = new PackFileContainer("Version1") { SystemFilePath = @"c:\game" }; @@ -347,10 +370,12 @@ public void SaveCache_StoresFolderPathCorrectly() }; var parent = new PackedFileSourceParent { FilePath = @"c:\game\pack.pack" }; - container.AddOrUpdateFile("a\\b\\c\\file.txt", new PackFile("file.txt", + container.AddOrUpdateFile("a\\folder_marker.txt", new PackFile("folder_marker.txt", new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); + container.AddOrUpdateFile("a\\b\\c\\file.txt", new PackFile("file.txt", + new PackedFileSource(parent, 10, 10, false, false, CompressionFormat.None, 0))); container.AddOrUpdateFile("root_file.txt", new PackFile("root_file.txt", - new PackedFileSource(parent, 10, 20, false, false, CompressionFormat.None, 0))); + new PackedFileSource(parent, 20, 20, false, false, CompressionFormat.None, 0))); var dbOptions = CreateTestDbOptions(); PackFileContainerCacheHelper.SaveCache("fp", container, dbOptions); @@ -359,11 +384,11 @@ public void SaveCache_StoresFolderPathCorrectly() var loaded = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp"); Assert.That(loaded, Is.Not.Null); - var rootContent = loaded.GetDirectoryContent(""); + var rootContent = PackFileServiceUtility.SplitDirectoryEntries(loaded, ""); Assert.That(rootContent.Files.Any(f => f.FileName == "root_file.txt"), Is.True); Assert.That(rootContent.SubFolders, Does.Contain("a")); - var deepContent = loaded.GetDirectoryContent("a\\b\\c"); + var deepContent = PackFileServiceUtility.SplitDirectoryEntries(loaded, "a\\b\\c"); Assert.That(deepContent.Files.Any(f => f.FileName == "file.txt"), Is.True); } diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Utility/PackFileContainerLoaderTests.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Utility/PackFileContainerLoaderTests.cs index 1f0c835db..61a55ec32 100644 --- a/Shared/SharedCore/Shared.CoreTest/PackFiles/Utility/PackFileContainerLoaderTests.cs +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Utility/PackFileContainerLoaderTests.cs @@ -1,4 +1,4 @@ -using Moq; +using Moq; using Shared.Core.Misc; using Shared.Core.PackFiles.Utility; using Shared.Core.Services; @@ -36,8 +36,7 @@ public void Setup() _localizationManager = new LocalizationManager(); _settingsService = new ApplicationSettingsService(GameTypeEnum.Warhammer3); - _settingsService.CurrentSettings.GameDirectories.Add( - new ApplicationSettings.GamePathPair { Game = GameTypeEnum.Warhammer3, Path = _tempGameDir }); + _settingsService.CurrentSettings.GameDirectories.Add(new ApplicationSettings.GamePathPair(GameTypeEnum.Warhammer3, _tempGameDir)); } [TearDown] diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Utility/PackFileServiceUtilityTests.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Utility/PackFileServiceUtilityTests.cs new file mode 100644 index 000000000..2de8df6a0 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Utility/PackFileServiceUtilityTests.cs @@ -0,0 +1,61 @@ +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.PackFiles.Utility; +using Shared.Core.Settings; + +namespace Shared.CoreTest.PackFiles.Utility +{ + internal class PackFileServiceUtilityTests + { + [Test] + public void SplitDirectoryEntries_ComposesSortedFoldersAndFiles() + { + var container = CreateContainer(); + + var result = PackFileServiceUtility.SplitDirectoryEntries(container, "texture"); + + Assert.That(result.DirectoryPath, Is.EqualTo("texture")); + Assert.That(result.SubFolders, Is.EqualTo(new[] { "mesha", "meshb" })); + Assert.That(result.Files.Select(x => x.FileName), Is.EqualTo(new[] { "alpha.dds", "texture_file.dds" })); + } + + [Test] + public void SplitDirectoryEntries_RootDirectory_ReturnsTopLevelFoldersAndFiles() + { + var container = CreateContainer(); + + var result = PackFileServiceUtility.SplitDirectoryEntries(container, ""); + + Assert.That(result.DirectoryPath, Is.EqualTo(string.Empty)); + Assert.That(result.SubFolders, Is.EqualTo(new[] { "audio", "texture" })); + Assert.That(result.Files.Select(x => x.FileName), Is.EqualTo(new[] { "root.txt" })); + } + + [Test] + public void SplitDirectoryEntries_EmptyDirectory_ReturnsEmptyResult() + { + var container = CreateContainer(); + var result = PackFileServiceUtility.SplitDirectoryEntries(container, "missing"); + + Assert.That(result.DirectoryPath, Is.EqualTo("missing")); + Assert.That(result.SubFolders, Is.Empty); + Assert.That(result.Files, Is.Empty); + } + + private static PackFileContainer CreateContainer() + { + var container = new PackFileContainer("Test"); + var parent = new PackedFileSourceParent { FilePath = @"c:\game\p.pack" }; + + container.AddOrUpdateFile(@"root.txt", new PackFile("root.txt", new PackedFileSource(parent, 0, 1, false, false, CompressionFormat.None, 0))); + container.AddOrUpdateFile(@"audio\a.wem", new PackFile("a.wem", new PackedFileSource(parent, 1, 1, false, false, CompressionFormat.None, 0))); + container.AddOrUpdateFile(@"texture\texture_file.dds", new PackFile("texture_file.dds", new PackedFileSource(parent, 2, 1, false, false, CompressionFormat.None, 0))); + container.AddOrUpdateFile(@"texture\alpha.dds", new PackFile("alpha.dds", new PackedFileSource(parent, 3, 1, false, false, CompressionFormat.None, 0))); + container.AddOrUpdateFile(@"texture\mesha\filea", new PackFile("filea", new PackedFileSource(parent, 4, 1, false, false, CompressionFormat.None, 0))); + container.AddOrUpdateFile(@"texture\meshb\fileb", new PackFile("fileb", new PackedFileSource(parent, 5, 1, false, false, CompressionFormat.None, 0))); + + return container; + } + } +} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/AeAttribute.xaml.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/AeAttribute.xaml.cs index a1204b7fc..ea513030b 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/AeAttribute.xaml.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/AeAttribute.xaml.cs @@ -3,7 +3,7 @@ using System.Windows.Controls; using System.Windows.Markup; using Shared.EmbeddedResources; -using Shared.Ui.Common.ToolTipSystem; + namespace Shared.Ui.BaseDialogs { @@ -14,36 +14,11 @@ public partial class AeAttribute : UserControl public AeAttribute() { InitializeComponent(); - + InformationImage.Source = IconLibrary.InformationIcon; InformationImage.Visibility = Visibility.Hidden; } - private static void OnToolTipEnumChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - var owner = (AeAttribute)d; - var value = (ToolTipEnum)e.NewValue; - if (value == ToolTipEnum.None) - owner.InformationImage.Visibility = Visibility.Hidden; - else - owner.InformationImage.Visibility = Visibility.Visible; - - if (value == ToolTipEnum.None) - { - owner.InformationImage.Visibility = Visibility.Collapsed; - owner.InformationImage.ToolTip = null; - owner.Text.ToolTip = null; - return; - } - - var text = ToolTips.List[value]; - var toolTip = new ToolTip() { Content = text }; - - owner.InformationImage.Visibility = Visibility.Visible; - owner.InformationImage.ToolTip = toolTip; - owner.Text.ToolTip = toolTip; - } - public static readonly DependencyProperty InnerContentProperty = DependencyProperty.Register("InnerContent", typeof(object), typeof(AeAttribute)); public object InnerContent { @@ -51,13 +26,6 @@ public object InnerContent set { SetValue(InnerContentProperty, value); } } - public static readonly DependencyProperty ToolTipEnumValueProperty = DependencyProperty.Register(nameof(ToolTipEnumValue), typeof(ToolTipEnum), typeof(AeAttribute), new PropertyMetadata(ToolTipEnum.None, OnToolTipEnumChanged)); - public ToolTipEnum ToolTipEnumValue - { - get { return (ToolTipEnum)GetValue(ToolTipEnumValueProperty); } - set { SetValue(ToolTipEnumValueProperty, value);} - } - public static readonly DependencyProperty LabelTextProperty = DependencyProperty.Register(nameof(LabelText), typeof(string), typeof(AeAttribute), new PropertyMetadata(null)); public string LabelText { diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/AdvancedExportCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/AdvancedExportCommand.cs deleted file mode 100644 index fe88472fd..000000000 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/AdvancedExportCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.External; - -namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands -{ - public class AdvancedExportCommand(IExportFileContextMenuHelper exportFileContextMenuHelper) : IContextMenuCommand - { - public string GetDisplayName(TreeNode node) => "Advanced Export"; - public bool IsEnabled(TreeNode node) => exportFileContextMenuHelper.CanExportFile(node.Item); - - public void Execute(TreeNode _selectedNode) => exportFileContextMenuHelper.ShowDialog(_selectedNode.Item); - } -} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/AdvancedImportCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/AdvancedImportCommand.cs deleted file mode 100644 index 1e31b4aeb..000000000 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/AdvancedImportCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.External; - -namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands -{ - public class AdvancedImportCommand(IImportFileContextMenuHelper importFileContextMenuHelper) : IContextMenuCommand - { - public string GetDisplayName(TreeNode node) => "Advanced Import"; - public bool IsEnabled(TreeNode node) => true; - - public void Execute(TreeNode _selectedNode) => importFileContextMenuHelper.ShowDialog(_selectedNode); - } -} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ClosePackContainerFileCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ClosePackContainerFileCommand.cs index 09f515679..251e88006 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ClosePackContainerFileCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ClosePackContainerFileCommand.cs @@ -1,16 +1,17 @@ -using System.Windows; -using Shared.Core.PackFiles; +using Shared.Core.PackFiles; +using Shared.Core.Services; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { - public class ClosePackContainerFileCommand(IPackFileService packFileService) : IContextMenuCommand + public class ClosePackContainerFileCommand(IPackFileService packFileService, IStandardDialogs standardDialogs) : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Close"; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.Root; public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode selectedNode) { - if (MessageBox.Show("Are you sure you want to close the packfile?", "", MessageBoxButton.YesNo) == MessageBoxResult.Yes) + if (standardDialogs.ShowYesNoBox("Are you sure you want to close the packfile?", "") == ShowMessageBoxResult.OK) packFileService.UnloadPackContainer(selectedNode.FileOwner); } } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CollapseNodeCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CollapseNodeCommand.cs index e08dda2e2..03c1be15d 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CollapseNodeCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CollapseNodeCommand.cs @@ -3,6 +3,7 @@ public class CollapseNodeCommand() : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Collapse all"; + public bool ShouldAdd(TreeNode node) => node.NodeType != NodeType.File; public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) => CollapsAllRecursive(_selectedNode); @@ -10,7 +11,7 @@ public class CollapseNodeCommand() : IContextMenuCommand void CollapsAllRecursive(TreeNode node) { node.IsNodeExpanded = false; - foreach (var child in node.Children) + foreach (var child in node.BackingChildren) CollapsAllRecursive(child); } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyNodePathCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyNodePathCommand.cs index 912d1065c..fe165f89a 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyNodePathCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyNodePathCommand.cs @@ -6,6 +6,7 @@ namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands public class CopyNodePathCommand(IPackFileService packFileService) : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Copy full path"; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && node.Item != null; public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyToEditablePackCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyToEditablePackCommand.cs index 497ab0b80..008230315 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyToEditablePackCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyToEditablePackCommand.cs @@ -9,6 +9,11 @@ namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands public class CopyToEditablePackCommand(IPackFileService packFileService, IStandardDialogs standardDialogs) : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Copy to editable pack"; + public bool ShouldAdd(TreeNode node) + { + var editablePack = packFileService.GetEditablePack(); + return editablePack != null && editablePack != node.FileOwner; + } public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) @@ -27,6 +32,4 @@ public void Execute(TreeNode _selectedNode) } } } - - } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CreateFolderCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CreateFolderCommand.cs index dbd4b8b17..6c0642da6 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CreateFolderCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CreateFolderCommand.cs @@ -1,26 +1,26 @@ using System.Linq; -using System.Windows; -using Shared.Core.PackFiles; +using Shared.Core.Services; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { - public class CreateFolderCommand(IPackFileService packFileService) : IContextMenuCommand + public class CreateFolderCommand(IStandardDialogs standardDialogs) : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Create Folder"; + public bool ShouldAdd(TreeNode node) => node.NodeType != NodeType.File && !node.FileOwner.IsCaPackFile; public bool IsEnabled(TreeNode node) => true; - public void Execute(TreeNode _selectedNode) + public void Execute(TreeNode selectedNode) { - if (_selectedNode.FileOwner.IsCaPackFile) + if (selectedNode.FileOwner.IsCaPackFile) { - MessageBox.Show("Unable to edit CA packfile"); + standardDialogs.ShowDialogBox("Unable to edit CA packfile"); return; } - var folderName = EditFileNameDialog.ShowDialog(_selectedNode, ""); + var folderName = standardDialogs.ShowFolderNameDialog(selectedNode.Children.Select(x => x.Name), ""); if (folderName.Any()) - _selectedNode.AddDirectoryChild(folderName); + selectedNode.AddDirectoryChild(folderName); } } } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/DeleteNodeCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/DeleteNodeCommand.cs index 14a5ef1f1..f72dd7db2 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/DeleteNodeCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/DeleteNodeCommand.cs @@ -6,6 +6,7 @@ namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands public class DeleteNodeCommand(IPackFileService packFileService, IStandardDialogs standardDialogs) : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Delete"; + public bool ShouldAdd(TreeNode node) => (node.NodeType == NodeType.File || node.NodeType == NodeType.Directory) && !node.FileOwner.IsCaPackFile; public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/DuplicateFileCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/DuplicateFileCommand.cs index f9863a797..5e7f08696 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/DuplicateFileCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/DuplicateFileCommand.cs @@ -2,12 +2,14 @@ using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.Services; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { - public class DuplicateFileCommand(IPackFileService packFileService) : IContextMenuCommand + public class DuplicateFileCommand(IPackFileService packFileService, IStandardDialogs standardDialogs) : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Duplicate"; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && node.Item != null && !node.FileOwner.IsCaPackFile; public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) => Execute(_selectedNode.Item); @@ -28,11 +30,17 @@ public void Execute(PackFile item) private void ReadAndSave(string newName, PackFile item) { + var editablePack = packFileService.GetEditablePack(); + if (editablePack == null) + { + standardDialogs.ShowDialogBox("No editable pack selected."); + return; + } + var bytes = item.DataSource.ReadData(); var packFile = new PackFile(newName, new MemorySource(bytes)); var parentPath = packFileService.GetFullPath(item); var path = Path.GetDirectoryName(parentPath); - var editablePack = packFileService.GetEditablePack(); var fileEntry = new NewPackFileEntry(path, packFile); packFileService.AddFilesToPack(editablePack, [fileEntry]); diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ExpandNodeCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ExpandNodeCommand.cs index dfcfb5bae..7ff64a3ad 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ExpandNodeCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ExpandNodeCommand.cs @@ -3,6 +3,7 @@ public class ExpandNodeCommand() : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Expand all"; + public bool ShouldAdd(TreeNode node) => node.NodeType != NodeType.File; public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) => ExpandAllRecursive(_selectedNode); @@ -10,7 +11,7 @@ public class ExpandNodeCommand() : IContextMenuCommand void ExpandAllRecursive(TreeNode node) { node.IsNodeExpanded = true; - foreach (var child in node.Children) + foreach (var child in node.BackingChildren) ExpandAllRecursive(child); } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ExportToDirectoryCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ExportToDirectoryCommand.cs index 3cecbea6d..682ff3b92 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ExportToDirectoryCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ExportToDirectoryCommand.cs @@ -1,59 +1,67 @@ -using System.IO; -using System.Windows.Forms; +using System; +using System.IO; using Shared.Core.Misc; +using Shared.Core.Services; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { - public class ExportToDirectoryCommand() : IContextMenuCommand + public class ExportToDirectoryCommand(IStandardDialogs standardDialogs, IFileSystemAccess fileSystemAccess) : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Export to system folder"; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.Directory || node.NodeType == NodeType.Root || (node.NodeType == NodeType.File && node.Item != null); public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode selectedNode) { // TODO: Fix bug where if you export the packfilecontainer itself it doesn't export correctly. - var dialog = new FolderBrowserDialog(); - if (dialog.ShowDialog() == DialogResult.OK) + var folderDialogResult = standardDialogs.ShowSystemFolderBrowserDialog(); + if (folderDialogResult.Result && !string.IsNullOrEmpty(folderDialogResult.FolderPath)) { - var nodeStartDir = Path.GetDirectoryName(selectedNode.GetFullPath()); + // For root nodes, use empty string as base path; for others, use parent directory + var nodeStartDir = selectedNode.NodeType == NodeType.Root + ? "" + : fileSystemAccess.PathGetDirectoryName(selectedNode.GetFullPath()); var fileCounter = 0; - SaveSelfAndChildren(selectedNode, dialog.SelectedPath, nodeStartDir, ref fileCounter); - MessageBox.Show($"{fileCounter} files exported!"); + SaveSelfAndChildren(selectedNode, folderDialogResult.FolderPath, nodeStartDir, ref fileCounter); + standardDialogs.ShowDialogBox($"{fileCounter} files exported!", "Export"); } } - void SaveSelfAndChildren(TreeNode node, string outputDirectory, string rootPath, ref int fileCounter) + void SaveSelfAndChildren(TreeNode node, string outputDirectory, string? rootPath, ref int fileCounter) { - if (node.NodeType == NodeType.Directory) + if (node.NodeType == NodeType.Directory || node.NodeType == NodeType.Root) { - foreach (var item in node.Children) + foreach (var item in node.BackingChildren) SaveSelfAndChildren(item, outputDirectory, rootPath, ref fileCounter); } else { var nodeOriginalPath = node.GetFullPath(); - - var nodePathWithoutRoot = nodeOriginalPath; - if (rootPath != null && rootPath.Length != 0) - nodePathWithoutRoot = nodeOriginalPath.Replace(rootPath, ""); - - if (nodePathWithoutRoot.StartsWith("\\") == false) - nodePathWithoutRoot = "\\" + nodePathWithoutRoot; - + var nodePathWithoutRoot = ComputeRelativePath(nodeOriginalPath, rootPath); var fileOutputPath = outputDirectory + nodePathWithoutRoot; - var fileOutputDir = Path.GetDirectoryName(fileOutputPath); - DirectoryHelper.EnsureCreated(fileOutputDir); + var fileOutputDir = fileSystemAccess.PathGetDirectoryName(fileOutputPath); + if (!string.IsNullOrEmpty(fileOutputDir)) + DirectoryHelper.EnsureCreated(fileOutputDir); var packFile = node.Item; var bytes = packFile.DataSource.ReadData(); - File.WriteAllBytes(fileOutputPath, bytes); + fileSystemAccess.FileWriteAllBytes(fileOutputPath, bytes); fileCounter++; } } + internal static string ComputeRelativePath(string nodeFullPath, string? rootPath) + { + var relative = nodeFullPath; + if (!string.IsNullOrEmpty(rootPath) && nodeFullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase)) + relative = nodeFullPath[rootPath.Length..]; + if (!relative.StartsWith("\\")) + relative = "\\" + relative; + return relative; + } } } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/IContextMenuCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/IContextMenuCommand.cs index 5ed5d20ba..2e9a56a17 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/IContextMenuCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/IContextMenuCommand.cs @@ -5,6 +5,7 @@ namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands public interface IContextMenuCommand : IUiCommand { public string GetDisplayName(TreeNode node); + public bool ShouldAdd(TreeNode node); public bool IsEnabled(TreeNode node); public void Execute(TreeNode node); } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/IRmvToTextCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/IRmvToTextCommand.cs deleted file mode 100644 index bd23f3c6b..000000000 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/IRmvToTextCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands -{ - public interface IRmvToTextCommand : IContextMenuCommand - { - } -} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportDirectoryCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportDirectoryCommand.cs index 605674eb5..08283b8eb 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportDirectoryCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportDirectoryCommand.cs @@ -1,32 +1,33 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Windows.Forms; using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.Services; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { - public class ImportDirectoryCommand(IPackFileService packFileService) : IContextMenuCommand + public class ImportDirectoryCommand(IPackFileService packFileService, IStandardDialogs standardDialogs, IFileSystemAccess fileSystemAccess) : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Import Directory"; + public bool ShouldAdd(TreeNode node) => node.NodeType != NodeType.File && !node.FileOwner.IsCaPackFile; public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) { if (_selectedNode.FileOwner.IsCaPackFile) { - System.Windows.MessageBox.Show("Unable to edit CA packfile"); + standardDialogs.ShowDialogBox("Unable to edit CA packfile"); return; } - var dialog = new FolderBrowserDialog(); - if (dialog.ShowDialog() == DialogResult.OK) + var folderDialogResult = standardDialogs.ShowSystemFolderBrowserDialog(); + if (folderDialogResult.Result && !string.IsNullOrEmpty(folderDialogResult.FolderPath)) { - var folderPath = dialog.SelectedPath; - var folderName = new DirectoryInfo(folderPath).Name; - var originalFilePaths = Directory.GetFiles(folderPath, "*", SearchOption.AllDirectories); + var folderPath = folderDialogResult.FolderPath; + var folderName = fileSystemAccess.CreateDirectoryInfo(folderPath).Name; + var originalFilePaths = fileSystemAccess.DirectoryGetFiles(folderPath, "*", SearchOption.AllDirectories); var filePaths = originalFilePaths.Select(x => x.Replace($"{folderPath}\\", "")).ToList(); var packNodeParentPath = _selectedNode.GetFullPath(); @@ -37,7 +38,7 @@ public void Execute(TreeNode _selectedNode) for (var i = 0; i < filePaths.Count; i++) { var currentPath = filePaths[i]; - var fileName = Path.GetFileName(currentPath); + var fileName = fileSystemAccess.PathGetFileName(currentPath); var packDirectoryPath = $"{packNodeParentPath.ToLower()}{folderName}"; @@ -48,7 +49,7 @@ public void Execute(TreeNode _selectedNode) packDirectoryPath = $"{packDirectoryPath}\\{directoryPath}"; } - var source = MemorySource.FromFile(originalFilePaths[i]); + var source = new MemorySource(fileSystemAccess.FileReadAllBytes(originalFilePaths[i])); var file = new PackFile(fileName, source); filesAdded.Add(new NewPackFileEntry(packDirectoryPath, file)); diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportFilesCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportFilesCommand.cs index 2ae1cfbf5..68374f0ae 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportFilesCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportFilesCommand.cs @@ -1,37 +1,34 @@ using System.IO; -using System.Windows.Forms; using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.Services; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { - public class ImportFileCommand(IPackFileService packFileService) : IContextMenuCommand + public class ImportFileCommand(IPackFileService packFileService, IStandardDialogs standardDialogs, IFileSystemAccess fileSystemAccess) : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Import File"; + public bool ShouldAdd(TreeNode node) => node.NodeType != NodeType.File && !node.FileOwner.IsCaPackFile; public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) { if (_selectedNode.FileOwner.IsCaPackFile) { - System.Windows.MessageBox.Show("Unable to edit CA packfile"); + standardDialogs.ShowDialogBox("Unable to edit CA packfile"); return; } - var dialog = new OpenFileDialog() - { - Multiselect = true, - }; - - if (dialog.ShowDialog() == DialogResult.OK) + var dialogResult = standardDialogs.ShowSystemOpenFileDialog(multiselect: true); + if (dialogResult.Result) { var parentPath = _selectedNode.GetFullPath(); - var files = dialog.FileNames; + var files = dialogResult.FilePaths; foreach (var file in files) { var fileName = Path.GetFileName(file); - var packFile = new PackFile(fileName, new MemorySource(File.ReadAllBytes(file))); + var packFile = new PackFile(fileName, new MemorySource(fileSystemAccess.FileReadAllBytes(file))); var item = new NewPackFileEntry(parentPath, packFile); packFileService.AddFilesToPack(_selectedNode.FileOwner, [item]); } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInCommand.cs index f8159cfec..5117febed 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInCommand.cs @@ -1,23 +1,24 @@ -using System.Diagnostics; -using System; -using System.Windows; -using Shared.Core.PackFiles.Models; +using System; +using System.Diagnostics; using System.IO; +using Shared.Core.PackFiles.Models; +using Shared.Core.Services; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { - public abstract class OpenNodeInCommand() : IContextMenuCommand + public abstract class OpenNodeInCommand(IStandardDialogs standardDialogs, IFileSystemAccess fileSystemAccess) : IContextMenuCommand { public abstract string GetDisplayName(TreeNode node); + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && node.Item != null; public bool IsEnabled(TreeNode node) => true; public abstract void Execute(TreeNode _selectedNode); protected void OpenPackFileUsing(string applicationPath, PackFile packFile) { - if (File.Exists(applicationPath) == false) + if (fileSystemAccess.FileExists(applicationPath) == false) { - MessageBox.Show($"Application {applicationPath} does not exist"); + standardDialogs.ShowDialogBox($"Application {applicationPath} does not exist"); return; } @@ -26,21 +27,28 @@ protected void OpenPackFileUsing(string applicationPath, PackFile packFile) var path = tempFolder + "\\" + fileName; var bytes = packFile.DataSource.ReadData(); - File.WriteAllBytes(path, bytes); + fileSystemAccess.FileWriteAllBytes(path, bytes); - Process.Start(applicationPath, $"\"{path}\""); + using var process = fileSystemAccess.ProcessStart(applicationPath, $"\"{path}\""); + } + + protected string ResolveApplicationPath(string appRelativePath) + { + var x64 = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), appRelativePath); + if (fileSystemAccess.FileExists(x64)) return x64; + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), appRelativePath); } } - public class OpenNodeInNotepadCommand() : OpenNodeInCommand + public class OpenNodeInNotepadCommand(IStandardDialogs standardDialogs, IFileSystemAccess fileSystemAccess) : OpenNodeInCommand(standardDialogs, fileSystemAccess) { public override string GetDisplayName(TreeNode node) => "Open in Notepad++"; - public override void Execute(TreeNode _selectedNode) => OpenPackFileUsing(@"C:\Program Files\Notepad++\notepad++.exe", _selectedNode.Item!); + public override void Execute(TreeNode _selectedNode) => OpenPackFileUsing(ResolveApplicationPath(@"Notepad++\notepad++.exe"), _selectedNode.Item!); } - public class OpenNodeInHxDCommand() : OpenNodeInCommand + public class OpenNodeInHxDCommand(IStandardDialogs standardDialogs, IFileSystemAccess fileSystemAccess) : OpenNodeInCommand(standardDialogs, fileSystemAccess) { public override string GetDisplayName(TreeNode node) => "Open in Hxd"; - public override void Execute(TreeNode _selectedNode) => OpenPackFileUsing(@"C:\Program Files\HxD\HxD.exe", _selectedNode.Item!); + public override void Execute(TreeNode _selectedNode) => OpenPackFileUsing(ResolveApplicationPath(@"HxD\HxD.exe"), _selectedNode.Item!); } } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenPackInFileExplorerCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenPackInFileExplorerCommand.cs index 4aed7cd9e..3b6ffd6e9 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenPackInFileExplorerCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenPackInFileExplorerCommand.cs @@ -1,21 +1,34 @@ using System.Diagnostics; -using System.IO; using Shared.Core.PackFiles; +using Shared.Core.Services; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { - public class OpenPackInFileExplorerCommand(IPackFileService packFileService) : IContextMenuCommand + public class OpenPackInFileExplorerCommand(IPackFileService packFileService, IStandardDialogs standardDialogs, IFileSystemAccess fileSystemAccess) : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Open In File Explorer"; + public bool ShouldAdd(TreeNode node) => node.NodeType != NodeType.File && !node.FileOwner.IsCaPackFile; public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) { var systemFilePath = _selectedNode.FileOwner.SystemFilePath; - if (!Directory.Exists(systemFilePath)) - systemFilePath = Path.GetDirectoryName(systemFilePath); + if (string.IsNullOrEmpty(systemFilePath)) + { + standardDialogs.ShowDialogBox("Pack file has not been saved to disk yet."); + return; + } + + if (!fileSystemAccess.DirectoryExists(systemFilePath)) + systemFilePath = fileSystemAccess.PathGetDirectoryName(systemFilePath); + + if (systemFilePath == null) + { + standardDialogs.ShowDialogBox("Unable to determine folder for pack file."); + return; + } - Process.Start(new ProcessStartInfo + using var process = fileSystemAccess.ProcessStart(new ProcessStartInfo { FileName = "explorer.exe", Arguments = $"\"{systemFilePath}\"", diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/OnRenameNodeCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/RenameNodeCommand.cs similarity index 85% rename from Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/OnRenameNodeCommand.cs rename to Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/RenameNodeCommand.cs index 063f9beee..11a592387 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/OnRenameNodeCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/RenameNodeCommand.cs @@ -4,9 +4,10 @@ namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { - public class OnRenameNodeCommand(IPackFileService packFileService, IStandardDialogs standardDialogs) : IContextMenuCommand + public class RenameNodeCommand(IPackFileService packFileService, IStandardDialogs standardDialogs) : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Rename"; + public bool ShouldAdd(TreeNode node) => (node.NodeType == NodeType.File || node.NodeType == NodeType.Directory) && !node.FileOwner.IsCaPackFile; public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SaveAsPackFileContainerCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SaveAsPackFileContainerCommand.cs index c52244665..655a49ffb 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SaveAsPackFileContainerCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SaveAsPackFileContainerCommand.cs @@ -1,30 +1,40 @@ -using System.Windows.Forms; +using System; +using Serilog; +using Shared.Core.ErrorHandling; using Shared.Core.PackFiles; +using Shared.Core.Services; using Shared.Core.Settings; using Shared.Ui.Common; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { - public class SaveAsPackFileContainerCommand(IPackFileService packFileService, ApplicationSettingsService applicationSettingsService) : IContextMenuCommand + public class SaveAsPackFileContainerCommand(IPackFileService packFileService, ApplicationSettingsService applicationSettingsService, IStandardDialogs standardDialogs) : IContextMenuCommand { + private readonly ILogger _logger = Logging.Create(); public string GetDisplayName(TreeNode node) => "Save As"; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.Root && !node.FileOwner.IsCaPackFile; public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) { - var saveFileDialog = new SaveFileDialog(); - saveFileDialog.FileName = _selectedNode.FileOwner.Name; - saveFileDialog.Filter = "PackFile | *.pack"; - saveFileDialog.DefaultExt = "pack"; - if (saveFileDialog.ShowDialog() != DialogResult.OK) + var saveDialogResult = standardDialogs.ShowSystemSaveFileDialog(_selectedNode.FileOwner.Name, "PackFile | *.pack", "pack"); + if (!saveDialogResult.Result || string.IsNullOrEmpty(saveDialogResult.FilePath)) return; using (new WaitCursor()) { - var gameInformation = GameInformationDatabase.GetGameById(applicationSettingsService.CurrentSettings.CurrentGame); - packFileService.SavePackContainer(_selectedNode.FileOwner, saveFileDialog.FileName, false, gameInformation); - _selectedNode.UnsavedChanged = false; - _selectedNode.ForeachNode((node) => node.UnsavedChanged = false); + try + { + var gameInformation = GameInformationDatabase.GetGameById(applicationSettingsService.CurrentSettings.CurrentGame); + packFileService.SavePackContainer(_selectedNode.FileOwner, saveDialogResult.FilePath, false, gameInformation); + _selectedNode.UnsavedChanged = false; + _selectedNode.ForeachNode((node) => node.UnsavedChanged = false); + } + catch (Exception e) + { + _logger.Here().Error(e, "Exception while saving"); + standardDialogs.ShowDialogBox("Error saving:\n\n" + e.Message, "Error"); + } } } } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SavePackFileContainerCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SavePackFileContainerCommand.cs index fb5af6391..c0a09c87b 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SavePackFileContainerCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SavePackFileContainerCommand.cs @@ -1,5 +1,4 @@ using System; -using System.Windows.Forms; using Serilog; using Shared.Core.ErrorHandling; using Shared.Core.PackFiles; @@ -16,6 +15,7 @@ public class SavePackFileContainerCommand( { private readonly ILogger _logger = Logging.Create(); public string GetDisplayName(TreeNode node) => "Save"; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.Root && !node.FileOwner.IsCaPackFile; public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) @@ -23,16 +23,13 @@ public void Execute(TreeNode _selectedNode) var systemPath = _selectedNode.FileOwner.SystemFilePath; if (string.IsNullOrWhiteSpace(systemPath)) { - var saveFileDialog = new SaveFileDialog(); - saveFileDialog.FileName = _selectedNode.FileOwner.Name; - saveFileDialog.Filter = "PackFile | *.pack"; - saveFileDialog.DefaultExt = "pack"; - if (saveFileDialog.ShowDialog() != DialogResult.OK) + var saveDialogResult = standardDialogs.ShowSystemSaveFileDialog(_selectedNode.FileOwner.Name, "PackFile | *.pack", "pack"); + if (!saveDialogResult.Result || string.IsNullOrEmpty(saveDialogResult.FilePath)) return; - systemPath = saveFileDialog.FileName; + systemPath = saveDialogResult.FilePath; } - using (new WaitCursor()) + using (standardDialogs.ShowWaitCursor()) { try { @@ -42,7 +39,7 @@ public void Execute(TreeNode _selectedNode) catch (Exception e) { _logger.Here().Error(e, "Exception while saving"); - System.Windows.MessageBox.Show("Error saving:\n\n" + e.Message, "Error"); + standardDialogs.ShowDialogBox("Error saving:\n\n" + e.Message, "Error"); } } } @@ -59,16 +56,13 @@ public void Execute() var systemPath = pack.SystemFilePath; if (string.IsNullOrWhiteSpace(systemPath)) { - var saveFileDialog = new SaveFileDialog(); - saveFileDialog.FileName = pack.Name; - saveFileDialog.Filter = "PackFile | *.pack"; - saveFileDialog.DefaultExt = "pack"; - if (saveFileDialog.ShowDialog() != DialogResult.OK) + var saveDialogResult = standardDialogs.ShowSystemSaveFileDialog(pack.Name, "PackFile | *.pack", "pack"); + if (!saveDialogResult.Result || string.IsNullOrEmpty(saveDialogResult.FilePath)) return; - systemPath = saveFileDialog.FileName; + systemPath = saveDialogResult.FilePath; } - using (new WaitCursor()) + using (standardDialogs.ShowWaitCursor()) { try { @@ -78,7 +72,7 @@ public void Execute() catch (Exception e) { _logger.Here().Error(e, "Exception while saving"); - System.Windows.MessageBox.Show("Error saving:\n\n" + e.Message, "Error"); + standardDialogs.ShowDialogBox("Error saving:\n\n" + e.Message, "Error"); } } } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SetAsEditablePackCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SetAsEditablePackCommand.cs index fc25a80e4..c18f7cf9e 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SetAsEditablePackCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SetAsEditablePackCommand.cs @@ -5,6 +5,7 @@ namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands public class SetAsEditablePackCommand(IPackFileService packFileService) : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Set as Editable Pack"; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.Root && !node.FileOwner.IsCaPackFile && packFileService.GetEditablePack() != node.FileOwner; public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode selectedNode) diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/ContextMenuBuilder.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/ContextMenuBuilder.cs index 3dfdb464a..1946e5488 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/ContextMenuBuilder.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/ContextMenuBuilder.cs @@ -1,7 +1,8 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; -using Shared.Core.Events; +using Microsoft.Extensions.DependencyInjection; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu @@ -14,77 +15,156 @@ public enum ContextMenuType SceneExplorer } - public class ContextMenuFactory + public enum ContextMenuCluster { - private readonly IEnumerable _builders; - - public ContextMenuFactory(IEnumerable builders) - { - _builders = builders; - } + PackFileOperation, + FolderOperation, + FileOperation, + Export, + Reports, + Misc + } - public IContextMenuBuilder GetContextMenu(ContextMenuType menuType) => _builders.First(x => x.Type == menuType); + public interface IPackFileContextMenuRegistration + { + void Register(PackFileContextMenuRegistry registry); } - public interface IContextMenuBuilder + public sealed class PackFileContextMenuItemRegistration { - ContextMenuType Type { get; } - public ObservableCollection Build(TreeNode? node); + public required Type CommandType { get; init; } + public required ContextMenuType ContextMenuType { get; init; } + public required ContextMenuCluster Cluster { get; init; } + public required string Path { get; init; } + public required int Priority { get; init; } } - public abstract class ContextMenuBuilder : IContextMenuBuilder + public class PackFileContextMenuRegistry { - private readonly IUiCommandFactory _commandFactory; + private readonly List _items = []; - public ContextMenuType Type { get; private set; } + public void RegisterPackFileContextMenuItem(ContextMenuType contextMenuType, string path, int priority, ContextMenuCluster cluster) + where TCommand : IContextMenuCommand + { + RegisterPackFileContextMenuItem(typeof(TCommand), contextMenuType, path, priority, cluster); + } - public ContextMenuBuilder(ContextMenuType type, IUiCommandFactory commandFactory) + public void RegisterPackFileContextMenuItem(Type commandType, ContextMenuType contextMenuType, string path, int priority, ContextMenuCluster cluster) { - Type = type; - _commandFactory = commandFactory; + if (!typeof(IContextMenuCommand).IsAssignableFrom(commandType)) + throw new System.ArgumentException($"{commandType.Name} must implement {nameof(IContextMenuCommand)}"); + + _items.Add(new PackFileContextMenuItemRegistration + { + CommandType = commandType, + ContextMenuType = contextMenuType, + Cluster = cluster, + Path = path ?? string.Empty, + Priority = priority + }); } - protected abstract void Create(ContextMenuItem2 rootNode, TreeNode selectedNode); + public IReadOnlyList Get(ContextMenuType contextMenuType) + { + return _items + .Where(x => x.ContextMenuType == contextMenuType) + .OrderBy(x => x.Cluster) + .ThenBy(x => x.Priority) + .ThenBy(x => x.Path) + .ThenBy(x => x.CommandType.FullName) + .ToList(); + } + } + + public class PackFileContextMenuComposer + { + private readonly PackFileContextMenuRegistry _registry; + private readonly IServiceProvider _serviceProvider; + + public PackFileContextMenuComposer(PackFileContextMenuRegistry registry, IServiceProvider serviceProvider) + { + _registry = registry; + _serviceProvider = serviceProvider; + } - public ObservableCollection Build(TreeNode? node) + public ObservableCollection Build(ContextMenuType contextMenuType, TreeNode? node) { - var output = new ObservableCollection(); + var output = new ObservableCollection(); if (node == null) return output; - var placeholderRoot = new ContextMenuItem2("Root", null); - Create(placeholderRoot, node); + var items = _registry.Get(contextMenuType); + var hasClusterContent = false; - foreach (var item in placeholderRoot.ContextMenu) - output.Add(item); + foreach (var cluster in System.Enum.GetValues()) + { + var clusterRoot = new ContextMenuItem("Root", null); + var pathToMenuLookup = new Dictionary(System.StringComparer.OrdinalIgnoreCase); - return output; - } + foreach (var item in items.Where(x => x.Cluster == cluster)) + { + var command = (IContextMenuCommand)_serviceProvider.GetRequiredService(item.CommandType); + if (!command.ShouldAdd(node) || !command.IsEnabled(node)) + continue; + var parent = GetOrCreateMenuPath(item.Path, clusterRoot, pathToMenuLookup); + parent.ContextMenu.Add(new ContextMenuItem(command.GetDisplayName(node), () => command.Execute(node))); + } - protected void Add(TreeNode? node, ContextMenuItem2 parent) where T : IContextMenuCommand - { - var instance = _commandFactory.Create(); + RemoveEmptySubmenus(clusterRoot); + if (clusterRoot.ContextMenu.Count == 0) + continue; + + if (hasClusterContent) + output.Add(null); + + foreach (var menuItem in clusterRoot.ContextMenu) + output.Add(menuItem); - if (instance.IsEnabled(node) == false) - return; + hasClusterContent = true; + } - var name = instance.GetDisplayName(node); - var item = new ContextMenuItem2(name, () => instance.Execute(node)); - parent.ContextMenu.Add(item); + return output; } - protected void AddSeperator(ContextMenuItem2 parent) + private static ContextMenuItem GetOrCreateMenuPath(string path, ContextMenuItem root, Dictionary pathToMenuLookup) { - parent.ContextMenu.Add(null); + if (string.IsNullOrWhiteSpace(path)) + return root; + + var current = root; + var runningPath = string.Empty; + foreach (var pathSegment in path.Split('/').Where(x => !string.IsNullOrWhiteSpace(x))) + { + runningPath = string.IsNullOrEmpty(runningPath) ? pathSegment : $"{runningPath}/{pathSegment}"; + + if (pathToMenuLookup.TryGetValue(runningPath, out var menu) == false) + { + menu = new ContextMenuItem(pathSegment, null); + current.ContextMenu.Add(menu); + pathToMenuLookup[runningPath] = menu; + } + + current = menu; + } + + return current; } - public ContextMenuItem2 AddChildMenu(string name, ContextMenuItem2 parent) + private static void RemoveEmptySubmenus(ContextMenuItem root) { - var newItem = new ContextMenuItem2(name, null); - parent.ContextMenu.Add(newItem); - return newItem; - + for (var i = root.ContextMenu.Count - 1; i >= 0; i--) + { + var menuItem = root.ContextMenu[i]; + if (menuItem == null) + continue; + + if (menuItem.ContextMenu.Count > 0) + RemoveEmptySubmenus(menuItem); + + if (menuItem.Command == null && menuItem.ContextMenu.Count == 0) + root.ContextMenu.RemoveAt(i); + } } } } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/ContextMenuItem.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/ContextMenuItem.cs index 01bc1e241..7e861652a 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/ContextMenuItem.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/ContextMenuItem.cs @@ -9,29 +9,9 @@ public class ContextMenuItem { public string Name { get; set; } public ICommand? Command { get; set; } - public ObservableCollection ContextMenu { get; set; } = []; + public ObservableCollection ContextMenu { get; set; } = []; - public ContextMenuItem(string name = "", ICommand? command = null) - { - Name = name; - Command = command; - } - - public override string ToString() - { - return Name; - } - } - - - - public class ContextMenuItem2 - { - public string Name { get; set; } - public ICommand? Command { get; set; } - public ObservableCollection ContextMenu { get; set; } = []; - - public ContextMenuItem2(string name, Action? action) + public ContextMenuItem(string name, Action? action) { Name = name; if (action != null) @@ -40,6 +20,4 @@ public ContextMenuItem2(string name, Action? action) public override string ToString() => Name; } - - } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/External/IImportFileContextMenuHelper.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/External/IImportFileContextMenuHelper.cs deleted file mode 100644 index 667c9f6f5..000000000 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/External/IImportFileContextMenuHelper.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Shared.Core.PackFiles.Models; - -namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.External -{ - public interface IImportFileContextMenuHelper - { - /// - /// Implemeation checks if the can be file imported - /// - /// - /// - bool CanImportFile(PackFile file); - - // implemenation returns packfile container, so it can create new foldders - public void ShowDialog(TreeNode node); - } -} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/MainApplicationContextMenuBuilder.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/MainApplicationContextMenuBuilder.cs deleted file mode 100644 index cad77f5c8..000000000 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/MainApplicationContextMenuBuilder.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Shared.Core.Events; -using Shared.Core.PackFiles; -using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; - -namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu -{ - public class MainApplicationContextMenuBuilder : ContextMenuBuilder - { - private readonly IPackFileService _packFileService; - - public MainApplicationContextMenuBuilder(IPackFileService packFileService, IUiCommandFactory commandFactory) : base(ContextMenuType.MainApplication, commandFactory) - { - _packFileService = packFileService; - } - - protected override void Create(ContextMenuItem2 rootNode, TreeNode selectedNode) - { - var nodeType = selectedNode.NodeType; - switch (nodeType) - { - case NodeType.File: - CreateForFile(rootNode, selectedNode); - break; - case NodeType.Root: - CreateForDirectory(rootNode, selectedNode); - break; - case NodeType.Directory: - CreateForDirectory(rootNode, selectedNode); - break; - } - } - - void CreateForFile(ContextMenuItem2 rootNode, TreeNode selectedNode) - { - if (_packFileService.GetEditablePack() != selectedNode.FileOwner) - Add(selectedNode, rootNode); - - if (!selectedNode.FileOwner.IsCaPackFile) - { - AddSeperator(rootNode); - Add(selectedNode, rootNode); - Add(selectedNode, rootNode); - Add(selectedNode, rootNode); - AddSeperator(rootNode); - } - - Add(selectedNode, rootNode); - - var exportFolder = AddChildMenu("Export", rootNode); - Add(selectedNode, exportFolder); - Add(selectedNode, exportFolder); - - var openFolder = AddChildMenu("Open", rootNode); - Add(selectedNode, openFolder); - Add(selectedNode, openFolder); - - var reportsFolder = AddChildMenu("Reports", rootNode); - Add(selectedNode, reportsFolder); - } - - void CreateForDirectory(ContextMenuItem2 rootNode, TreeNode selectedNode) - { - var isCaPack = selectedNode.FileOwner.IsCaPackFile; - var isCurrentEditable = _packFileService.GetEditablePack() == selectedNode.FileOwner; - - if (selectedNode.NodeType == NodeType.Root) - { - // Close - Add(selectedNode, rootNode); - AddSeperator(rootNode); - - - if (!isCaPack && isCurrentEditable == false) - Add(selectedNode, rootNode); - - if (!isCaPack) - { - AddSeperator(rootNode); - Add(selectedNode, rootNode); - Add(selectedNode, rootNode); - AddSeperator(rootNode); - } - } - - if (isCurrentEditable == false) - Add(selectedNode, rootNode); - - if (!isCaPack) - { - var importFolder = AddChildMenu("Import", rootNode); - Add(selectedNode, importFolder); - Add(selectedNode, importFolder); - - if (selectedNode.NodeType != NodeType.Root) - Add(selectedNode, importFolder); - - var createMenu = AddChildMenu("Create", rootNode); - Add(selectedNode, createMenu); - - AddSeperator(rootNode); - Add(selectedNode, rootNode); - Add(selectedNode, rootNode); - AddSeperator(rootNode); - } - - Add(selectedNode, rootNode); - Add(selectedNode, rootNode); - Add(selectedNode, rootNode); - - if (!isCaPack) - Add(selectedNode, rootNode); - } - } -} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/NoContextMenuBuilder.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/NoContextMenuBuilder.cs deleted file mode 100644 index 1ed78e57e..000000000 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/NoContextMenuBuilder.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Shared.Core.Events; - -namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu -{ - public class NoContextMenuBuilder : ContextMenuBuilder - { - public NoContextMenuBuilder(IUiCommandFactory commandFactory) : base(ContextMenuType.None, commandFactory) - { - - } - - protected override void Create(ContextMenuItem2 rootNode, TreeNode selectedNode) { } - } -} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/SimpleContextMenuBuilder.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/SimpleContextMenuBuilder.cs deleted file mode 100644 index 91943af1a..000000000 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/SimpleContextMenuBuilder.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Shared.Core.Events; -using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; - -namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu -{ - public class SimpleContextMenuBuilder : ContextMenuBuilder - { - public SimpleContextMenuBuilder(IUiCommandFactory commandFactory) : base(ContextMenuType.Simple, commandFactory) - { - } - - protected override void Create(ContextMenuItem2 rootNode, TreeNode selectedNode) - { - var nodeType = selectedNode.NodeType; - if (nodeType == NodeType.File) - return; - - Add(selectedNode, rootNode); - Add(selectedNode, rootNode); - AddSeperator(rootNode); - Add(selectedNode, rootNode); - } - } -} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/EditFileNameDialog.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/EditFileNameDialog.cs deleted file mode 100644 index fd79fe496..000000000 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/EditFileNameDialog.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; -using System.IO; -using CommonControls.BaseDialogs; -using MessageBox = System.Windows.MessageBox; - -namespace Shared.Ui.BaseDialogs.PackFileTree -{ - /// - /// Shows simple value entry dialog, for file/folder names - /// Shows error boxes on invalid input, asks the user to retry, and shows the dialog again - /// TODO: Make neater solution? Edit-in-place; make own simple value editing dialog, with checking - /// - public class EditFileNameDialog - { - /// - /// Shows dialog for entering a new folder name - /// - /// Parent Node for checking for folders with identical names - /// The Value of the property to edit - /// Edit Name, empty string if user pressed "cancel" - static public string ShowDialog(TreeNode parentNode, string currentValue) - { - var isInputCorrect = false; - var dialogTextBoxValue = currentValue; - var newFileName = ""; - - while (!isInputCorrect) - { - isInputCorrect = true; - var window = new TextInputWindow("Create folder", dialogTextBoxValue, true); - - if (window.ShowDialog() == false) - return ""; // exit if "cancel" pressed - - dialogTextBoxValue = window.TextValue; // store for next dialog instance (if ínput is invalud) - - isInputCorrect = isInputCorrect && IsStringProper(window.TextValue); - - newFileName = window.TextValue.ToLower().Trim(); - - isInputCorrect = isInputCorrect && IsFileNameUniqueInFolder(parentNode, newFileName); - - isInputCorrect = isInputCorrect && AreStringCharsValidForFileName(newFileName); - } - - return newFileName; - } - - private static bool IsStringProper(string value) - { - if (string.IsNullOrWhiteSpace(value)) - { - MessageBox.Show("Folder name can not be empty! Please Try Again.\nPlease Try Again."); - return false; - } - return true; - } - - private static bool IsFileNameUniqueInFolder(TreeNode parentNode, string newFileName) - { - if (parentNode == null) - throw new ArgumentNullException(nameof(parentNode), "Parent node can not be null!"); - - foreach (var node in parentNode.Children) - { - if (node.Name == newFileName) - { - MessageBox.Show($"Folder with name '{node.Name}' already exists in this folder!\nPlease Try Again."); - return false; - } - } - - return true; - } - - static private bool AreStringCharsValidForFileName(string fileNameToCheck) - { - var listOfInvalidChars = Path.GetInvalidFileNameChars(); - foreach (var c in fileNameToCheck) - { - foreach (var invalidChar in listOfInvalidChars) - { - if (c == invalidChar) - { - MessageBox.Show($"Folder name contains invalid character: {c}. \nPlease Try Again."); - return false; - } - } - } - - return true; - } - } -} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserViewModel.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserViewModel.cs index 7c1f9475c..81db110fa 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserViewModel.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserViewModel.cs @@ -10,6 +10,7 @@ using Shared.Core.Events.Global; using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Utility; using Shared.Core.Settings; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu; using Shared.Ui.Common; @@ -22,24 +23,13 @@ namespace Shared.Ui.BaseDialogs.PackFileTree public partial class PackFileBrowserViewModel : ObservableObject, IDisposable, IDropTarget { - private sealed class PackFileTreeState - { - public TreeNodeSource RootSource { get; } - public TreeNode RootNode { get; } - - public PackFileTreeState(TreeNodeSource rootSource, TreeNode rootNode) - { - RootSource = rootSource; - RootNode = rootNode; - } - } - protected IPackFileService _packFileService; private readonly IEventHub? _eventHub; private readonly IWindowsKeyboard _windowKeyboard; private readonly ApplicationSettingsService _applicationSettingsService; - private readonly IContextMenuBuilder _contextMenuBuilder; - private readonly Dictionary _treeStates = []; + private readonly PackFileContextMenuComposer _contextMenuComposer; + private readonly ContextMenuType _contextMenuType; + private readonly Dictionary _treeRoots = []; public event FileSelectedDelegate FileOpen; public event NodeSelectedDelegate NodeSelected; @@ -48,32 +38,33 @@ public PackFileTreeState(TreeNodeSource rootSource, TreeNode rootNode) public SearchFilter Filter { get; private set; } [ObservableProperty] TreeNode _selectedItem; - [ObservableProperty] ObservableCollection _contextMenu = []; + [ObservableProperty] ObservableCollection _contextMenu = []; public bool ShowFoldersOnly { get; } - public PackFileBrowserViewModel(ApplicationSettingsService applicationSettingsService, IContextMenuBuilder contextMenuBuilder, IPackFileService packFileService, IEventHub? eventHub, IWindowsKeyboard windowKeyboard, bool showCaFiles, bool showFoldersOnly) + public PackFileBrowserViewModel(ApplicationSettingsService applicationSettingsService, PackFileContextMenuComposer contextMenuComposer, ContextMenuType contextMenuType, IPackFileService packFileService, IEventHub? eventHub, IWindowsKeyboard windowKeyboard, bool showCaFiles, bool showFoldersOnly) { _packFileService = packFileService; _eventHub = eventHub; _windowKeyboard = windowKeyboard; _applicationSettingsService = applicationSettingsService; - _contextMenuBuilder = contextMenuBuilder; + _contextMenuComposer = contextMenuComposer; + _contextMenuType = contextMenuType; ShowFoldersOnly = showFoldersOnly; - _eventHub?.Register(this, MainEditablePackChanged); - _eventHub?.Register(this, PackFileContainerRemoved); - _eventHub?.Register(this, x => ReloadTree(x.Container)); - _eventHub?.Register(this, Database_PackFilesUpdated); - _eventHub?.Register(this, x => AddFiles(x.Container, x.AddedFiles)); - _eventHub?.Register(this, x => Database_PackFilesRemoved(x.Container, x.RemovedFiles)); - _eventHub?.Register(this, x => Database_PackFileFolderRemoved(x.Container, x.Folder)); - _eventHub?.Register(this, x => Database_PackFileFolderRenamed(x.Container, x.OldNodePath, x.NewNodePath)); - _eventHub?.Register(this, ContainerSaved); - - Filter = new SearchFilter(Files, () => _treeStates.Values.Select(x => x.RootSource)); - Filter.ShowFoldersOnly = ShowFoldersOnly; + _eventHub?.Register(this, OnMainEditablePackChanged); + _eventHub?.Register(this, OnPackFileContainerRemoved); + _eventHub?.Register(this, OnPackFileContainerAddedEvent); + _eventHub?.Register(this, OnPackFileContainerFilesUpdatedEvent); + _eventHub?.Register(this, x => OnPackFileContainerFilesAddedEvent(x.Container, x.AddedFiles)); + _eventHub?.Register(this, x => OnPackFileContainerFilesRemovedEvent(x.Container, x.RemovedFiles)); + _eventHub?.Register(this, x => OnPackFileContainerFolderRemovedEvent(x.Container, x.Folder)); + _eventHub?.Register(this, x => OnPackFileContainerFolderRenamedEvent(x.Container, x.OldNodePath, x.NewNodePath)); + _eventHub?.Register(this, OnPackFileContainerSavedEvent); + + Filter = new SearchFilter(Files, () => _treeRoots.Values); + Filter.ShowFoldersOnly = showFoldersOnly; foreach (var item in _packFileService.GetAllPackfileContainers()) { var loadFile = true; @@ -88,39 +79,37 @@ public PackFileBrowserViewModel(ApplicationSettingsService applicationSettingsSe public void Dispose() { _eventHub?.UnRegister(this); + Filter?.Dispose(); } partial void OnSelectedItemChanged(TreeNode value) { - ContextMenu = _contextMenuBuilder.Build(value); + ContextMenu = _contextMenuComposer.Build(_contextMenuType, value); NodeSelected?.Invoke(_selectedItem); } - private void Database_PackFileFolderRemoved(IPackFileContainer container, string folder) + private void OnPackFileContainerAddedEvent(PackFileContainerAddedEvent e) => ReloadTree(e.Container); + + private void OnPackFileContainerFolderRemovedEvent(IPackFileContainer container, string folder) { - var state = GetPackFileTreeState(container); - var root = state.RootNode; - var nodeToDelete = GetNodeFromPath(state.RootSource, folder, false); + var root = GetRootNode(container); + var nodeToDelete = GetNodeFromPath(root, folder, false); if (nodeToDelete == null) return; - var materializedFolder = nodeToDelete.MaterializedNode; - nodeToDelete.Parent?.RemoveChild(nodeToDelete); - if (materializedFolder != null) - { - materializedFolder.Parent?.Children.Remove(materializedFolder); - materializedFolder.RemoveSelf(); - } + var parentNode = nodeToDelete.Parent; + parentNode?.RemoveChild(nodeToDelete); + parentNode?.Children.Remove(nodeToDelete); + nodeToDelete.RemoveSelf(); - state.RootSource.UnsavedChanged = true; root.UnsavedChanged = true; Filter.Reapply(); } - private void Database_PackFileFolderRenamed(IPackFileContainer container, string oldPath, string newPath) + private void OnPackFileContainerFolderRenamedEvent(IPackFileContainer container, string oldPath, string newPath) { - var state = GetPackFileTreeState(container); - var node = GetNodeFromPath(state.RootSource, oldPath, false); + var root = GetRootNode(container); + var node = GetNodeFromPath(root, oldPath, false); if (node == null) return; @@ -134,34 +123,20 @@ private void Database_PackFileFolderRenamed(IPackFileContainer container, string Filter.Reapply(); } - private void ContainerSaved(PackFileContainerSavedEvent e) + private void OnPackFileContainerSavedEvent(PackFileContainerSavedEvent e) { - var state = GetPackFileTreeState(e.Container); - var root = state.RootNode; + var root = GetRootNode(e.Container); - ClearUnsavedOnLoadedSourceNodes(state.RootSource); + TreeNodeStateHelper.ClearUnsavedOnLoadedNodes(root); - root.UnsavedChanged = false; root.ForeachNode((node) => node.UnsavedChanged = false); Filter.Reapply(); } - private static void ClearUnsavedOnLoadedSourceNodes(TreeNodeSource node) + private void OnPackFileContainerFilesRemovedEvent(IPackFileContainer container, List files) { - node.UnsavedChanged = false; - if (!node.ChildrenLoaded) - return; - - foreach (var child in node.Children) - ClearUnsavedOnLoadedSourceNodes(child); - } - - private void Database_PackFilesRemoved(IPackFileContainer container, List files) - { - var state = GetPackFileTreeState(container); - var root = state.RootNode; + var root = GetRootNode(container); root.UnsavedChanged = true; - state.RootSource.UnsavedChanged = true; foreach (var file in files) { @@ -169,26 +144,21 @@ private void Database_PackFilesRemoved(IPackFileContainer container, List files) + private void OnPackFileContainerFilesAddedEvent(IPackFileContainer container, List files) { - var state = GetPackFileTreeState(container); - var root = state.RootNode; + var root = GetRootNode(container); root.UnsavedChanged = true; - state.RootSource.UnsavedChanged = true; foreach (var item in files) { @@ -271,35 +239,33 @@ private void AddFiles(IPackFileContainer container, List files) var directoryEnd = fullPath.LastIndexOf(Path.DirectorySeparatorChar); - // Check if alreayd added - this happens moving files. - - TreeNodeSource newNode; + TreeNode newNode; if (numSeperators == 0) { - RemoveExistingFileNode(state.RootSource, item.Name, item); - newNode = new TreeNodeSource(item.Name, NodeType.File, container, state.RootSource, item); - InsertChildSorted(state.RootSource, newNode); + // EnsureChildrenPopulated runs inside InsertChildSorted and may load the + // just-added file from the container, so remove any duplicate after population. + root.EnsureChildrenPopulated(); + TreeNodeManipulationHelper.RemoveExistingFileNode(root, item.Name, item); + newNode = new TreeNode(item.Name, NodeType.File, container, root, item); + TreeNodeManipulationHelper.InsertChildSorted(root, newNode); } else { var directory = fullPath.Substring(0, directoryEnd); - var folder = GetNodeFromPath(state.RootSource, directory)!; - newNode = new TreeNodeSource(item.Name, NodeType.File, container, folder, item); - - // remove any existing files with same name - var existingFile = folder.Children.FirstOrDefault(node => node.Name == item.Name && node.NodeType == NodeType.File); - if (existingFile != null) - { - folder.RemoveChild(existingFile); - existingFile.MaterializedNode?.RemoveSelf(); - } - - InsertChildSorted(folder, newNode); + var folder = GetNodeFromPath(root, directory)!; + + // Populate the folder first so that any duplicate loaded from the container + // by EnsureChildrenPopulated (inside InsertChildSorted) is visible here. + folder.EnsureChildrenPopulated(); + TreeNodeManipulationHelper.RemoveExistingFileNode(folder, item.Name, item); + + newNode = new TreeNode(item.Name, NodeType.File, container, folder, item); + TreeNodeManipulationHelper.InsertChildSorted(folder, newNode); } newNode.UnsavedChanged = true; var parent = newNode.Parent; - while (parent != null && parent != state.RootSource) + while (parent != null && parent != root) { parent.UnsavedChanged = true; parent = parent.Parent; @@ -309,76 +275,7 @@ private void AddFiles(IPackFileContainer container, List files) Filter.Reapply(); } - public TreeNode? GetFromPath(TreeNode parent, string path) - { - if (parent.Source != null) - { - var sourceNode = GetAnyNodeFromPath(parent.Source, path); - if (sourceNode == null) - return null; - - if (sourceNode.NodeType == NodeType.File) - return GetFromPathMaterialized(parent, path); - - return sourceNode.MaterializedNode; - } - - var numSeperators = path.Count(x => x == Path.DirectorySeparatorChar); - if (path.Length == 0) - return parent; - - var nodeName = path; - var remainingStr = ""; - - if (numSeperators != 0) - { - var currentIndex = path.IndexOf(Path.DirectorySeparatorChar, 0); - nodeName = path.Substring(0, currentIndex); - remainingStr = path.Substring(currentIndex + 1); - } - - foreach (var child in parent.Children) - { - if (child.Name == nodeName) - return GetFromPath(child, remainingStr); - } - - return null; - } - - private TreeNode? GetFromPathMaterialized(TreeNode parent, string path) - { - if (path.Length == 0) - return parent; - - var separatorIndex = path.IndexOf(Path.DirectorySeparatorChar); - var nodeName = separatorIndex == -1 ? path : path.Substring(0, separatorIndex); - var remainingPath = separatorIndex == -1 ? string.Empty : path.Substring(separatorIndex + 1); - - parent.EnsureChildrenLoaded(); - var child = parent.Children.FirstOrDefault(x => x.Name == nodeName); - return child == null ? null : GetFromPathMaterialized(child, remainingPath); - } - - private static TreeNodeSource? GetAnyNodeFromPath(TreeNodeSource parent, string path) - { - if (path.Length == 0) - return parent; - - parent.EnsureChildrenPopulated(); - - var separatorIndex = path.IndexOf(Path.DirectorySeparatorChar); - if (separatorIndex == -1) - return parent.Children.FirstOrDefault(x => x.Name == path); - - var nodeName = path.Substring(0, separatorIndex); - var remainingPath = path.Substring(separatorIndex + 1); - - var childDirectory = parent.Children.FirstOrDefault(x => x.Name == nodeName && x.NodeType == NodeType.Directory); - return childDirectory == null ? null : GetAnyNodeFromPath(childDirectory, remainingPath); - } - - private TreeNodeSource? GetNodeFromPath(TreeNodeSource parent, string path, bool createIfMissing = true) + private TreeNode? GetNodeFromPath(TreeNode parent, string path, bool createIfMissing = true) { var numSeperators = path.Count(x => x == Path.DirectorySeparatorChar); if (path.Length == 0) @@ -396,7 +293,7 @@ private void AddFiles(IPackFileContainer container, List files) remainingStr = path.Substring(currentIndex + 1); } - foreach (var child in parent.Children) + foreach (var child in parent.BackingChildren) { if (child.Name == nodeName && child.NodeType == NodeType.Directory) return GetNodeFromPath(child, remainingStr, createIfMissing); @@ -404,18 +301,17 @@ private void AddFiles(IPackFileContainer container, List files) if (createIfMissing) { - var newNode = new TreeNodeSource(nodeName, NodeType.Directory, parent.FileOwner, parent); + var newNode = new TreeNode(nodeName, NodeType.Directory, parent.FileOwner, parent, () => Filter.HasActiveFilter); newNode.MarkChildrenLoaded(); - InsertChildSorted(parent, newNode); + TreeNodeManipulationHelper.InsertChildSorted(parent, newNode); return GetNodeFromPath(newNode, remainingStr, createIfMissing); } return null; } - private TreeNodeSource? GetNodeFromPackFile(IPackFileContainer container, PackFile pf, bool createIfMissing = true) + private TreeNode? GetNodeFromPackFile(IPackFileContainer container, PackFile pf, bool createIfMissing = true) { - var state = GetPackFileTreeState(container); - var root = state.RootSource; + var root = GetRootNode(container); var fullPath = _packFileService.GetFullPath(pf, container); var numSeperators = fullPath.Count(x => x == Path.DirectorySeparatorChar); @@ -423,7 +319,7 @@ private void AddFiles(IPackFileContainer container, List files) if (numSeperators == 0) { - return root.Children.FirstOrDefault(x => x.Item == pf); + return root.BackingChildren.FirstOrDefault(x => x.Item == pf); } else { @@ -431,84 +327,82 @@ private void AddFiles(IPackFileContainer container, List files) var directory = fullPath.Substring(0, directoryEnd); var parent = GetNodeFromPath(root, directory, createIfMissing); - return parent?.Children.FirstOrDefault(x => x.Item == pf); + return parent?.BackingChildren.FirstOrDefault(x => x.Item == pf); } } private void ReloadTree(IPackFileContainer container) { - if (_treeStates.TryGetValue(container, out var existingState)) + if (_treeRoots.TryGetValue(container, out var existingRoot)) { - Files.Remove(existingState.RootNode); - _treeStates.Remove(container); + Files.Remove(existingRoot); + _treeRoots.Remove(container); } var skipWemFiles = container.IsCaPackFile && _applicationSettingsService.CurrentSettings.ShowCAWemFiles == false; - var rootSource = new TreeNodeSource(container.Name, NodeType.Root, container, null); - rootSource.SetChildLoader(node => LoadChildrenFromContainer(node, container, skipWemFiles)); - - var root = CreateTreeNode(rootSource, null); + var root = new TreeNode(container.Name, NodeType.Root, container, null, () => Filter.HasActiveFilter); + root.SetChildLoader(node => LoadChildrenFromContainer(node, container, skipWemFiles)); root.IsMainEditabelPack = _packFileService.GetEditablePack() == container; - _treeStates[container] = new PackFileTreeState(rootSource, root); + _treeRoots[container] = root; Files.Add(root); Filter.Reapply(); } - private bool LoadChildrenFromContainer(TreeNodeSource node, IPackFileContainer container, bool skipWemFiles) + private bool LoadChildrenFromContainer(TreeNode node, IPackFileContainer container, bool skipWemFiles) { var directoryPath = node.GetFullPath(); - var content = container.GetDirectoryContent(directoryPath); + var split = PackFileServiceUtility.SplitDirectoryEntries(container, directoryPath); - foreach (var folderName in content.SubFolders) + foreach (var folderName in split.SubFolders) { - var childNode = new TreeNodeSource(folderName, NodeType.Directory, container, node); + var childNode = new TreeNode(folderName, NodeType.Directory, container, node, () => Filter.HasActiveFilter); childNode.SetChildLoader(n => LoadChildrenFromContainer(n, container, skipWemFiles)); node.AddChild(childNode); } - foreach (var (fileName, file) in content.Files) + foreach (var fileEntry in split.Files) { + var fileName = fileEntry.FileName; + var file = fileEntry.File; if (skipWemFiles && fileName.EndsWith(".wem", StringComparison.OrdinalIgnoreCase)) continue; - var fileNode = new TreeNodeSource(fileName, NodeType.File, container, node, file); + var fileNode = new TreeNode(fileName, NodeType.File, container, node, file); node.AddChild(fileNode); } return true; } - private void PackFileContainerRemoved(PackFileContainerRemovedEvent e) + private void OnPackFileContainerRemoved(PackFileContainerRemovedEvent e) { - if (_treeStates.TryGetValue(e.Container, out var state)) + if (_treeRoots.TryGetValue(e.Container, out var root)) { - Files.Remove(state.RootNode); - _treeStates.Remove(e.Container); + Files.Remove(root); + _treeRoots.Remove(e.Container); } } - - - public bool AllowDrop(TreeNode node, TreeNode targetNode = null) + public bool AllowDrop(TreeNode node, TreeNode? targetNode = null) { - if (node.Item == null) // dragging a folder not supported + if (node.Item == null) return false; - if (node.FileOwner != targetNode.FileOwner) // dragging between different packs not supported + if (node.FileOwner != targetNode.FileOwner) return false; - if (node.FileOwner.IsCaPackFile) // dragging inside CA pack not supported + if (node.FileOwner.IsCaPackFile) return false; - if (targetNode.Item != null) // dragging file onto a file not supported + if (targetNode.Item != null) return false; return true; } - public bool Drop(TreeNode node, TreeNode targeNode) + public bool Drop(TreeNode node, TreeNode? targeNode) { var container = node.FileOwner; var draggedFile = node.Item; @@ -523,41 +417,9 @@ public bool Drop(TreeNode node, TreeNode targeNode) return true; } - private PackFileTreeState GetPackFileTreeState(IPackFileContainer container) - { - return _treeStates[container]; - } - - private TreeNode CreateTreeNode(TreeNodeSource source, TreeNode? parent) - { - return new TreeNode(source, parent, CreateTreeNode, () => Filter.HasActiveFilter); - } - - private static readonly Comparison TreeNodeSourceComparison = (left, right) => + private TreeNode GetRootNode(IPackFileContainer container) { - var nodeTypeComparison = left.NodeType.CompareTo(right.NodeType); - if (nodeTypeComparison != 0) - return nodeTypeComparison; - - return StringComparer.CurrentCultureIgnoreCase.Compare(left.Name, right.Name); - }; - - private static void InsertChildSorted(TreeNodeSource parent, TreeNodeSource child) - { - parent.EnsureChildrenPopulated(); - parent.AddChild(child); - parent.Children.Sort(TreeNodeSourceComparison); - } - - private static void RemoveExistingFileNode(TreeNodeSource parent, string fileName, PackFile packFile) - { - parent.EnsureChildrenPopulated(); - var existingFile = parent.Children.FirstOrDefault(node => node.NodeType == NodeType.File && (node.Item == packFile || node.Name == fileName)); - if (existingFile == null) - return; - - parent.RemoveChild(existingFile); - existingFile.MaterializedNode?.RemoveSelf(); + return _treeRoots[container]; } } } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileTreeViewFactory.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileTreeViewFactory.cs index afd9689e3..dfa79f7b5 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileTreeViewFactory.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileTreeViewFactory.cs @@ -11,22 +11,21 @@ public class PackFileTreeViewFactory private readonly ApplicationSettingsService _applicationSettingsService; private readonly IPackFileService _packFileService; private readonly IEventHub _eventHub; - private readonly ContextMenuFactory _contextMenuFactory; + private readonly PackFileContextMenuComposer _contextMenuComposer; private readonly IWindowsKeyboard _windowKeyboard; - public PackFileTreeViewFactory(ApplicationSettingsService applicationSettingsService, IPackFileService packFileService, IEventHub eventHub, ContextMenuFactory contextMenuFactory, IWindowsKeyboard windowKeyboard) + public PackFileTreeViewFactory(ApplicationSettingsService applicationSettingsService, IPackFileService packFileService, IEventHub eventHub, PackFileContextMenuComposer contextMenuComposer, IWindowsKeyboard windowKeyboard) { _applicationSettingsService = applicationSettingsService; _packFileService = packFileService; _eventHub = eventHub; - _contextMenuFactory = contextMenuFactory; + _contextMenuComposer = contextMenuComposer; _windowKeyboard = windowKeyboard; } public PackFileBrowserViewModel Create(ContextMenuType contextMenu, bool showCaFiles, bool showFoldersOnly) { - var contextMenuBuilder = _contextMenuFactory.GetContextMenu(contextMenu); - var fileTree = new PackFileBrowserViewModel(_applicationSettingsService, contextMenuBuilder, _packFileService, _eventHub, _windowKeyboard, showCaFiles, showFoldersOnly); + var fileTree = new PackFileBrowserViewModel(_applicationSettingsService, _contextMenuComposer, contextMenu, _packFileService, _eventHub, _windowKeyboard, showCaFiles, showFoldersOnly); return fileTree; } } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/SearchFilter.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/SearchFilter.cs index 31948142b..de2fca6ec 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/SearchFilter.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/SearchFilter.cs @@ -1,20 +1,27 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq; -using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; using Shared.Core.Misc; +using Shared.Core.PackFiles.Models; namespace Shared.Ui.BaseDialogs.PackFileTree { - public class SearchFilter : NotifyPropertyChangedImpl, IDataErrorInfo + public class SearchFilter : NotifyPropertyChangedImpl, IDataErrorInfo, IDisposable { public string Error { get; set; } = string.Empty; - public string this[string columnName] => Filter(FilterText); + public string this[string columnName] => ApplyFilter(FilterText); private readonly ObservableCollection _nodeCollection; - private readonly Func> _sourceRootsFactory; + private readonly Func> _rootNodesFactory; + private readonly List _nodesPopulatedByFilter = []; + + private CancellationTokenSource? _debounceCts; + private const int DebounceMilliseconds = 250; + private bool _useDebounce; string _filterText = ""; public string FilterText @@ -23,10 +30,19 @@ public string FilterText set { SetAndNotify(ref _filterText, value); - Filter(_filterText); + if (_useDebounce) + DebounceFilter(); + else + ApplyFilter(_filterText); } } + public bool UseDebounce + { + get => _useDebounce; + set => _useDebounce = value; + } + private bool _showFoldersOnly; public bool ShowFoldersOnly { @@ -34,62 +50,85 @@ public bool ShowFoldersOnly set { SetAndNotify(ref _showFoldersOnly, value); - Filter(FilterText); + ApplyFilter(FilterText); } } - List _extensionFilter; + List? _extensionFilter; public int AutoExapandResultsAfterLimitedCount { get; set; } = 25; public bool HasActiveFilter => !string.IsNullOrWhiteSpace(FilterText) || ShowFoldersOnly || (_extensionFilter?.Count > 0); public Action? FilterCleared { get; set; } - internal SearchFilter(ObservableCollection nodes, Func> sourceRootsFactory) + internal SearchFilter(ObservableCollection nodes, Func> rootNodesFactory) { _nodeCollection = nodes; - _sourceRootsFactory = sourceRootsFactory; + _rootNodesFactory = rootNodesFactory; } - string Filter(string text) + private async void DebounceFilter() { - Regex expression = null; + _debounceCts?.Cancel(); + _debounceCts?.Dispose(); + _debounceCts = new CancellationTokenSource(); + var token = _debounceCts.Token; + try { - expression = new Regex(text, RegexOptions.Compiled | RegexOptions.IgnoreCase); + await Task.Delay(DebounceMilliseconds, token); + if (!token.IsCancellationRequested) + ApplyFilter(FilterText); } - catch (Exception e) + catch (TaskCanceledException) { - return e.Message; + // Debounce cancelled — next keystroke will restart } + } - var rootSources = _sourceRootsFactory().ToList(); + string ApplyFilter(string text) + { + var rootNodes = _rootNodesFactory().ToList(); + + // Reset any nodes we force-populated in a previous filter pass + ResetFilterPopulatedNodes(); + + // Update child visibility predicate on all roots + Func? childPredicate = ShowFoldersOnly + ? (child => child.NodeType != NodeType.File) + : null; + foreach (var rootNode in rootNodes) + SetChildVisibilityPredicateRecursive(rootNode, childPredicate); - // Force-populate all source children when an active filter requires full traversal if (HasActiveFilter) { - foreach (var item in rootSources) - item.EnsureFullyPopulated(); - } + var textFilter = string.IsNullOrWhiteSpace(text) ? null : text; + var hasSearchFilter = textFilter != null || (_extensionFilter?.Count > 0); - foreach (var item in rootSources) - HasChildWithFilterMatch(item, expression); + if (hasSearchFilter) + { + foreach (var rootNode in rootNodes) + { + var container = rootNode.FileOwner; + var matchingFiles = container.SearchFiles(textFilter, _extensionFilter); - if (ShowFoldersOnly) - { - foreach (var node in rootSources) - ApplyFoldersOnlyFilter(node); - } + RebuildTreeFromSearchResults(rootNode, matchingFiles); + } + } - foreach (var rootNode in _nodeCollection) - rootNode.RefreshLoadedBranch(); + if (ShowFoldersOnly) + { + foreach (var rootNode in rootNodes) + ApplyFoldersOnlyFilter(rootNode); + } - if (AutoExapandResultsAfterLimitedCount != -1) - { - var visibleNodes = 0; - foreach (var item in rootSources) - visibleNodes += CountVisibleNodes(item); + foreach (var rootNode in _nodeCollection) + rootNode.RefreshLoadedBranch(); - if (HasActiveFilter) + if (AutoExapandResultsAfterLimitedCount != -1) { + var visibleNodes = 0; + foreach (var item in rootNodes) + visibleNodes += CountVisibleNodes(item); + foreach (var item in _nodeCollection) { item.ExpandForFilter(); @@ -102,16 +141,23 @@ string Filter(string text) item.ExpandIfVisible(markAsFilterExpansion: true); } } - else - { - foreach (var item in _nodeCollection) - { - item.AbsorbFilterExpansion(); - item.NormalizeLazyState(); - } + } + else + { + // Filter cleared — restore lazy tree state + foreach (var rootNode in rootNodes) + RestoreFullTree(rootNode); - FilterCleared?.Invoke(); + foreach (var rootNode in _nodeCollection) + rootNode.RefreshLoadedBranch(); + + foreach (var item in _nodeCollection) + { + item.AbsorbFilterExpansion(); + item.NormalizeLazyState(); } + + FilterCleared?.Invoke(); } return ""; @@ -119,82 +165,177 @@ string Filter(string text) public void Reapply() { - Filter(FilterText); + ApplyFilter(FilterText); } - private static void ApplyFoldersOnlyFilter(TreeNodeSource node) + public void SetExtensions(List extentions) { - if (node.NodeType == NodeType.File) - node.IsVisible = false; - else + _extensionFilter = extentions; + ApplyFilter(FilterText); + } + + private void RebuildTreeFromSearchResults(TreeNode rootNode, List<(string Path, PackFile File)> matchingFiles) + { + // Mark all currently-loaded nodes as not visible + SetVisibilityRecursive(rootNode, false); + rootNode.IsVisible = true; + + if (matchingFiles.Count == 0) + return; + + // Build the visible tree directly from paths — no container calls + foreach (var (path, file) in matchingFiles) { - node.IsVisible = true; - foreach (var child in node.Children) - ApplyFoldersOnlyFilter(child); + MarkPathVisibleFromData(rootNode, path, file); } } - private static int CountVisibleNodes(TreeNodeSource file) + private void MarkPathVisibleFromData(TreeNode rootNode, string filePath, PackFile file) { - if (file.NodeType == NodeType.File && file.IsVisible) - return 1; + var current = rootNode; + var segments = filePath.Split('\\'); - var count = 0; - foreach (var child in file.Children) - count += CountVisibleNodes(child); + // Navigate directory segments, creating nodes from path data if not loaded + for (var i = 0; i < segments.Length - 1; i++) + { + PopulateDirectoryIfNeeded(current, rootNode.FileOwner); + current.IsVisible = true; - return count; + var segmentName = segments[i]; + var child = current.BackingChildren.FirstOrDefault( + c => c.Name.Equals(segmentName, StringComparison.OrdinalIgnoreCase) && c.NodeType == NodeType.Directory); + + if (child == null) + { + // Create the directory node from path data + child = new TreeNode(segmentName, NodeType.Directory, rootNode.FileOwner, current); + current.AddChild(child); + } + + current = child; + } + + // Handle the final directory containing the file + PopulateDirectoryIfNeeded(current, rootNode.FileOwner); + current.IsVisible = true; + + // Find or create the file node + var fileName = segments[^1]; + var fileNode = current.BackingChildren.FirstOrDefault( + c => c.Name.Equals(fileName, StringComparison.OrdinalIgnoreCase) && c.NodeType == NodeType.File); + + if (fileNode == null) + { + fileNode = new TreeNode(fileName, NodeType.File, rootNode.FileOwner, current, file); + current.AddChild(fileNode); + } + + fileNode.IsVisible = true; } - public void SetExtensions(List extentions) + /// + /// If this node hasn't been populated yet (either by lazy-load or by a previous filter pass), + /// mark it as populated so we can add children to it. Track it for reset on filter clear. + /// + private void PopulateDirectoryIfNeeded(TreeNode node, IPackFileContainer container) { - _extensionFilter = extentions; - Filter(FilterText); + if (node.ChildrenLoaded) + { + // Already loaded (either from container or previous filter) — hide new children by default + foreach (var child in node.BackingChildren) + { + if (child.IsVisible) + continue; // already processed in a previous iteration + } + return; + } + + // Mark as loaded so we can add children directly. Track for reset. + node.MarkChildrenLoaded(); + _nodesPopulatedByFilter.Add(node); } - private bool HasChildWithFilterMatch(TreeNodeSource file, Regex expression) + /// + /// Resets nodes that were force-populated during filtering back to unloaded state. + /// This ensures the normal lazy-load from container works correctly when the filter changes/clears. + /// + private void ResetFilterPopulatedNodes() { - if (file.NodeType == NodeType.Root && file.Children.Count == 0) + foreach (var node in _nodesPopulatedByFilter) { - file.IsVisible = true; - return true; + node.BackingChildren.Clear(); + node.ResetChildrenLoaded(); } - if (file.NodeType == NodeType.File) + _nodesPopulatedByFilter.Clear(); + } + + private static void SetVisibilityRecursive(TreeNode node, bool visible) + { + node.IsVisible = visible; + if (node.ChildrenLoaded) { - var hasValidExtention = true; - if (_extensionFilter != null) - { - hasValidExtention = false; - foreach (var extention in _extensionFilter) - { - if (file.Name.Contains(extention)) - { - hasValidExtention = true; - continue; - } - } - } + foreach (var child in node.BackingChildren) + SetVisibilityRecursive(child, visible); + } + } - if (hasValidExtention) - { - if (expression.IsMatch(file.Name)) - { - file.IsVisible = true; - return true; - } - } + private static void RestoreFullTree(TreeNode node) + { + node.IsVisible = true; + if (node.ChildrenLoaded) + { + foreach (var child in node.BackingChildren) + RestoreFullTree(child); + } + } + + private static void ApplyFoldersOnlyFilter(TreeNode node) + { + if (node.NodeType == NodeType.File) + { + node.IsVisible = false; + return; + } + + node.IsVisible = true; + if (node.ChildrenLoaded) + { + foreach (var child in node.BackingChildren) + ApplyFoldersOnlyFilter(child); + } + } + + private static int CountVisibleNodes(TreeNode node) + { + if (node.NodeType == NodeType.File && node.IsVisible) + return 1; + + var count = 0; + if (node.ChildrenLoaded) + { + foreach (var child in node.BackingChildren) + count += CountVisibleNodes(child); } - var hasChildMatch = false; - foreach (var child in file.Children) + return count; + } + + private static void SetChildVisibilityPredicateRecursive(TreeNode node, Func? predicate) + { + node.SetChildVisibilityPredicate(predicate); + if (node.ChildrenLoaded) { - if (HasChildWithFilterMatch(child, expression)) - hasChildMatch = true; + foreach (var child in node.BackingChildren) + SetChildVisibilityPredicateRecursive(child, predicate); } + } - file.IsVisible = hasChildMatch; - return hasChildMatch; + public void Dispose() + { + _debounceCts?.Cancel(); + _debounceCts?.Dispose(); + _debounceCts = null; } } } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNode.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNode.cs index bec939cc1..44512d23d 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNode.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNode.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; @@ -15,22 +15,24 @@ public enum NodeType File } - /// - /// Represents the materialized UI node shown in the pack-file tree. - /// This class is optimized for WPF binding/state (expanded, visible, selected, unsaved changes) - /// and can be created lazily from a when branches are expanded or queried. - /// public partial class TreeNode : ObservableObject { - private readonly TreeNodeSource? _source; - private readonly Func? _nodeFactory; - private readonly Func? _isFilterActive; + private Func? _childLoader; + private bool _childrenLoaded; private bool _isExpandedByFilter; private readonly bool _isPlaceholder; + private readonly Func? _isFilterActive; + private Func? _childVisibilityPredicate; public IPackFileContainer FileOwner { get; private set; } public PackFile? Item { get; set; } public TreeNode? Parent { get; set; } + public List BackingChildren { get; } = []; + + public bool HasChildren => NodeType == NodeType.Directory + ? (BackingChildren.Count > 0 || !_childrenLoaded) + : (BackingChildren.Count > 0 || (!_childrenLoaded && _childLoader != null && NodeType != NodeType.File)); + public bool ChildrenLoaded => _childrenLoaded; [ObservableProperty] public partial ObservableCollection Children { get; set; } = []; [ObservableProperty] public partial bool UnsavedChanged { get; set; } @@ -40,36 +42,34 @@ public partial class TreeNode : ObservableObject [ObservableProperty] public partial bool IsNodeExpanded { get; set; } = false; [ObservableProperty] public partial NodeType NodeType { get; private set; } - internal TreeNodeSource? Source => _source; internal bool HasMaterializedChildren => Children.Any(x => x._isPlaceholder == false); - public TreeNode(string name, NodeType type, IPackFileContainer ower, TreeNode? parent, PackFile? packFile = null) + public TreeNode(string name, NodeType type, IPackFileContainer owner, TreeNode? parent, PackFile? packFile = null) { Name = name; Item = packFile; - FileOwner = ower; + FileOwner = owner; Parent = parent; NodeType = type; if (string.IsNullOrWhiteSpace(name)) throw new Exception($"Packfile name or folder is empty '{GetFullPath()}', this is not allowed! Please report as a bug if it happens outside of packfile loading! If it happens while loading clean up the packfile in RPFM"); + + if (ShouldUsePlaceholder()) + AddPlaceholderChild(); } - internal TreeNode(TreeNodeSource source, TreeNode? parent, Func nodeFactory, Func isFilterActive) + internal TreeNode(string name, NodeType type, IPackFileContainer owner, TreeNode? parent, Func isFilterActive, PackFile? packFile = null) { - _source = source; - _nodeFactory = nodeFactory; _isFilterActive = isFilterActive; - - Name = source.Name; - Item = source.Item; - FileOwner = source.FileOwner; + Name = name; + Item = packFile; + FileOwner = owner; Parent = parent; - NodeType = source.NodeType; - UnsavedChanged = source.UnsavedChanged; - IsVisible = source.IsVisible; + NodeType = type; - source.MaterializedNode = this; + if (string.IsNullOrWhiteSpace(name)) + throw new Exception($"Packfile name or folder is empty '{GetFullPath()}', this is not allowed! Please report as a bug if it happens outside of packfile loading! If it happens while loading clean up the packfile in RPFM"); if (ShouldUsePlaceholder()) AddPlaceholderChild(); @@ -85,11 +85,55 @@ private TreeNode(TreeNode parent) IsVisible = false; } - public string GetFullPath() + public void SetChildLoader(Func childLoader) { - if (_source != null) - return _source.GetFullPath(); + _childLoader = childLoader; + } + + public void SetChildVisibilityPredicate(Func? predicate) + { + _childVisibilityPredicate = predicate; + } + + public void MarkChildrenLoaded() + { + _childrenLoaded = true; + } + + public void ResetChildrenLoaded() + { + _childrenLoaded = false; + } + public void EnsureChildrenPopulated() + { + if (_childrenLoaded || _childLoader == null || NodeType == NodeType.File) + return; + + _childrenLoaded = _childLoader(this); + } + + public void EnsureFullyPopulated() + { + EnsureChildrenPopulated(); + foreach (var child in BackingChildren) + child.EnsureFullyPopulated(); + } + + public void AddChild(TreeNode child) + { + child.Parent = this; + BackingChildren.Add(child); + } + + public void RemoveChild(TreeNode child) + { + BackingChildren.Remove(child); + child.Parent = null; + } + + public string GetFullPath() + { if (NodeType == NodeType.Root) return ""; @@ -111,38 +155,45 @@ public string GetFullPath() public List GetAllChildFileNodes() { - if (_source != null) + return EnumerateFileNodesDepthFirst().ToList(); + } + + public IEnumerable EnumerateAllNodesDepthFirst() + { + var stack = new Stack(); + stack.Push(this); + + while (stack.Count > 0) { - return _source.EnumerateFileNodesDepthFirst() - .Select(sourceNode => sourceNode.MaterializedNode ?? _nodeFactory!(sourceNode, null)) - .ToList(); - } + var current = stack.Pop(); + current.EnsureChildrenPopulated(); + yield return current; - var output = new List(); + for (var i = current.BackingChildren.Count - 1; i >= 0; i--) + stack.Push(current.BackingChildren[i]); + } + } - var nodes = new Stack(new[] { this }); - while (nodes.Any()) + public IEnumerable EnumerateFileNodesDepthFirst() + { + foreach (var node in EnumerateAllNodesDepthFirst()) { - var node = nodes.Pop(); if (node.NodeType == NodeType.File) - output.Add(node); - - foreach (var n in node.Children) - nodes.Push(n); + yield return node; } - return output; } public void RemoveSelf() { - if (_source != null) - _source.MaterializedNode = null; - foreach (var child in Children) child.RemoveSelf(); Children.Clear(); Parent = null; + + // Clear closures and delegates to break reference cycles + _childLoader = null; + _childVisibilityPredicate = null; } public void ForeachNode(Action func) @@ -166,7 +217,7 @@ public void ExpandIfVisible(bool includeChildren = true, bool markAsFilterExpans else IsNodeExpanded = true; - EnsureChildrenLoaded(); + MaterializeChildren(); if (includeChildren) { foreach (var child in Children) @@ -182,46 +233,26 @@ public void ExpandIfVisible(bool includeChildren = true, bool markAsFilterExpans public TreeNode AddDirectoryChild(string name) { - var newNode = new TreeNode(name, NodeType.Directory, FileOwner, this); - - if (_source == null) - { - Children.Add(newNode); - return newNode; - } - - _source.EnsureChildrenPopulated(); - var sourceNode = new TreeNodeSource(name, NodeType.Directory, FileOwner, _source); - sourceNode.MarkChildrenLoaded(); - _source.AddChild(sourceNode); + EnsureChildrenPopulated(); + var newNode = new TreeNode(name, NodeType.Directory, FileOwner, this, _isFilterActive ?? (() => false)); + newNode.MarkChildrenLoaded(); + InsertChildSorted(this, newNode); if (HasMaterializedChildren || IsNodeExpanded || (_isFilterActive?.Invoke() ?? false)) - EnsureChildrenLoaded(); + MaterializeChildren(); if (Children.Count == 1 && Children[0]._isPlaceholder) - EnsureChildrenLoaded(); + MaterializeChildren(); - return sourceNode.MaterializedNode ?? newNode; - } - - internal void SyncFromSource() - { - if (_source == null) - return; - - Name = _source.Name; - UnsavedChanged = _source.UnsavedChanged; - IsVisible = _source.IsVisible; + return newNode; } internal void RefreshLoadedBranch() { - SyncFromSource(); - if (!HasMaterializedChildren) return; - EnsureChildrenLoaded(); + MaterializeChildren(); foreach (var child in Children) { if (child._isPlaceholder) @@ -231,52 +262,51 @@ internal void RefreshLoadedBranch() } } - internal void EnsureChildrenLoaded() + internal void MaterializeChildren() { - if (_source == null || _nodeFactory == null) - return; + EnsureChildrenPopulated(); - _source.EnsureChildrenPopulated(); - - if (!_source.HasChildren) + if (!HasChildren) return; - var existingChildren = Children - .Where(x => x._isPlaceholder == false && x._source != null) - .ToDictionary(x => x._source!, x => x); + // Propagate the visibility predicate to newly loaded children + if (_childVisibilityPredicate != null) + { + foreach (var child in BackingChildren) + { + if (child._childVisibilityPredicate != _childVisibilityPredicate) + child._childVisibilityPredicate = _childVisibilityPredicate; + } + } - var childSources = _source.Children + var childNodes = BackingChildren .Where(ShouldMaterializeChild) .ToList(); - foreach (var removedChild in Children.Where(x => x._isPlaceholder == false && x._source != null && childSources.Contains(x._source) == false).ToList()) - { - removedChild.RemoveSelf(); - } - Children.Clear(); - foreach (var childSource in childSources) + foreach (var child in childNodes) { - if (!existingChildren.TryGetValue(childSource, out var childNode)) - childNode = _nodeFactory(childSource, this); + child.Parent = this; + Children.Add(child); - childNode.Parent = this; - childNode.SyncFromSource(); - Children.Add(childNode); + if (child.IsNodeExpanded && !child.HasMaterializedChildren) + child.MaterializeChildren(); } if (Children.Count == 0 && ShouldUsePlaceholder()) AddPlaceholderChild(); } - internal void NormalizeLazyState() + internal void EnsureChildrenLoaded() { - if (_source == null) - return; + MaterializeChildren(); + } + internal void NormalizeLazyState() + { if (IsNodeExpanded) { - EnsureChildrenLoaded(); + MaterializeChildren(); foreach (var child in Children) { if (child._isPlaceholder) @@ -331,60 +361,44 @@ internal void AbsorbFilterExpansion() partial void OnIsNodeExpandedChanged(bool value) { - if (_source == null) - return; - if (value) - EnsureChildrenLoaded(); + MaterializeChildren(); else UnloadChildren(); LogLoadedNodeCount(value); } - partial void OnNameChanged(string value) - { - if (_source != null && !_isPlaceholder) - _source.Name = value; - } - - partial void OnUnsavedChangedChanged(bool value) - { - if (_source != null && !_isPlaceholder) - _source.UnsavedChanged = value; - } - - partial void OnIsVisibleChanged(bool value) - { - if (_source != null && !_isPlaceholder) - _source.IsVisible = value; - } - private void UnloadChildren() { - if (_source == null) - return; - + // Recursively unload all children to break closure cycles foreach (var child in Children) child.RemoveSelf(); + // Also unload any backing children that weren't materialized + foreach (var child in BackingChildren.Where(c => !Children.Contains(c))) + child.RemoveSelf(); + Children.Clear(); if (ShouldUsePlaceholder()) AddPlaceholderChild(); } - private bool ShouldMaterializeChild(TreeNodeSource childSource) + private bool ShouldMaterializeChild(TreeNode child) { if (_isFilterActive?.Invoke() != true) - return true; + return _childVisibilityPredicate?.Invoke(child) ?? true; + + if (_childVisibilityPredicate != null && !_childVisibilityPredicate(child)) + return false; - return childSource.IsVisible || childSource.MaterializedNode != null; + return child.IsVisible; } private bool ShouldUsePlaceholder() { - return _source != null && _source.HasChildren && !IsNodeExpanded && (_isFilterActive?.Invoke() != true); + return HasChildren && !IsNodeExpanded && (_isFilterActive?.Invoke() != true); } private void AddPlaceholderChild() @@ -420,5 +434,21 @@ private void LogLoadedNodeCount(bool expanded) var action = expanded ? "expanded" : "collapsed"; Console.WriteLine($"[PackFileTree] Node '{Name}' {action}. Loaded nodes: {loadedNodes}"); } + + private static readonly Comparison ChildComparison = (left, right) => + { + var nodeTypeComparison = left.NodeType.CompareTo(right.NodeType); + if (nodeTypeComparison != 0) + return nodeTypeComparison; + + return StringComparer.CurrentCultureIgnoreCase.Compare(left.Name, right.Name); + }; + + private static void InsertChildSorted(TreeNode parent, TreeNode child) + { + parent.EnsureChildrenPopulated(); + parent.AddChild(child); + parent.BackingChildren.Sort(ChildComparison); + } } } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNodeSource.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNodeSource.cs deleted file mode 100644 index 118ed45a4..000000000 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNodeSource.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System; -using System.Collections.Generic; -using Shared.Core.PackFiles.Models; - -namespace Shared.Ui.BaseDialogs.PackFileTree -{ - /// - /// Represents the lightweight backing data model for the pack-file tree. - /// It stores the canonical hierarchy and metadata independent of UI state, and may optionally - /// reference a materialized when that branch has been created for display. - /// Children are loaded lazily from the container on first access of a directory/root node. - /// - internal sealed class TreeNodeSource - { - private Func? _childLoader; - private bool _childrenLoaded; - - public string Name { get; set; } - public NodeType NodeType { get; } - public IPackFileContainer FileOwner { get; } - public PackFile? Item { get; } - public TreeNodeSource? Parent { get; private set; } - public List Children { get; } = []; - public bool IsVisible { get; set; } = true; - public bool UnsavedChanged { get; set; } - public TreeNode? MaterializedNode { get; set; } - - /// - /// Returns true if this node has children (loaded or potentially unloaded). - /// - public bool HasChildren => Children.Count > 0 || (!_childrenLoaded && _childLoader != null && NodeType != NodeType.File); - - /// - /// Returns true if children have been loaded from the container. - /// - public bool ChildrenLoaded => _childrenLoaded; - - public TreeNodeSource(string name, NodeType nodeType, IPackFileContainer fileOwner, TreeNodeSource? parent, PackFile? item = null) - { - Name = name; - NodeType = nodeType; - FileOwner = fileOwner; - Parent = parent; - Item = item; - - if (string.IsNullOrWhiteSpace(name)) - throw new Exception($"Packfile name or folder is empty '{GetFullPath()}', this is not allowed! Please report as a bug if it happens outside of packfile loading! If it happens while loading clean up the packfile in RPFM"); - } - - /// - /// Sets the child loader delegate used for lazy population of children. - /// The delegate receives this node and returns true if children were loaded. - /// - public void SetChildLoader(Func childLoader) - { - _childLoader = childLoader; - } - - /// - /// Ensures children are populated. If not yet loaded and a child loader is set, loads them now. - /// - public void EnsureChildrenPopulated() - { - if (_childrenLoaded || _childLoader == null || NodeType == NodeType.File) - return; - - _childrenLoaded = _childLoader(this); - } - - /// - /// Recursively ensures all descendants are populated. - /// - public void EnsureFullyPopulated() - { - EnsureChildrenPopulated(); - foreach (var child in Children) - child.EnsureFullyPopulated(); - } - - /// - /// Marks children as already loaded (used when children are added externally). - /// - public void MarkChildrenLoaded() - { - _childrenLoaded = true; - } - - public string GetFullPath() - { - if (NodeType == NodeType.Root) - return string.Empty; - - var currentParent = Parent; - var path = Name; - while (currentParent != null) - { - if (currentParent.NodeType == NodeType.Root) - break; - - path = currentParent.Name + "\\" + path; - currentParent = currentParent.Parent; - } - - return path; - } - - public void AddChild(TreeNodeSource child) - { - child.Parent = this; - Children.Add(child); - } - - public void RemoveChild(TreeNodeSource child) - { - Children.Remove(child); - child.Parent = null; - } - - public IEnumerable EnumerateAllNodesDepthFirst() - { - var stack = new Stack(); - stack.Push(this); - - while (stack.Count > 0) - { - var current = stack.Pop(); - current.EnsureChildrenPopulated(); - yield return current; - - for (var i = current.Children.Count - 1; i >= 0; i--) - stack.Push(current.Children[i]); - } - } - - public IEnumerable EnumerateFileNodesDepthFirst() - { - foreach (var node in EnumerateAllNodesDepthFirst()) - { - if (node.NodeType == NodeType.File) - yield return node; - } - } - } -} \ No newline at end of file diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodeManipulationHelper.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodeManipulationHelper.cs new file mode 100644 index 000000000..3b68680a8 --- /dev/null +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodeManipulationHelper.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using Shared.Core.PackFiles.Models; + +namespace Shared.Ui.BaseDialogs.PackFileTree +{ + /// + /// Helper class for tree node manipulation operations including sorting and removal. + /// + public static class TreeNodeManipulationHelper + { + /// + /// Comparison function for sorting tree nodes by type and then by name. + /// + public static readonly Comparison TreeNodeComparison = (left, right) => + { + var nodeTypeComparison = left.NodeType.CompareTo(right.NodeType); + if (nodeTypeComparison != 0) + return nodeTypeComparison; + + return StringComparer.CurrentCultureIgnoreCase.Compare(left.Name, right.Name); + }; + + /// + /// Inserts a child node into the parent's children collection in sorted order. + /// + /// The parent node + /// The child node to insert + public static void InsertChildSorted(TreeNode parent, TreeNode child) + { + parent.EnsureChildrenPopulated(); + parent.AddChild(child); + parent.BackingChildren.Sort(TreeNodeComparison); + } + + /// + /// Removes an existing file node from the parent's children collection. + /// + /// The parent node + /// The name of the file to remove + /// The pack file object to match + public static void RemoveExistingFileNode(TreeNode parent, string fileName, PackFile packFile) + { + parent.EnsureChildrenPopulated(); + var existingFile = parent.BackingChildren.FirstOrDefault(node => + node.NodeType == NodeType.File && + (node.Item == packFile || node.Name == fileName)); + + if (existingFile == null) + return; + + parent.RemoveChild(existingFile); + parent.Children.Remove(existingFile); + existingFile.RemoveSelf(); + } + } +} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodePathHelper.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodePathHelper.cs new file mode 100644 index 000000000..9cefcd4fe --- /dev/null +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodePathHelper.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; + +namespace Shared.Ui.BaseDialogs.PackFileTree +{ + /// + /// Helper class for tree node path navigation and lookup operations. + /// + public static class TreeNodePathHelper + { + /// + /// Recursively searches for a node in the backing children collection by path. + /// + /// The parent node to search from + /// The path to search for (e.g., "folder1/folder2/file") + /// The found node or null if not found + public static TreeNode? FindInBackingChildren(TreeNode parent, string path) + { + if (path.Length == 0) + return parent; + + parent.EnsureChildrenPopulated(); + + var separatorIndex = path.IndexOf(System.IO.Path.DirectorySeparatorChar); + var nodeName = separatorIndex == -1 ? path : path.Substring(0, separatorIndex); + var remainingPath = separatorIndex == -1 ? string.Empty : path.Substring(separatorIndex + 1); + + var child = parent.BackingChildren.FirstOrDefault(x => x.Name == nodeName); + return child == null ? null : FindInBackingChildren(child, remainingPath); + } + + /// + /// Recursively searches for a node in the materialized children collection by path, + /// materializing children as needed. + /// + /// The parent node to search from + /// The path to search for + /// The found node or null if not found + public static TreeNode? GetFromPathViaMaterialization(TreeNode parent, string path) + { + if (path.Length == 0) + return parent; + + parent.MaterializeChildren(); + + var separatorIndex = path.IndexOf(System.IO.Path.DirectorySeparatorChar); + var nodeName = separatorIndex == -1 ? path : path.Substring(0, separatorIndex); + var remainingPath = separatorIndex == -1 ? string.Empty : path.Substring(separatorIndex + 1); + + var child = parent.Children.FirstOrDefault(x => x.Name == nodeName); + return child == null ? null : GetFromPathViaMaterialization(child, remainingPath); + } + + /// + /// Recursively searches for a node in the materialized children collection by path. + /// Only returns results that are already materialized (visible in the WPF tree). + /// + /// The parent node to search from + /// The path to search for + /// The found node or null if not found + public static TreeNode? FindInMaterializedChildren(TreeNode parent, string path) + { + if (path.Length == 0) + return parent; + + var separatorIndex = path.IndexOf(System.IO.Path.DirectorySeparatorChar); + var nodeName = separatorIndex == -1 ? path : path.Substring(0, separatorIndex); + var remainingPath = separatorIndex == -1 ? string.Empty : path.Substring(separatorIndex + 1); + + var child = parent.Children.FirstOrDefault(x => x.Name == nodeName); + return child == null ? null : FindInMaterializedChildren(child, remainingPath); + } + } +} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodeStateHelper.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodeStateHelper.cs new file mode 100644 index 000000000..cc1ce18d4 --- /dev/null +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodeStateHelper.cs @@ -0,0 +1,23 @@ +namespace Shared.Ui.BaseDialogs.PackFileTree +{ + /// + /// Helper class for tree node state management operations. + /// + public static class TreeNodeStateHelper + { + /// + /// Recursively clears the UnsavedChanged flag on a node and all its loaded children. + /// Only processes children that have already been loaded (materialized). + /// + /// The node to start clearing from + public static void ClearUnsavedOnLoadedNodes(TreeNode node) + { + node.UnsavedChanged = false; + if (!node.ChildrenLoaded) + return; + + foreach (var child in node.BackingChildren) + ClearUnsavedOnLoadedNodes(child); + } + } +} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/StandardDialogs.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/StandardDialogs.cs index 9ee375ac8..608c5631d 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/StandardDialogs.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/StandardDialogs.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Windows; using CommonControls.BaseDialogs; using CommonControls.BaseDialogs.ErrorListDialog; @@ -89,6 +91,95 @@ public BrowseDialogResultFolder DisplayBrowseFolderDialog() return output; } + public SystemOpenFileDialogResult ShowSystemOpenFileDialog(bool multiselect = false, string filter = "All files|*.*") + { + using var dialog = new System.Windows.Forms.OpenFileDialog + { + Multiselect = multiselect, + Filter = filter + }; + + if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) + return new SystemOpenFileDialogResult(true, dialog.FileNames); + + return new SystemOpenFileDialogResult(false, Array.Empty()); + } + + public SystemSaveFileDialogResult ShowSystemSaveFileDialog(string initialFileName, string filter, string defaultExt) + { + using var dialog = new System.Windows.Forms.SaveFileDialog + { + FileName = initialFileName, + Filter = filter, + DefaultExt = defaultExt + }; + + if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) + return new SystemSaveFileDialogResult(true, dialog.FileName); + + return new SystemSaveFileDialogResult(false, null); + } + + public SystemBrowseFolderDialogResult ShowSystemFolderBrowserDialog() + { + using var dialog = new System.Windows.Forms.FolderBrowserDialog(); + + if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) + return new SystemBrowseFolderDialogResult(true, dialog.SelectedPath); + + return new SystemBrowseFolderDialogResult(false, null); + } + + public string ShowFolderNameDialog(IEnumerable existingNames, string currentValue = "") + { + var isInputCorrect = false; + var dialogTextBoxValue = currentValue; + var normalizedExistingNames = new HashSet(existingNames.Select(x => x.ToLower().Trim())); + + while (!isInputCorrect) + { + isInputCorrect = true; + var window = new TextInputWindow("Create folder", dialogTextBoxValue, true); + + if (window.ShowDialog() == false) + return string.Empty; + + dialogTextBoxValue = window.TextValue; + var newFolderName = window.TextValue.ToLower().Trim(); + + if (string.IsNullOrWhiteSpace(newFolderName)) + { + System.Windows.MessageBox.Show("Folder name can not be empty! Please Try Again."); + isInputCorrect = false; + } + + if (isInputCorrect && normalizedExistingNames.Contains(newFolderName)) + { + System.Windows.MessageBox.Show($"Folder with name '{newFolderName}' already exists in this folder!\nPlease Try Again."); + isInputCorrect = false; + } + + if (isInputCorrect) + { + var listOfInvalidChars = Path.GetInvalidFileNameChars(); + foreach (var c in newFolderName) + { + if (listOfInvalidChars.Contains(c)) + { + System.Windows.MessageBox.Show($"Folder name contains invalid character: {c}. \nPlease Try Again."); + isInputCorrect = false; + break; + } + } + } + + if (isInputCorrect) + return newFolderName; + } + + return string.Empty; + } + public void ShowExceptionWindow(Exception e, string userInfo = "") { var extendedException = _exceptionService.Create(e); @@ -118,12 +209,12 @@ public TextInputDialogResult ShowTextInputDialog(string title, string initialTex public void ShowDialogBox(string message, string title) { - MessageBox.Show(message, title); + System.Windows.MessageBox.Show(message, title); } public ShowMessageBoxResult ShowYesNoBox(string message, string title) { - var result = MessageBox.Show(message, title, MessageBoxButton.YesNo); + var result = System.Windows.MessageBox.Show(message, title, MessageBoxButton.YesNo); if (result == MessageBoxResult.Yes) return ShowMessageBoxResult.OK; return ShowMessageBoxResult.Cancel; diff --git a/Shared/SharedUI/Shared.Ui/Common/AssetEditorWindow.cs b/Shared/SharedUI/Shared.Ui/Common/AssetEditorWindow.cs index de4f06de8..bc200c6d6 100644 --- a/Shared/SharedUI/Shared.Ui/Common/AssetEditorWindow.cs +++ b/Shared/SharedUI/Shared.Ui/Common/AssetEditorWindow.cs @@ -1,5 +1,6 @@ using System; using System.Windows; +using CommunityToolkit.Diagnostics; // there is a bug in visual studio code generation. Having generated classes with // Overlapping names (Shared.Ui and Editors.Shared) causes compile errors as the code @@ -28,9 +29,10 @@ public AssetEditorWindow() private void AssetEdWindow_Deactivated(object? sender, EventArgs e) { - if (AlwaysOnTop) + if (AlwaysOnTop && sender != null) { var window = (Window)sender; + Guard.IsNotNull(window, "window should not be null"); window.Topmost = true; } } diff --git a/Shared/SharedUI/Shared.Ui/Common/DataGridNumericColumn.cs b/Shared/SharedUI/Shared.Ui/Common/DataGridNumericColumn.cs index 7640b127e..dd47cbff9 100644 --- a/Shared/SharedUI/Shared.Ui/Common/DataGridNumericColumn.cs +++ b/Shared/SharedUI/Shared.Ui/Common/DataGridNumericColumn.cs @@ -1,5 +1,6 @@ using System; using System.Windows.Controls; +using CommunityToolkit.Diagnostics; namespace Shared.Ui.Common { @@ -8,6 +9,7 @@ public class DataGridNumericColumn : DataGridTextColumn protected override object PrepareCellForEdit(System.Windows.FrameworkElement editingElement, System.Windows.RoutedEventArgs editingEventArgs) { var edit = editingElement as TextBox; + Guard.IsNotNull(edit, "Casting to textbox returned null"); edit.PreviewTextInput += OnPreviewTextInput; return base.PrepareCellForEdit(editingElement, editingEventArgs); diff --git a/Shared/SharedUI/Shared.Ui/Common/IDropTarget.cs b/Shared/SharedUI/Shared.Ui/Common/IDropTarget.cs index 2f853ff9c..fa3bb95e2 100644 --- a/Shared/SharedUI/Shared.Ui/Common/IDropTarget.cs +++ b/Shared/SharedUI/Shared.Ui/Common/IDropTarget.cs @@ -1,18 +1,14 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -namespace Shared.Ui.Common +namespace Shared.Ui.Common { public interface IDropTarget { - bool AllowDrop(T node, T targeNode = default); - bool Drop(T node, T targeNode = default); + bool AllowDrop(T node, T? targeNode = default); + bool Drop(T node, T? targeNode = default); } public interface IDropTarget { - bool AllowDrop(T node, T targeNode = default, U additionalData = default); - bool Drop(T node, T targeNode = default, U additionalData = default); + bool AllowDrop(T node, T? targeNode = default, U? additionalData = default); + bool Drop(T node, T? targeNode = default, U? additionalData = default); } } diff --git a/Shared/SharedUI/Shared.Ui/Common/MenuSystem/ActionHotkeyHandler.cs b/Shared/SharedUI/Shared.Ui/Common/MenuSystem/ActionHotkeyHandler.cs index d181b86b3..cb1efbd6c 100644 --- a/Shared/SharedUI/Shared.Ui/Common/MenuSystem/ActionHotkeyHandler.cs +++ b/Shared/SharedUI/Shared.Ui/Common/MenuSystem/ActionHotkeyHandler.cs @@ -1,15 +1,11 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Collections.Generic; +using System.Collections.Generic; using System.Windows.Input; namespace Shared.Ui.Common.MenuSystem { public class ActionHotkeyHandler { - List _actions = new List(); + readonly List _actions = new(); public void Register(MenuAction action) { @@ -34,6 +30,5 @@ public bool TriggerCommand(Key key, ModifierKeys modifierKeys) return isHandled; } - } } diff --git a/Shared/SharedUI/Shared.Ui/Common/MenuSystem/Hotkey.cs b/Shared/SharedUI/Shared.Ui/Common/MenuSystem/Hotkey.cs index 3a56ce003..a1eda74fe 100644 --- a/Shared/SharedUI/Shared.Ui/Common/MenuSystem/Hotkey.cs +++ b/Shared/SharedUI/Shared.Ui/Common/MenuSystem/Hotkey.cs @@ -1,8 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Windows.Input; +using System.Windows.Input; namespace Shared.Ui.Common.MenuSystem { diff --git a/Shared/SharedUI/Shared.Ui/Common/MenuSystem/MenuBarButton.cs b/Shared/SharedUI/Shared.Ui/Common/MenuSystem/MenuBarButton.cs index fb2e2eb6b..221dee5e4 100644 --- a/Shared/SharedUI/Shared.Ui/Common/MenuSystem/MenuBarButton.cs +++ b/Shared/SharedUI/Shared.Ui/Common/MenuSystem/MenuBarButton.cs @@ -1,8 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Windows.Media.Imaging; +using System.Windows.Media.Imaging; using Shared.Core.Misc; namespace Shared.Ui.Common.MenuSystem @@ -11,8 +7,8 @@ namespace Shared.Ui.Common.MenuSystem public class MenuBarButton { public NotifyAttr IsVisible { get; set; } = new NotifyAttr(true); - public BitmapImage Image { get; set; } - public MenuAction Action { get; set; } + public BitmapImage? Image { get; set; } + public MenuAction? Action { get; set; } public ButtonVisibilityRule ShowRule { get; set; } = ButtonVisibilityRule.Always; public bool IsSeperator { get; set; } = false; diff --git a/Shared/SharedUI/Shared.Ui/Common/MenuSystem/ToolbarItem.cs b/Shared/SharedUI/Shared.Ui/Common/MenuSystem/ToolbarItem.cs index 99f6afced..65a14554a 100644 --- a/Shared/SharedUI/Shared.Ui/Common/MenuSystem/ToolbarItem.cs +++ b/Shared/SharedUI/Shared.Ui/Common/MenuSystem/ToolbarItem.cs @@ -1,8 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using Shared.Core.Misc; namespace Shared.Ui.Common.MenuSystem @@ -12,7 +8,7 @@ public class ToolbarItem public NotifyAttr NameAttribute { get; set; } = new NotifyAttr(""); public ObservableCollection Children { get; set; } = new ObservableCollection(); - string _name; + string _name = ""; public string Name { get => _name; diff --git a/Shared/SharedUI/Shared.Ui/Common/ToolTipSystem/ToolTipEnum.cs b/Shared/SharedUI/Shared.Ui/Common/ToolTipSystem/ToolTipEnum.cs deleted file mode 100644 index e4d2e92ca..000000000 --- a/Shared/SharedUI/Shared.Ui/Common/ToolTipSystem/ToolTipEnum.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Collections.Generic; - -namespace Shared.Ui.Common.ToolTipSystem -{ - public enum ToolTipEnum - { - None, - - Kitbash_WsMaterial_Emissive_Strength - } - - public static class ToolTips - { - private readonly static Dictionary s_toolTips = new() - { - {ToolTipEnum.None, ""}, - - }; - - public static Dictionary List { get => s_toolTips; } - } - -} diff --git a/Shared/SharedUI/Shared.Ui/Common/ToolTipSystem/ToolTipManager.cs b/Shared/SharedUI/Shared.Ui/Common/ToolTipSystem/ToolTipManager.cs deleted file mode 100644 index e6078ae92..000000000 --- a/Shared/SharedUI/Shared.Ui/Common/ToolTipSystem/ToolTipManager.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Shared.Ui.Common.ToolTipSystem -{ - public static class ToolTipManager - { - readonly static Dictionary _toolTips = new Dictionary { { ToolTipEnum.None, "" } }; - - static void RegisterToolTip(ToolTipEnum enumValue, string toolTip) - { - if (_toolTips.ContainsKey(enumValue)) - throw new Exception($"{nameof(ToolTipManager)} alread have been assigned {enumValue}. Current value is '{_toolTips[enumValue]}'"); - _toolTips.Add(enumValue, toolTip); - } - static string GetToolTip(ToolTipEnum enumValue) => _toolTips[enumValue]; - - public static void Validate() - { - foreach (ToolTipEnum item in Enum.GetValues(typeof(ToolTipEnum))) - { - var result = _toolTips.TryGetValue(item, out var toolTip); - if (result == false) - throw new Exception($"ToolTipEnum {item} has not been assigned!"); - } - } - } - -} diff --git a/Shared/SharedUI/Shared.Ui/DependencyInjectionContainer.cs b/Shared/SharedUI/Shared.Ui/DependencyInjectionContainer.cs index f5436f4ed..286a2889e 100644 --- a/Shared/SharedUI/Shared.Ui/DependencyInjectionContainer.cs +++ b/Shared/SharedUI/Shared.Ui/DependencyInjectionContainer.cs @@ -22,19 +22,25 @@ public override void Register(IServiceCollection services) // Implement required interfaces services.AddScoped(); services.AddScoped(); + services.AddSingleton(); services.AddTransient(); services.AddScoped(); // Context menu - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddSingleton(provider => + { + var registry = new PackFileContextMenuRegistry(); + RegisterPackFileContextMenuItems(registry); + + foreach (var registration in provider.GetServices()) + registration.Register(registry); + + return registry; + }); + services.AddScoped(); // TODO: Should all be transient? - services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -46,16 +52,47 @@ public override void Register(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + } + + private static void RegisterPackFileContextMenuItems(PackFileContextMenuRegistry registry) + { + // MainApplication context menu + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "", priority: 0, ContextMenuCluster.PackFileOperation); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "", priority: 10, ContextMenuCluster.PackFileOperation); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "", priority: 20, ContextMenuCluster.PackFileOperation); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "", priority: 30, ContextMenuCluster.PackFileOperation); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "", priority: 40, ContextMenuCluster.PackFileOperation); + + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "Import", priority: 0, ContextMenuCluster.FolderOperation); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "Import", priority: 10, ContextMenuCluster.FolderOperation); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "Create", priority: 30, ContextMenuCluster.FolderOperation); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "", priority: 40, ContextMenuCluster.FolderOperation); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "", priority: 50, ContextMenuCluster.FolderOperation); + + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "", priority: 0, ContextMenuCluster.FileOperation); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "", priority: 10, ContextMenuCluster.FileOperation); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "", priority: 20, ContextMenuCluster.FileOperation); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "", priority: 30, ContextMenuCluster.FileOperation); + + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "Export", priority: 0, ContextMenuCluster.Export); + + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "", priority: 0, ContextMenuCluster.Misc); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "", priority: 10, ContextMenuCluster.Misc); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "", priority: 20, ContextMenuCluster.Misc); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "Open", priority: 30, ContextMenuCluster.Misc); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "Open", priority: 40, ContextMenuCluster.Misc); - // Interfaces for context menu commands implemented externally - // IRmvToTextCommand is registered in Editors.Reports + // Simple context menu + registry.RegisterPackFileContextMenuItem(ContextMenuType.Simple, path: "", priority: 0, ContextMenuCluster.FolderOperation); + registry.RegisterPackFileContextMenuItem(ContextMenuType.Simple, path: "", priority: 10, ContextMenuCluster.FolderOperation); + registry.RegisterPackFileContextMenuItem(ContextMenuType.Simple, path: "", priority: 20, ContextMenuCluster.FolderOperation); } public override void RegisterTools(IEditorDatabase factory) diff --git a/Shared/SharedUI/Shared.Ui/Shared.Ui.csproj b/Shared/SharedUI/Shared.Ui/Shared.Ui.csproj index 26c6703e6..cb9c550e7 100644 --- a/Shared/SharedUI/Shared.Ui/Shared.Ui.csproj +++ b/Shared/SharedUI/Shared.Ui/Shared.Ui.csproj @@ -19,6 +19,12 @@ + + + <_Parameter1>Shared.UiTest + + + Code diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileBrowserViewModelTestHelper.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileBrowserViewModelTestHelper.cs new file mode 100644 index 000000000..34f15beab --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileBrowserViewModelTestHelper.cs @@ -0,0 +1,34 @@ +using Shared.Ui.BaseDialogs.PackFileTree; + +namespace Shared.UiTest.BaseDialogs +{ + /// + /// Test utility helper for PackFileBrowserViewModel testing. + /// Provides helper methods for navigating the tree structure created by the view model. + /// + public static class PackFileBrowserViewModelTestHelper + { + /// + /// Finds a node by its path within the tree hierarchy. + /// + /// The parent node to search from + /// The path to search for (e.g., "folder1/folder2/file") + /// The found node or null if not found. For files, requires materialization. For directories, only returns if materialized. + public static TreeNode? GetFromPath(TreeNode parent, string path) + { + if (path.Length == 0) + return parent; + + // First determine what the target is by searching through backing children + var target = TreeNodePathHelper.FindInBackingChildren(parent, path); + if (target == null) + return null; + + if (target.NodeType == NodeType.File) + return TreeNodePathHelper.GetFromPathViaMaterialization(parent, path); + + // Directory: only return if already materialized (visible in the WPF tree) + return TreeNodePathHelper.FindInMaterializedChildren(parent, path); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/PackFileBrowserViewModelTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileBrowserViewModelTests.cs similarity index 60% rename from Shared/SharedUI/Shared.UiTest/PackFileBrowserViewModelTests.cs rename to Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileBrowserViewModelTests.cs index 6eab570f9..8749a00a8 100644 --- a/Shared/SharedUI/Shared.UiTest/PackFileBrowserViewModelTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileBrowserViewModelTests.cs @@ -1,28 +1,30 @@ // PackFileBrowserViewModel Lazy Loading Architecture: // // The tree is built lazily. When a container is added (via PackFileContainerAddedEvent), only -// the root TreeNodeSource is created with a child-loader delegate. Children are NOT loaded from +// the root TreeNode is created with a child-loader delegate. Children are NOT loaded from // the container's GetDirectoryContent() until the node is expanded or explicitly queried. // -// TreeNodeSource is the canonical backing model (lightweight, no UI state). -// TreeNode is the materialized WPF-bound node, created on demand when a branch is expanded. -// A placeholder child is used so the WPF TreeView shows the expand arrow for unloaded directories. +// TreeNode serves as both the data model (BackingChildren) and the WPF-bound node. +// BackingChildren holds the full logical tree once loaded; Children (ObservableCollection) holds +// only the materialized subset visible to WPF. A placeholder child is used so the WPF TreeView +// shows the expand arrow for unloaded directories. // // Event-driven updates from PackFileService: -// - PackFileContainerAddedEvent → ReloadTree: creates root source + lazy child loader -// - PackFileContainerRemovedEvent → removes container's tree state and TreeNode from Files -// - PackFileContainerFilesAddedEvent → AddFiles: inserts nodes into loaded source branches, creates dirs as needed -// - PackFileContainerFilesRemovedEvent → removes source + materialized nodes for deleted files -// - PackFileContainerFilesUpdatedEvent → updates Name/UnsavedChanged on source nodes (for rename/save) -// - PackFileContainerFolderRemovedEvent → finds and removes folder source node and materialized subtree -// - PackFileContainerFolderRenamedEvent → finds folder by OLD path, renames leaf, marks unsaved -// - PackFileContainerSetAsMainEditableEvent → toggles IsMainEditabelPack on root TreeNodes -// - PackFileContainerSavedEvent → clears UnsavedChanged on loaded nodes only (avoids forcing full population) +// - PackFileContainerAddedEvent ? ReloadTree: creates root node + lazy child loader +// - PackFileContainerRemovedEvent ? removes container's root and TreeNode from Files +// - PackFileContainerFilesAddedEvent ? AddFiles: inserts nodes into loaded branches, creates dirs as needed +// - PackFileContainerFilesRemovedEvent ? removes nodes for deleted files +// - PackFileContainerFilesUpdatedEvent ? updates Name/UnsavedChanged on nodes (for rename/save) +// - PackFileContainerFolderRemovedEvent ? finds and removes folder node and subtree +// - PackFileContainerFolderRenamedEvent ? finds folder by OLD path, renames leaf, marks unsaved +// - PackFileContainerSetAsMainEditableEvent ? toggles IsMainEditabelPack on root TreeNodes +// - PackFileContainerSavedEvent ? clears UnsavedChanged on loaded nodes only (avoids forcing full population) // // SearchFilter: when active, calls EnsureFullyPopulated() to load entire subtree for regex matching. // When cleared, filter-expanded nodes are absorbed as user expansions and collapsed nodes are unloaded. using System.Windows.Input; +using System.IO; using AssetEditor.ViewModels; using Microsoft.Extensions.DependencyInjection; using Shared.Core.Events; @@ -33,7 +35,7 @@ using Test.TestingUtility.Shared; using Test.TestingUtility.TestUtility; -namespace Shared.UiTest +namespace Shared.UiTest.BaseDialogs { internal class PackFileBrowserViewModelTests { @@ -69,8 +71,8 @@ public void DragAndDrop_FileInFolder() var packRootNode = _viewModel.Files[1]; // Act - var fileToMove = _viewModel.GetFromPath(packRootNode, @"animations\battle\humanoid01\2handed_hammer\stand\hu1_2hh_stand_idle_01.anim"); - var destinationNode = _viewModel.GetFromPath(packRootNode, @"animations"); + var fileToMove = PackFileBrowserViewModelTestHelper.GetFromPath(packRootNode, @"animations\battle\humanoid01\2handed_hammer\stand\hu1_2hh_stand_idle_01.anim"); + var destinationNode = PackFileBrowserViewModelTestHelper.GetFromPath(packRootNode, @"animations"); _viewModel.Drop(fileToMove, destinationNode); // Assert @@ -79,7 +81,7 @@ public void DragAndDrop_FileInFolder() Assert.That(movedFile, Is.Not.Null); // Get file node from - var movedNode = _viewModel.GetFromPath(packRootNode, @"animations\hu1_2hh_stand_idle_01.anim"); + var movedNode = PackFileBrowserViewModelTestHelper.GetFromPath(packRootNode, @"animations\hu1_2hh_stand_idle_01.anim"); Assert.That(movedNode, Is.Not.Null); Assert.That(movedNode.UnsavedChanged, Is.True); Assert.That(movedNode.Parent.UnsavedChanged, Is.True); @@ -93,12 +95,12 @@ public void OnlyRootExpandedByDefault() Assert.That(_viewModel.Files.Count, Is.EqualTo(1)); var root = _viewModel.Files[0]; - Assert.That(_viewModel.GetFromPath(root, "foldera"), Is.Null); + Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"), Is.Null); root.IsNodeExpanded = true; - Assert.That(_viewModel.GetFromPath(root, "foldera"), Is.Not.Null); - Assert.That(_viewModel.GetFromPath(root, "folderb"), Is.Not.Null); + Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"), Is.Not.Null); + Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "folderb"), Is.Not.Null); } [Test] @@ -109,7 +111,7 @@ public void GetFileFromCollapsedNode() Assert.That(root.IsNodeExpanded, Is.False); - var fileNode = _viewModel.GetFromPath(root, "animations\\battle\\humanoid01\\test.anim"); + var fileNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, "animations\\battle\\humanoid01\\test.anim"); Assert.That(fileNode, Is.Not.Null); Assert.That(fileNode.NodeType, Is.EqualTo(NodeType.File)); @@ -124,15 +126,15 @@ public void CollapsingNodeUnloadsItsChildren() var root = _viewModel.Files[0]; root.IsNodeExpanded = true; - var folderA = _viewModel.GetFromPath(root, "foldera"); + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); Assert.That(folderA, Is.Not.Null); folderA.IsNodeExpanded = true; - Assert.That(_viewModel.GetFromPath(root, "foldera\\sub"), Is.Not.Null); + Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\sub"), Is.Not.Null); folderA.IsNodeExpanded = false; - Assert.That(_viewModel.GetFromPath(root, "foldera\\sub"), Is.Null); + Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\sub"), Is.Null); } [Test] @@ -148,16 +150,16 @@ public void SearchMaterializesOnlyMatchingBranchAndClearsBackToLazyState() _viewModel.Filter.FilterText = "match_file"; Assert.That(root.IsNodeExpanded, Is.True); - Assert.That(_viewModel.GetFromPath(root, "foldera\\sub\\match_file.txt"), Is.Not.Null); - Assert.That(_viewModel.GetFromPath(root, "folderb"), Is.Null); + Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\sub\\match_file.txt"), Is.Not.Null); + Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "folderb"), Is.Null); _viewModel.Filter.FilterText = string.Empty; // After clearing, filter-expanded folders are preserved (absorbed as user expansions) Assert.That(root.IsNodeExpanded, Is.True); - Assert.That(_viewModel.GetFromPath(root, "foldera"), Is.Not.Null); + Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"), Is.Not.Null); // folderB is now also visible and materialized since filter is cleared - Assert.That(_viewModel.GetFromPath(root, "folderb"), Is.Not.Null); + Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "folderb"), Is.Not.Null); } [Test] @@ -172,7 +174,7 @@ public void SearchDoesNotCollapseNodesExpandedByUserBeforeFiltering() _viewModel.Filter.FilterText = string.Empty; Assert.That(root.IsNodeExpanded, Is.True); - Assert.That(_viewModel.GetFromPath(root, "foldera"), Is.Not.Null); + Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"), Is.Not.Null); } [Test] @@ -187,14 +189,14 @@ public void ClearingFilterKeepsExpandedFoldersAndShowsAllFiles() // User manually expands root and folderA root.IsNodeExpanded = true; - var folderA = _viewModel.GetFromPath(root, "foldera"); + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); Assert.That(folderA, Is.Not.Null); folderA.IsNodeExpanded = true; // Search for a specific file — folderA/sub gets expanded by filter, folderB hidden _viewModel.Filter.FilterText = "match_file"; - var sub = _viewModel.GetFromPath(root, "foldera\\sub"); + var sub = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\sub"); Assert.That(sub, Is.Not.Null); Assert.That(sub.IsNodeExpanded, Is.True, "sub should be expanded by filter"); @@ -206,18 +208,18 @@ public void ClearingFilterKeepsExpandedFoldersAndShowsAllFiles() Assert.That(folderA.IsNodeExpanded, Is.True, "folderA was user-expanded, should stay open"); // Filter-expanded nodes are preserved (absorbed) - sub = _viewModel.GetFromPath(root, "foldera\\sub"); + sub = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\sub"); Assert.That(sub, Is.Not.Null, "sub should still be materialized"); Assert.That(sub.IsNodeExpanded, Is.True, "sub was filter-expanded, now absorbed as user expansion"); // Previously hidden folder is now visible and materialized - var folderB = _viewModel.GetFromPath(root, "folderb"); + var folderB = PackFileBrowserViewModelTestHelper.GetFromPath(root, "folderb"); Assert.That(folderB, Is.Not.Null, "folderB should be materialized after filter cleared"); Assert.That(folderB.IsVisible, Is.True, "folderB should be visible after filter cleared"); // All files in expanded folders are visible - var matchFile = _viewModel.GetFromPath(root, "foldera\\sub\\match_file.txt"); - var otherA = _viewModel.GetFromPath(root, "foldera\\sub\\other_a.txt"); + var matchFile = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\sub\\match_file.txt"); + var otherA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\sub\\other_a.txt"); Assert.That(matchFile, Is.Not.Null, "match_file should be visible"); Assert.That(otherA, Is.Not.Null, "other_a should be visible after filter cleared"); } @@ -232,7 +234,7 @@ public void CtrlDoubleClickDirectoryExpandsAllChildren() var root = _viewModel.Files[0]; root.IsNodeExpanded = true; - var folderA = _viewModel.GetFromPath(root, "foldera"); + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); Assert.That(folderA, Is.Not.Null); // Intentionally set a different selected item to verify command uses clicked node argument. @@ -240,8 +242,8 @@ public void CtrlDoubleClickDirectoryExpandsAllChildren() _viewModel.SelectedItem = root; _viewModel.DoubleClickCommand.Execute(folderA); - var subA = _viewModel.GetFromPath(root, "foldera\\suba"); - var subB = _viewModel.GetFromPath(root, "foldera\\subb"); + var subA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\suba"); + var subB = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\subb"); Assert.That(folderA.IsNodeExpanded, Is.True); Assert.That(subA, Is.Not.Null); @@ -259,7 +261,7 @@ public void DeleteFileUsingPfs_EnsureTreeViewUpdated() var root = _viewModel.Files[0]; root.IsNodeExpanded = true; - var folderA = _viewModel.GetFromPath(root, "foldera"); + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); Assert.That(folderA, Is.Not.Null); folderA.IsNodeExpanded = true; @@ -286,7 +288,7 @@ public void RenameFileUsingPfs_EnsureTreeViewUpdated() var root = _viewModel.Files[0]; root.IsNodeExpanded = true; - var folderA = _viewModel.GetFromPath(root, "foldera"); + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); Assert.That(folderA, Is.Not.Null); folderA.IsNodeExpanded = true; @@ -300,8 +302,8 @@ public void RenameFileUsingPfs_EnsureTreeViewUpdated() _packageFileService.RenameFile(container, filePackFile, "file_renamed.txt"); // Assert: old node/path should be gone and new node/path should exist in the tree - var oldNode = _viewModel.GetFromPath(root, "foldera\\file.txt"); - var renamedNode = _viewModel.GetFromPath(root, "foldera\\file_renamed.txt"); + var oldNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\file.txt"); + var renamedNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\file_renamed.txt"); Assert.That(oldNode, Is.Null, "Old file path should not exist after rename"); Assert.That(renamedNode, Is.Not.Null, "Renamed file should exist in the tree view"); @@ -325,7 +327,7 @@ public void AddDuplicateNamedFileToNewFolderUsingPfs_EnsureTreeViewUpdated() var addedFile = _packageFileService.FindFile(saveAsTargetPath, container); var resolvedPathForAddedFile = _packageFileService.GetFullPath(copiedRigidModel, container); - var addedNode = _viewModel.GetFromPath(root, saveAsTargetPath); + var addedNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, saveAsTargetPath); Assert.Multiple(() => { @@ -340,7 +342,9 @@ private void CreatePackfiles(params (string Path, string FileName)[] files) var container = _packageFileService.CreateNewPackFileContainer("test.pack", PackFileVersion.PFH5, PackFileCAType.MOD); foreach (var (path, fileName) in files) { - container.AddOrUpdateFile(path.ToLowerInvariant(), PackFile.CreateFromASCII(fileName, fileName)); + var normalizedPath = path.ToLowerInvariant().Replace('/', '\\'); + var directory = Path.GetDirectoryName(normalizedPath)?.Replace('/', '\\') ?? string.Empty; + _packageFileService.AddFilesToPack(container, [new NewPackFileEntry(directory, PackFile.CreateFromASCII(fileName, fileName))]); } _packageFileService.AddContainer(container); @@ -353,15 +357,15 @@ public void RenameFolderUsingPfs_EnsureTreeViewUpdated() var root = _viewModel.Files[0]; root.IsNodeExpanded = true; - var folderA = _viewModel.GetFromPath(root, "foldera"); + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); Assert.That(folderA, Is.Not.Null); var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); _packageFileService.RenameDirectory(container, "foldera", "folderb"); // Old path gone, new path available - var oldNode = _viewModel.GetFromPath(root, "foldera"); - var newNode = _viewModel.GetFromPath(root, "folderb"); + var oldNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); + var newNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, "folderb"); Assert.That(oldNode, Is.Null, "Old folder path should not exist after rename"); Assert.That(newNode, Is.Not.Null, "Renamed folder should be accessible via the new name"); @@ -375,14 +379,14 @@ public void RenameFolderUsingPfs_NestedFolder_EnsureTreeViewUpdated() var root = _viewModel.Files[0]; root.IsNodeExpanded = true; - var parent = _viewModel.GetFromPath(root, "parent"); + var parent = PackFileBrowserViewModelTestHelper.GetFromPath(root, "parent"); Assert.That(parent, Is.Not.Null); parent.IsNodeExpanded = true; var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); _packageFileService.RenameDirectory(container, "parent\\child", "renamed"); - var newNode = _viewModel.GetFromPath(root, "parent\\renamed"); + var newNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, "parent\\renamed"); Assert.That(newNode, Is.Not.Null, "Nested renamed folder should be accessible"); Assert.That(newNode.UnsavedChanged, Is.True); } @@ -394,16 +398,16 @@ public void DeleteFolderUsingPfs_EnsureTreeViewUpdated() var root = _viewModel.Files[0]; root.IsNodeExpanded = true; - var folderA = _viewModel.GetFromPath(root, "foldera"); + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); Assert.That(folderA, Is.Not.Null); var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); _packageFileService.DeleteFolder(container, "foldera"); - var deletedNode = _viewModel.GetFromPath(root, "foldera"); + var deletedNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); Assert.That(deletedNode, Is.Null, "Deleted folder should not exist in tree"); - var survivingNode = _viewModel.GetFromPath(root, "folderb"); + var survivingNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, "folderb"); Assert.That(survivingNode, Is.Not.Null, "Non-deleted folder should still exist"); } @@ -464,14 +468,14 @@ public void ContainerSaved_ClearsUnsavedFlags() _packageFileService.AddFilesToPack(container, [new NewPackFileEntry("foldera", newFile)]); root.IsNodeExpanded = true; - var folderA = _viewModel.GetFromPath(root, "foldera"); + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); Assert.That(folderA, Is.Not.Null); Assert.That(root.UnsavedChanged, Is.True, "Root should be unsaved after adding file"); // Simulate save via PFS — we can't call SavePackContainer without disk I/O, // so trigger the event directly through the event hub var eventHub = _runner.ServiceProvider.GetRequiredService(); - eventHub.PublishGlobalEvent(new Shared.Core.Events.Global.PackFileContainerSavedEvent(container)); + eventHub.PublishGlobalEvent(new Core.Events.Global.PackFileContainerSavedEvent(container)); Assert.That(root.UnsavedChanged, Is.False, "Root should be cleared after save"); } @@ -487,7 +491,7 @@ public void ContainerSaved_DoesNotForcePopulationOfUnloadedBranches() Assert.That(root.IsNodeExpanded, Is.False); var eventHub = _runner.ServiceProvider.GetRequiredService(); - eventHub.PublishGlobalEvent(new Shared.Core.Events.Global.PackFileContainerSavedEvent(container)); + eventHub.PublishGlobalEvent(new Core.Events.Global.PackFileContainerSavedEvent(container)); // After save, the tree should NOT have expanded/materialized children Assert.That(root.Children.All(c => c.Name == "" || c.NodeType == NodeType.Root), @@ -506,7 +510,7 @@ public void AddFileToRootLevel_EnsureTreeViewUpdated() var root = _viewModel.Files.First(x => x.Name == "test.pack"); root.IsNodeExpanded = true; - var fileNode = _viewModel.GetFromPath(root, "rootfile.txt"); + var fileNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, "rootfile.txt"); Assert.That(fileNode, Is.Not.Null, "File added at root level should appear in tree"); Assert.That(fileNode.NodeType, Is.EqualTo(NodeType.File)); } @@ -518,10 +522,10 @@ public void DoubleClick_File_InvokesFileOpenEvent() var root = _viewModel.Files[0]; root.IsNodeExpanded = true; - var folderA = _viewModel.GetFromPath(root, "foldera"); + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); folderA.IsNodeExpanded = true; - var fileNode = _viewModel.GetFromPath(root, "foldera\\file.txt"); + var fileNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\file.txt"); Assert.That(fileNode, Is.Not.Null); PackFile? openedFile = null; @@ -540,7 +544,7 @@ public void DoubleClick_Directory_TogglesExpansion() var root = _viewModel.Files[0]; root.IsNodeExpanded = true; - var folderA = _viewModel.GetFromPath(root, "foldera"); + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); Assert.That(folderA, Is.Not.Null); Assert.That(folderA.IsNodeExpanded, Is.False); @@ -559,24 +563,24 @@ public void MoveFileUsingPfs_EnsureTreeViewUpdated() root.IsNodeExpanded = true; var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); - var sourceFolder = _viewModel.GetFromPath(root, "source"); + var sourceFolder = PackFileBrowserViewModelTestHelper.GetFromPath(root, "source"); Assert.That(sourceFolder, Is.Not.Null); sourceFolder.IsNodeExpanded = true; - var fileNode = _viewModel.GetFromPath(root, "source\\file.txt"); + var fileNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, "source\\file.txt"); Assert.That(fileNode, Is.Not.Null); var file = _packageFileService.FindFile("source\\file.txt", container); _packageFileService.MoveFile(container, file, "target"); // Old location gone - var oldNode = _viewModel.GetFromPath(root, "source\\file.txt"); + var oldNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, "source\\file.txt"); Assert.That(oldNode, Is.Null, "File should not be in old folder after move"); // New location has the file - var targetFolder = _viewModel.GetFromPath(root, "target"); + var targetFolder = PackFileBrowserViewModelTestHelper.GetFromPath(root, "target"); targetFolder.IsNodeExpanded = true; - var newNode = _viewModel.GetFromPath(root, "target\\file.txt"); + var newNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, "target\\file.txt"); Assert.That(newNode, Is.Not.Null, "File should appear in target folder after move"); } @@ -587,11 +591,11 @@ public void DragDrop_FileOntoFile_NotAllowed() var root = _viewModel.Files[0]; root.IsNodeExpanded = true; - var folderA = _viewModel.GetFromPath(root, "foldera"); + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); folderA.IsNodeExpanded = true; - var file1 = _viewModel.GetFromPath(root, "foldera\\file1.txt"); - var file2 = _viewModel.GetFromPath(root, "foldera\\file2.txt"); + var file1 = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\file1.txt"); + var file2 = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\file2.txt"); Assert.That(_viewModel.AllowDrop(file1, file2), Is.False, "Should not allow dropping file onto file"); } @@ -603,8 +607,8 @@ public void DragDrop_FolderDrag_NotAllowed() var root = _viewModel.Files[0]; root.IsNodeExpanded = true; - var folderA = _viewModel.GetFromPath(root, "foldera"); - var folderB = _viewModel.GetFromPath(root, "folderb"); + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); + var folderB = PackFileBrowserViewModelTestHelper.GetFromPath(root, "folderb"); Assert.That(_viewModel.AllowDrop(folderA, folderB), Is.False, "Should not allow dragging folders"); } @@ -618,20 +622,179 @@ public void ShowFoldersOnly_HidesFiles() _viewModel.Filter.ShowFoldersOnly = true; root.IsNodeExpanded = true; - var folderA = _viewModel.GetFromPath(root, "foldera"); + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); Assert.That(folderA, Is.Not.Null, "Folder should be visible with ShowFoldersOnly"); Assert.That(folderA.IsVisible, Is.True); folderA.IsNodeExpanded = true; // File nodes are not materialized when ShowFoldersOnly is active - var fileNode = _viewModel.GetFromPath(root, "foldera\\file.txt"); + var fileNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\file.txt"); Assert.That(fileNode, Is.Null, "File should not be materialized with ShowFoldersOnly active"); // Only folder children should be present Assert.That(folderA.Children.All(c => c.NodeType != NodeType.File || !c.IsVisible), Is.True, "No visible file nodes should exist under folder with ShowFoldersOnly"); } + + [Test] + public void SearchAutoExpandsWhenResultCountBelowThreshold() + { + // Create a small number of files (below default threshold of 25) + CreatePackfiles( + ("folderA\\sub\\file1.txt", "file1.txt"), + ("folderA\\sub\\file2.txt", "file2.txt"), + ("folderB\\file3.txt", "file3.txt")); + + var root = _viewModel.Files[0]; + Assert.That(root.IsNodeExpanded, Is.False, "Root should start collapsed"); + + // Filter matches all 3 files — well below the 25 threshold + _viewModel.Filter.FilterText = "file"; + + // All matching nodes should be auto-expanded + Assert.That(root.IsNodeExpanded, Is.True, "Root should be expanded when results < threshold"); + + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); + Assert.That(folderA, Is.Not.Null); + Assert.That(folderA.IsNodeExpanded, Is.True, "folderA should be auto-expanded"); + + var sub = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\sub"); + Assert.That(sub, Is.Not.Null); + Assert.That(sub.IsNodeExpanded, Is.True, "sub should be auto-expanded"); + + var folderB = PackFileBrowserViewModelTestHelper.GetFromPath(root, "folderb"); + Assert.That(folderB, Is.Not.Null); + Assert.That(folderB.IsNodeExpanded, Is.True, "folderB should be auto-expanded"); + } + + [Test] + public void SearchDoesNotAutoExpandWhenResultCountAboveThreshold() + { + // Create more files than the threshold + var files = new List<(string Path, string FileName)>(); + for (var i = 0; i < 30; i++) + files.Add(($"folder{i}\\file{i}.txt", $"file{i}.txt")); + + CreatePackfiles(files.ToArray()); + + var root = _viewModel.Files[0]; + + // Set threshold low to ensure we exceed it + _viewModel.Filter.AutoExapandResultsAfterLimitedCount = 5; + _viewModel.Filter.FilterText = "file"; + + // Root gets expanded by ExpandForFilter (always happens), but children should NOT be recursively expanded + Assert.That(root.IsNodeExpanded, Is.True, "Root is always expanded by ExpandForFilter"); + + // Check that deep nodes are NOT expanded (since results exceed threshold) + var expandedFolders = root.Children.Where(c => c.IsNodeExpanded).ToList(); + Assert.That(expandedFolders.Count, Is.EqualTo(0), + "Folders should NOT be auto-expanded when results exceed threshold"); + } + + [Test] + public void DoubleClickCollapseAndReexpand_ChildrenRemainVisible() + { + // Scenario: user expands folders manually, collapses a parent, + // then re-expands it — child folders that were expanded should still show their children. + CreatePackfiles( + ("folderA\\sub\\file1.txt", "file1.txt"), + ("folderA\\sub\\file2.txt", "file2.txt")); + + var root = _viewModel.Files[0]; + + // Expand the tree manually + root.IsNodeExpanded = true; + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); + Assert.That(folderA, Is.Not.Null); + folderA.IsNodeExpanded = true; + var sub = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\sub"); + Assert.That(sub, Is.Not.Null); + sub.IsNodeExpanded = true; + + // Verify files are visible + var file1 = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\sub\\file1.txt"); + Assert.That(file1, Is.Not.Null); + + // User double-clicks root to collapse + _viewModel.DoubleClickCommand.Execute(root); + Assert.That(root.IsNodeExpanded, Is.False); + + // User double-clicks root to re-expand + _viewModel.DoubleClickCommand.Execute(root); + Assert.That(root.IsNodeExpanded, Is.True); + + // folderA should still be visible and expanded with its children materialized + folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); + Assert.That(folderA, Is.Not.Null, "folderA should be visible after re-expanding root"); + Assert.That(folderA.IsNodeExpanded, Is.True, "folderA should still be expanded"); + + sub = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\sub"); + Assert.That(sub, Is.Not.Null, "sub should be visible since folderA is expanded"); + Assert.That(sub.IsNodeExpanded, Is.True, "sub should still be expanded"); + + // Files inside sub should be materialized + file1 = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\sub\\file1.txt"); + Assert.That(file1, Is.Not.Null, "file1 should be visible inside expanded sub folder"); + } + + [Test] + public void OverwriteFileInCollapsedFolder_NoDuplicateNodeInTree() + { + // Arrange: file exists in a folder that is NOT expanded (lazy state). + CreatePackfiles(("foldera\\file.txt", "file.txt")); + var root = _viewModel.Files[0]; + var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); + + // Folder is collapsed — BackingChildren are NOT yet loaded. + Assert.That(root.IsNodeExpanded, Is.False); + + // Act: add a second file with the same name (simulates Save As / overwrite import). + var replacement = PackFile.CreateFromASCII("file.txt", "updated content"); + _packageFileService.AddFilesToPack(container, [new NewPackFileEntry("foldera", replacement)]); + + // Expand to materialise children and assert only ONE node with that name exists. + root.IsNodeExpanded = true; + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); + Assert.That(folderA, Is.Not.Null); + folderA.IsNodeExpanded = true; + + var duplicates = folderA.Children + .Where(n => n.NodeType == NodeType.File && n.Name.Equals("file.txt", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + Assert.That(duplicates.Count, Is.EqualTo(1), "Overwriting a file in a collapsed folder must not create a duplicate node"); + Assert.That(duplicates[0].Item, Is.SameAs(replacement), "The surviving node should reference the replacement PackFile"); + } + + [Test] + public void OverwriteFileInExpandedFolder_NoDuplicateNodeInTree() + { + // Arrange: file exists in a folder that IS already expanded (children materialised). + CreatePackfiles(("foldera\\file.txt", "file.txt")); + var root = _viewModel.Files[0]; + var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); + + root.IsNodeExpanded = true; + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); + Assert.That(folderA, Is.Not.Null); + folderA.IsNodeExpanded = true; + + Assert.That(folderA.Children.Count(n => n.NodeType == NodeType.File), Is.EqualTo(1)); + + // Act: add a second file with the same name (simulates overwrite while folder is visible). + var replacement = PackFile.CreateFromASCII("file.txt", "updated content"); + _packageFileService.AddFilesToPack(container, [new NewPackFileEntry("foldera", replacement)]); + + var duplicates = folderA.Children + .Where(n => n.NodeType == NodeType.File && n.Name.Equals("file.txt", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + Assert.That(duplicates.Count, Is.EqualTo(1), "Overwriting a file in an expanded folder must not create a duplicate node"); + Assert.That(duplicates[0].Item, Is.SameAs(replacement), "The surviving node should reference the replacement PackFile"); + } } } + diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ClosePackContainerFileCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ClosePackContainerFileCommandTests.cs new file mode 100644 index 000000000..75dd44d3d --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ClosePackContainerFileCommandTests.cs @@ -0,0 +1,51 @@ +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class ClosePackContainerFileCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForRoot() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + var command = new ClosePackContainerFileCommand(new Mock().Object, new Mock().Object); + + Assert.That(command.ShouldAdd(root), Is.True); + Assert.That(command.ShouldAdd(file), Is.False); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var command = new ClosePackContainerFileCommand(new Mock().Object, new Mock().Object); + + Assert.That(command.IsEnabled(root), Is.True); + } + + [Test] + public void Execute_ConfirmsAndUnloadsPack() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var service = new Mock(); + var dialogs = new Mock(); + dialogs.Setup(x => x.ShowYesNoBox(It.IsAny(), It.IsAny())).Returns(ShowMessageBoxResult.OK); + + var command = new ClosePackContainerFileCommand(service.Object, dialogs.Object); + + command.Execute(root); + + service.Verify(x => x.UnloadPackContainer(owner), Times.Once); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CollapseNodeCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CollapseNodeCommandTests.cs new file mode 100644 index 000000000..144faf6cc --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CollapseNodeCommandTests.cs @@ -0,0 +1,80 @@ +using Shared.Core.PackFiles.Models; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class CollapseNodeCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForFolderAndFalseForFile() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var folder = new TreeNode("folder", NodeType.Directory, owner, root); + var file = new TreeNode("file.txt", NodeType.File, owner, folder, PackFile.CreateFromASCII("file.txt", "a")); + var command = new CollapseNodeCommand(); + + Assert.That(command.ShouldAdd(folder), Is.True); + Assert.That(command.ShouldAdd(file), Is.False); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var folder = new TreeNode("folder", NodeType.Directory, owner, root); + var command = new CollapseNodeCommand(); + + Assert.That(command.IsEnabled(folder), Is.True); + } + + [Test] + public void Execute_CollapsesRootNode() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var folder = new TreeNode("folder", NodeType.Directory, owner, root); + var file = new TreeNode("file.txt", NodeType.File, owner, folder, PackFile.CreateFromASCII("file.txt", "a")); + root.AddChild(folder); + folder.AddChild(file); + root.Children.Add(folder); + folder.Children.Add(file); + + root.IsNodeExpanded = true; + folder.IsNodeExpanded = true; + file.IsNodeExpanded = true; + + var command = new CollapseNodeCommand(); + + command.Execute(root); + + Assert.That(root.IsNodeExpanded, Is.False); + } + + [Test] + public void Execute_CollapsesUnmaterializedChildren() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var folder = new TreeNode("folder", NodeType.Directory, owner, root); + var nested = new TreeNode("nested", NodeType.Directory, owner, folder); + + root.AddChild(folder); + folder.AddChild(nested); + + root.IsNodeExpanded = true; + folder.IsNodeExpanded = true; + nested.IsNodeExpanded = true; + + var command = new CollapseNodeCommand(); + command.Execute(root); + + Assert.That(root.IsNodeExpanded, Is.False); + Assert.That(folder.IsNodeExpanded, Is.False); + Assert.That(nested.IsNodeExpanded, Is.False); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ContextMenuCommandTestBase.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ContextMenuCommandTestBase.cs new file mode 100644 index 000000000..5cf4cf9c8 --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ContextMenuCommandTestBase.cs @@ -0,0 +1,17 @@ +using Moq; +using Shared.Core.PackFiles.Models; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + internal abstract class ContextMenuCommandTestBase + { + protected static IPackFileContainer CreateContainer(bool isCa = false, string name = "pack", string systemFilePath = "C:\\temp\\pack.pack") + { + var container = new Mock(); + container.SetupGet(x => x.Name).Returns(name); + container.SetupGet(x => x.SystemFilePath).Returns(systemFilePath); + container.SetupProperty(x => x.IsCaPackFile, isCa); + return container.Object; + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyNodePathCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyNodePathCommandTests.cs new file mode 100644 index 000000000..536e103f0 --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyNodePathCommandTests.cs @@ -0,0 +1,52 @@ +using System.Threading; +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class CopyNodePathCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForFile() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + var command = new CopyNodePathCommand(new Mock().Object); + + Assert.That(command.ShouldAdd(file), Is.True); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + var command = new CopyNodePathCommand(new Mock().Object); + + Assert.That(command.IsEnabled(file), Is.True); + } + + [Test] + [Apartment(ApartmentState.STA)] + public void Execute_CopiesNodePathToClipboard() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + var service = new Mock(); + service.Setup(x => x.GetFullPath(file.Item!, null)).Returns("folder\\file.txt"); + + var command = new CopyNodePathCommand(service.Object); + + command.Execute(file); + + Assert.That(System.Windows.Clipboard.GetText(), Is.EqualTo("folder\\file.txt")); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyToEditablePackCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyToEditablePackCommandTests.cs new file mode 100644 index 000000000..b59e16c8e --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyToEditablePackCommandTests.cs @@ -0,0 +1,62 @@ +using System.Threading; +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class CopyToEditablePackCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueWhenEditablePackExists() + { + var source = CreateContainer(name: "source"); + var target = CreateContainer(name: "target"); + var service = new Mock(); + service.Setup(x => x.GetEditablePack()).Returns(target); + var command = new CopyToEditablePackCommand(service.Object, new Mock().Object); + var node = new TreeNode("folder", NodeType.Directory, source, null); + + Assert.That(command.ShouldAdd(node), Is.True); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var source = CreateContainer(name: "source"); + var node = new TreeNode("folder", NodeType.Directory, source, null); + var command = new CopyToEditablePackCommand(new Mock().Object, new Mock().Object); + + Assert.That(command.IsEnabled(node), Is.True); + } + + [Test] + public void Execute_CopiesChildFilesToEditablePack() + { + var source = CreateContainer(name: "source"); + var target = CreateContainer(name: "target"); + var service = new Mock(); + service.Setup(x => x.GetEditablePack()).Returns(target); + + var dialogs = new Mock(); + var waitCursor = new Mock(); + dialogs.Setup(x => x.ShowWaitCursor()).Returns(waitCursor.Object); + + var root = new TreeNode("root", NodeType.Root, source, null); + var folder = new TreeNode("folder", NodeType.Directory, source, root); + root.AddChild(folder); + var file = new TreeNode("file.txt", NodeType.File, source, folder, PackFile.CreateFromASCII("file.txt", "a")); + folder.AddChild(file); + + var command = new CopyToEditablePackCommand(service.Object, dialogs.Object); + + command.Execute(folder); + + service.Verify(x => x.CopyFileFromOtherPackFile(source, It.IsAny(), target), Times.Once); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CreateFolderCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CreateFolderCommandTests.cs new file mode 100644 index 000000000..258b13df7 --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CreateFolderCommandTests.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Moq; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class CreateFolderCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForEditableRoot() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var command = new CreateFolderCommand(new Mock().Object); + + Assert.That(command.ShouldAdd(root), Is.True); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var command = new CreateFolderCommand(new Mock().Object); + + Assert.That(command.IsEnabled(root), Is.True); + } + + [Test] + public void Execute_AddsFolderChild() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var existing = new TreeNode("existing", NodeType.Directory, owner, root); + root.AddChild(existing); + + var dialogs = new Mock(); + dialogs.Setup(x => x.ShowFolderNameDialog(It.IsAny>(), It.IsAny())).Returns("new_folder"); + + var command = new CreateFolderCommand(dialogs.Object); + + command.Execute(root); + + Assert.That(root.BackingChildren.Any(x => x.Name == "new_folder"), Is.True); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/DeleteNodeCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/DeleteNodeCommandTests.cs new file mode 100644 index 000000000..5aec83984 --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/DeleteNodeCommandTests.cs @@ -0,0 +1,55 @@ +using System.Threading; +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class DeleteNodeCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForFile() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + var command = new DeleteNodeCommand(new Mock().Object, new Mock().Object); + + Assert.That(command.ShouldAdd(file), Is.True); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + var command = new DeleteNodeCommand(new Mock().Object, new Mock().Object); + + Assert.That(command.IsEnabled(file), Is.True); + } + + [Test] + public void Execute_DeletesFileAfterConfirmation() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + root.AddChild(file); + + var service = new Mock(); + var dialogs = new Mock(); + dialogs.Setup(x => x.ShowYesNoBox(It.IsAny(), It.IsAny())).Returns(ShowMessageBoxResult.OK); + + var command = new DeleteNodeCommand(service.Object, dialogs.Object); + + command.Execute(file); + + service.Verify(x => x.DeleteFile(owner, file.Item!), Times.Once); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/DuplicateFileCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/DuplicateFileCommandTests.cs new file mode 100644 index 000000000..c461b343a --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/DuplicateFileCommandTests.cs @@ -0,0 +1,85 @@ +using System.Threading; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; +using Test.TestingUtility.Shared; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class DuplicateFileCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForFileNode() + { + var runner = new AssetEditorTestRunner(); + runner.PackFileService.EnforceGameFilesMustBeLoaded = false; + var sourcePackFile = runner.CreateEmptyPackFile("SourcePack", false); + var fileEntry = new NewPackFileEntry("Animation\\Meta", PackFile.CreateFromASCII("testFile.anm", "DummyContent")); + runner.PackFileService.AddFilesToPack(sourcePackFile, [fileEntry]); + var fileToCopy = runner.PackFileService.FindFile("Animation\\Meta\\testFile.anm", sourcePackFile)!; + var node = new TreeNode(fileToCopy.Name, NodeType.File, sourcePackFile, null, fileToCopy); + var command = runner.CommandFactory.Create(); + + Assert.That(command.ShouldAdd(node), Is.True); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var runner = new AssetEditorTestRunner(); + runner.PackFileService.EnforceGameFilesMustBeLoaded = false; + var sourcePackFile = runner.CreateEmptyPackFile("SourcePack", false); + var fileEntry = new NewPackFileEntry("Animation\\Meta", PackFile.CreateFromASCII("testFile.anm", "DummyContent")); + runner.PackFileService.AddFilesToPack(sourcePackFile, [fileEntry]); + var fileToCopy = runner.PackFileService.FindFile("Animation\\Meta\\testFile.anm", sourcePackFile)!; + var node = new TreeNode(fileToCopy.Name, NodeType.File, sourcePackFile, null, fileToCopy); + var command = runner.CommandFactory.Create(); + + Assert.That(command.IsEnabled(node), Is.True); + } + + [Test] + public void Execute_DuplicatesFileIntoEditablePack() + { + var runner = new AssetEditorTestRunner(); + runner.PackFileService.EnforceGameFilesMustBeLoaded = false; + var sourcePackFile = runner.CreateEmptyPackFile("SourcePack", false); + var outputPackFile = runner.CreateEmptyPackFile("OutputPack", true); + + var fileEntry = new NewPackFileEntry("Animation\\Meta", PackFile.CreateFromASCII("testFile.anm", "DummyContent")); + runner.PackFileService.AddFilesToPack(sourcePackFile, [fileEntry]); + var fileToCopy = runner.PackFileService.FindFile("Animation\\Meta\\testFile.anm", sourcePackFile)!; + + var node = new TreeNode(fileToCopy.Name, NodeType.File, sourcePackFile, null, fileToCopy); + var command = runner.CommandFactory.Create(); + + command.Execute(node); + + var foundFile = runner.PackFileService.FindFile("Animation\\Meta\\testFile_copy.anm", outputPackFile); + Assert.That(foundFile, Is.Not.Null); + } + + [Test] + [TestCase("testFile", "testFile_copy")] // No extension + [TestCase("testFile.anm", "testFile_copy.anm")] // Single extension + [TestCase("testFile.anm.meta", "testFile_copy.anm.meta")] // Double extension + public void DuplicateFileCommand(string fileName, string result) + { + var runner = new AssetEditorTestRunner(); + runner.PackFileService.EnforceGameFilesMustBeLoaded = false; + var sourcePackFile = runner.CreateEmptyPackFile("SourcePack", false); + var outputPackFile = runner.CreateEmptyPackFile("OutputPack", true); + + var fileEntry = new NewPackFileEntry("Animation\\Meta", PackFile.CreateFromASCII(fileName, "DummyContent")); + runner.PackFileService.AddFilesToPack(sourcePackFile, [fileEntry]); + var fileToCopy = runner.PackFileService.FindFile("Animation\\Meta\\" + fileName, sourcePackFile); + + runner.CommandFactory.Create().Execute(fileToCopy); + var foundFile = runner.PackFileService.FindFile("Animation\\Meta\\" + result, outputPackFile); + Assert.That(foundFile, Is.Not.Null); + } + } +} + diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ExpandNodeCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ExpandNodeCommandTests.cs new file mode 100644 index 000000000..8d1f693ea --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ExpandNodeCommandTests.cs @@ -0,0 +1,83 @@ +using System.Threading; +using Shared.Core.PackFiles.Models; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class ExpandNodeCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForFolderAndFalseForFile() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var folder = new TreeNode("folder", NodeType.Directory, owner, root); + var file = new TreeNode("file.txt", NodeType.File, owner, folder, PackFile.CreateFromASCII("file.txt", "a")); + var command = new ExpandNodeCommand(); + + Assert.That(command.ShouldAdd(folder), Is.True); + Assert.That(command.ShouldAdd(file), Is.False); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var folder = new TreeNode("folder", NodeType.Directory, owner, root); + var command = new ExpandNodeCommand(); + + Assert.That(command.IsEnabled(folder), Is.True); + } + + [Test] + public void Execute_ExpandsAllNodes() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var folder = new TreeNode("folder", NodeType.Directory, owner, root); + var file = new TreeNode("file.txt", NodeType.File, owner, folder, PackFile.CreateFromASCII("file.txt", "a")); + root.AddChild(folder); + folder.AddChild(file); + root.Children.Add(folder); + folder.Children.Add(file); + + root.IsNodeExpanded = false; + folder.IsNodeExpanded = false; + file.IsNodeExpanded = false; + + var command = new ExpandNodeCommand(); + + command.Execute(root); + + Assert.That(root.IsNodeExpanded, Is.True); + Assert.That(folder.IsNodeExpanded, Is.True); + Assert.That(file.IsNodeExpanded, Is.True); + } + + [Test] + public void Execute_ExpandsUnmaterializedChildren() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var folder = new TreeNode("folder", NodeType.Directory, owner, root); + var nested = new TreeNode("nested", NodeType.Directory, owner, folder); + + root.AddChild(folder); + folder.AddChild(nested); + + root.IsNodeExpanded = false; + folder.IsNodeExpanded = false; + nested.IsNodeExpanded = false; + + var command = new ExpandNodeCommand(); + command.Execute(root); + + Assert.That(root.IsNodeExpanded, Is.True); + Assert.That(folder.IsNodeExpanded, Is.True); + Assert.That(nested.IsNodeExpanded, Is.True); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ExportToDirectoryCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ExportToDirectoryCommandTests.cs new file mode 100644 index 000000000..334934297 --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ExportToDirectoryCommandTests.cs @@ -0,0 +1,117 @@ +using System.IO; +using System.Threading; +using Moq; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class ExportToDirectoryCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForRoot() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var command = new ExportToDirectoryCommand(new Mock().Object, new Mock().Object); + + Assert.That(command.ShouldAdd(root), Is.True); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var command = new ExportToDirectoryCommand(new Mock().Object, new Mock().Object); + + Assert.That(command.IsEnabled(root), Is.True); + } + + [Test] + public void Execute_IgnoredUntilFilesystemPassTwo() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var dialogs = new Mock(); + dialogs.Setup(x => x.ShowSystemFolderBrowserDialog()).Returns(new SystemBrowseFolderDialogResult(false, null)); + var fileSystem = new Mock(); + var command = new ExportToDirectoryCommand(dialogs.Object, fileSystem.Object); + + command.Execute(root); + + fileSystem.Verify(x => x.FileWriteAllBytes(It.IsAny(), It.IsAny()), Times.Never); + dialogs.Verify(x => x.ShowDialogBox(It.Is(s => s.Contains("exported")), It.IsAny()), Times.Never); + } + + [Test] + public void ComputeRelativePath_RepeatedSegmentDoesNotCorruptPath() + { + // Root dir is "data", file fullPath is "data\data\unit.mesh" — old Replace would strip both occurrences. + var result = ExportToDirectoryCommand.ComputeRelativePath("data\\data\\unit.mesh", "data"); + Assert.That(result, Is.EqualTo("\\data\\unit.mesh")); + } + + [Test] + public void ComputeRelativePath_NullRootReturnsFullPath() + { + var result = ExportToDirectoryCommand.ComputeRelativePath("sub\\file.mesh", null); + Assert.That(result, Is.EqualTo("\\sub\\file.mesh")); + } + + [Test] + public void Execute_ExportMultipleFilesFromRoot_ExportsSuccessfully() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + + // Create directory structure: root -> [dir -> file1, file2] + var dir = new TreeNode("models", NodeType.Directory, owner, root); + + // Create real PackFile objects with MemorySource + var packFile1 = new PackFile("mesh1.mesh", new MemorySource(new byte[] { 0x01, 0x02 })); + var packFile2 = new PackFile("mesh2.mesh", new MemorySource(new byte[] { 0x03, 0x04 })); + + var file1 = new TreeNode("mesh1.mesh", NodeType.File, owner, dir, packFile1); + var file2 = new TreeNode("mesh2.mesh", NodeType.File, owner, dir, packFile2); + + dir.AddChild(file1); + dir.AddChild(file2); + root.AddChild(dir); + root.MarkChildrenLoaded(); + dir.MarkChildrenLoaded(); + + var outputDir = "C:\\export"; + var dialogs = new Mock(); + dialogs.Setup(x => x.ShowSystemFolderBrowserDialog()) + .Returns(new SystemBrowseFolderDialogResult(true, outputDir)); + + var fileSystem = new Mock(); + // Mock PathGetDirectoryName to extract directory from path + fileSystem.Setup(x => x.PathGetDirectoryName(It.IsAny())) + .Returns(p => { + if (string.IsNullOrEmpty(p)) return null; + var lastSlash = p.LastIndexOf('\\'); + return lastSlash > 0 ? p.Substring(0, lastSlash) : null; + }); + fileSystem.Setup(x => x.PathGetFileName(It.IsAny())) + .Returns(p => { + if (string.IsNullOrEmpty(p)) return p; + var lastSlash = p.LastIndexOf('\\'); + return lastSlash >= 0 ? p.Substring(lastSlash + 1) : p; + }); + + var command = new ExportToDirectoryCommand(dialogs.Object, fileSystem.Object); + + command.Execute(root); + + // Verify files were written + fileSystem.Verify(x => x.FileWriteAllBytes(It.IsAny(), It.IsAny()), Times.Exactly(2)); + dialogs.Verify(x => x.ShowDialogBox("2 files exported!", "Export"), Times.Once); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportDirectoryCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportDirectoryCommandTests.cs new file mode 100644 index 000000000..e677f2ab1 --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportDirectoryCommandTests.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading; +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class ImportDirectoryCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForDirectoryNode() + { + var command = new ImportDirectoryCommand(new Mock().Object, new Mock().Object, new Mock().Object); + Assert.That(command.ShouldAdd(new TreeNode("dir", NodeType.Directory, CreateContainer(), null)), Is.True); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(isCa: true); + var root = new TreeNode("root", NodeType.Root, owner, null); + var command = new ImportDirectoryCommand(new Mock().Object, new Mock().Object, new Mock().Object); + + Assert.That(command.IsEnabled(root), Is.True); + } + + [Test] + public void Execute_CaPackShowsErrorAndDoesNotImport() + { + var owner = CreateContainer(isCa: true); + var root = new TreeNode("root", NodeType.Root, owner, null); + + var service = new Mock(); + var dialogs = new Mock(); + var command = new ImportDirectoryCommand(service.Object, dialogs.Object, new Mock().Object); + + command.Execute(root); + + dialogs.Verify(x => x.ShowDialogBox("Unable to edit CA packfile", "Error"), Times.Once); + service.Verify(x => x.AddFilesToPack(It.IsAny(), It.IsAny>()), Times.Never); + } + + [Test] + public void Execute_DialogCancelled_DoesNotImport() + { + var owner = CreateContainer(isCa: false); + var root = new TreeNode("root", NodeType.Root, owner, null); + + var service = new Mock(); + var dialogs = new Mock(); + dialogs.Setup(x => x.ShowSystemFolderBrowserDialog()) + .Returns(new SystemBrowseFolderDialogResult(Result: false, FolderPath: string.Empty)); + var fileSystem = new Mock(); + var command = new ImportDirectoryCommand(service.Object, dialogs.Object, fileSystem.Object); + + command.Execute(root); + + service.Verify(x => x.AddFilesToPack(It.IsAny(), It.IsAny>()), Times.Never); + fileSystem.Verify(x => x.FileReadAllBytes(It.IsAny()), Times.Never); + } + + [Test] + public void Execute_DirectorySelected_ImportsFilesWithMockedReads() + { + var owner = CreateContainer(isCa: false); + var root = new TreeNode("root", NodeType.Root, owner, null); + + var folderPath = "C:\\test\\folder"; + var file1Path = "C:\\test\\folder\\file1.txt"; + var file2Path = "C:\\test\\folder\\subdir\\file2.txt"; + var file1Bytes = new byte[] { 0x01, 0x02 }; + var file2Bytes = new byte[] { 0x03, 0x04, 0x05 }; + + var service = new Mock(); + var dialogs = new Mock(); + dialogs.Setup(x => x.ShowSystemFolderBrowserDialog()) + .Returns(new SystemBrowseFolderDialogResult(Result: true, FolderPath: folderPath)); + var fileSystem = new Mock(); + fileSystem.Setup(x => x.CreateDirectoryInfo(folderPath)).Returns(new DirectoryInfo(folderPath)); + fileSystem.Setup(x => x.DirectoryGetFiles(folderPath, "*", SearchOption.AllDirectories)) + .Returns([file1Path, file2Path]); + fileSystem.Setup(x => x.PathGetFileName(file1Path)).Returns("file1.txt"); + fileSystem.Setup(x => x.PathGetFileName(file2Path)).Returns("file2.txt"); + fileSystem.Setup(x => x.FileReadAllBytes(file1Path)).Returns(file1Bytes); + fileSystem.Setup(x => x.FileReadAllBytes(file2Path)).Returns(file2Bytes); + var command = new ImportDirectoryCommand(service.Object, dialogs.Object, fileSystem.Object); + + command.Execute(root); + + fileSystem.Verify(x => x.FileReadAllBytes(file1Path), Times.Once); + fileSystem.Verify(x => x.FileReadAllBytes(file2Path), Times.Once); + service.Verify(x => x.AddFilesToPack(owner, It.Is>(items => + items.Count == 2)), Times.Once); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportFileCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportFileCommandTests.cs new file mode 100644 index 000000000..bdb6d64a3 --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportFileCommandTests.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading; +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class ImportFileCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForDirectoryNode() + { + var command = new ImportFileCommand(new Mock().Object, new Mock().Object, new Mock().Object); + Assert.That(command.ShouldAdd(new TreeNode("dir", NodeType.Directory, CreateContainer(), null)), Is.True); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(isCa: true); + var root = new TreeNode("root", NodeType.Root, owner, null); + var command = new ImportFileCommand(new Mock().Object, new Mock().Object, new Mock().Object); + + Assert.That(command.IsEnabled(root), Is.True); + } + + [Test] + public void Execute_CaPackShowsErrorAndDoesNotImport() + { + var owner = CreateContainer(isCa: true); + var root = new TreeNode("root", NodeType.Root, owner, null); + + var service = new Mock(); + var dialogs = new Mock(); + var fileSystem = new Mock(); + var command = new ImportFileCommand(service.Object, dialogs.Object, fileSystem.Object); + + command.Execute(root); + + dialogs.Verify(x => x.ShowDialogBox("Unable to edit CA packfile", "Error"), Times.Once); + service.Verify(x => x.AddFilesToPack(It.IsAny(), It.IsAny>()), Times.Never); + } + + [Test] + public void Execute_DialogCancelled_DoesNotImport() + { + var owner = CreateContainer(isCa: false); + var root = new TreeNode("root", NodeType.Root, owner, null); + + var service = new Mock(); + var dialogs = new Mock(); + dialogs.Setup(x => x.ShowSystemOpenFileDialog(It.IsAny(), It.IsAny())) + .Returns(new SystemOpenFileDialogResult(Result: false, FilePaths: [])); + var fileSystem = new Mock(); + var command = new ImportFileCommand(service.Object, dialogs.Object, fileSystem.Object); + + command.Execute(root); + + service.Verify(x => x.AddFilesToPack(It.IsAny(), It.IsAny>()), Times.Never); + fileSystem.Verify(x => x.FileReadAllBytes(It.IsAny()), Times.Never); + } + + [Test] + public void Execute_FileSelected_ImportsFileWithMockedRead() + { + var owner = CreateContainer(isCa: false); + var root = new TreeNode("root", NodeType.Root, owner, null); + + var fileBytes = new byte[] { 0x01, 0x02, 0x03 }; + var filePath = "C:\\test\\file.txt"; + + var service = new Mock(); + var dialogs = new Mock(); + dialogs.Setup(x => x.ShowSystemOpenFileDialog(It.IsAny(), It.IsAny())) + .Returns(new SystemOpenFileDialogResult(Result: true, FilePaths: [filePath])); + var fileSystem = new Mock(); + fileSystem.Setup(x => x.FileReadAllBytes(filePath)).Returns(fileBytes); + var command = new ImportFileCommand(service.Object, dialogs.Object, fileSystem.Object); + + command.Execute(root); + + fileSystem.Verify(x => x.FileReadAllBytes(filePath), Times.Once); + service.Verify(x => x.AddFilesToPack(owner, It.Is>(items => + items.Count == 1)), Times.Once); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInHxDCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInHxDCommandTests.cs new file mode 100644 index 000000000..cd506d795 --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInHxDCommandTests.cs @@ -0,0 +1,71 @@ +using System.Threading; +using Moq; +using Shared.Core.PackFiles.Models; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class OpenNodeInHxDCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForFileNode() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + var command = new OpenNodeInHxDCommand(new Mock().Object, new Mock().Object); + + Assert.That(command.ShouldAdd(file), Is.True); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + var command = new OpenNodeInHxDCommand(new Mock().Object, new Mock().Object); + + Assert.That(command.IsEnabled(file), Is.True); + } + + [Test] + public void Execute_AppMissing_ShowsError() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + + var dialogs = new Mock(); + var fileSystem = new Mock(); + fileSystem.Setup(x => x.FileExists(It.IsAny())).Returns(false); + + var command = new OpenNodeInHxDCommand(dialogs.Object, fileSystem.Object); + command.Execute(file); + + dialogs.Verify(x => x.ShowDialogBox(It.Is(s => s.Contains("does not exist")), It.IsAny()), Times.Once); + fileSystem.Verify(x => x.ProcessStart(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void Execute_AppExists_WritesTempFileAndStartsProcess() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "abc")); + + var dialogs = new Mock(); + var fileSystem = new Mock(); + fileSystem.Setup(x => x.FileExists(It.IsAny())).Returns(true); + + var command = new OpenNodeInHxDCommand(dialogs.Object, fileSystem.Object); + command.Execute(file); + + fileSystem.Verify(x => x.FileWriteAllBytes(It.IsAny(), It.Is(b => b.Length == 3)), Times.Once); + fileSystem.Verify(x => x.ProcessStart(It.IsAny(), It.IsAny()), Times.Once); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInNotepadCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInNotepadCommandTests.cs new file mode 100644 index 000000000..dc87e9f9b --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInNotepadCommandTests.cs @@ -0,0 +1,71 @@ +using System.Threading; +using Moq; +using Shared.Core.PackFiles.Models; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class OpenNodeInNotepadCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForFileNode() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + var command = new OpenNodeInNotepadCommand(new Mock().Object, new Mock().Object); + + Assert.That(command.ShouldAdd(file), Is.True); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + var command = new OpenNodeInNotepadCommand(new Mock().Object, new Mock().Object); + + Assert.That(command.IsEnabled(file), Is.True); + } + + [Test] + public void Execute_AppMissing_ShowsError() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + + var dialogs = new Mock(); + var fileSystem = new Mock(); + fileSystem.Setup(x => x.FileExists(It.IsAny())).Returns(false); + + var command = new OpenNodeInNotepadCommand(dialogs.Object, fileSystem.Object); + command.Execute(file); + + dialogs.Verify(x => x.ShowDialogBox(It.Is(s => s.Contains("does not exist")), It.IsAny()), Times.Once); + fileSystem.Verify(x => x.ProcessStart(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void Execute_AppExists_WritesTempFileAndStartsProcess() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "abc")); + + var dialogs = new Mock(); + var fileSystem = new Mock(); + fileSystem.Setup(x => x.FileExists(It.IsAny())).Returns(true); + + var command = new OpenNodeInNotepadCommand(dialogs.Object, fileSystem.Object); + command.Execute(file); + + fileSystem.Verify(x => x.FileWriteAllBytes(It.IsAny(), It.Is(b => b.Length == 3)), Times.Once); + fileSystem.Verify(x => x.ProcessStart(It.IsAny(), It.IsAny()), Times.Once); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenPackInFileExplorerCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenPackInFileExplorerCommandTests.cs new file mode 100644 index 000000000..9c209e58e --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenPackInFileExplorerCommandTests.cs @@ -0,0 +1,62 @@ +using System.Threading; +using System.Diagnostics; +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class OpenPackInFileExplorerCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForRoot() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var command = new OpenPackInFileExplorerCommand(new Mock().Object, new Mock().Object, new Mock().Object); + + Assert.That(command.ShouldAdd(root), Is.True); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var command = new OpenPackInFileExplorerCommand(new Mock().Object, new Mock().Object, new Mock().Object); + + Assert.That(command.IsEnabled(root), Is.True); + } + + [Test] + public void Execute_ValidPath_StartsExplorer() + { + var owner = CreateContainer(systemFilePath: "C:\\temp\\pack.pack"); + var root = new TreeNode("root", NodeType.Root, owner, null); + var fileSystem = new Mock(); + fileSystem.Setup(x => x.DirectoryExists(It.IsAny())).Returns(false); + fileSystem.Setup(x => x.PathGetDirectoryName(It.IsAny())).Returns("C:\\temp"); + + var command = new OpenPackInFileExplorerCommand(new Mock().Object, new Mock().Object, fileSystem.Object); + command.Execute(root); + + fileSystem.Verify(x => x.ProcessStart(It.Is(p => p.FileName == "explorer.exe")), Times.Once); + } + + [Test] + public void Execute_NullSystemFilePath_ShowsError() + { + var owner = CreateContainer(systemFilePath: ""); + var root = new TreeNode("root", NodeType.Root, owner, null); + var dialogs = new Mock(); + var command = new OpenPackInFileExplorerCommand(new Mock().Object, dialogs.Object, new Mock().Object); + + command.Execute(root); + + dialogs.Verify(x => x.ShowDialogBox(It.IsAny(), It.IsAny()), Times.Once); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/RenameNodeCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/RenameNodeCommandTests.cs new file mode 100644 index 000000000..b87a7cfd5 --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/RenameNodeCommandTests.cs @@ -0,0 +1,54 @@ +using System.Threading; +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class RenameNodeCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForFile() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + var command = new RenameNodeCommand(new Mock().Object, new Mock().Object); + + Assert.That(command.ShouldAdd(file), Is.True); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + var command = new RenameNodeCommand(new Mock().Object, new Mock().Object); + + Assert.That(command.IsEnabled(file), Is.True); + } + + [Test] + public void Execute_RenamesFile() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var file = new TreeNode("file.txt", NodeType.File, owner, root, PackFile.CreateFromASCII("file.txt", "a")); + root.AddChild(file); + + var service = new Mock(); + var dialogs = new Mock(); + dialogs.Setup(x => x.ShowTextInputDialog("Rename file", file.Name)).Returns(new TextInputDialogResult(true, "renamed.txt")); + var command = new RenameNodeCommand(service.Object, dialogs.Object); + + command.Execute(file); + + service.Verify(x => x.RenameFile(owner, file.Item!, "renamed.txt"), Times.Once); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SaveAsPackFileContainerCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SaveAsPackFileContainerCommandTests.cs new file mode 100644 index 000000000..a7e0abcb0 --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SaveAsPackFileContainerCommandTests.cs @@ -0,0 +1,51 @@ +using System.Threading; +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.Services; +using Shared.Core.Settings; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class SaveAsPackFileContainerCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForRoot() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var command = new SaveAsPackFileContainerCommand(new Mock().Object, new ApplicationSettingsService(GameTypeEnum.Warhammer3), new Mock().Object); + + Assert.That(command.ShouldAdd(root), Is.True); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var command = new SaveAsPackFileContainerCommand(new Mock().Object, new ApplicationSettingsService(GameTypeEnum.Warhammer3), new Mock().Object); + + Assert.That(command.IsEnabled(root), Is.True); + } + + [Test] + public void Execute_SaveDialogCancelled_DoesNotSave() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var service = new Mock(); + var dialogs = new Mock(); + dialogs.Setup(x => x.ShowSystemSaveFileDialog(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new SystemSaveFileDialogResult(false, null)); + + var command = new SaveAsPackFileContainerCommand(service.Object, new ApplicationSettingsService(GameTypeEnum.Warhammer3), dialogs.Object); + command.Execute(root); + + service.Verify(x => x.SavePackContainer(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SavePackFileContainerCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SavePackFileContainerCommandTests.cs new file mode 100644 index 000000000..35c48b6cb --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SavePackFileContainerCommandTests.cs @@ -0,0 +1,50 @@ +using System.Threading; +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.Services; +using Shared.Core.Settings; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class SavePackFileContainerCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForRoot() + { + var owner = CreateContainer(systemFilePath: "C:\\temp\\pack.pack"); + var root = new TreeNode("root", NodeType.Root, owner, null); + var command = new SavePackFileContainerCommand(new Mock().Object, new Mock().Object, new ApplicationSettingsService(GameTypeEnum.Warhammer3)); + + Assert.That(command.ShouldAdd(root), Is.True); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(systemFilePath: "C:\\temp\\pack.pack"); + var root = new TreeNode("root", NodeType.Root, owner, null); + var command = new SavePackFileContainerCommand(new Mock().Object, new Mock().Object, new ApplicationSettingsService(GameTypeEnum.Warhammer3)); + + Assert.That(command.IsEnabled(root), Is.True); + } + + [Test] + public void Execute_SavesPackContainer() + { + var owner = CreateContainer(systemFilePath: "C:\\temp\\pack.pack"); + var root = new TreeNode("root", NodeType.Root, owner, null); + var service = new Mock(); + var dialogs = new Mock(); + var appSettings = new ApplicationSettingsService(GameTypeEnum.Warhammer3); + + var command = new SavePackFileContainerCommand(service.Object, dialogs.Object, appSettings); + + command.Execute(root); + + service.Verify(x => x.SavePackContainer(owner, owner.SystemFilePath, false, It.IsAny()), Times.Once); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SetAsEditablePackCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SetAsEditablePackCommandTests.cs new file mode 100644 index 000000000..af9b2a916 --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SetAsEditablePackCommandTests.cs @@ -0,0 +1,47 @@ +using System.Threading; +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands +{ + [TestFixture] + internal class SetAsEditablePackCommandTests : ContextMenuCommandTestBase + { + [Test] + public void ShouldAdd_ReturnsTrueForRoot() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var command = new SetAsEditablePackCommand(new Mock().Object); + + Assert.That(command.ShouldAdd(root), Is.True); + } + + [Test] + public void IsEnabled_ReturnsTrue() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var command = new SetAsEditablePackCommand(new Mock().Object); + + Assert.That(command.IsEnabled(root), Is.True); + } + + [Test] + public void Execute_SetsEditablePack() + { + var owner = CreateContainer(); + var root = new TreeNode("root", NodeType.Root, owner, null); + var service = new Mock(); + service.Setup(x => x.GetEditablePack()).Returns((IPackFileContainer?)null); + var command = new SetAsEditablePackCommand(service.Object); + + command.Execute(root); + + service.Verify(x => x.SetEditablePack(owner), Times.Once); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/TreeNodeTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/TreeNodeTests.cs new file mode 100644 index 000000000..b7ab988e7 --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/TreeNodeTests.cs @@ -0,0 +1,19 @@ +using Shared.Core.PackFiles.Models.Containers; +using Shared.Ui.BaseDialogs.PackFileTree; + +namespace Shared.UiTest.BaseDialogs +{ + internal class TreeNodeTests + { + [Test] + public void DirectoryWithoutLoadedChildren_ShowsPlaceholderForExpandIcon() + { + var container = new PackFileContainer("test.pack"); + + var directoryNode = new TreeNode("documents", NodeType.Directory, container, null); + + Assert.That(directoryNode.Children.Any(c => c.Name == ""), Is.True, + "Collapsed directory without loaded children should contain a placeholder so TreeView shows expand icon."); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/UiCommand_DuplicateFile_Tests.cs b/Shared/SharedUI/Shared.UiTest/UiCommand_DuplicateFile_Tests.cs deleted file mode 100644 index 37fbf47a4..000000000 --- a/Shared/SharedUI/Shared.UiTest/UiCommand_DuplicateFile_Tests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Shared.Core.PackFiles; -using Shared.Core.PackFiles.Models; -using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; -using Test.TestingUtility.Shared; - -namespace Shared.UiTest -{ - internal class UiCommand_DuplicateFile_Tests - { - [Test] - [TestCase("testFile", "testFile_copy")] // No extension - [TestCase("testFile.anm", "testFile_copy.anm")] // Single extension - [TestCase("testFile.anm.meta", "testFile_copy.anm.meta")] // Double extension - public void DuplicateFileCommand(string fileName, string result) - { - var runner = new AssetEditorTestRunner(); - runner.PackFileService.EnforceGameFilesMustBeLoaded = false; - var sourcePackFile = runner.CreateEmptyPackFile("SourcePack", false); - var outputPackFile = runner.CreateEmptyPackFile("OutputPack", true); - - var fileEntry = new NewPackFileEntry("Animation\\Meta", PackFile.CreateFromASCII(fileName, "DummyContent")); - runner.PackFileService.AddFilesToPack(sourcePackFile, [fileEntry]); - var fileToCopy = runner.PackFileService.FindFile("Animation\\Meta\\" + fileName, sourcePackFile); - - runner.CommandFactory.Create().Execute(fileToCopy); - var foundFile = runner.PackFileService.FindFile("Animation\\Meta\\" + result, outputPackFile); - Assert.That(foundFile, Is.Not.Null); - } - } -}